Improve expenses receipts UX and Coolify MinIO deployment.
Extract receipt UI components, add view/edit/create dialog modes with list receipt previews, add docker-compose.coolify.yml and clearer COOLIFY/S3 path-style guidance for Application + MinIO setups. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+302
-238
@@ -28,17 +28,10 @@ import {
|
||||
} from "~/components/ui/select";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { FileUpload } from "~/components/forms/file-upload";
|
||||
import { ExpenseReceiptsPanel } from "~/components/expenses/expense-receipts-panel";
|
||||
import { ExpenseReceiptIndicator } from "~/components/expenses/expense-receipt-indicator";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Receipt,
|
||||
FileText,
|
||||
Paperclip,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { Plus, Pencil, Trash2, Receipt, Eye } from "lucide-react";
|
||||
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||
|
||||
@@ -70,14 +63,42 @@ const defaultForm: ExpenseFormData = {
|
||||
businessId: "",
|
||||
};
|
||||
|
||||
function formatFileSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
type ExpenseDialogMode = "create" | "view" | "edit";
|
||||
|
||||
function expenseToForm(
|
||||
expense: {
|
||||
date: Date | string;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
category: string | null;
|
||||
billable: boolean;
|
||||
reimbursable: boolean;
|
||||
taxDeductible: boolean | null;
|
||||
notes: string | null;
|
||||
clientId: string | null;
|
||||
businessId: string | null;
|
||||
},
|
||||
defaultBusinessId: string,
|
||||
): ExpenseFormData {
|
||||
return {
|
||||
date: new Date(expense.date),
|
||||
description: expense.description,
|
||||
amount: expense.amount,
|
||||
currency: expense.currency,
|
||||
category: expense.category ?? "",
|
||||
billable: expense.billable,
|
||||
reimbursable: expense.reimbursable,
|
||||
taxDeductible: expense.taxDeductible ?? false,
|
||||
notes: expense.notes ?? "",
|
||||
clientId: expense.clientId ?? "",
|
||||
businessId: expense.businessId ?? defaultBusinessId,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ExpensesPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<ExpenseDialogMode>("create");
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
@@ -89,10 +110,6 @@ export default function ExpensesPage() {
|
||||
businessFilter === "all" ? undefined : { businessId: businessFilter },
|
||||
);
|
||||
const { data: clients = [] } = api.clients.getAll.useQuery();
|
||||
const { data: receipts = [] } = api.expenses.listReceipts.useQuery(
|
||||
{ expenseId: editId! },
|
||||
{ enabled: !!editId },
|
||||
);
|
||||
|
||||
const defaultBusinessId = useMemo(
|
||||
() => businesses.find((b) => b.isDefault)?.id ?? businesses[0]?.id ?? "",
|
||||
@@ -100,16 +117,18 @@ export default function ExpensesPage() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || editId || !defaultBusinessId || form.businessId) return;
|
||||
if (!open || dialogMode !== "create" || !defaultBusinessId || form.businessId)
|
||||
return;
|
||||
setForm((prev) => ({ ...prev, businessId: defaultBusinessId }));
|
||||
}, [open, editId, defaultBusinessId, form.businessId]);
|
||||
}, [open, dialogMode, defaultBusinessId, form.businessId]);
|
||||
|
||||
const create = api.expenses.create.useMutation({
|
||||
onSuccess: (expense) => {
|
||||
if (!expense) return;
|
||||
toast.success("Expense added");
|
||||
toast.success("Expense saved — you can now attach receipts");
|
||||
void utils.expenses.getAll.invalidate();
|
||||
setEditId(expense.id);
|
||||
setDialogMode("edit");
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
@@ -119,6 +138,7 @@ export default function ExpensesPage() {
|
||||
void utils.expenses.getAll.invalidate();
|
||||
setOpen(false);
|
||||
setEditId(null);
|
||||
setDialogMode("create");
|
||||
setForm(defaultForm);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
@@ -131,47 +151,30 @@ export default function ExpensesPage() {
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Receipt uploaded");
|
||||
if (editId) {
|
||||
void utils.expenses.listReceipts.invalidate({ expenseId: editId });
|
||||
void utils.expenses.getAll.invalidate();
|
||||
}
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Receipt removed");
|
||||
if (editId) {
|
||||
void utils.expenses.listReceipts.invalidate({ expenseId: editId });
|
||||
void utils.expenses.getAll.invalidate();
|
||||
}
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const closeDialog = () => {
|
||||
setOpen(false);
|
||||
setEditId(null);
|
||||
setDialogMode("create");
|
||||
setForm(defaultForm);
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
setEditId(null);
|
||||
setDialogMode("create");
|
||||
setForm({ ...defaultForm, businessId: defaultBusinessId });
|
||||
setOpen(true);
|
||||
};
|
||||
const handleView = (expense: (typeof expenses)[0]) => {
|
||||
setEditId(expense.id);
|
||||
setDialogMode("view");
|
||||
setForm(expenseToForm(expense, defaultBusinessId));
|
||||
setOpen(true);
|
||||
};
|
||||
const handleEdit = (expense: (typeof expenses)[0]) => {
|
||||
setEditId(expense.id);
|
||||
setForm({
|
||||
date: new Date(expense.date),
|
||||
description: expense.description,
|
||||
amount: expense.amount,
|
||||
currency: expense.currency,
|
||||
category: expense.category ?? "",
|
||||
billable: expense.billable,
|
||||
reimbursable: expense.reimbursable,
|
||||
taxDeductible: expense.taxDeductible ?? false,
|
||||
notes: expense.notes ?? "",
|
||||
clientId: expense.clientId ?? "",
|
||||
businessId: expense.businessId ?? defaultBusinessId,
|
||||
});
|
||||
setDialogMode("edit");
|
||||
setForm(expenseToForm(expense, defaultBusinessId));
|
||||
setOpen(true);
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
@@ -195,36 +198,6 @@ export default function ExpensesPage() {
|
||||
else create.mutate(payload);
|
||||
};
|
||||
|
||||
const handleReceiptFiles = async (files: File[]) => {
|
||||
if (!editId) {
|
||||
toast.error("Save the expense before uploading receipts");
|
||||
return;
|
||||
}
|
||||
for (const file of files) {
|
||||
const data = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(",")[1];
|
||||
if (!base64) {
|
||||
reject(new Error("Failed to read file"));
|
||||
return;
|
||||
}
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
await uploadReceipt.mutateAsync({
|
||||
expenseId: editId,
|
||||
filename: file.name,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
data,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
||||
const billableTotal = expenses
|
||||
.filter((e) => e.billable)
|
||||
@@ -232,6 +205,30 @@ export default function ExpensesPage() {
|
||||
const deductibleTotal = expenses
|
||||
.filter((e) => e.taxDeductible)
|
||||
.reduce((s, e) => s + e.amount, 0);
|
||||
const withReceipts = expenses.filter((e) => e.receiptCount > 0).length;
|
||||
|
||||
const isViewMode = dialogMode === "view";
|
||||
const isEditMode = dialogMode === "edit";
|
||||
const isCreateMode = dialogMode === "create";
|
||||
|
||||
const dialogTitle = isCreateMode
|
||||
? "Add expense"
|
||||
: isViewMode
|
||||
? "View expense"
|
||||
: "Edit expense";
|
||||
|
||||
const businessName =
|
||||
businesses.find((b) => b.id === form.businessId)?.name ??
|
||||
(form.businessId ? "Unknown business" : "Default business");
|
||||
const clientName = form.clientId
|
||||
? (clients.find((c) => c.id === form.clientId)?.name ?? "Unknown client")
|
||||
: "No client";
|
||||
|
||||
const formattedDate = new Intl.DateTimeFormat("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(form.date);
|
||||
|
||||
return (
|
||||
<DashboardPage>
|
||||
@@ -299,9 +296,9 @@ export default function ExpensesPage() {
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
Count
|
||||
With receipts
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
|
||||
<p className="mt-1 text-2xl font-bold">{withReceipts}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -330,84 +327,126 @@ export default function ExpensesPage() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{expenses.map((expense) => (
|
||||
<div
|
||||
key={expense.id}
|
||||
className="flex items-start justify-between gap-3 p-4"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium">{expense.description}</p>
|
||||
{expense.billable && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Billable
|
||||
</Badge>
|
||||
)}
|
||||
{expense.reimbursable && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Reimbursable
|
||||
</Badge>
|
||||
)}
|
||||
{expense.taxDeductible && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-300 text-xs text-green-600"
|
||||
>
|
||||
Tax Deductible
|
||||
</Badge>
|
||||
)}
|
||||
{expense.category && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{expense.category}
|
||||
</Badge>
|
||||
)}
|
||||
{(expense.receipts?.length ?? 0) > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Paperclip className="mr-1 h-3 w-3" />
|
||||
{expense.receipts?.length}
|
||||
</Badge>
|
||||
<>
|
||||
<div className="text-muted-foreground hidden border-b px-4 py-2 text-xs font-medium tracking-wide uppercase sm:grid sm:grid-cols-[1fr_88px_96px_auto] sm:gap-3">
|
||||
<span>Expense</span>
|
||||
<span className="text-center">Receipts</span>
|
||||
<span className="text-right">Amount</span>
|
||||
<span className="w-[108px]" />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{expenses.map((expense) => (
|
||||
<div
|
||||
key={expense.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleView(expense)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleView(expense);
|
||||
}
|
||||
}}
|
||||
className="hover:bg-muted/40 focus-visible:ring-ring flex cursor-pointer flex-col gap-3 p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none sm:grid sm:grid-cols-[1fr_88px_96px_auto] sm:items-start sm:gap-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-medium">{expense.description}</p>
|
||||
{expense.billable && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Billable
|
||||
</Badge>
|
||||
)}
|
||||
{expense.reimbursable && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Reimbursable
|
||||
</Badge>
|
||||
)}
|
||||
{expense.taxDeductible && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-300 text-xs text-green-600"
|
||||
>
|
||||
Tax Deductible
|
||||
</Badge>
|
||||
)}
|
||||
{expense.category && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{expense.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(expense.date))}
|
||||
{expense.business ? ` · ${expense.business.name}` : ""}
|
||||
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||
</p>
|
||||
{expense.notes && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{expense.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(new Date(expense.date))}
|
||||
{expense.business ? ` · ${expense.business.name}` : ""}
|
||||
{expense.client ? ` · ${expense.client.name}` : ""}
|
||||
</p>
|
||||
{expense.notes && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{expense.notes}
|
||||
|
||||
<div
|
||||
className="flex items-center sm:justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-muted-foreground mr-2 text-xs sm:hidden">
|
||||
Receipts
|
||||
</span>
|
||||
<ExpenseReceiptIndicator
|
||||
expenseId={expense.id}
|
||||
receiptCount={expense.receiptCount}
|
||||
receiptPreview={expense.receiptPreview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between sm:contents">
|
||||
<p className="font-semibold sm:text-right">
|
||||
{formatCurrency(expense.amount, expense.currency)}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-shrink-0 items-center gap-1 sm:gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleView(expense)}
|
||||
title="View expense"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleEdit(expense)}
|
||||
title="Edit expense"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive h-8 w-8 p-0"
|
||||
onClick={() => setDeleteId(expense.id)}
|
||||
title="Delete expense"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<p className="font-semibold">
|
||||
{formatCurrency(expense.amount, expense.currency)}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleEdit(expense)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive h-8 w-8 p-0"
|
||||
onClick={() => setDeleteId(expense.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -418,15 +457,95 @@ export default function ExpensesPage() {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setEditId(null);
|
||||
setDialogMode("create");
|
||||
setForm(defaultForm);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
{isCreateMode && (
|
||||
<DialogDescription>
|
||||
Fill in the details below. You can attach receipts after saving.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{isViewMode ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Description
|
||||
</p>
|
||||
<p className="text-sm">{form.description}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Amount
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{formatCurrency(form.amount, form.currency)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Date
|
||||
</p>
|
||||
<p className="text-sm">{formattedDate}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Category
|
||||
</p>
|
||||
<p className="text-sm">{form.category || "None"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Business
|
||||
</p>
|
||||
<p className="text-sm">{businessName}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Client
|
||||
</p>
|
||||
<p className="text-sm">{clientName}</p>
|
||||
</div>
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Flags
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{form.billable ? (
|
||||
<Badge variant="secondary">Billable</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Not billable</Badge>
|
||||
)}
|
||||
{form.reimbursable ? (
|
||||
<Badge variant="outline">Reimbursable</Badge>
|
||||
) : null}
|
||||
{form.taxDeductible ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-300 text-green-600"
|
||||
>
|
||||
Tax deductible
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{form.notes ? (
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Notes
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{form.notes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Description *</Label>
|
||||
<Input
|
||||
@@ -584,82 +703,25 @@ export default function ExpensesPage() {
|
||||
placeholder="Additional details…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editId ? (
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<Label>Receipts</Label>
|
||||
{receipts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{receipts.map((receipt) => {
|
||||
const isImage = receipt.mimeType.startsWith("image/");
|
||||
const url = `/api/receipts/${receipt.id}`;
|
||||
return (
|
||||
<div
|
||||
key={receipt.id}
|
||||
className="flex items-center gap-3 rounded-md border p-2"
|
||||
>
|
||||
{isImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={url}
|
||||
alt={receipt.originalFilename}
|
||||
className="h-12 w-12 rounded object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded">
|
||||
<FileText className="text-muted-foreground h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{receipt.originalFilename}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatFileSize(receipt.sizeBytes)}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive"
|
||||
onClick={() =>
|
||||
deleteReceipt.mutate({ id: receipt.id })
|
||||
}
|
||||
disabled={deleteReceipt.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<FileUpload
|
||||
onFilesSelected={(files) => void handleReceiptFiles(files)}
|
||||
accept={{
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic"],
|
||||
"application/pdf": [".pdf"],
|
||||
}}
|
||||
maxFiles={5}
|
||||
maxSize={10 * 1024 * 1024}
|
||||
disabled={uploadReceipt.isPending}
|
||||
placeholder="Drop receipts here"
|
||||
description="Images or PDF, up to 10MB each"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Save the expense first, then you can attach receipts.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ExpenseReceiptsPanel expenseId={editId} readOnly={isViewMode} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={() => setDialogMode("edit")}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@@ -668,10 +730,12 @@ export default function ExpensesPage() {
|
||||
>
|
||||
{create.isPending || update.isPending
|
||||
? "Saving…"
|
||||
: editId
|
||||
: isEditMode
|
||||
? "Update"
|
||||
: "Add Expense"}
|
||||
: "Save & add receipts"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText, Loader2, Paperclip } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { ExpenseReceiptItem } from "~/components/expenses/expense-receipt-item";
|
||||
import { ReceiptViewerDialog } from "~/components/expenses/receipt-viewer-dialog";
|
||||
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
|
||||
import { isImageReceipt, receiptUrl } from "~/components/expenses/receipt-utils";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface ReceiptPreview {
|
||||
id: string;
|
||||
mimeType: string;
|
||||
originalFilename: string;
|
||||
}
|
||||
|
||||
interface ExpenseReceiptIndicatorProps {
|
||||
expenseId: string;
|
||||
receiptCount: number;
|
||||
receiptPreview: ReceiptPreview | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExpenseReceiptIndicator({
|
||||
expenseId,
|
||||
receiptCount,
|
||||
receiptPreview,
|
||||
className,
|
||||
}: ExpenseReceiptIndicatorProps) {
|
||||
const [listOpen, setListOpen] = useState(false);
|
||||
const [viewerReceipt, setViewerReceipt] = useState<ReceiptViewerTarget | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery(
|
||||
{ expenseId },
|
||||
{ enabled: listOpen && receiptCount > 1 },
|
||||
);
|
||||
|
||||
if (receiptCount === 0) {
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-xs", className)}>—</span>
|
||||
);
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (receiptCount === 1 && receiptPreview) {
|
||||
setViewerReceipt({
|
||||
id: receiptPreview.id,
|
||||
originalFilename: receiptPreview.originalFilename,
|
||||
mimeType: receiptPreview.mimeType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setListOpen(true);
|
||||
};
|
||||
|
||||
const previewIsImage =
|
||||
receiptPreview && isImageReceipt(receiptPreview.mimeType);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"hover:bg-muted h-auto gap-2 px-2 py-1.5 font-normal",
|
||||
className,
|
||||
)}
|
||||
title={
|
||||
receiptCount === 1
|
||||
? "View receipt"
|
||||
: `View ${receiptCount} receipts`
|
||||
}
|
||||
>
|
||||
{previewIsImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={receiptUrl(receiptPreview.id)}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded object-cover ring-1 ring-black/5"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-muted flex h-8 w-8 items-center justify-center rounded ring-1 ring-black/5">
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{receiptCount}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<ReceiptViewerDialog
|
||||
receipt={viewerReceipt}
|
||||
open={!!viewerReceipt}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setViewerReceipt(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={listOpen} onOpenChange={setListOpen}>
|
||||
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Receipts ({receiptCount})</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading receipts…
|
||||
</div>
|
||||
) : (
|
||||
receipts.map((receipt) => (
|
||||
<ExpenseReceiptItem
|
||||
key={receipt.id}
|
||||
receipt={receipt}
|
||||
expenseId={expenseId}
|
||||
onView={(r) => {
|
||||
setListOpen(false);
|
||||
setViewerReceipt(r);
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ExternalLink, Eye, FileText, Loader2, Trash2 } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import {
|
||||
formatReceiptSize,
|
||||
isImageReceipt,
|
||||
receiptUrl,
|
||||
} from "~/components/expenses/receipt-utils";
|
||||
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
|
||||
|
||||
export interface ExpenseReceiptRecord {
|
||||
id: string;
|
||||
originalFilename: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
interface ExpenseReceiptItemProps {
|
||||
receipt: ExpenseReceiptRecord;
|
||||
expenseId: string;
|
||||
onView: (receipt: ReceiptViewerTarget) => void;
|
||||
compact?: boolean;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function ExpenseReceiptItem({
|
||||
receipt,
|
||||
expenseId,
|
||||
onView,
|
||||
compact = false,
|
||||
readOnly = false,
|
||||
}: ExpenseReceiptItemProps) {
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteReceipt = api.expenses.deleteReceipt.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Receipt removed");
|
||||
void utils.expenses.listReceipts.invalidate({ expenseId });
|
||||
void utils.expenses.getAll.invalidate();
|
||||
setConfirmDelete(false);
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const url = receiptUrl(receipt.id);
|
||||
const isImage = isImageReceipt(receipt.mimeType);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? "flex items-center gap-2 rounded-md border p-2"
|
||||
: "flex items-center gap-3 rounded-md border p-2 sm:p-3"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onView(receipt)}
|
||||
className="hover:ring-primary/40 focus-visible:ring-ring shrink-0 overflow-hidden rounded transition hover:ring-2 focus-visible:ring-2 focus-visible:outline-none"
|
||||
aria-label={`View ${receipt.originalFilename}`}
|
||||
>
|
||||
{isImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
className={
|
||||
compact ? "h-10 w-10 object-cover" : "h-12 w-12 object-cover sm:h-14 sm:w-14"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? "bg-muted flex h-10 w-10 items-center justify-center"
|
||||
: "bg-muted flex h-12 w-12 items-center justify-center sm:h-14 sm:w-14"
|
||||
}
|
||||
>
|
||||
<FileText className="text-muted-foreground h-5 w-5 sm:h-6 sm:w-6" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{receipt.originalFilename}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatReceiptSize(receipt.sizeBytes)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => onView(receipt)}
|
||||
title="View receipt"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" asChild>
|
||||
<a href={url} target="_blank" rel="noreferrer" title="Open in new tab">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 p-0"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={deleteReceipt.isPending}
|
||||
title="Delete receipt"
|
||||
>
|
||||
{deleteReceipt.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete receipt?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
“{receipt.originalFilename}” will be permanently
|
||||
removed. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteReceipt.isPending}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={deleteReceipt.isPending}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
deleteReceipt.mutate({ id: receipt.id });
|
||||
}}
|
||||
>
|
||||
{deleteReceipt.isPending ? "Deleting…" : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Loader2, Paperclip } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { FileUpload } from "~/components/forms/file-upload";
|
||||
import { ExpenseReceiptItem } from "~/components/expenses/expense-receipt-item";
|
||||
import { ReceiptViewerDialog } from "~/components/expenses/receipt-viewer-dialog";
|
||||
import type { ReceiptViewerTarget } from "~/components/expenses/receipt-viewer-dialog";
|
||||
import {
|
||||
fileToBase64,
|
||||
RECEIPT_ACCEPT,
|
||||
RECEIPT_MAX_SIZE,
|
||||
RECEIPT_UPLOAD_HINT,
|
||||
} from "~/components/expenses/receipt-utils";
|
||||
|
||||
interface ExpenseReceiptsPanelProps {
|
||||
expenseId: string | null;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function ExpenseReceiptsPanel({
|
||||
expenseId,
|
||||
readOnly = false,
|
||||
}: ExpenseReceiptsPanelProps) {
|
||||
const [viewerReceipt, setViewerReceipt] = useState<ReceiptViewerTarget | null>(
|
||||
null,
|
||||
);
|
||||
const [uploadKey, setUploadKey] = useState(0);
|
||||
const processedFileCountRef = useRef(0);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: receipts = [], isLoading } = api.expenses.listReceipts.useQuery(
|
||||
{ expenseId: expenseId! },
|
||||
{ enabled: !!expenseId },
|
||||
);
|
||||
|
||||
const uploadReceipt = api.expenses.uploadReceipt.useMutation({
|
||||
onSuccess: () => {
|
||||
if (expenseId) {
|
||||
void utils.expenses.listReceipts.invalidate({ expenseId });
|
||||
void utils.expenses.getAll.invalidate();
|
||||
}
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const handleFiles = async (files: File[]) => {
|
||||
if (!expenseId || files.length === 0) return;
|
||||
|
||||
const newFiles = files.slice(processedFileCountRef.current);
|
||||
processedFileCountRef.current = files.length;
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
let uploaded = 0;
|
||||
for (const file of newFiles) {
|
||||
try {
|
||||
const data = await fileToBase64(file);
|
||||
await uploadReceipt.mutateAsync({
|
||||
expenseId,
|
||||
filename: file.name,
|
||||
mimeType: file.type || "application/octet-stream",
|
||||
data,
|
||||
});
|
||||
uploaded++;
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Upload failed";
|
||||
toast.error(`${file.name}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploaded > 0) {
|
||||
toast.success(
|
||||
uploaded === 1 ? "Receipt uploaded" : `${uploaded} receipts uploaded`,
|
||||
);
|
||||
processedFileCountRef.current = 0;
|
||||
setUploadKey((k) => k + 1);
|
||||
}
|
||||
};
|
||||
|
||||
if (!expenseId) {
|
||||
return (
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
Receipts
|
||||
</Label>
|
||||
<div className="bg-muted/40 text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
|
||||
Save the expense first, then drag and drop receipts here.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
Receipts
|
||||
{receipts.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
({receipts.length})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{uploadReceipt.isPending && (
|
||||
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Uploading…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 rounded-md border border-dashed p-6 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading receipts…
|
||||
</div>
|
||||
) : receipts.length === 0 ? (
|
||||
<div className="text-muted-foreground rounded-md border border-dashed p-4 text-center text-sm">
|
||||
{readOnly
|
||||
? "No receipts attached."
|
||||
: "No receipts yet. Drop images or PDFs below."}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{receipts.map((receipt) => (
|
||||
<ExpenseReceiptItem
|
||||
key={receipt.id}
|
||||
receipt={receipt}
|
||||
expenseId={expenseId}
|
||||
onView={setViewerReceipt}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<FileUpload
|
||||
key={`${expenseId}-${uploadKey}`}
|
||||
onFilesSelected={(files) => void handleFiles(files)}
|
||||
accept={RECEIPT_ACCEPT}
|
||||
maxFiles={5}
|
||||
maxSize={RECEIPT_MAX_SIZE}
|
||||
disabled={uploadReceipt.isPending}
|
||||
placeholder="Drop receipts here or tap to browse"
|
||||
description={RECEIPT_UPLOAD_HINT}
|
||||
className="[&>div:first-child]:p-4 sm:[&>div:first-child]:p-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
<ReceiptViewerDialog
|
||||
receipt={viewerReceipt}
|
||||
open={!!viewerReceipt}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setViewerReceipt(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export const RECEIPT_ACCEPT: Record<string, string[]> = {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic"],
|
||||
"application/pdf": [".pdf"],
|
||||
};
|
||||
|
||||
export const RECEIPT_MAX_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
export const RECEIPT_UPLOAD_HINT =
|
||||
"PNG, JPG, or PDF · up to 10MB each";
|
||||
|
||||
export function receiptUrl(receiptId: string) {
|
||||
return `/api/receipts/${receiptId}`;
|
||||
}
|
||||
|
||||
export function isImageReceipt(mimeType: string) {
|
||||
return mimeType.startsWith("image/");
|
||||
}
|
||||
|
||||
export function isPdfReceipt(mimeType: string) {
|
||||
return mimeType === "application/pdf";
|
||||
}
|
||||
|
||||
export function formatReceiptSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export async function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(",")[1];
|
||||
if (!base64) {
|
||||
reject(new Error("Failed to read file"));
|
||||
return;
|
||||
}
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, FileText } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
isImageReceipt,
|
||||
isPdfReceipt,
|
||||
receiptUrl,
|
||||
} from "~/components/expenses/receipt-utils";
|
||||
|
||||
export interface ReceiptViewerTarget {
|
||||
id: string;
|
||||
originalFilename: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
interface ReceiptViewerDialogProps {
|
||||
receipt: ReceiptViewerTarget | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ReceiptViewerDialog({
|
||||
receipt,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ReceiptViewerDialogProps) {
|
||||
if (!receipt) return null;
|
||||
|
||||
const url = receiptUrl(receipt.id);
|
||||
const isImage = isImageReceipt(receipt.mimeType);
|
||||
const isPdf = isPdfReceipt(receipt.mimeType);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-4">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="truncate pr-8">
|
||||
{receipt.originalFilename}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="bg-muted/30 min-h-[200px] flex-1 overflow-auto rounded-md border">
|
||||
{isImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={url}
|
||||
alt={receipt.originalFilename}
|
||||
className="mx-auto max-h-[min(70vh,720px)] w-full object-contain"
|
||||
/>
|
||||
) : isPdf ? (
|
||||
<iframe
|
||||
src={url}
|
||||
title={receipt.originalFilename}
|
||||
className="h-[min(70vh,720px)] w-full border-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-48 flex-col items-center justify-center gap-3 p-6 text-center text-sm">
|
||||
<FileText className="h-10 w-10" />
|
||||
<p>Preview not available for this file type.</p>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open file
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 sm:justify-between">
|
||||
<Button variant="outline" asChild>
|
||||
<a href={url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open in new tab
|
||||
</a>
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export const env = createEnv({
|
||||
S3_ACCESS_KEY: z.string().optional(),
|
||||
S3_SECRET_KEY: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
S3_FORCE_PATH_STYLE: optionalEnvBoolean(),
|
||||
// SSO / Authentik (optional)
|
||||
AUTHENTIK_ISSUER: z.string().url().optional(),
|
||||
AUTHENTIK_CLIENT_ID: z.string().optional(),
|
||||
@@ -87,6 +88,7 @@ export const env = createEnv({
|
||||
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
|
||||
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
|
||||
S3_REGION: process.env.S3_REGION,
|
||||
S3_FORCE_PATH_STYLE: process.env.S3_FORCE_PATH_STYLE,
|
||||
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||
|
||||
@@ -22,6 +22,34 @@ type S3Module = typeof import("@aws-sdk/client-s3");
|
||||
let s3ModulePromise: Promise<S3Module> | null = null;
|
||||
let s3Client: InstanceType<S3Module["S3Client"]> | null = null;
|
||||
let s3DnsHintLogged = false;
|
||||
let s3BareMinioHintLogged = false;
|
||||
|
||||
function shouldForcePathStyle(): boolean {
|
||||
const override = process.env.S3_FORCE_PATH_STYLE?.trim().toLowerCase();
|
||||
if (override === "true" || override === "1") return true;
|
||||
if (override === "false" || override === "0") return false;
|
||||
return Boolean(process.env.S3_ENDPOINT);
|
||||
}
|
||||
|
||||
function logBareMinioEndpointHint(): void {
|
||||
if (s3BareMinioHintLogged || process.env.NODE_ENV !== "production") return;
|
||||
const endpoint = process.env.S3_ENDPOINT;
|
||||
if (!endpoint) return;
|
||||
try {
|
||||
const { hostname } = new URL(endpoint);
|
||||
if (hostname !== "minio") return;
|
||||
s3BareMinioHintLogged = true;
|
||||
console.warn(
|
||||
"[object-storage] S3_ENDPOINT hostname is bare 'minio'. " +
|
||||
"That only resolves inside a single Docker Compose stack. " +
|
||||
"Coolify Application + separate MinIO compose: set S3_ENDPOINT to " +
|
||||
"SERVICE_URL_MINIO_9000 (public domain) or http://minio-<resource-uuid>:9000. " +
|
||||
"See docs/COOLIFY.md.",
|
||||
);
|
||||
} catch {
|
||||
// Invalid URL — env validation or S3 client will surface it.
|
||||
}
|
||||
}
|
||||
|
||||
function logS3DnsHint(error: unknown): void {
|
||||
if (s3DnsHintLogged) return;
|
||||
@@ -50,6 +78,7 @@ async function getS3() {
|
||||
}
|
||||
const mod = await s3ModulePromise;
|
||||
if (!s3Client) {
|
||||
logBareMinioEndpointHint();
|
||||
s3Client = new mod.S3Client({
|
||||
region: process.env.S3_REGION ?? "us-east-1",
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
@@ -57,8 +86,8 @@ async function getS3() {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY!,
|
||||
secretAccessKey: process.env.S3_SECRET_KEY!,
|
||||
},
|
||||
// Required for MinIO and most S3-compatible endpoints.
|
||||
forcePathStyle: Boolean(process.env.S3_ENDPOINT),
|
||||
// Required for MinIO and most S3-compatible endpoints (including HTTPS proxies).
|
||||
forcePathStyle: shouldForcePathStyle(),
|
||||
});
|
||||
}
|
||||
return { client: s3Client, ...mod };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { eq, and, desc, inArray } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import {
|
||||
expenses,
|
||||
@@ -132,16 +132,67 @@ export const expensesRouter = createTRPCRouter({
|
||||
conditions.push(eq(expenses.businessId, input.businessId));
|
||||
}
|
||||
|
||||
return await ctx.db.query.expenses.findMany({
|
||||
const rows = await ctx.db.query.expenses.findMany({
|
||||
where: and(...conditions),
|
||||
with: {
|
||||
client: true,
|
||||
business: true,
|
||||
invoice: true,
|
||||
receipts: true,
|
||||
},
|
||||
orderBy: [desc(expenses.date)],
|
||||
});
|
||||
|
||||
const expenseIds = rows.map((e) => e.id);
|
||||
if (expenseIds.length === 0) return [];
|
||||
|
||||
const receiptMeta = await ctx.db
|
||||
.select({
|
||||
expenseId: expenseReceipts.expenseId,
|
||||
id: expenseReceipts.id,
|
||||
mimeType: expenseReceipts.mimeType,
|
||||
originalFilename: expenseReceipts.originalFilename,
|
||||
createdAt: expenseReceipts.createdAt,
|
||||
})
|
||||
.from(expenseReceipts)
|
||||
.where(inArray(expenseReceipts.expenseId, expenseIds))
|
||||
.orderBy(desc(expenseReceipts.createdAt));
|
||||
|
||||
const receiptStats = new Map<
|
||||
string,
|
||||
{
|
||||
receiptCount: number;
|
||||
receiptPreview: {
|
||||
id: string;
|
||||
mimeType: string;
|
||||
originalFilename: string;
|
||||
} | null;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const receipt of receiptMeta) {
|
||||
const existing = receiptStats.get(receipt.expenseId);
|
||||
if (existing) {
|
||||
existing.receiptCount += 1;
|
||||
} else {
|
||||
receiptStats.set(receipt.expenseId, {
|
||||
receiptCount: 1,
|
||||
receiptPreview: {
|
||||
id: receipt.id,
|
||||
mimeType: receipt.mimeType,
|
||||
originalFilename: receipt.originalFilename,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map((expense) => {
|
||||
const stats = receiptStats.get(expense.id);
|
||||
return {
|
||||
...expense,
|
||||
receiptCount: stats?.receiptCount ?? 0,
|
||||
receiptPreview: stats?.receiptPreview ?? null,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
|
||||
Reference in New Issue
Block a user