From 1928084acbe95753d44c9f236a626d2b3bb04541 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Mon, 22 Jun 2026 16:06:11 -0400 Subject: [PATCH] Add draft-only invoicing rules, send reminders, and time clock billing. Restrict line item edits to draft invoices, auto-create drafts on clock-out, and add sendReminderAt scheduling with dashboard due reminders. Co-authored-by: Cursor --- drizzle/0015_invoice_send_reminder_at.sql | 1 + src/app/dashboard/invoices/[id]/page.tsx | 92 ++++++++++++++++- .../forms/invoice-calendar-view.tsx | 57 ++++++----- src/components/forms/invoice-form.tsx | 2 + src/components/forms/invoice-line-items.tsx | 99 ++++++++++++------- .../time-clock/time-clock-panel.tsx | 2 +- src/lib/draft-invoice.ts | 11 +++ src/lib/time-clock.ts | 2 +- src/server/api/routers/dashboard.ts | 23 ++++- src/server/api/routers/invoices.ts | 22 ++++- src/server/api/routers/time-entries.ts | 84 ++++++++++++---- src/server/db/schema.ts | 1 + 12 files changed, 311 insertions(+), 85 deletions(-) create mode 100644 drizzle/0015_invoice_send_reminder_at.sql create mode 100644 src/lib/draft-invoice.ts diff --git a/drizzle/0015_invoice_send_reminder_at.sql b/drizzle/0015_invoice_send_reminder_at.sql new file mode 100644 index 0000000..a8125e0 --- /dev/null +++ b/drizzle/0015_invoice_send_reminder_at.sql @@ -0,0 +1 @@ +ALTER TABLE "beenvoice_invoice" ADD COLUMN "send_reminder_at" timestamp; diff --git a/src/app/dashboard/invoices/[id]/page.tsx b/src/app/dashboard/invoices/[id]/page.tsx index 1abb732..d7134be 100644 --- a/src/app/dashboard/invoices/[id]/page.tsx +++ b/src/app/dashboard/invoices/[id]/page.tsx @@ -52,6 +52,7 @@ import { Separator } from "~/components/ui/separator"; import { Textarea } from "~/components/ui/textarea"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; +import { DatePicker } from "~/components/ui/date-picker"; import { getEffectiveInvoiceStatus, isInvoiceOverdue, @@ -166,6 +167,15 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { onError: (e) => toast.error(e.message ?? "Failed to send reminder"), }); + const updateInvoice = api.invoices.update.useMutation({ + onSuccess: () => { + toast.success("Reminder saved"); + void utils.invoices.getById.invalidate({ id: invoiceId }); + void utils.dashboard.getStats.invalidate(); + }, + onError: (e) => toast.error(e.message ?? "Failed to save reminder"), + }); + if (isLoading) return ; if (!invoice) notFound(); @@ -522,7 +532,7 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { {/* Right Column - Actions */}
- {effectiveStatus !== "paid" && ( + {storedStatus === "draft" && ( )} @@ -553,6 +563,25 @@ function InvoiceViewContent({ invoiceId }: { invoiceId: string }) { /> )} + {effectiveStatus === "draft" && ( + + updateInvoice.mutate({ + id: invoiceId, + sendReminderAt, + }) + } + onClear={() => + updateInvoice.mutate({ id: invoiceId, sendReminderAt: null }) + } + /> + )} + {(effectiveStatus === "sent" || effectiveStatus === "overdue") && ( string; + isSaving: boolean; + onSave: (sendReminderAt: Date | null) => void; + onClear: () => void; +}) { + const [sendReminderAt, setSendReminderAt] = useState(() => + savedReminderAt ? new Date(savedReminderAt) : undefined, + ); + + return ( +
+ + +
+ + {sendReminderAt ? ( + + ) : null} +
+ {savedReminderAt ? ( +

+ {new Date(savedReminderAt) <= new Date() + ? "Reminder is due — time to send this invoice." + : `Scheduled for ${formatDate(savedReminderAt)}`} +

+ ) : null} +
+ ); +} + export default function InvoiceViewPage() { const params = useParams(); const router = useRouter(); diff --git a/src/components/forms/invoice-calendar-view.tsx b/src/components/forms/invoice-calendar-view.tsx index 1b80889..e9d9dac 100644 --- a/src/components/forms/invoice-calendar-view.tsx +++ b/src/components/forms/invoice-calendar-view.tsx @@ -54,6 +54,7 @@ interface InvoiceCalendarViewProps { onRemoveItem: (index: number) => void; className?: string; defaultHourlyRate: number | null; + readOnly?: boolean; } export function InvoiceCalendarView({ @@ -63,6 +64,7 @@ export function InvoiceCalendarView({ onRemoveItem, className, defaultHourlyRate: _defaultHourlyRate, + readOnly = false, }: InvoiceCalendarViewProps) { const [date, setDate] = React.useState(undefined); // Start unselected const [viewDate, setViewDate] = React.useState(new Date()); // Controls the view (month/week) @@ -403,10 +405,12 @@ export function InvoiceCalendarView({ There are no time entries recorded for this day yet.

- + {!readOnly ? ( + + ) : null} ) : (
@@ -428,6 +432,7 @@ export function InvoiceCalendarView({ } placeholder="Describe the work performed..." className="pl-3 text-sm" + disabled={readOnly} />
@@ -443,6 +448,7 @@ export function InvoiceCalendarView({ step={0.25} min={0} width="full" + disabled={readOnly} />
@@ -456,6 +462,7 @@ export function InvoiceCalendarView({ min={0} step={1} width="full" + disabled={readOnly} />
@@ -464,15 +471,17 @@ export function InvoiceCalendarView({ {/* Bottom section with controls, item name, and total */}
- + {!readOnly ? ( + + ) : null}
@@ -490,16 +499,18 @@ export function InvoiceCalendarView({
))} - + {!readOnly ? ( + + ) : null} )} diff --git a/src/components/forms/invoice-form.tsx b/src/components/forms/invoice-form.tsx index f71ea4e..2dc6551 100644 --- a/src/components/forms/invoice-form.tsx +++ b/src/components/forms/invoice-form.tsx @@ -808,6 +808,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { invoiceId={invoiceId && invoiceId !== "new" ? invoiceId : undefined} clientId={formData.clientId || undefined} defaultRate={formData.items[0]?.rate} + readOnly={formData.status !== "draft"} /> @@ -831,6 +832,7 @@ export default function InvoiceForm({ invoiceId }: InvoiceFormProps) { onRemoveItem={removeItem} onUpdateItem={updateItem} defaultHourlyRate={formData.defaultHourlyRate} + readOnly={formData.status !== "draft"} /> diff --git a/src/components/forms/invoice-line-items.tsx b/src/components/forms/invoice-line-items.tsx index 48d9b46..6d6af0e 100644 --- a/src/components/forms/invoice-line-items.tsx +++ b/src/components/forms/invoice-line-items.tsx @@ -40,6 +40,7 @@ interface InvoiceLineItemsProps { clientId?: string; defaultRate?: number; className?: string; + readOnly?: boolean; } interface LineItemRowProps { @@ -55,6 +56,7 @@ interface LineItemRowProps { suggestions: LineItemSuggestion[]; onSelectSuggestion: (index: number, suggestion: LineItemSuggestion) => void; onDescriptionChange: (index: number, value: string) => void; + readOnly?: boolean; } interface DescriptionAutocompleteProps { @@ -64,6 +66,7 @@ interface DescriptionAutocompleteProps { suggestions: LineItemSuggestion[]; placeholder?: string; className?: string; + disabled?: boolean; } function DescriptionAutocomplete({ @@ -73,6 +76,7 @@ function DescriptionAutocomplete({ suggestions, placeholder, className, + disabled, }: DescriptionAutocompleteProps) { const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); @@ -116,6 +120,7 @@ function DescriptionAutocomplete({ onKeyDown={handleKeyDown} placeholder={placeholder} className={className} + disabled={disabled} /> {showDropdown && (
@@ -146,7 +151,7 @@ function DescriptionAutocomplete({ } const LineItemCard = React.forwardRef( - ({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange }, ref) => { + ({ item, index, canRemove, onRemove, onUpdate, suggestions, onSelectSuggestion, onDescriptionChange, readOnly }, ref) => { return (
( size="sm" className="w-full" inputClassName="h-9" + disabled={readOnly} /> ( suggestions={suggestions} placeholder="Describe the work performed..." className="h-9 w-full text-sm font-medium" + disabled={readOnly} /> ( width="full" className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-12" suffix="h" + disabled={readOnly} /> ( prefix="$" width="full" className="h-9 font-mono [&_button]:w-6 [&_input]:min-w-14" + disabled={readOnly} />
${(item.hours * item.rate).toFixed(2)}
- + {!readOnly ? ( + + ) : ( + + )}
); }, @@ -221,6 +234,7 @@ function MobileLineItem({ suggestions, onSelectSuggestion, onDescriptionChange, + readOnly, }: LineItemRowProps) { return (
@@ -254,6 +269,7 @@ function MobileLineItem({ onDateChange={(date) => onUpdate(index, "date", date ?? new Date())} size="sm" inputClassName="h-9" + disabled={readOnly} /> @@ -267,6 +283,7 @@ function MobileLineItem({ min={0} step={0.25} width="full" + disabled={readOnly} />
@@ -279,6 +296,7 @@ function MobileLineItem({ prefix="$" width="full" className="font-mono" + disabled={readOnly} />
@@ -287,17 +305,19 @@ function MobileLineItem({ {/* Bottom section with controls, item name, and total */}
- + {!readOnly ? ( + + ) : null}
@@ -352,6 +372,7 @@ export function InvoiceLineItems({ clientId, defaultRate: _defaultRate, className, + readOnly = false, }: InvoiceLineItemsProps) { const canRemoveItems = items.length > 1; const { search } = useLineItemSuggestions(); @@ -378,6 +399,11 @@ export function InvoiceLineItems({ return (
+ {readOnly ? ( +

+ Line items are locked after an invoice is sent. Revert to draft to edit entries. +

+ ) : null}
@@ -408,6 +434,7 @@ export function InvoiceLineItems({ suggestions={getSuggestionsForIndex(index)} onSelectSuggestion={handleSelectSuggestion} onDescriptionChange={handleDescriptionChange} + readOnly={readOnly} /> @@ -421,6 +448,7 @@ export function InvoiceLineItems({ suggestions={getSuggestionsForIndex(index)} onSelectSuggestion={handleSelectSuggestion} onDescriptionChange={handleDescriptionChange} + readOnly={readOnly} /> ))} @@ -444,22 +472,23 @@ export function InvoiceLineItems({
)} - {onAddItemWithValues && ( + {onAddItemWithValues && !readOnly ? ( - )} + ) : null}
- {/* Add Item Button */} - + {!readOnly ? ( + + ) : null}
); } diff --git a/src/components/time-clock/time-clock-panel.tsx b/src/components/time-clock/time-clock-panel.tsx index 72ba706..6482764 100644 --- a/src/components/time-clock/time-clock-panel.tsx +++ b/src/components/time-clock/time-clock-panel.tsx @@ -134,7 +134,7 @@ export function TimeClockPanel({ invoicePrefix: string | null; invoiceNumber: string; status: string; - }) => `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber} (${inv.status})`; + }) => `${inv.invoicePrefix ?? "#"}${inv.invoiceNumber}`; const displayDescription = running ? running.description : description; const displayRate = running ? (running.rate ?? 0) : rate; diff --git a/src/lib/draft-invoice.ts b/src/lib/draft-invoice.ts new file mode 100644 index 0000000..db83980 --- /dev/null +++ b/src/lib/draft-invoice.ts @@ -0,0 +1,11 @@ +/** Default invoice number format (matches web/mobile create forms). */ +export function generateInvoiceNumber(now = new Date()): string { + const date = now.toISOString().slice(0, 10).replace(/-/g, ""); + return `INV-${date}-${String(now.getTime()).slice(-6)}`; +} + +export function defaultDueDate(issueDate: Date): Date { + const due = new Date(issueDate); + due.setDate(due.getDate() + 30); + return due; +} diff --git a/src/lib/time-clock.ts b/src/lib/time-clock.ts index 0ef6094..c04ffe3 100644 --- a/src/lib/time-clock.ts +++ b/src/lib/time-clock.ts @@ -32,7 +32,7 @@ export function describeClockOutOutcome(input: { } return `Added ${input.hours}h to invoice`; case "saved_no_invoice": - return `Saved ${input.hours}h — no open invoice found for this client. Pick an invoice on the time clock or create one.`; + return `Saved ${input.hours}h — could not create or find a draft invoice for this client.`; case "saved_no_client": return `Saved ${input.hours}h — assign a client and invoice to bill this time.`; case "zero_hours": diff --git a/src/server/api/routers/dashboard.ts b/src/server/api/routers/dashboard.ts index 474f2aa..49453c2 100644 --- a/src/server/api/routers/dashboard.ts +++ b/src/server/api/routers/dashboard.ts @@ -1,6 +1,6 @@ import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import { invoices, clients } from "~/server/db/schema"; -import { eq, desc } from "drizzle-orm"; +import { and, desc, eq, isNotNull, lte } from "drizzle-orm"; export const dashboardRouter = createTRPCRouter({ getStats: protectedProcedure.query(async ({ ctx }) => { @@ -118,6 +118,26 @@ export const dashboardRouter = createTRPCRouter({ }, }); + const sendReminderDue = await ctx.db.query.invoices.findMany({ + where: and( + eq(invoices.createdById, userId), + eq(invoices.status, "draft"), + isNotNull(invoices.sendReminderAt), + lte(invoices.sendReminderAt, now), + ), + columns: { + id: true, + invoiceNumber: true, + invoicePrefix: true, + sendReminderAt: true, + }, + with: { + client: { columns: { name: true } }, + }, + orderBy: [desc(invoices.sendReminderAt)], + limit: 10, + }); + return { totalRevenue, pendingAmount, @@ -129,6 +149,7 @@ export const dashboardRouter = createTRPCRouter({ : 0, revenueChartData, recentInvoices, + sendReminderDue, }; }), }); diff --git a/src/server/api/routers/invoices.ts b/src/server/api/routers/invoices.ts index 41335ba..2ef4428 100644 --- a/src/server/api/routers/invoices.ts +++ b/src/server/api/routers/invoices.ts @@ -43,6 +43,7 @@ const createInvoiceSchema = z.object({ emailMessage: z.string().optional().or(z.literal("")), taxRate: z.number().min(0).max(100).default(0), currency: z.string().length(3).default("USD"), + sendReminderAt: z.date().nullable().optional(), items: z.array(invoiceItemSchema).min(1, "At least one item is required"), }); @@ -155,13 +156,13 @@ export const invoicesRouter = createTRPCRouter({ } }), - /** Draft and sent invoices available for time-clock billing. */ + /** Draft invoices available for time-clock billing. */ getBillable: protectedProcedure .input(z.object({ clientId: z.string().optional() }).optional()) .query(async ({ ctx, input }) => { const conditions = [ eq(invoices.createdById, ctx.session.user.id), - inArray(invoices.status, ["draft", "sent"]), + eq(invoices.status, "draft"), ]; if (input?.clientId) conditions.push(eq(invoices.clientId, input.clientId)); @@ -418,6 +419,23 @@ export const invoicesRouter = createTRPCRouter({ }); } + if (items && existingInvoice.status !== "draft") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Line items can only be edited on draft invoices", + }); + } + + if ( + cleanInvoiceData.sendReminderAt !== undefined && + existingInvoice.status !== "draft" + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Send reminders can only be set on draft invoices", + }); + } + // If business is being updated, verify it belongs to user if ( cleanInvoiceData.businessId && diff --git a/src/server/api/routers/time-entries.ts b/src/server/api/routers/time-entries.ts index c486f6f..6340cb8 100644 --- a/src/server/api/routers/time-entries.ts +++ b/src/server/api/routers/time-entries.ts @@ -1,13 +1,14 @@ import { z } from "zod"; -import { eq, and, desc, isNull, isNotNull, gte, lte, or } from "drizzle-orm"; +import { eq, and, desc, isNull, isNotNull, gte, lte } from "drizzle-orm"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { timeEntries, clients, invoices, invoiceItems } from "~/server/db/schema"; +import { timeEntries, clients, invoices, invoiceItems, businesses } from "~/server/db/schema"; import { TRPCError } from "@trpc/server"; import type { db } from "~/server/db"; import { computeTrackedHours, type ClockOutOutcome, } from "~/lib/time-clock"; +import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice"; type Db = typeof db; @@ -86,6 +87,60 @@ async function addEntryToInvoice( }; } +async function findOrCreateDraftInvoice( + database: Db, + userId: string, + clientId: string, +) { + const existing = await database.query.invoices.findFirst({ + where: and( + eq(invoices.clientId, clientId), + eq(invoices.createdById, userId), + eq(invoices.status, "draft"), + ), + with: { items: true }, + orderBy: [ + desc(invoices.updatedAt), + desc(invoices.issueDate), + desc(invoices.invoiceNumber), + ], + }); + + if (existing) return existing; + + const client = await database.query.clients.findFirst({ + where: and(eq(clients.id, clientId), eq(clients.createdById, userId)), + columns: { currency: true }, + }); + if (!client) return null; + + const defaultBusiness = await database.query.businesses.findFirst({ + where: and(eq(businesses.createdById, userId), eq(businesses.isDefault, true)), + columns: { id: true }, + }); + + const issueDate = new Date(); + const [created] = await database + .insert(invoices) + .values({ + invoiceNumber: generateInvoiceNumber(issueDate), + clientId, + businessId: defaultBusiness?.id ?? null, + issueDate, + dueDate: defaultDueDate(issueDate), + status: "draft", + totalAmount: 0, + taxRate: 0, + currency: client.currency, + createdById: userId, + }) + .returning(); + + if (!created) return null; + + return { ...created, items: [] as { amount: number; position: number }[] }; +} + async function addEntryToLatestInvoice( database: Db, userId: string, @@ -96,20 +151,7 @@ async function addEntryToLatestInvoice( rate: number, date: Date, ): Promise<{ id: string; invoiceNumber: string; invoicePrefix: string } | null> { - const invoice = await database.query.invoices.findFirst({ - where: and( - eq(invoices.clientId, clientId), - eq(invoices.createdById, userId), - or(eq(invoices.status, "draft"), eq(invoices.status, "sent")), - ), - with: { items: true }, - orderBy: [ - desc(invoices.issueDate), - desc(invoices.dueDate), - desc(invoices.invoiceNumber), - ], - }); - + const invoice = await findOrCreateDraftInvoice(database, userId, clientId); if (!invoice) return null; return addEntryToInvoice(database, invoice, entryId, description, hours, rate, date); } @@ -128,7 +170,7 @@ async function addEntryToSpecificInvoice( where: and( eq(invoices.id, invoiceId), eq(invoices.createdById, userId), - or(eq(invoices.status, "draft"), eq(invoices.status, "sent")), + eq(invoices.status, "draft"), ), with: { items: true }, }); @@ -231,14 +273,14 @@ export const timeEntriesRouter = createTRPCRouter({ where: and( eq(invoices.id, invoiceId), eq(invoices.createdById, ctx.session.user.id), - or(eq(invoices.status, "draft"), eq(invoices.status, "sent")), + eq(invoices.status, "draft"), ), columns: { id: true, clientId: true }, }); if (!invoice) { throw new TRPCError({ code: "FORBIDDEN", - message: "Invoice not found or not open for time tracking", + message: "Only draft invoices accept new time entries", }); } if (resolvedClientId && invoice.clientId !== resolvedClientId) { @@ -345,14 +387,14 @@ export const timeEntriesRouter = createTRPCRouter({ where: and( eq(invoices.id, invoiceId), eq(invoices.createdById, ctx.session.user.id), - or(eq(invoices.status, "draft"), eq(invoices.status, "sent")), + eq(invoices.status, "draft"), ), columns: { id: true, clientId: true }, }); if (!invoice) { throw new TRPCError({ code: "FORBIDDEN", - message: "Invoice not found or not open for time tracking", + message: "Only draft invoices accept new time entries", }); } if (resolvedClientId && invoice.clientId !== resolvedClientId) { diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 657e6e8..ade6c24 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -369,6 +369,7 @@ export const invoices = createTable( publicToken: d.varchar({ length: 255 }).unique(), publicTokenExpiresAt: d.timestamp(), lastReminderSentAt: d.timestamp(), + sendReminderAt: d.timestamp(), createdAt: d .timestamp() .default(sql`CURRENT_TIMESTAMP`)