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) */} -
- From: - {fromName} - <{fromEmail}> + {/* From */} +
+ + {identities.length > 0 ? ( + <> + +

+ Must be a verified sender in Brevo or sending will fail. +

+ + ) : ( +
+ {fromName} + <{fromEmail}> +
+ )}
diff --git a/web/src/routes/contractor/Settings.tsx b/web/src/routes/contractor/Settings.tsx index 99cd0b6..1a66fea 100644 --- a/web/src/routes/contractor/Settings.tsx +++ b/web/src/routes/contractor/Settings.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import type { AppSettings } from '../../lib/types'; +import type { AppSettings, FromIdentity } from '../../lib/types'; import { updateSettings } from '../../services/firestore'; import { IconMail, IconLightbulb, IconBook, IconDocument, IconLock, IconBell, IconNotebook } from '../../components/icons'; import { useNotion } from '../../hooks/useNotion'; @@ -74,6 +74,11 @@ export default function Settings({ settings, userId }: SettingsProps) { const [savedTemplate, setSavedTemplate] = useState(false); const [themeMode, setThemeMode] = useState(getThemeMode); + // Email From identities + const [fromIdentities, setFromIdentities] = useState(settings.fromIdentities ?? []); + const [savingIdentities, setSavingIdentities] = useState(false); + const [savedIdentities, setSavedIdentities] = useState(false); + const { user } = useAuth(); const { integration } = useIntegration(user?.uid); const { accounts: githubAccounts } = useGitHubAccounts(user?.uid); @@ -148,8 +153,50 @@ export default function Settings({ settings, userId }: SettingsProps) { setInvoiceFromAddress(settings.invoiceFromAddress ?? ''); setInvoiceTerms(settings.invoiceTerms ?? ''); setInvoiceNotes(settings.invoiceNotes ?? ''); + setFromIdentities(settings.fromIdentities ?? []); }, [settings]); + function updateIdentity(id: string, patch: Partial) { + setFromIdentities((prev) => prev.map((idn) => (idn.id === id ? { ...idn, ...patch } : idn))); + } + + function addIdentity() { + setFromIdentities((prev) => [ + ...prev, + { id: crypto.randomUUID(), name: '', email: '', isDefault: prev.length === 0 }, + ]); + } + + function removeIdentity(id: string) { + setFromIdentities((prev) => { + const next = prev.filter((idn) => idn.id !== id); + // Ensure a default still exists if any remain. + if (next.length > 0 && !next.some((idn) => idn.isDefault)) { + return next.map((idn, i) => (i === 0 ? { ...idn, isDefault: true } : idn)); + } + return next; + }); + } + + function setDefaultIdentity(id: string) { + setFromIdentities((prev) => prev.map((idn) => ({ ...idn, isDefault: idn.id === id }))); + } + + async function handleSaveIdentities() { + setSavingIdentities(true); + const cleaned = fromIdentities + .map((idn) => ({ ...idn, name: idn.name.trim(), email: idn.email.trim() })) + .filter((idn) => idn.name && idn.email); + if (cleaned.length > 0 && !cleaned.some((idn) => idn.isDefault)) { + cleaned[0] = { ...cleaned[0], isDefault: true }; + } + await updateSettings(userId, { fromIdentities: cleaned }); + setFromIdentities(cleaned); + setSavingIdentities(false); + setSavedIdentities(true); + setTimeout(() => setSavedIdentities(false), 2000); + } + async function handleSave() { setSaving(true); await updateSettings(userId, { @@ -475,6 +522,111 @@ export default function Settings({ settings, userId }: SettingsProps) {
+ {/* ── Email From Addresses ── */} +
+

+ Email From Addresses +

+
+
+

+ 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. +

+ ) : ( +
    + {fromIdentities.map((idn) => ( +
  • +
    + + updateIdentity(idn.id, { name: e.target.value })} + placeholder="Your Business" + className="w-full h-11 px-3 rounded-lg border border-[var(--border)] bg-[var(--bg-input)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:border-transparent" + /> +
    +
    + + updateIdentity(idn.id, { email: e.target.value })} + placeholder="you@example.com" + className="w-full h-11 px-3 rounded-lg border border-[var(--border)] bg-[var(--bg-input)] text-[var(--text-primary)] text-sm placeholder:text-[var(--text-secondary)] focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:border-transparent" + /> +
    +
    + + +
    +
  • + ))} +
+ )} + +
+ + +
+
+
+
+ {/* ── Push Notifications ── */}

diff --git a/web/src/routes/contractor/WorkItemDetail.tsx b/web/src/routes/contractor/WorkItemDetail.tsx index 95148bc..4981be6 100644 --- a/web/src/routes/contractor/WorkItemDetail.tsx +++ b/web/src/routes/contractor/WorkItemDetail.tsx @@ -17,7 +17,9 @@ import { discardWorkItem, updateInvoiceStatus, unlinkTimeEntriesForLineItem, + convertToInvoice, } from '../../services/firestore'; +import { isWorkOrder } from '../../lib/workItem'; import { buildChangeOrderPdf } from '../../lib/buildPdf'; import { IconClose } from '../../components/icons'; @@ -56,6 +58,7 @@ export default function WorkItemDetail({ const [item, setItem] = useState(null); const [showEmail, setShowEmail] = useState(true); const [saving, setSaving] = useState(false); + const [converting, setConverting] = useState(false); const [generatingPdf, setGeneratingPdf] = useState(false); const [showPdfPreview, setShowPdfPreview] = useState(false); const [previewUrl, setPreviewUrl] = useState(null); @@ -606,6 +609,40 @@ export default function WorkItemDetail({ Invoice Status

+ {/* Convert work order → invoice */} + {isWorkOrder(item) && ( +
+
+

+ This is a work order +

+

+ Convert it to an invoice to track and bill it under Invoices. +

+
+ +
+ )} + {/* Draft / No invoice */} {(!item.invoiceStatus || item.invoiceStatus === 'draft') && (
diff --git a/web/src/routes/contractor/WorkItems.tsx b/web/src/routes/contractor/WorkItems.tsx index 0ccaef7..4a9ed86 100644 --- a/web/src/routes/contractor/WorkItems.tsx +++ b/web/src/routes/contractor/WorkItems.tsx @@ -7,7 +7,7 @@ import type { WorkItem, Client, AppSettings, App } from '../../lib/types'; import { WORK_ITEM_TYPE_LABELS, WORK_ITEM_STATUS_LABELS } from '../../lib/types'; import { formatDate, exportToCsv } from '../../lib/utils'; import { isWorkOrder } from '../../lib/workItem'; -import { bulkUpdateStatus } from '../../services/firestore'; +import { bulkUpdateStatus, convertToInvoice } from '../../services/firestore'; import { IconDocument } from '../../components/icons'; import { useInsights } from '../../hooks/useFirestore'; import { InsightBadge } from '../../components/insights/InsightBadge'; @@ -176,6 +176,15 @@ export default function WorkItems({ workItems, clients, apps, settings }: WorkIt setBulkLoading(false); } + async function handleBulkConvertToInvoice() { + const ids = [...selectedIds]; + if (!ids.length) return; + setBulkLoading(true); + await convertToInvoice(ids); + setSelectedIds(new Set()); + setBulkLoading(false); + } + function clearAllFilters() { setSelectedClients([]); setSelectedApps([]); @@ -351,6 +360,13 @@ export default function WorkItems({ workItems, clients, apps, settings }: WorkIt > Approve Selected +