From e6b79ce2c2c6c0388c6e0c30c02f4eab7ee53606 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 02:34:06 +0000 Subject: [PATCH] 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 --- drizzle/0001_supreme_the_enforcers.sql | 43 + drizzle/meta/0001_snapshot.json | 1618 +++++++++++++++++ drizzle/meta/_journal.json | 7 + src/app/dashboard/expenses/page.tsx | 273 +++ .../_components/invoices-data-table.tsx | 339 ++-- src/app/dashboard/invoices/templates/page.tsx | 214 +++ src/app/dashboard/reports/page.tsx | 261 +++ src/components/data/data-table.tsx | 23 + src/components/forms/client-form.tsx | 35 + src/components/forms/invoice-form.tsx | 125 +- src/components/forms/invoice/types.ts | 1 + src/lib/currency.ts | 30 + src/lib/navigation.ts | 4 + src/server/api/root.ts | 4 + src/server/api/routers/clients.ts | 1 + src/server/api/routers/expenses.ts | 163 ++ src/server/api/routers/invoiceTemplates.ts | 120 ++ src/server/api/routers/invoices.ts | 82 +- src/server/db/schema.ts | 104 +- 19 files changed, 3233 insertions(+), 214 deletions(-) create mode 100644 drizzle/0001_supreme_the_enforcers.sql create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 src/app/dashboard/expenses/page.tsx create mode 100644 src/app/dashboard/invoices/templates/page.tsx create mode 100644 src/app/dashboard/reports/page.tsx create mode 100644 src/lib/currency.ts create mode 100644 src/server/api/routers/expenses.ts create mode 100644 src/server/api/routers/invoiceTemplates.ts diff --git a/drizzle/0001_supreme_the_enforcers.sql b/drizzle/0001_supreme_the_enforcers.sql new file mode 100644 index 0000000..0873a66 --- /dev/null +++ b/drizzle/0001_supreme_the_enforcers.sql @@ -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"); \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..b88fdc2 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1618 @@ +{ + "id": "7dc54995-f82a-4650-a7d6-9e7e4db678ee", + "prevId": "f6c70548-143c-48a3-a0c5-85873eaaa326", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.beenvoice_account": { + "name": "beenvoice_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "beenvoice_account_userId_beenvoice_user_id_fk": { + "name": "beenvoice_account_userId_beenvoice_user_id_fk", + "tableFrom": "beenvoice_account", + "tableTo": "beenvoice_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.beenvoice_business": { + "name": "beenvoice_business", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "nickname": { + "name": "nickname", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "addressLine1": { + "name": "addressLine1", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "addressLine2": { + "name": "addressLine2", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "postalCode": { + "name": "postalCode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "website": { + "name": "website", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "taxId": { + "name": "taxId", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "logoUrl": { + "name": "logoUrl", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "isDefault": { + "name": "isDefault", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "resendApiKey": { + "name": "resendApiKey", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "resendDomain": { + "name": "resendDomain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "emailFromName": { + "name": "emailFromName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "business_created_by_idx": { + "name": "business_created_by_idx", + "columns": [ + { + "expression": "createdById", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "business_name_idx": { + "name": "business_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "business_nickname_idx": { + "name": "business_nickname_idx", + "columns": [ + { + "expression": "nickname", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "business_email_idx": { + "name": "business_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "business_is_default_idx": { + "name": "business_is_default_idx", + "columns": [ + { + "expression": "isDefault", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "beenvoice_business_createdById_beenvoice_user_id_fk": { + "name": "beenvoice_business_createdById_beenvoice_user_id_fk", + "tableFrom": "beenvoice_business", + "tableTo": "beenvoice_user", + "columnsFrom": [ + "createdById" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.beenvoice_client": { + "name": "beenvoice_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "addressLine1": { + "name": "addressLine1", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "addressLine2": { + "name": "addressLine2", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "postalCode": { + "name": "postalCode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "defaultHourlyRate": { + "name": "defaultHourlyRate", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "createdById": { + "name": "createdById", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "client_created_by_idx": { + "name": "client_created_by_idx", + "columns": [ + { + "expression": "createdById", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "client_name_idx": { + "name": "client_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "client_email_idx": { + "name": "client_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "beenvoice_client_createdById_beenvoice_user_id_fk": { + "name": "beenvoice_client_createdById_beenvoice_user_id_fk", + "tableFrom": "beenvoice_client", + "tableTo": "beenvoice_user", + "columnsFrom": [ + "createdById" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.beenvoice_expense": { + "name": "beenvoice_expense", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "businessId": { + "name": "businessId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "invoiceId": { + "name": "invoiceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "category": { + "name": "category", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "billable": { + "name": "billable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reimbursable": { + "name": "reimbursable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "expense_created_by_idx": { + "name": "expense_created_by_idx", + "columns": [ + { + "expression": "createdById", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "expense_client_id_idx": { + "name": "expense_client_id_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "expense_invoice_id_idx": { + "name": "expense_invoice_id_idx", + "columns": [ + { + "expression": "invoiceId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "expense_date_idx": { + "name": "expense_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "expense_billable_idx": { + "name": "expense_billable_idx", + "columns": [ + { + "expression": "billable", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "beenvoice_expense_businessId_beenvoice_business_id_fk": { + "name": "beenvoice_expense_businessId_beenvoice_business_id_fk", + "tableFrom": "beenvoice_expense", + "tableTo": "beenvoice_business", + "columnsFrom": [ + "businessId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "beenvoice_expense_clientId_beenvoice_client_id_fk": { + "name": "beenvoice_expense_clientId_beenvoice_client_id_fk", + "tableFrom": "beenvoice_expense", + "tableTo": "beenvoice_client", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "beenvoice_expense_invoiceId_beenvoice_invoice_id_fk": { + "name": "beenvoice_expense_invoiceId_beenvoice_invoice_id_fk", + "tableFrom": "beenvoice_expense", + "tableTo": "beenvoice_invoice", + "columnsFrom": [ + "invoiceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "beenvoice_expense_createdById_beenvoice_user_id_fk": { + "name": "beenvoice_expense_createdById_beenvoice_user_id_fk", + "tableFrom": "beenvoice_expense", + "tableTo": "beenvoice_user", + "columnsFrom": [ + "createdById" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.beenvoice_invoice_item": { + "name": "beenvoice_invoice_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "invoiceId": { + "name": "invoiceId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "hours": { + "name": "hours", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "rate": { + "name": "rate", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "invoice_item_invoice_id_idx": { + "name": "invoice_item_invoice_id_idx", + "columns": [ + { + "expression": "invoiceId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invoice_item_date_idx": { + "name": "invoice_item_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invoice_item_position_idx": { + "name": "invoice_item_position_idx", + "columns": [ + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk": { + "name": "beenvoice_invoice_item_invoiceId_beenvoice_invoice_id_fk", + "tableFrom": "beenvoice_invoice_item", + "tableTo": "beenvoice_invoice", + "columnsFrom": [ + "invoiceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.beenvoice_invoice_template": { + "name": "beenvoice_invoice_template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'notes'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "isDefault": { + "name": "isDefault", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "createdById": { + "name": "createdById", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invoice_template_created_by_idx": { + "name": "invoice_template_created_by_idx", + "columns": [ + { + "expression": "createdById", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invoice_template_type_idx": { + "name": "invoice_template_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "beenvoice_invoice_template_createdById_beenvoice_user_id_fk": { + "name": "beenvoice_invoice_template_createdById_beenvoice_user_id_fk", + "tableFrom": "beenvoice_invoice_template", + "tableTo": "beenvoice_user", + "columnsFrom": [ + "createdById" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.beenvoice_invoice": { + "name": "beenvoice_invoice", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "invoiceNumber": { + "name": "invoiceNumber", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "businessId": { + "name": "businessId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "clientId": { + "name": "clientId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "issueDate": { + "name": "issueDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "dueDate": { + "name": "dueDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "totalAmount": { + "name": "totalAmount", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "taxRate": { + "name": "taxRate", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "createdById": { + "name": "createdById", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invoice_business_id_idx": { + "name": "invoice_business_id_idx", + "columns": [ + { + "expression": "businessId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invoice_client_id_idx": { + "name": "invoice_client_id_idx", + "columns": [ + { + "expression": "clientId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invoice_created_by_idx": { + "name": "invoice_created_by_idx", + "columns": [ + { + "expression": "createdById", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invoice_number_idx": { + "name": "invoice_number_idx", + "columns": [ + { + "expression": "invoiceNumber", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invoice_status_idx": { + "name": "invoice_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "beenvoice_invoice_businessId_beenvoice_business_id_fk": { + "name": "beenvoice_invoice_businessId_beenvoice_business_id_fk", + "tableFrom": "beenvoice_invoice", + "tableTo": "beenvoice_business", + "columnsFrom": [ + "businessId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "beenvoice_invoice_clientId_beenvoice_client_id_fk": { + "name": "beenvoice_invoice_clientId_beenvoice_client_id_fk", + "tableFrom": "beenvoice_invoice", + "tableTo": "beenvoice_client", + "columnsFrom": [ + "clientId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "beenvoice_invoice_createdById_beenvoice_user_id_fk": { + "name": "beenvoice_invoice_createdById_beenvoice_user_id_fk", + "tableFrom": "beenvoice_invoice", + "tableTo": "beenvoice_user", + "columnsFrom": [ + "createdById" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.beenvoice_session": { + "name": "beenvoice_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "beenvoice_session_userId_beenvoice_user_id_fk": { + "name": "beenvoice_session_userId_beenvoice_user_id_fk", + "tableFrom": "beenvoice_session", + "tableTo": "beenvoice_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "beenvoice_session_token_unique": { + "name": "beenvoice_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.beenvoice_sso_provider": { + "name": "beenvoice_sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "redirectURI": { + "name": "redirectURI", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "oidcConfig": { + "name": "oidcConfig", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "samlConfig": { + "name": "samlConfig", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "beenvoice_sso_provider_userId_beenvoice_user_id_fk": { + "name": "beenvoice_sso_provider_userId_beenvoice_user_id_fk", + "tableFrom": "beenvoice_sso_provider", + "tableTo": "beenvoice_user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "beenvoice_sso_provider_providerId_unique": { + "name": "beenvoice_sso_provider_providerId_unique", + "nullsNotDistinct": false, + "columns": [ + "providerId" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.beenvoice_user": { + "name": "beenvoice_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "resetToken": { + "name": "resetToken", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "resetTokenExpiry": { + "name": "resetTokenExpiry", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "prefersReducedMotion": { + "name": "prefersReducedMotion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "animationSpeedMultiplier": { + "name": "animationSpeedMultiplier", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "colorTheme": { + "name": "colorTheme", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'slate'" + }, + "customColor": { + "name": "customColor", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "theme": { + "name": "theme", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'system'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "beenvoice_user_email_unique": { + "name": "beenvoice_user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.beenvoice_verification_token": { + "name": "beenvoice_verification_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_token_identifier_idx": { + "name": "verification_token_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2c528ff..226fccb 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/src/app/dashboard/expenses/page.tsx b/src/app/dashboard/expenses/page.tsx new file mode 100644 index 0000000..8d29be7 --- /dev/null +++ b/src/app/dashboard/expenses/page.tsx @@ -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(null); + const [form, setForm] = useState(defaultForm); + const [deleteId, setDeleteId] = useState(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 ( +
+ + + + + {/* Summary cards */} +
+ + +

Total

+

{formatCurrency(totalExpenses)}

+
+
+ + +

Billable

+

{formatCurrency(billableTotal)}

+
+
+ + +

Count

+

{expenses.length}

+
+
+
+ + {/* Expenses list */} + + + + All Expenses + + + + {isLoading ? ( +
Loading…
+ ) : expenses.length === 0 ? ( +
+ +

No expenses yet. Add your first expense.

+
+ ) : ( +
+ {expenses.map((expense) => ( +
+
+
+

{expense.description}

+ {expense.billable && Billable} + {expense.reimbursable && Reimbursable} + {expense.category && {expense.category}} +
+

+ {new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(expense.date))} + {expense.client ? ` · ${expense.client.name}` : ""} +

+ {expense.notes &&

{expense.notes}

} +
+
+

{formatCurrency(expense.amount, expense.currency)}

+ + +
+
+ ))} +
+ )} +
+
+ + {/* Add/Edit dialog */} + + + + {editId ? "Edit Expense" : "Add Expense"} + +
+
+ + setForm((p) => ({ ...p, description: e.target.value }))} placeholder="e.g. Laptop charger" /> +
+
+
+ + setForm((p) => ({ ...p, amount: v }))} min={0} step={0.01} /> +
+
+ + +
+
+
+
+ + setForm((p) => ({ ...p, date: d ?? new Date() }))} className="w-full" /> +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + setForm((p) => ({ ...p, notes: e.target.value }))} placeholder="Additional details…" /> +
+
+ + + + +
+
+ + {/* Delete dialog */} + !o && setDeleteId(null)}> + + + Delete Expense + This action cannot be undone. + + + + + + + +
+ ); +} diff --git a/src/app/dashboard/invoices/_components/invoices-data-table.tsx b/src/app/dashboard/invoices/_components/invoices-data-table.tsx index 84a1f6a..3867e49 100644 --- a/src/app/dashboard/invoices/_components/invoices-data-table.tsx +++ b/src/app/dashboard/invoices/_components/invoices-data-table.tsx @@ -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(null); + const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); + const [pendingBulkDelete, setPendingBulkDelete] = useState([]); 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[] = [ { - accessorKey: "client.name", - header: ({ column }) => ( - + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!v)} + aria-label="Select all" + data-action-button="true" + /> ), + cell: ({ row }: { row: Row }) => ( + row.toggleSelected(!!v)} + aria-label="Select row" + data-action-button="true" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "client.name", + header: ({ column }) => , cell: ({ row }) => { const invoice = row.original; return ( @@ -135,20 +133,12 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) {
-

- {invoice.client?.name ?? "—"} -

-

- {invoice.invoiceNumber} -

- {/* Show status + amount inline on mobile only */} +

{invoice.client?.name ?? "—"}

+

{invoice.invoiceNumber}

- + - {formatCurrency(invoice.totalAmount)} + {formatCurrency(invoice.totalAmount, invoice.currency)}
@@ -158,69 +148,38 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { }, { accessorKey: "issueDate", - header: ({ column }) => ( - + header: ({ column }) => , + cell: ({ row }) => ( +
+

{formatDate(row.getValue("issueDate") as Date)}

+

Due {formatDate(new Date(row.original.dueDate))}

+
), - cell: ({ row }) => { - const date = row.getValue("issueDate"); - return ( -
-

{formatDate(date as Date)}

-

- Due {formatDate(new Date(row.original.dueDate))} -

-
- ); - }, }, { accessorKey: "status", - header: ({ column }) => ( - + header: ({ column }) => , + cell: ({ row }) => ( + ), - cell: ({ row }) => { - const invoice = row.original; - return ( - - ); - }, - 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 }) => ( - + header: ({ column }) => , + cell: ({ row }) => ( +
+

+ {formatCurrency(row.getValue("totalAmount") as number, row.original.currency)} +

+

{row.original.items?.length ?? 0} items

+
), - cell: ({ row }) => { - const amount = row.getValue("totalAmount"); - return ( -
-

- {formatCurrency(amount as number)} -

-

- {row.original.items?.length ?? 0} items -

-
- ); - }, - 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 (
- - + + + + bulkUpdateStatus.mutate( + { ids: selected.map((i) => i.id), status: "sent" }, + { onSuccess: clear }, + ) + } + > + Mark Sent + + + bulkUpdateStatus.mutate( + { ids: selected.map((i) => i.id), status: "paid" }, + { onSuccess: clear }, + ) + } + > + Mark Paid + + + bulkUpdateStatus.mutate( + { ids: selected.map((i) => i.id), status: "draft" }, + { onSuccess: clear }, + ) + } + > + Mark Draft + + + + + + + )} /> - {/* Delete Confirmation Dialog */} + {/* Single delete dialog */} @@ -303,21 +306,16 @@ export function InvoicesDataTable({ invoices }: InvoicesDataTableProps) { Are you sure you want to delete invoice{" "} {invoiceToDelete?.invoiceNumber} for{" "} - {invoiceToDelete?.client?.name}? This action - cannot be undone. + {invoiceToDelete?.client?.name}? This action cannot be undone. - + + {/* Bulk delete dialog */} + + + + Delete {pendingBulkDelete.length} Invoice{pendingBulkDelete.length !== 1 ? "s" : ""} + + This will permanently delete {pendingBulkDelete.length} invoice{pendingBulkDelete.length !== 1 ? "s" : ""}. + This action cannot be undone. + + + + + + + + ); } diff --git a/src/app/dashboard/invoices/templates/page.tsx b/src/app/dashboard/invoices/templates/page.tsx new file mode 100644 index 0000000..1e901aa --- /dev/null +++ b/src/app/dashboard/invoices/templates/page.tsx @@ -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(null); + const [form, setForm] = useState(defaultForm); + const [deleteId, setDeleteId] = useState(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" }) => ( +
+
+ +
+ {isLoading ? ( +
Loading…
+ ) : items.length === 0 ? ( +
+ No {type} templates yet. +
+ ) : ( + items.map((t) => ( + + +
+
+
+

{t.name}

+ {t.isDefault && ( + + Default + + )} +
+

+ {t.content} +

+
+
+ + +
+
+
+
+ )) + )} +
+ ); + + return ( +
+ + + setTab(v as "notes" | "terms")}> + + + Notes ({notesTemplates.length}) + + + Terms ({termsTemplates.length}) + + + + + + + + + + + {/* Create/Edit dialog */} + + + + {editId ? "Edit Template" : "New Template"} + +
+
+ + setForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Standard Payment Terms" /> +
+
+ + setForm((p) => ({ ...p, type: v as "notes" | "terms" }))}> + + Notes + Terms + + +
+
+ +