Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/src/components/finance/NewInvoiceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions web/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = [
Expand Down
31 changes: 31 additions & 0 deletions web/src/lib/workItem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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);
});
});
13 changes: 10 additions & 3 deletions web/src/lib/workItem.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
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
* overlap.
*/
export function isInvoice(item: WorkItem): boolean {
return (
Boolean(item.invoicedAt) ||
Boolean(item.invoiceSentDate) ||
item.invoiceStatus === 'sent' ||
item.invoiceStatus === 'paid' ||
Expand Down
64 changes: 57 additions & 7 deletions web/src/routes/contractor/EmailComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -711,11 +734,38 @@ export default function EmailComposer({ workItems, clients, settings }: EmailCom
</div>
</div>

{/* From (read-only) */}
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-input)]/50">
<span className="text-xs text-[var(--text-secondary)] font-medium">From:</span>
<span className="text-xs text-[var(--text-primary)]">{fromName}</span>
<span className="text-xs text-[var(--text-secondary)]">&lt;{fromEmail}&gt;</span>
{/* From */}
<div>
<label
htmlFor="email-from-select"
className="block text-xs text-[var(--text-secondary)] font-medium mb-1"
>
From
</label>
{identities.length > 0 ? (
<>
<select
id="email-from-select"
value={selectedFromId}
onChange={(e) => setSelectedFromId(e.target.value)}
className="w-full h-11 px-3 rounded-lg border border-[var(--border)] bg-[var(--bg-input)] text-[var(--text-primary)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)] focus:border-transparent"
>
{identities.map((idn) => (
<option key={idn.id} value={idn.id}>
{idn.name} &lt;{idn.email}&gt;{idn.isDefault ? ' (default)' : ''}
</option>
))}
</select>
<p className="mt-1 text-[11px] text-[var(--text-secondary)]">
Must be a verified sender in Brevo or sending will fail.
</p>
</>
) : (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-input)]/50">
<span className="text-xs text-[var(--text-primary)]">{fromName}</span>
<span className="text-xs text-[var(--text-secondary)]">&lt;{fromEmail}&gt;</span>
</div>
)}
</div>

<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
Expand Down
154 changes: 153 additions & 1 deletion web/src/routes/contractor/Settings.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -74,6 +74,11 @@ export default function Settings({ settings, userId }: SettingsProps) {
const [savedTemplate, setSavedTemplate] = useState(false);
const [themeMode, setThemeMode] = useState<ThemeMode>(getThemeMode);

// Email From identities
const [fromIdentities, setFromIdentities] = useState<FromIdentity[]>(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);
Expand Down Expand Up @@ -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<FromIdentity>) {
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, {
Expand Down Expand Up @@ -475,6 +522,111 @@ export default function Settings({ settings, userId }: SettingsProps) {
</div>
</div>

{/* ── Email From Addresses ── */}
<div className="mt-8">
<h2 className="text-xs font-bold text-[var(--text-secondary)] uppercase tracking-wider mb-3">
Email From Addresses
</h2>
<div className="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] divide-y divide-[var(--border)]">
<div className="p-5">
<p className="text-xs text-[var(--text-secondary)] mb-4">
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.
</p>

{fromIdentities.length === 0 ? (
<p className="text-sm text-[var(--text-secondary)] italic mb-4">
No sender addresses configured yet.
</p>
) : (
<ul className="flex flex-col gap-3 mb-4">
{fromIdentities.map((idn) => (
<li
key={idn.id}
className="flex flex-col sm:flex-row sm:items-end gap-3 p-3 rounded-lg border border-[var(--border)] bg-[var(--bg-page)]"
>
<div className="flex-1">
<label
htmlFor={`from-name-${idn.id}`}
className="block text-xs text-[var(--text-secondary)] font-medium mb-1"
>
Name
</label>
<input
id={`from-name-${idn.id}`}
type="text"
value={idn.name}
onChange={(e) => 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"
/>
</div>
<div className="flex-1">
<label
htmlFor={`from-email-${idn.id}`}
className="block text-xs text-[var(--text-secondary)] font-medium mb-1"
>
Email
</label>
<input
id={`from-email-${idn.id}`}
type="email"
autoComplete="email"
value={idn.email}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => setDefaultIdentity(idn.id)}
aria-pressed={idn.isDefault ?? false}
className={`min-h-[44px] px-3 rounded-lg text-xs font-semibold transition-colors ${
idn.isDefault
? 'bg-[var(--accent)] text-white'
: 'border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
{idn.isDefault ? 'Default' : 'Set Default'}
</button>
<button
type="button"
onClick={() => removeIdentity(idn.id)}
aria-label={`Remove ${idn.name || 'sender'}`}
className="min-h-[44px] px-3 rounded-lg text-xs font-semibold text-[var(--color-red)] hover:bg-[var(--color-red)]/10 transition-colors"
>
Remove
</button>
</div>
</li>
))}
</ul>
)}

<div className="flex flex-col sm:flex-row gap-2">
<button
type="button"
onClick={addIdentity}
className="min-h-[44px] px-4 rounded-xl border border-[var(--border)] text-sm font-semibold text-[var(--text-primary)] hover:bg-[var(--bg-input)] transition-colors"
>
+ Add Address
</button>
<button
type="button"
onClick={handleSaveIdentities}
disabled={savingIdentities}
className="min-h-[44px] px-4 rounded-xl bg-[var(--accent)] text-white text-sm font-semibold hover:bg-[var(--accent-dark)] disabled:opacity-50 transition-colors"
>
{savingIdentities ? 'Saving...' : savedIdentities ? 'Saved!' : 'Save Addresses'}
</button>
</div>
</div>
</div>
</div>

{/* ── Push Notifications ── */}
<div className="mt-8">
<h2 className="text-xs font-bold text-[var(--text-secondary)] uppercase tracking-wider mb-3">
Expand Down
Loading
Loading