mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-05-08 09:38:55 -04:00
Add bulk actions, multi-currency, expenses, templates, and reports
Schema (migration 0001): - clients: add currency column (default USD) - invoices: add currency column (default USD) - New expenses table: amount, currency, category, billable, reimbursable, client/invoice/business relations, notes - New invoice_templates table: name, type (notes|terms), content, isDefault API: - invoices: add bulkUpdateStatus and bulkDelete procedures (ownership-safe) - invoices: currency field threaded through create/update schemas - clients: currency field added to create/update schemas - New expenses router: full CRUD with authorization - New invoiceTemplates router: full CRUD, isDefault management per type - Root router: wire in expenses and invoiceTemplates Currency (src/lib/currency.ts): - Shared formatCurrency(amount, currency) utility replacing hardcoded USD - SUPPORTED_CURRENCIES list (17 currencies) - Invoice form: currency selector in Config card, auto-fills from client - Client form: currency selector in Billing Information card Bulk actions (invoices list): - Checkbox column with select-all support - Selection toolbar: Mark as Sent/Paid/Draft dropdown, Delete (N) button - DataTable: new selectionActions prop renders toolbar when rows selected Notes templates: - Invoice form: Notes card with textarea in Details tab - Template dropdown button appears when templates exist - /dashboard/invoices/templates: full CRUD page for notes and terms templates New pages: - /dashboard/expenses: expense list with summary cards, add/edit dialog - /dashboard/reports: KPI cards, 12-month revenue area chart, top clients bar chart, status breakdown, recent activity - Navigation: Expenses and Reports added to Main section https://claude.ai/code/session_012sqEgNQpx676isepeoX4Mi
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
CREATE TABLE "beenvoice_expense" (
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"businessId" varchar(255),
|
||||
"clientId" varchar(255),
|
||||
"invoiceId" varchar(255),
|
||||
"date" timestamp NOT NULL,
|
||||
"description" varchar(500) NOT NULL,
|
||||
"amount" real NOT NULL,
|
||||
"currency" varchar(3) DEFAULT 'USD' NOT NULL,
|
||||
"category" varchar(100),
|
||||
"billable" boolean DEFAULT false NOT NULL,
|
||||
"reimbursable" boolean DEFAULT false NOT NULL,
|
||||
"notes" varchar(500),
|
||||
"createdById" varchar(255) NOT NULL,
|
||||
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "beenvoice_invoice_template" (
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"type" varchar(50) DEFAULT 'notes' NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"isDefault" boolean DEFAULT false NOT NULL,
|
||||
"createdById" varchar(255) NOT NULL,
|
||||
"createdAt" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
"updatedAt" timestamp
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_client" ADD COLUMN "currency" varchar(3) DEFAULT 'USD' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_invoice" ADD COLUMN "currency" varchar(3) DEFAULT 'USD' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_businessId_beenvoice_business_id_fk" FOREIGN KEY ("businessId") REFERENCES "public"."beenvoice_business"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_clientId_beenvoice_client_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."beenvoice_client"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_invoiceId_beenvoice_invoice_id_fk" FOREIGN KEY ("invoiceId") REFERENCES "public"."beenvoice_invoice"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_expense" ADD CONSTRAINT "beenvoice_expense_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "beenvoice_invoice_template" ADD CONSTRAINT "beenvoice_invoice_template_createdById_beenvoice_user_id_fk" FOREIGN KEY ("createdById") REFERENCES "public"."beenvoice_user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "expense_created_by_idx" ON "beenvoice_expense" USING btree ("createdById");--> statement-breakpoint
|
||||
CREATE INDEX "expense_client_id_idx" ON "beenvoice_expense" USING btree ("clientId");--> statement-breakpoint
|
||||
CREATE INDEX "expense_invoice_id_idx" ON "beenvoice_expense" USING btree ("invoiceId");--> statement-breakpoint
|
||||
CREATE INDEX "expense_date_idx" ON "beenvoice_expense" USING btree ("date");--> statement-breakpoint
|
||||
CREATE INDEX "expense_billable_idx" ON "beenvoice_expense" USING btree ("billable");--> statement-breakpoint
|
||||
CREATE INDEX "invoice_template_created_by_idx" ON "beenvoice_invoice_template" USING btree ("createdById");--> statement-breakpoint
|
||||
CREATE INDEX "invoice_template_type_idx" ON "beenvoice_invoice_template" USING btree ("type");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
||||
"when": 1775354242672,
|
||||
"tag": "0000_glossy_magneto",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1775356013998,
|
||||
"tag": "0001_supreme_the_enforcers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { DatePicker } from "~/components/ui/date-picker";
|
||||
import { NumberInput } from "~/components/ui/number-input";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Trash2, Receipt } from "lucide-react";
|
||||
import { formatCurrency, SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||
import { EXPENSE_CATEGORIES } from "~/server/api/routers/expenses";
|
||||
|
||||
interface ExpenseFormData {
|
||||
date: Date;
|
||||
description: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
category: string;
|
||||
billable: boolean;
|
||||
reimbursable: boolean;
|
||||
notes: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
const defaultForm: ExpenseFormData = {
|
||||
date: new Date(),
|
||||
description: "",
|
||||
amount: 0,
|
||||
currency: "USD",
|
||||
category: "",
|
||||
billable: false,
|
||||
reimbursable: false,
|
||||
notes: "",
|
||||
clientId: "",
|
||||
};
|
||||
|
||||
export default function ExpensesPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<ExpenseFormData>(defaultForm);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: expenses = [], isLoading } = api.expenses.getAll.useQuery();
|
||||
const { data: clients = [] } = api.clients.getAll.useQuery();
|
||||
|
||||
const create = api.expenses.create.useMutation({
|
||||
onSuccess: () => { toast.success("Expense added"); void utils.expenses.getAll.invalidate(); setOpen(false); setForm(defaultForm); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const update = api.expenses.update.useMutation({
|
||||
onSuccess: () => { toast.success("Expense updated"); void utils.expenses.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const del = api.expenses.delete.useMutation({
|
||||
onSuccess: () => { toast.success("Expense deleted"); void utils.expenses.getAll.invalidate(); setDeleteId(null); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const handleOpen = () => { setEditId(null); setForm(defaultForm); 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,
|
||||
notes: expense.notes ?? "",
|
||||
clientId: expense.clientId ?? "",
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
if (!form.description.trim()) { toast.error("Description is required"); return; }
|
||||
if (form.amount <= 0) { toast.error("Amount must be greater than 0"); return; }
|
||||
const payload = { ...form, clientId: form.clientId || undefined, category: form.category || undefined, notes: form.notes || undefined };
|
||||
if (editId) update.mutate({ id: editId, ...payload });
|
||||
else create.mutate(payload);
|
||||
};
|
||||
|
||||
const totalExpenses = expenses.reduce((s, e) => s + e.amount, 0);
|
||||
const billableTotal = expenses.filter((e) => e.billable).reduce((s, e) => s + e.amount, 0);
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6 pb-6">
|
||||
<PageHeader title="Expenses" description="Track billable and non-billable expenses" variant="gradient">
|
||||
<Button onClick={handleOpen} variant="default" className="hover-lift shadow-md">
|
||||
<Plus className="mr-2 h-5 w-5" /> Add Expense
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Total</p>
|
||||
<p className="mt-1 text-2xl font-bold">{formatCurrency(totalExpenses)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Billable</p>
|
||||
<p className="text-primary mt-1 text-2xl font-bold">{formatCurrency(billableTotal)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-2 sm:col-span-1">
|
||||
<CardContent className="p-4">
|
||||
<p className="text-muted-foreground text-xs font-medium uppercase tracking-wide">Count</p>
|
||||
<p className="mt-1 text-2xl font-bold">{expenses.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Expenses list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Receipt className="h-5 w-5" /> All Expenses
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">Loading…</div>
|
||||
) : expenses.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<Receipt className="text-muted-foreground mx-auto mb-3 h-10 w-10" />
|
||||
<p className="text-muted-foreground text-sm">No expenses yet. Add your first expense.</p>
|
||||
</div>
|
||||
) : (
|
||||
<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.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.client ? ` · ${expense.client.name}` : ""}
|
||||
</p>
|
||||
{expense.notes && <p className="text-muted-foreground mt-1 text-xs">{expense.notes}</p>}
|
||||
</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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add/Edit dialog */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editId ? "Edit Expense" : "Add Expense"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Description *</Label>
|
||||
<Input value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} placeholder="e.g. Laptop charger" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Amount *</Label>
|
||||
<NumberInput value={form.amount} onChange={(v) => setForm((p) => ({ ...p, amount: v }))} min={0} step={0.01} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Currency</Label>
|
||||
<Select value={form.currency} onValueChange={(v) => setForm((p) => ({ ...p, currency: v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{SUPPORTED_CURRENCIES.map((c) => <SelectItem key={c.code} value={c.code}>{c.code}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Date</Label>
|
||||
<DatePicker date={form.date} onDateChange={(d) => setForm((p) => ({ ...p, date: d ?? new Date() }))} className="w-full" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select value={form.category || "none"} onValueChange={(v) => setForm((p) => ({ ...p, category: v === "none" ? "" : v }))}>
|
||||
<SelectTrigger><SelectValue placeholder="Select…" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{EXPENSE_CATEGORIES.map((c) => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Client (optional)</Label>
|
||||
<Select value={form.clientId || "none"} onValueChange={(v) => setForm((p) => ({ ...p, clientId: v === "none" ? "" : v }))}>
|
||||
<SelectTrigger><SelectValue placeholder="No client" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No client</SelectItem>
|
||||
{clients.map((c) => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox checked={form.billable} onCheckedChange={(v) => setForm((p) => ({ ...p, billable: !!v }))} />
|
||||
<span className="text-sm">Billable</span>
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox checked={form.reimbursable} onCheckedChange={(v) => setForm((p) => ({ ...p, reimbursable: !!v }))} />
|
||||
<span className="text-sm">Reimbursable</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notes (optional)</Label>
|
||||
<Input value={form.notes} onChange={(e) => setForm((p) => ({ ...p, notes: e.target.value }))} placeholder="Additional details…" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
|
||||
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Add Expense"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete dialog */}
|
||||
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Expense</DialogTitle>
|
||||
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}>
|
||||
{del.isPending ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { ColumnDef, Row } from "@tanstack/react-table";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { StatusBadge, type StatusType } from "~/components/data/status-badge";
|
||||
import { PDFDownloadButton } from "~/app/dashboard/invoices/[id]/_components/pdf-download-button";
|
||||
@@ -16,13 +17,19 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Eye, Edit, Trash2, FileText } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Eye, Edit, Trash2, FileText, CheckCircle, Send, ChevronDown } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import { formatCurrency } from "~/lib/currency";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
|
||||
// Type for invoice data
|
||||
interface Invoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
@@ -33,32 +40,16 @@ interface Invoice {
|
||||
status: string;
|
||||
totalAmount: number;
|
||||
taxRate: number;
|
||||
currency: string;
|
||||
notes: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date | null;
|
||||
client?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
} | null;
|
||||
business?: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
} | null;
|
||||
client?: { id: string; name: string; email: string | null; phone: string | null } | null;
|
||||
business?: { id: string; name: string; email: string | null; phone: string | null } | null;
|
||||
items?: Array<{
|
||||
id: string;
|
||||
invoiceId: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
hours: number;
|
||||
rate: number;
|
||||
amount: number;
|
||||
position: number;
|
||||
createdAt: Date;
|
||||
id: string; invoiceId: string; date: Date; description: string;
|
||||
hours: number; rate: number; amount: number; position: number; createdAt: Date;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
@@ -66,67 +57,74 @@ interface InvoicesDataTableProps {
|
||||
invoices: Invoice[];
|
||||
}
|
||||
|
||||
const getStatusType = (invoice: Invoice): StatusType => {
|
||||
return getEffectiveInvoiceStatus(
|
||||
invoice.status as StoredInvoiceStatus,
|
||||
invoice.dueDate,
|
||||
) as StatusType;
|
||||
};
|
||||
const getStatusType = (invoice: Invoice): StatusType =>
|
||||
getEffectiveInvoiceStatus(invoice.status as StoredInvoiceStatus, invoice.dueDate) as StatusType;
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(new Date(date));
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(amount);
|
||||
};
|
||||
const formatDate = (date: Date) =>
|
||||
new Intl.DateTimeFormat("en-US", { month: "short", day: "2-digit", year: "numeric" }).format(new Date(date));
|
||||
|
||||
export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
const router = useRouter();
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [invoiceToDelete, setInvoiceToDelete] = useState<Invoice | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [pendingBulkDelete, setPendingBulkDelete] = useState<Invoice[]>([]);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteInvoice = api.invoices.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Invoice deleted successfully");
|
||||
toast.success("Invoice deleted");
|
||||
void utils.invoices.getAll.invalidate();
|
||||
setDeleteDialogOpen(false);
|
||||
setInvoiceToDelete(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message ?? "Failed to delete invoice");
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to delete invoice"),
|
||||
});
|
||||
|
||||
const handleRowClick = (invoice: Invoice) => {
|
||||
router.push(`/dashboard/invoices/${invoice.id}`);
|
||||
};
|
||||
const bulkDelete = api.invoices.bulkDelete.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${data.deleted} invoice${data.deleted !== 1 ? "s" : ""} deleted`);
|
||||
void utils.invoices.getAll.invalidate();
|
||||
setBulkDeleteDialogOpen(false);
|
||||
setPendingBulkDelete([]);
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to delete invoices"),
|
||||
});
|
||||
|
||||
const handleDelete = (invoice: Invoice) => {
|
||||
setInvoiceToDelete(invoice);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (invoiceToDelete) {
|
||||
deleteInvoice.mutate({ id: invoiceToDelete.id });
|
||||
}
|
||||
};
|
||||
const bulkUpdateStatus = api.invoices.bulkUpdateStatus.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${data.updated} invoice${data.updated !== 1 ? "s" : ""} updated`);
|
||||
void utils.invoices.getAll.invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(e.message ?? "Failed to update invoices"),
|
||||
});
|
||||
|
||||
const columns: ColumnDef<Invoice>[] = [
|
||||
{
|
||||
accessorKey: "client.name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Client" />
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
|
||||
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
|
||||
aria-label="Select all"
|
||||
data-action-button="true"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: Row<Invoice> }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(v) => row.toggleSelected(!!v)}
|
||||
aria-label="Select row"
|
||||
data-action-button="true"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "client.name",
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Client" />,
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
return (
|
||||
@@ -135,20 +133,12 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
<FileText className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">
|
||||
{invoice.client?.name ?? "—"}
|
||||
</p>
|
||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
||||
{invoice.invoiceNumber}
|
||||
</p>
|
||||
{/* Show status + amount inline on mobile only */}
|
||||
<p className="truncate font-medium">{invoice.client?.name ?? "—"}</p>
|
||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">{invoice.invoiceNumber}</p>
|
||||
<div className="mt-1 flex items-center gap-2 sm:hidden">
|
||||
<StatusBadge
|
||||
status={getStatusType(invoice)}
|
||||
className="text-xs"
|
||||
/>
|
||||
<StatusBadge status={getStatusType(invoice)} className="text-xs" />
|
||||
<span className="text-foreground text-xs font-semibold">
|
||||
{formatCurrency(invoice.totalAmount)}
|
||||
{formatCurrency(invoice.totalAmount, invoice.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,69 +148,38 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
},
|
||||
{
|
||||
accessorKey: "issueDate",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Date" />
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">{formatDate(row.getValue("issueDate") as Date)}</p>
|
||||
<p className="text-muted-foreground truncate text-xs">Due {formatDate(new Date(row.original.dueDate))}</p>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("issueDate");
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">{formatDate(date as Date)}</p>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
Due {formatDate(new Date(row.original.dueDate))}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge
|
||||
status={getStatusType(row.original)}
|
||||
className={getStatusType(row.original) === "sent" ? "status-pending" : ""}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const invoice = row.original;
|
||||
return (
|
||||
<StatusBadge
|
||||
status={getStatusType(invoice)}
|
||||
className={
|
||||
getStatusType(invoice) === "sent" ? "status-pending" : ""
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value: string[]) => {
|
||||
const invoice = row.original;
|
||||
const status = getStatusType(invoice);
|
||||
return value.includes(status);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
filterFn: (row, _id, value: string[]) => value.includes(getStatusType(row.original)),
|
||||
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" },
|
||||
},
|
||||
{
|
||||
accessorKey: "totalAmount",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Amount" />
|
||||
header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold">
|
||||
{formatCurrency(row.getValue("totalAmount") as number, row.original.currency)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">{row.original.items?.length ?? 0} items</p>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const amount = row.getValue("totalAmount");
|
||||
return (
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold">
|
||||
{formatCurrency(amount as number)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{row.original.items?.length ?? 0} items
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
headerClassName: "hidden sm:table-cell",
|
||||
cellClassName: "hidden sm:table-cell",
|
||||
},
|
||||
meta: { headerClassName: "hidden sm:table-cell", cellClassName: "hidden sm:table-cell" },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
@@ -229,33 +188,19 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Link href={`/dashboard/invoices/${invoice.id}`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover-scale h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/dashboard/invoices/${invoice.id}/edit`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="hover-scale h-8 w-8 p-0"
|
||||
data-action-button="true"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="hover-scale h-8 w-8 p-0" data-action-button="true">
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
variant="ghost" size="sm"
|
||||
className="hover-scale text-destructive hover:text-destructive/80 h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(invoice);
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); setInvoiceToDelete(invoice); setDeleteDialogOpen(true); }}
|
||||
data-action-button="true"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
@@ -292,10 +237,68 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
searchKey="invoiceNumber"
|
||||
searchPlaceholder="Search invoices..."
|
||||
filterableColumns={filterableColumns}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClick={(invoice) => router.push(`/dashboard/invoices/${invoice.id}`)}
|
||||
selectionActions={(selected, clear) => (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={bulkUpdateStatus.isPending}>
|
||||
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||
Mark as
|
||||
<ChevronDown className="ml-1.5 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
bulkUpdateStatus.mutate(
|
||||
{ ids: selected.map((i) => i.id), status: "sent" },
|
||||
{ onSuccess: clear },
|
||||
)
|
||||
}
|
||||
>
|
||||
<Send className="mr-2 h-4 w-4" /> Mark Sent
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
bulkUpdateStatus.mutate(
|
||||
{ ids: selected.map((i) => i.id), status: "paid" },
|
||||
{ onSuccess: clear },
|
||||
)
|
||||
}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" /> Mark Paid
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
bulkUpdateStatus.mutate(
|
||||
{ ids: selected.map((i) => i.id), status: "draft" },
|
||||
{ onSuccess: clear },
|
||||
)
|
||||
}
|
||||
>
|
||||
<FileText className="mr-2 h-4 w-4" /> Mark Draft
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={bulkDelete.isPending}
|
||||
onClick={() => {
|
||||
setPendingBulkDelete(selected);
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete ({selected.length})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{/* Single delete dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
@@ -303,21 +306,16 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete invoice{" "}
|
||||
<strong>{invoiceToDelete?.invoiceNumber}</strong> for{" "}
|
||||
<strong>{invoiceToDelete?.client?.name}</strong>? This action
|
||||
cannot be undone.
|
||||
<strong>{invoiceToDelete?.client?.name}</strong>? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
disabled={deleteInvoice.isPending}
|
||||
>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleteInvoice.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
onClick={() => invoiceToDelete && deleteInvoice.mutate({ id: invoiceToDelete.id })}
|
||||
disabled={deleteInvoice.isPending}
|
||||
>
|
||||
{deleteInvoice.isPending ? "Deleting..." : "Delete Invoice"}
|
||||
@@ -325,6 +323,31 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk delete dialog */}
|
||||
<Dialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete {pendingBulkDelete.length} Invoice{pendingBulkDelete.length !== 1 ? "s" : ""}</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete {pendingBulkDelete.length} invoice{pendingBulkDelete.length !== 1 ? "s" : ""}.
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setBulkDeleteDialogOpen(false)} disabled={bulkDelete.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => bulkDelete.mutate({ ids: pendingBulkDelete.map((i) => i.id) })}
|
||||
disabled={bulkDelete.isPending}
|
||||
>
|
||||
{bulkDelete.isPending ? "Deleting..." : `Delete ${pendingBulkDelete.length} Invoice${pendingBulkDelete.length !== 1 ? "s" : ""}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "~/components/ui/tabs";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Pencil, Trash2, FileText, Star } from "lucide-react";
|
||||
|
||||
interface TemplateForm {
|
||||
name: string;
|
||||
type: "notes" | "terms";
|
||||
content: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
const defaultForm: TemplateForm = { name: "", type: "notes", content: "", isDefault: false };
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<TemplateForm>(defaultForm);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<"notes" | "terms">("notes");
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: templates = [], isLoading } = api.invoiceTemplates.getAll.useQuery();
|
||||
|
||||
const create = api.invoiceTemplates.create.useMutation({
|
||||
onSuccess: () => { toast.success("Template created"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setForm(defaultForm); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const update = api.invoiceTemplates.update.useMutation({
|
||||
onSuccess: () => { toast.success("Template updated"); void utils.invoiceTemplates.getAll.invalidate(); setOpen(false); setEditId(null); setForm(defaultForm); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
const del = api.invoiceTemplates.delete.useMutation({
|
||||
onSuccess: () => { toast.success("Template deleted"); void utils.invoiceTemplates.getAll.invalidate(); setDeleteId(null); },
|
||||
onError: (e) => toast.error(e.message),
|
||||
});
|
||||
|
||||
const handleOpen = (type: "notes" | "terms") => {
|
||||
setEditId(null);
|
||||
setForm({ ...defaultForm, type });
|
||||
setOpen(true);
|
||||
};
|
||||
const handleEdit = (t: typeof templates[0]) => {
|
||||
setEditId(t.id);
|
||||
setForm({ name: t.name, type: t.type as "notes" | "terms", content: t.content, isDefault: t.isDefault });
|
||||
setOpen(true);
|
||||
};
|
||||
const handleSubmit = () => {
|
||||
if (!form.name.trim()) { toast.error("Name is required"); return; }
|
||||
if (!form.content.trim()) { toast.error("Content is required"); return; }
|
||||
if (editId) update.mutate({ id: editId, ...form });
|
||||
else create.mutate(form);
|
||||
};
|
||||
|
||||
const notesTemplates = templates.filter((t) => t.type === "notes");
|
||||
const termsTemplates = templates.filter((t) => t.type === "terms");
|
||||
|
||||
const TemplateList = ({ items, type }: { items: typeof templates; type: "notes" | "terms" }) => (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => handleOpen(type)}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" /> New {type === "notes" ? "Notes" : "Terms"} Template
|
||||
</Button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">Loading…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
No {type} templates yet.
|
||||
</div>
|
||||
) : (
|
||||
items.map((t) => (
|
||||
<Card key={t.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{t.name}</p>
|
||||
{t.isDefault && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Star className="mr-1 h-3 w-3" /> Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 line-clamp-3 text-sm whitespace-pre-wrap">
|
||||
{t.content}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => handleEdit(t)}>
|
||||
<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(t.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6 pb-6">
|
||||
<PageHeader
|
||||
title="Invoice Templates"
|
||||
description="Reusable notes and payment terms for your invoices"
|
||||
variant="gradient"
|
||||
/>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "notes" | "terms")}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="notes">
|
||||
<FileText className="mr-1.5 h-4 w-4" /> Notes ({notesTemplates.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="terms">
|
||||
<FileText className="mr-1.5 h-4 w-4" /> Terms ({termsTemplates.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="notes" className="mt-4">
|
||||
<TemplateList items={notesTemplates} type="notes" />
|
||||
</TabsContent>
|
||||
<TabsContent value="terms" className="mt-4">
|
||||
<TemplateList items={termsTemplates} type="terms" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Create/Edit dialog */}
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editId ? "Edit Template" : "New Template"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Name *</Label>
|
||||
<Input value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Standard Payment Terms" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Tabs value={form.type} onValueChange={(v) => setForm((p) => ({ ...p, type: v as "notes" | "terms" }))}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="notes">Notes</TabsTrigger>
|
||||
<TabsTrigger value="terms">Terms</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Content *</Label>
|
||||
<Textarea
|
||||
value={form.content}
|
||||
onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))}
|
||||
placeholder="Template content…"
|
||||
className="min-h-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox checked={form.isDefault} onCheckedChange={(v) => setForm((p) => ({ ...p, isDefault: !!v }))} />
|
||||
<span className="text-sm">Set as default for {form.type}</span>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={create.isPending || update.isPending}>
|
||||
{create.isPending || update.isPending ? "Saving…" : editId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete dialog */}
|
||||
<Dialog open={!!deleteId} onOpenChange={(o) => !o && setDeleteId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Template</DialogTitle>
|
||||
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={() => deleteId && del.mutate({ id: deleteId })} disabled={del.isPending}>
|
||||
{del.isPending ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { StatusBadge } from "~/components/data/status-badge";
|
||||
import { formatCurrency } from "~/lib/currency";
|
||||
import { getEffectiveInvoiceStatus } from "~/lib/invoice-status";
|
||||
import type { StoredInvoiceStatus } from "~/types/invoice";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { TrendingUp, DollarSign, Clock, Users } from "lucide-react";
|
||||
|
||||
export default function ReportsPage() {
|
||||
const { data: invoices = [], isLoading } = api.invoices.getAll.useQuery();
|
||||
const { data: stats } = api.dashboard.getStats.useQuery();
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const reportData = useMemo(() => {
|
||||
if (!invoices.length) return null;
|
||||
|
||||
// Revenue by month (last 12 months)
|
||||
const monthMap: Record<string, number> = {};
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
monthMap[key] = 0;
|
||||
}
|
||||
|
||||
let totalRevenue = 0;
|
||||
let totalPending = 0;
|
||||
let totalHours = 0;
|
||||
let overdueCount = 0;
|
||||
|
||||
for (const inv of invoices) {
|
||||
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||
if (status === "paid") {
|
||||
totalRevenue += inv.totalAmount;
|
||||
const key = `${new Date(inv.issueDate).getFullYear()}-${String(new Date(inv.issueDate).getMonth() + 1).padStart(2, "0")}`;
|
||||
if (monthMap[key] !== undefined) monthMap[key] += inv.totalAmount;
|
||||
} else if (status === "sent" || status === "overdue") {
|
||||
totalPending += inv.totalAmount;
|
||||
}
|
||||
if (status === "overdue") overdueCount++;
|
||||
totalHours += (inv.items ?? []).reduce((s, item) => s + item.hours, 0);
|
||||
}
|
||||
|
||||
const revenueByMonth = Object.entries(monthMap).map(([month, revenue]) => ({
|
||||
month: new Date(month + "-01").toLocaleDateString("en-US", { month: "short", year: "2-digit" }),
|
||||
revenue,
|
||||
}));
|
||||
|
||||
// Top clients by revenue (paid only)
|
||||
const clientMap: Record<string, { name: string; revenue: number; count: number }> = {};
|
||||
for (const inv of invoices) {
|
||||
const status = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||
if (status === "paid" && inv.client) {
|
||||
const id = inv.client.id;
|
||||
if (!clientMap[id]) clientMap[id] = { name: inv.client.name, revenue: 0, count: 0 };
|
||||
clientMap[id]!.revenue += inv.totalAmount;
|
||||
clientMap[id]!.count += 1;
|
||||
}
|
||||
}
|
||||
const topClients = Object.values(clientMap)
|
||||
.sort((a, b) => b.revenue - a.revenue)
|
||||
.slice(0, 6);
|
||||
|
||||
// Status breakdown
|
||||
const statusCount: Record<string, number> = { draft: 0, sent: 0, paid: 0, overdue: 0 };
|
||||
for (const inv of invoices) {
|
||||
const s = getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate);
|
||||
statusCount[s] = (statusCount[s] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return { revenueByMonth, topClients, totalRevenue, totalPending, totalHours, overdueCount, statusCount };
|
||||
}, [invoices]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="page-enter space-y-6">
|
||||
<PageHeader title="Reports" description="Revenue and invoice analytics" variant="gradient" />
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => <div key={i} className="bg-muted h-24 animate-pulse rounded-xl" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const avgInvoice = invoices.length > 0 ? (reportData?.totalRevenue ?? 0) / invoices.filter((i) => getEffectiveInvoiceStatus(i.status as StoredInvoiceStatus, i.dueDate) === "paid").length || 0 : 0;
|
||||
|
||||
return (
|
||||
<div className="page-enter space-y-6 pb-6">
|
||||
<PageHeader title="Reports" description="Revenue and invoice analytics" variant="gradient" />
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 rounded p-1.5">
|
||||
<DollarSign className="text-primary h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs font-medium">Total Revenue</p>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(reportData?.totalRevenue ?? 0)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-yellow-500/10 rounded p-1.5">
|
||||
<Clock className="h-4 w-4 text-yellow-500" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs font-medium">Pending</p>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(reportData?.totalPending ?? 0)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-blue-500/10 rounded p-1.5">
|
||||
<TrendingUp className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs font-medium">Avg Invoice</p>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">{formatCurrency(isNaN(avgInvoice) ? 0 : avgInvoice)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-green-500/10 rounded p-1.5">
|
||||
<Users className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs font-medium">Total Hours</p>
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-bold">{(reportData?.totalHours ?? 0).toFixed(1)}h</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Revenue trend chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" /> Revenue (Last 12 Months)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-48 w-full md:h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={reportData?.revenueByMonth ?? []}>
|
||||
<defs>
|
||||
<linearGradient id="revenueGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="hsl(142, 76%, 36%)" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
||||
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
||||
<Area type="monotone" dataKey="revenue" stroke="hsl(142, 76%, 36%)" fill="url(#revenueGrad)" strokeWidth={2} dot={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Top clients */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" /> Top Clients by Revenue
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!reportData?.topClients.length ? (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">No paid invoices yet.</p>
|
||||
) : (
|
||||
<div className="h-48 md:h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={reportData.topClients} layout="vertical">
|
||||
<XAxis type="number" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
|
||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} width={80} />
|
||||
<Tooltip formatter={(v: number) => [formatCurrency(v), "Revenue"]} contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
|
||||
<Bar dataKey="revenue" fill="hsl(142, 76%, 36%)" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Invoice status breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invoice Status Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Object.entries(reportData?.statusCount ?? {}).map(([status, count]) => (
|
||||
<div key={status} className="flex items-center justify-between">
|
||||
<StatusBadge status={status as never} />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted h-2 w-24 overflow-hidden rounded-full sm:w-32">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full"
|
||||
style={{ width: `${invoices.length ? (count / invoices.length) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground w-8 text-right text-sm">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{invoices.length === 0 && (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">No invoices yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Monthly stats table */}
|
||||
{stats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="divide-y">
|
||||
{stats.recentInvoices.map((inv) => (
|
||||
<div key={inv.id} className="flex items-center justify-between py-3">
|
||||
<div>
|
||||
<p className="font-medium">{inv.client?.name ?? "—"}</p>
|
||||
<p className="text-muted-foreground text-xs">{new Date(inv.issueDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={getEffectiveInvoiceStatus(inv.status as StoredInvoiceStatus, inv.dueDate) as never} />
|
||||
<p className="font-semibold">{formatCurrency(inv.totalAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,11 @@ interface DataTableProps<TData, TValue> {
|
||||
options: { label: string; value: string }[];
|
||||
}[];
|
||||
onRowClick?: (row: TData) => void;
|
||||
/** Render bulk-action buttons when rows are selected. Receives selected rows and a clear function. */
|
||||
selectionActions?: (
|
||||
selectedRows: TData[],
|
||||
clearSelection: () => void,
|
||||
) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
@@ -89,6 +94,7 @@ export function DataTable<TData, TValue>({
|
||||
actions,
|
||||
filterableColumns = [],
|
||||
onRowClick,
|
||||
selectionActions,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
|
||||
@@ -335,6 +341,23 @@ export function DataTable<TData, TValue>({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Selection Toolbar */}
|
||||
{selectionActions && table.getSelectedRowModel().rows.length > 0 && (
|
||||
<Card className="bg-primary/5 border-primary/20 border py-2">
|
||||
<CardContent className="flex items-center justify-between gap-3 px-3 py-0">
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{table.getSelectedRowModel().rows.length} selected
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectionActions(
|
||||
table.getSelectedRowModel().rows.map((r) => r.original),
|
||||
() => table.resetRowSelection(),
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Table Content Card */}
|
||||
<Card className="bg-card border-border overflow-hidden border p-0">
|
||||
<div className="w-full overflow-x-auto">
|
||||
|
||||
@@ -28,6 +28,14 @@ import {
|
||||
VALIDATION_MESSAGES,
|
||||
PLACEHOLDERS,
|
||||
} from "~/lib/form-constants";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||
|
||||
interface ClientFormProps {
|
||||
clientId?: string;
|
||||
@@ -45,6 +53,7 @@ interface FormData {
|
||||
postalCode: string;
|
||||
country: string;
|
||||
defaultHourlyRate: number | null;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
@@ -70,6 +79,7 @@ const initialFormData: FormData = {
|
||||
postalCode: "",
|
||||
country: "United States",
|
||||
defaultHourlyRate: null,
|
||||
currency: "USD",
|
||||
};
|
||||
|
||||
export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
@@ -120,6 +130,7 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
postalCode: client.postalCode ?? "",
|
||||
country: client.country ?? "United States",
|
||||
defaultHourlyRate: client.defaultHourlyRate ?? null,
|
||||
currency: client.currency ?? "USD",
|
||||
});
|
||||
}
|
||||
}, [client, mode]);
|
||||
@@ -468,6 +479,30 @@ export function ClientForm({ clientId, mode }: ClientFormProps) {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currency" className="text-sm font-medium">
|
||||
Currency
|
||||
</Label>
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
Default currency for invoices created for this client.
|
||||
</p>
|
||||
<Select
|
||||
value={formData.currency}
|
||||
onValueChange={(v) => handleInputChange("currency", v)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_CURRENCIES.map((c) => (
|
||||
<SelectItem key={c.code} value={c.code}>
|
||||
{c.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,15 @@ import { InvoiceLineItems } from "./invoice-line-items";
|
||||
import { InvoiceCalendarView } from "./invoice-calendar-view";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { Save, Calendar as CalendarIcon, Tag, User, List } from "lucide-react";
|
||||
import { Save, Calendar as CalendarIcon, Tag, User, List, FileText, ChevronDown } from "lucide-react";
|
||||
import { SUPPORTED_CURRENCIES } from "~/lib/currency";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -71,6 +79,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
status: "draft",
|
||||
notes: "",
|
||||
taxRate: 0,
|
||||
currency: "USD",
|
||||
defaultHourlyRate: null,
|
||||
items: [
|
||||
{
|
||||
@@ -92,6 +101,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
// Queries (Same as before)
|
||||
const { data: clients, isLoading: loadingClients } =
|
||||
api.clients.getAll.useQuery();
|
||||
const { data: noteTemplates } = api.invoiceTemplates.getByType.useQuery({ type: "notes" });
|
||||
const { data: businesses, isLoading: loadingBusinesses } =
|
||||
api.businesses.getAll.useQuery();
|
||||
const { data: existingInvoice, isLoading: loadingInvoice } =
|
||||
@@ -137,6 +147,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
status: existingInvoice.status as "draft" | "sent" | "paid",
|
||||
notes: existingInvoice.notes ?? "",
|
||||
taxRate: existingInvoice.taxRate,
|
||||
currency: existingInvoice.currency ?? "USD",
|
||||
defaultHourlyRate: existingInvoice.client?.defaultHourlyRate ?? null,
|
||||
items:
|
||||
mappedItems.length > 0
|
||||
@@ -329,6 +340,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
status: formData.status,
|
||||
notes: formData.notes,
|
||||
taxRate: formData.taxRate,
|
||||
currency: formData.currency,
|
||||
items: formData.items
|
||||
.sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
|
||||
@@ -432,26 +444,23 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
value={formData.clientId}
|
||||
onValueChange={(v) => {
|
||||
updateField("clientId", v);
|
||||
// Auto-fill Hourly Rate
|
||||
const selectedClient = clients?.find((c) => c.id === v);
|
||||
const currentBusiness = businesses?.find(
|
||||
(b) => b.id === formData.businessId,
|
||||
);
|
||||
// Explicitly prioritize client rate, then business rate, then 0
|
||||
const clientRate =
|
||||
selectedClient && "defaultHourlyRate" in selectedClient
|
||||
? selectedClient.defaultHourlyRate
|
||||
: null;
|
||||
const businessRate =
|
||||
currentBusiness &&
|
||||
"defaultHourlyRate" in currentBusiness
|
||||
currentBusiness && "defaultHourlyRate" in currentBusiness
|
||||
? currentBusiness.defaultHourlyRate
|
||||
: null;
|
||||
const rateToSet: number = (clientRate ??
|
||||
businessRate ??
|
||||
0) as number;
|
||||
|
||||
updateField("defaultHourlyRate", rateToSet);
|
||||
updateField("defaultHourlyRate", (clientRate ?? businessRate ?? 0) as number);
|
||||
// Auto-fill currency from client
|
||||
if (selectedClient && "currency" in selectedClient && selectedClient.currency) {
|
||||
updateField("currency", selectedClient.currency as string);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
@@ -537,28 +546,86 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v: "draft" | "sent" | "paid") =>
|
||||
updateField("status", v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Status</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v: "draft" | "sent" | "paid") =>
|
||||
updateField("status", v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Currency</Label>
|
||||
<Select
|
||||
value={formData.currency}
|
||||
onValueChange={(v) => updateField("currency", v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SUPPORTED_CURRENCIES.map((c) => (
|
||||
<SelectItem key={c.code} value={c.code}>
|
||||
{c.code}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes card — spans both columns */}
|
||||
<Card className="h-fit lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2 text-base">
|
||||
<span className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" /> Notes
|
||||
</span>
|
||||
{noteTemplates && noteTemplates.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1 text-xs">
|
||||
Use template <ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{noteTemplates.map((t) => (
|
||||
<DropdownMenuItem
|
||||
key={t.id}
|
||||
onClick={() => updateField("notes", t.content)}
|
||||
>
|
||||
{t.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={formData.notes}
|
||||
onChange={(e) => updateField("notes", e.target.value)}
|
||||
placeholder="Add notes, payment terms, or other information for the client…"
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* ITEMS TAB */}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface InvoiceFormData {
|
||||
status: "draft" | "sent" | "paid";
|
||||
notes: string;
|
||||
taxRate: number;
|
||||
currency: string;
|
||||
defaultHourlyRate: number | null;
|
||||
items: InvoiceItem[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
export const SUPPORTED_CURRENCIES = [
|
||||
{ code: "USD", label: "USD – US Dollar" },
|
||||
{ code: "EUR", label: "EUR – Euro" },
|
||||
{ code: "GBP", label: "GBP – British Pound" },
|
||||
{ code: "CAD", label: "CAD – Canadian Dollar" },
|
||||
{ code: "AUD", label: "AUD – Australian Dollar" },
|
||||
{ code: "NZD", label: "NZD – New Zealand Dollar" },
|
||||
{ code: "CHF", label: "CHF – Swiss Franc" },
|
||||
{ code: "JPY", label: "JPY – Japanese Yen" },
|
||||
{ code: "SGD", label: "SGD – Singapore Dollar" },
|
||||
{ code: "HKD", label: "HKD – Hong Kong Dollar" },
|
||||
{ code: "SEK", label: "SEK – Swedish Krona" },
|
||||
{ code: "NOK", label: "NOK – Norwegian Krone" },
|
||||
{ code: "DKK", label: "DKK – Danish Krone" },
|
||||
{ code: "MXN", label: "MXN – Mexican Peso" },
|
||||
{ code: "BRL", label: "BRL – Brazilian Real" },
|
||||
{ code: "INR", label: "INR – Indian Rupee" },
|
||||
{ code: "ZAR", label: "ZAR – South African Rand" },
|
||||
] as const;
|
||||
|
||||
export type CurrencyCode = (typeof SUPPORTED_CURRENCIES)[number]["code"];
|
||||
|
||||
export function formatCurrency(amount: number, currency = "USD"): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
Users,
|
||||
FileText,
|
||||
Building,
|
||||
Receipt,
|
||||
BarChart2,
|
||||
} from "lucide-react";
|
||||
|
||||
export interface NavLink {
|
||||
@@ -25,6 +27,8 @@ export const navigationConfig: NavSection[] = [
|
||||
{ name: "Clients", href: "/dashboard/clients", icon: Users },
|
||||
{ name: "Businesses", href: "/dashboard/businesses", icon: Building },
|
||||
{ name: "Invoices", href: "/dashboard/invoices", icon: FileText },
|
||||
{ name: "Expenses", href: "/dashboard/expenses", icon: Receipt },
|
||||
{ name: "Reports", href: "/dashboard/reports", icon: BarChart2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,8 @@ import { invoicesRouter } from "~/server/api/routers/invoices";
|
||||
import { settingsRouter } from "~/server/api/routers/settings";
|
||||
import { emailRouter } from "~/server/api/routers/email";
|
||||
import { dashboardRouter } from "~/server/api/routers/dashboard";
|
||||
import { expensesRouter } from "~/server/api/routers/expenses";
|
||||
import { invoiceTemplatesRouter } from "~/server/api/routers/invoiceTemplates";
|
||||
import { createCallerFactory, createTRPCRouter } from "~/server/api/trpc";
|
||||
|
||||
/**
|
||||
@@ -18,6 +20,8 @@ export const appRouter = createTRPCRouter({
|
||||
settings: settingsRouter,
|
||||
email: emailRouter,
|
||||
dashboard: dashboardRouter,
|
||||
expenses: expensesRouter,
|
||||
invoiceTemplates: invoiceTemplatesRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -43,6 +43,7 @@ const createClientSchema = z.object({
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
defaultHourlyRate: z.number().min(0, "Rate must be positive").optional(),
|
||||
currency: z.string().length(3).default("USD").optional(),
|
||||
});
|
||||
|
||||
const updateClientSchema = createClientSchema.partial().extend({
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { z } from "zod";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { expenses, clients, businesses, invoices } from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export const EXPENSE_CATEGORIES = [
|
||||
"Travel",
|
||||
"Meals & Entertainment",
|
||||
"Software & Subscriptions",
|
||||
"Hardware & Equipment",
|
||||
"Office Supplies",
|
||||
"Marketing",
|
||||
"Professional Services",
|
||||
"Utilities",
|
||||
"Other",
|
||||
] as const;
|
||||
|
||||
const createExpenseSchema = z.object({
|
||||
date: z.date(),
|
||||
description: z.string().min(1, "Description is required"),
|
||||
amount: z.number().min(0, "Amount must be positive"),
|
||||
currency: z.string().length(3).default("USD"),
|
||||
category: z.string().optional().or(z.literal("")),
|
||||
billable: z.boolean().default(false),
|
||||
reimbursable: z.boolean().default(false),
|
||||
notes: z.string().optional().or(z.literal("")),
|
||||
clientId: z.string().optional().or(z.literal("")),
|
||||
businessId: z.string().optional().or(z.literal("")),
|
||||
invoiceId: z.string().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
const updateExpenseSchema = createExpenseSchema.partial().extend({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const expensesRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db.query.expenses.findMany({
|
||||
where: eq(expenses.createdById, ctx.session.user.id),
|
||||
with: { client: true, business: true, invoice: true },
|
||||
orderBy: [desc(expenses.date)],
|
||||
});
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const expense = await ctx.db.query.expenses.findFirst({
|
||||
where: and(
|
||||
eq(expenses.id, input.id),
|
||||
eq(expenses.createdById, ctx.session.user.id),
|
||||
),
|
||||
with: { client: true, business: true, invoice: true },
|
||||
});
|
||||
|
||||
if (!expense) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
|
||||
}
|
||||
|
||||
return expense;
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(createExpenseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const clean = {
|
||||
...input,
|
||||
clientId: input.clientId?.trim() || null,
|
||||
businessId: input.businessId?.trim() || null,
|
||||
invoiceId: input.invoiceId?.trim() || null,
|
||||
category: input.category?.trim() || null,
|
||||
notes: input.notes?.trim() || null,
|
||||
};
|
||||
|
||||
if (clean.clientId) {
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: and(
|
||||
eq(clients.id, clean.clientId),
|
||||
eq(clients.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!client) throw new TRPCError({ code: "FORBIDDEN", message: "Client not found" });
|
||||
}
|
||||
|
||||
if (clean.businessId) {
|
||||
const business = await ctx.db.query.businesses.findFirst({
|
||||
where: and(
|
||||
eq(businesses.id, clean.businessId),
|
||||
eq(businesses.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!business) throw new TRPCError({ code: "FORBIDDEN", message: "Business not found" });
|
||||
}
|
||||
|
||||
if (clean.invoiceId) {
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: and(
|
||||
eq(invoices.id, clean.invoiceId),
|
||||
eq(invoices.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!invoice) throw new TRPCError({ code: "FORBIDDEN", message: "Invoice not found" });
|
||||
}
|
||||
|
||||
const [expense] = await ctx.db
|
||||
.insert(expenses)
|
||||
.values({ ...clean, createdById: ctx.session.user.id })
|
||||
.returning();
|
||||
|
||||
return expense;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(updateExpenseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
|
||||
const existing = await ctx.db.query.expenses.findFirst({
|
||||
where: and(
|
||||
eq(expenses.id, id),
|
||||
eq(expenses.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
|
||||
}
|
||||
|
||||
const clean = {
|
||||
...data,
|
||||
clientId: data.clientId?.trim() || null,
|
||||
businessId: data.businessId?.trim() || null,
|
||||
invoiceId: data.invoiceId?.trim() || null,
|
||||
category: data.category?.trim() || null,
|
||||
notes: data.notes?.trim() || null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await ctx.db.update(expenses).set(clean).where(eq(expenses.id, id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.expenses.findFirst({
|
||||
where: and(
|
||||
eq(expenses.id, input.id),
|
||||
eq(expenses.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Expense not found" });
|
||||
}
|
||||
|
||||
await ctx.db.delete(expenses).where(eq(expenses.id, input.id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { z } from "zod";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { invoiceTemplates } from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
const createTemplateSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(255),
|
||||
type: z.enum(["notes", "terms"]).default("notes"),
|
||||
content: z.string().min(1, "Content is required"),
|
||||
isDefault: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const updateTemplateSchema = createTemplateSchema.partial().extend({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const invoiceTemplatesRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db.query.invoiceTemplates.findMany({
|
||||
where: eq(invoiceTemplates.createdById, ctx.session.user.id),
|
||||
orderBy: (t, { asc }) => [asc(t.type), asc(t.name)],
|
||||
});
|
||||
}),
|
||||
|
||||
getByType: protectedProcedure
|
||||
.input(z.object({ type: z.enum(["notes", "terms"]) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.invoiceTemplates.findMany({
|
||||
where: and(
|
||||
eq(invoiceTemplates.createdById, ctx.session.user.id),
|
||||
eq(invoiceTemplates.type, input.type),
|
||||
),
|
||||
orderBy: (t, { asc }) => [asc(t.name)],
|
||||
});
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(createTemplateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// If setting as default, unset others of same type
|
||||
if (input.isDefault) {
|
||||
await ctx.db
|
||||
.update(invoiceTemplates)
|
||||
.set({ isDefault: false })
|
||||
.where(
|
||||
and(
|
||||
eq(invoiceTemplates.createdById, ctx.session.user.id),
|
||||
eq(invoiceTemplates.type, input.type),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const [template] = await ctx.db
|
||||
.insert(invoiceTemplates)
|
||||
.values({ ...input, createdById: ctx.session.user.id })
|
||||
.returning();
|
||||
|
||||
return template;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(updateTemplateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input;
|
||||
|
||||
const existing = await ctx.db.query.invoiceTemplates.findFirst({
|
||||
where: and(
|
||||
eq(invoiceTemplates.id, id),
|
||||
eq(invoiceTemplates.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" });
|
||||
}
|
||||
|
||||
// If setting as default, unset others of same type
|
||||
if (data.isDefault) {
|
||||
const type = data.type ?? existing.type;
|
||||
await ctx.db
|
||||
.update(invoiceTemplates)
|
||||
.set({ isDefault: false })
|
||||
.where(
|
||||
and(
|
||||
eq(invoiceTemplates.createdById, ctx.session.user.id),
|
||||
eq(invoiceTemplates.type, type),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(invoiceTemplates)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(invoiceTemplates.id, id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.invoiceTemplates.findFirst({
|
||||
where: and(
|
||||
eq(invoiceTemplates.id, input.id),
|
||||
eq(invoiceTemplates.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Template not found" });
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.delete(invoiceTemplates)
|
||||
.where(eq(invoiceTemplates.id, input.id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import {
|
||||
invoices,
|
||||
@@ -29,6 +29,7 @@ const createInvoiceSchema = z.object({
|
||||
status: z.enum(["draft", "sent", "paid"]).default("draft"),
|
||||
notes: z.string().optional().or(z.literal("")),
|
||||
taxRate: z.number().min(0).max(100).default(0),
|
||||
currency: z.string().length(3).default("USD"),
|
||||
items: z.array(invoiceItemSchema).min(1, "At least one item is required"),
|
||||
});
|
||||
|
||||
@@ -410,47 +411,76 @@ export const invoicesRouter = createTRPCRouter({
|
||||
.input(updateStatusSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
// Verify invoice exists and belongs to user
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: eq(invoices.id, input.id),
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invoice not found" });
|
||||
}
|
||||
|
||||
if (invoice.createdById !== ctx.session.user.id) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to update this invoice",
|
||||
});
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "You don't have permission to update this invoice" });
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(invoices)
|
||||
.set({
|
||||
status: input.status,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.set({ status: input.status, updatedAt: new Date() })
|
||||
.where(eq(invoices.id, input.id));
|
||||
|
||||
console.log("Status update completed successfully");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Invoice status updated to ${input.status}`,
|
||||
};
|
||||
return { success: true, message: `Invoice status updated to ${input.status}` };
|
||||
} catch (error) {
|
||||
console.error("UpdateStatus error:", error);
|
||||
if (error instanceof TRPCError) throw error;
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to update invoice status",
|
||||
cause: error,
|
||||
});
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to update invoice status", cause: error });
|
||||
}
|
||||
}),
|
||||
|
||||
bulkUpdateStatus: protectedProcedure
|
||||
.input(z.object({
|
||||
ids: z.array(z.string()).min(1),
|
||||
status: z.enum(["draft", "sent", "paid"]),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Only update invoices owned by this user
|
||||
const owned = await ctx.db.query.invoices.findMany({
|
||||
where: inArray(invoices.id, input.ids),
|
||||
columns: { id: true, createdById: true },
|
||||
});
|
||||
|
||||
const ownedIds = owned
|
||||
.filter((inv) => inv.createdById === ctx.session.user.id)
|
||||
.map((inv) => inv.id);
|
||||
|
||||
if (ownedIds.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" });
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(invoices)
|
||||
.set({ status: input.status, updatedAt: new Date() })
|
||||
.where(inArray(invoices.id, ownedIds));
|
||||
|
||||
return { success: true, updated: ownedIds.length };
|
||||
}),
|
||||
|
||||
bulkDelete: protectedProcedure
|
||||
.input(z.object({ ids: z.array(z.string()).min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const owned = await ctx.db.query.invoices.findMany({
|
||||
where: inArray(invoices.id, input.ids),
|
||||
columns: { id: true, createdById: true },
|
||||
});
|
||||
|
||||
const ownedIds = owned
|
||||
.filter((inv) => inv.createdById === ctx.session.user.id)
|
||||
.map((inv) => inv.id);
|
||||
|
||||
if (ownedIds.length === 0) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "No matching invoices found" });
|
||||
}
|
||||
|
||||
await ctx.db.delete(invoices).where(inArray(invoices.id, ownedIds));
|
||||
|
||||
return { success: true, deleted: ownedIds.length };
|
||||
}),
|
||||
});
|
||||
|
||||
+103
-1
@@ -39,7 +39,9 @@ export const usersRelations = relations(users, ({ many }) => ({
|
||||
clients: many(clients),
|
||||
businesses: many(businesses),
|
||||
invoices: many(invoices),
|
||||
sessions: many(sessions), // Added missing relation
|
||||
sessions: many(sessions),
|
||||
expenses: many(expenses),
|
||||
invoiceTemplates: many(invoiceTemplates),
|
||||
}));
|
||||
|
||||
export const accounts = createTable(
|
||||
@@ -140,6 +142,7 @@ export const clients = createTable(
|
||||
postalCode: d.varchar({ length: 20 }),
|
||||
country: d.varchar({ length: 100 }),
|
||||
defaultHourlyRate: d.real(),
|
||||
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||
createdById: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
@@ -238,6 +241,7 @@ export const invoices = createTable(
|
||||
totalAmount: d.real().notNull().default(0),
|
||||
taxRate: d.real().notNull().default(0.0),
|
||||
notes: d.varchar({ length: 1000 }),
|
||||
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||
createdById: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
@@ -309,3 +313,101 @@ export const invoiceItemsRelations = relations(invoiceItems, ({ one }) => ({
|
||||
references: [invoices.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const expenses = createTable(
|
||||
"expense",
|
||||
(d) => ({
|
||||
id: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
businessId: d.varchar({ length: 255 }).references(() => businesses.id),
|
||||
clientId: d.varchar({ length: 255 }).references(() => clients.id),
|
||||
invoiceId: d
|
||||
.varchar({ length: 255 })
|
||||
.references(() => invoices.id, { onDelete: "set null" }),
|
||||
date: d.timestamp().notNull(),
|
||||
description: d.varchar({ length: 500 }).notNull(),
|
||||
amount: d.real().notNull(),
|
||||
currency: d.varchar({ length: 3 }).default("USD").notNull(),
|
||||
category: d.varchar({ length: 100 }),
|
||||
billable: d.boolean().default(false).notNull(),
|
||||
reimbursable: d.boolean().default(false).notNull(),
|
||||
notes: d.varchar({ length: 500 }),
|
||||
createdById: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||
}),
|
||||
(t) => [
|
||||
index("expense_created_by_idx").on(t.createdById),
|
||||
index("expense_client_id_idx").on(t.clientId),
|
||||
index("expense_invoice_id_idx").on(t.invoiceId),
|
||||
index("expense_date_idx").on(t.date),
|
||||
index("expense_billable_idx").on(t.billable),
|
||||
],
|
||||
);
|
||||
|
||||
export const expensesRelations = relations(expenses, ({ one }) => ({
|
||||
business: one(businesses, {
|
||||
fields: [expenses.businessId],
|
||||
references: [businesses.id],
|
||||
}),
|
||||
client: one(clients, {
|
||||
fields: [expenses.clientId],
|
||||
references: [clients.id],
|
||||
}),
|
||||
invoice: one(invoices, {
|
||||
fields: [expenses.invoiceId],
|
||||
references: [invoices.id],
|
||||
}),
|
||||
createdBy: one(users, {
|
||||
fields: [expenses.createdById],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const invoiceTemplates = createTable(
|
||||
"invoice_template",
|
||||
(d) => ({
|
||||
id: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
name: d.varchar({ length: 255 }).notNull(),
|
||||
type: d.varchar({ length: 50 }).notNull().default("notes"), // "notes" | "terms"
|
||||
content: d.text().notNull(),
|
||||
isDefault: d.boolean().default(false).notNull(),
|
||||
createdById: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: d
|
||||
.timestamp()
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: d.timestamp().$onUpdate(() => new Date()),
|
||||
}),
|
||||
(t) => [
|
||||
index("invoice_template_created_by_idx").on(t.createdById),
|
||||
index("invoice_template_type_idx").on(t.type),
|
||||
],
|
||||
);
|
||||
|
||||
export const invoiceTemplatesRelations = relations(
|
||||
invoiceTemplates,
|
||||
({ one }) => ({
|
||||
createdBy: one(users, {
|
||||
fields: [invoiceTemplates.createdById],
|
||||
references: [users.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user