diff --git a/web/src/components/finance/NewInvoiceModal.tsx b/web/src/components/finance/NewInvoiceModal.tsx index dd5aaa6..75e81de 100644 --- a/web/src/components/finance/NewInvoiceModal.tsx +++ b/web/src/components/finance/NewInvoiceModal.tsx @@ -136,6 +136,7 @@ export function NewInvoiceModal({ clients, workItems, settings, hourlyRate, paym totalCost, isBillable: true, deductFromRetainer, + invoicedAt: new Date(), invoiceStatus: 'draft', invoiceDueDate: dueDate ? new Date(dueDate) : undefined, isRetainerInvoice, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 99144e2..96289d5 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -59,6 +59,10 @@ export interface WorkItem { clientApproval?: 'pending' | 'approved' | 'rejected'; clientApprovalDate?: Date; invoiceStatus?: 'draft' | 'sent' | 'paid' | 'overdue'; + // Set when an item becomes an invoice via manual creation or conversion + // from a work order. Independent of whether it has been emailed/sent. + // Drives `isInvoice` so draft invoices are classified correctly. + invoicedAt?: Date; invoiceSentDate?: Date; invoicePaidDate?: Date; invoiceDueDate?: Date; @@ -125,6 +129,16 @@ export interface AppSettings { fcmToken?: string; mileageRate?: number; roundTimeToQuarterHour?: boolean; + // Configurable "From" sender identities for outbound email. Each must be a + // verified sender in Brevo or sending will fail. + fromIdentities?: FromIdentity[]; +} + +export interface FromIdentity { + id: string; + name: string; + email: string; + isDefault?: boolean; } export const PAYMENT_TERMS_OPTIONS = [ diff --git a/web/src/lib/workItem.test.ts b/web/src/lib/workItem.test.ts index 0b7cda2..ff40716 100644 --- a/web/src/lib/workItem.test.ts +++ b/web/src/lib/workItem.test.ts @@ -37,6 +37,25 @@ describe('isInvoice / isWorkOrder', () => { expect(isWorkOrder(item)).toBe(false); }); + it('treats a set invoicedAt as an invoice even when not yet sent', () => { + const item = make({ invoicedAt: new Date('2026-02-01'), invoiceStatus: 'draft' }); + expect(isInvoice(item)).toBe(true); + expect(isWorkOrder(item)).toBe(false); + }); + + it('treats invoicedAt as an invoice with no invoiceStatus at all', () => { + const item = make({ invoicedAt: new Date('2026-03-01') }); + expect(isInvoice(item)).toBe(true); + expect(isWorkOrder(item)).toBe(false); + }); + + it('remains a work order when invoicedAt is unset (backward compatible)', () => { + const item = make({ invoiceStatus: 'draft' }); + expect(item.invoicedAt).toBeUndefined(); + expect(isInvoice(item)).toBe(false); + expect(isWorkOrder(item)).toBe(true); + }); + it.each(['sent', 'paid', 'overdue'] as const)( 'treats invoiceStatus "%s" as an invoice even without invoiceSentDate', (status) => { @@ -52,4 +71,16 @@ describe('isInvoice / isWorkOrder', () => { expect(isInvoice(sent)).toBe(!isWorkOrder(sent)); expect(isInvoice(notSent)).toBe(!isWorkOrder(notSent)); }); + + it('convert-to-invoice effect: stamping invoicedAt flips a work order to an invoice', () => { + const workOrder = make({ invoiceStatus: 'draft' }); + expect(isWorkOrder(workOrder)).toBe(true); + + // Mirrors convertToInvoice(): set invoicedAt, keep existing invoiceStatus. + const converted = { ...workOrder, invoicedAt: new Date('2026-05-18') }; + expect(isInvoice(converted)).toBe(true); + expect(isWorkOrder(converted)).toBe(false); + // Original is untouched (immutability). + expect(isWorkOrder(workOrder)).toBe(true); + }); }); diff --git a/web/src/lib/workItem.ts b/web/src/lib/workItem.ts index b7321d2..8e93ace 100644 --- a/web/src/lib/workItem.ts +++ b/web/src/lib/workItem.ts @@ -1,9 +1,15 @@ import type { WorkItem } from './types'; /** - * Domain rule: a WorkItem that has been SENT OUT is an INVOICE, not a work - * order. "Sent out" means `invoiceSentDate` is set, OR `invoiceStatus` is one - * of 'sent' | 'paid' | 'overdue' (those statuses imply it was sent). + * Domain rule: a WorkItem is an INVOICE (not a work order) when any of: + * - `invoicedAt` is set — it was explicitly created as / converted to an + * invoice (independent of whether it has been emailed yet), OR + * - `invoiceSentDate` is set, OR + * - `invoiceStatus` is one of 'sent' | 'paid' | 'overdue' (those statuses + * imply it was sent). + * + * `invoicedAt` is backward compatible: legacy invoices that were only ever + * "sent" still classify correctly via the sent-date / status checks. * * Single source of truth — every work-order-facing list and every * invoice-facing list must derive membership from this pair so the two never @@ -11,6 +17,7 @@ import type { WorkItem } from './types'; */ export function isInvoice(item: WorkItem): boolean { return ( + Boolean(item.invoicedAt) || Boolean(item.invoiceSentDate) || item.invoiceStatus === 'sent' || item.invoiceStatus === 'paid' || diff --git a/web/src/routes/contractor/EmailComposer.tsx b/web/src/routes/contractor/EmailComposer.tsx index 55984f0..c047bc8 100644 --- a/web/src/routes/contractor/EmailComposer.tsx +++ b/web/src/routes/contractor/EmailComposer.tsx @@ -284,8 +284,22 @@ export default function EmailComposer({ workItems, clients, settings }: EmailCom ? `Invoice — ${item.subject}` : `Work Order Completed! — ${item.subject}`; }); - const fromEmail = BRAND.fromEmail; - const fromName = BRAND.fromName; + // Sender identities from settings; fall back to BRAND defaults when none + // are configured. Each must be a verified sender in Brevo or sending fails. + const identities = useMemo(() => settings.fromIdentities ?? [], [settings.fromIdentities]); + const defaultIdentityId = useMemo(() => { + if (identities.length === 0) return ''; + return (identities.find((i) => i.isDefault) ?? identities[0]).id; + }, [identities]); + const [selectedFromId, setSelectedFromId] = useState(defaultIdentityId); + + useEffect(() => { + setSelectedFromId(defaultIdentityId); + }, [defaultIdentityId]); + + const selectedIdentity = identities.find((i) => i.id === selectedFromId); + const fromEmail = selectedIdentity?.email ?? BRAND.fromEmail; + const fromName = selectedIdentity?.name ?? BRAND.fromName; const [greeting, setGreeting] = useState(`Hello ${client?.name ?? ''},`); const [message, setMessage] = useState(() => { @@ -384,6 +398,15 @@ export default function EmailComposer({ workItems, clients, settings }: EmailCom title: lines[2] || DEFAULT_SIGNATURE.title, }); } + // Restore the saved From identity by matching its email (falls back to + // the current default when the template predates identities or the + // address was removed). + if (tpl.fromEmail) { + const match = identities.find( + (i) => i.email.toLowerCase() === tpl.fromEmail?.toLowerCase(), + ); + if (match) setSelectedFromId(match.id); + } setHtmlOverride(tpl.html); setShowTemplateList(false); } @@ -711,11 +734,38 @@ export default function EmailComposer({ workItems, clients, settings }: EmailCom - {/* From (read-only) */} -
+ Must be a verified sender in Brevo or sending will fail. +
+ > + ) : ( ++ Sender identities available in the email composer when sending work + orders and invoices. Each address must be a verified sender in Brevo + or sending will fail. +
+ + {fromIdentities.length === 0 ? ( ++ No sender addresses configured yet. +
+ ) : ( ++ This is a work order +
++ Convert it to an invoice to track and bill it under Invoices. +
+