EMAILOUT / frontend /src /components /workspace /WonBillingModal.jsx
Seth
update
21bb60f
import React, { useCallback, useMemo, useState } from 'react';
import { ChevronDown, ChevronUp, GripVertical, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
function newLineRow() {
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
product_service: '',
description: '',
qty: '1',
rate: '0',
billing_interval: 'monthly',
currency: 'USD',
};
}
function parseNum(s) {
const n = parseFloat(String(s ?? '').replace(/,/g, '').trim());
return Number.isFinite(n) ? n : NaN;
}
function fmtMoneyAmount(n) {
if (!Number.isFinite(n)) return '—';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(n);
}
/**
* Modal to capture PO / customer / line items before marking a deal Won (invoicing).
* @param {boolean | undefined} gmailInvitesReady — from /api/auth/me; false blocks submit until user reconnects Gmail.
*/
export default function WonBillingModal({ dealName, busy, onCancel, onSubmit, gmailInvitesReady }) {
const [poNumber, setPoNumber] = useState('');
const [customerLegalName, setCustomerLegalName] = useState('');
const [customerAddress, setCustomerAddress] = useState('');
const [contactPersonName, setContactPersonName] = useState('');
const [channelPartnerName, setChannelPartnerName] = useState('');
const [noteToCustomer, setNoteToCustomer] = useState('');
const [noteToAccounts, setNoteToAccounts] = useState('');
const [lines, setLines] = useState(() => [newLineRow()]);
const [localError, setLocalError] = useState('');
const lineAmounts = useMemo(() => {
return lines.map((row) => {
const q = parseNum(row.qty);
const r = parseNum(row.rate);
if (!Number.isFinite(q) || !Number.isFinite(r)) return NaN;
return Math.round(q * r * 100) / 100;
});
}, [lines]);
const subtotal = useMemo(() => {
return lineAmounts.reduce((a, v) => a + (Number.isFinite(v) ? v : 0), 0);
}, [lineAmounts]);
const updateLine = useCallback((id, patch) => {
setLines((prev) => prev.map((row) => (row.id === id ? { ...row, ...patch } : row)));
}, []);
const removeLine = useCallback((id) => {
setLines((prev) => (prev.length <= 1 ? prev : prev.filter((r) => r.id !== id)));
}, []);
const addLine = useCallback(() => {
setLines((prev) => [...prev, newLineRow()]);
}, []);
const clearAllLines = useCallback(() => {
setLines([newLineRow()]);
}, []);
const moveLine = useCallback((index, dir) => {
setLines((prev) => {
const j = index + dir;
if (j < 0 || j >= prev.length) return prev;
const next = [...prev];
const t = next[index];
next[index] = next[j];
next[j] = t;
return next;
});
}, []);
const handleSubmit = () => {
setLocalError('');
if (gmailInvitesReady === false) {
setLocalError('Connect Gmail below before marking this deal won.');
return;
}
if (!poNumber.trim()) {
setLocalError('PO number is required.');
return;
}
if (!customerLegalName.trim()) {
setLocalError('Customer legal name is required.');
return;
}
if (!customerAddress.trim()) {
setLocalError('Customer address is required.');
return;
}
if (!contactPersonName.trim()) {
setLocalError('Contact person name is required.');
return;
}
if (!noteToAccounts.trim()) {
setLocalError('Note to our accounts is required.');
return;
}
const items = [];
for (const row of lines) {
const ps = row.product_service.trim();
if (!ps) {
setLocalError('Each line must have a product or service name.');
return;
}
if (!row.description.trim()) {
setLocalError('Each line must have a description.');
return;
}
if (!row.billing_interval) {
setLocalError('Each line must have a billing interval.');
return;
}
if (!row.currency) {
setLocalError('Each line must have a currency.');
return;
}
const q = parseNum(row.qty);
const r = parseNum(row.rate);
if (!Number.isFinite(q) || q <= 0) {
setLocalError('Each line needs a valid quantity greater than 0.');
return;
}
if (!Number.isFinite(r) || r < 0) {
setLocalError('Each line needs a valid rate (0 or greater).');
return;
}
items.push({
product_service: ps,
description: row.description.trim(),
qty: q,
rate: r,
billing_interval: row.billing_interval || 'monthly',
currency: row.currency || 'USD',
});
}
onSubmit({
po_number: poNumber.trim(),
customer_legal_name: customerLegalName.trim(),
customer_address: customerAddress.trim(),
contact_person_name: contactPersonName.trim(),
channel_partner_name: channelPartnerName.trim() || null,
note_to_customer: noteToCustomer.trim(),
note_to_accounts: noteToAccounts.trim(),
line_items: items,
});
};
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="won-billing-title"
onClick={() => {
if (!busy) onCancel();
}}
>
<div
className="max-h-[min(92vh,900px)] w-full max-w-4xl overflow-y-auto rounded-xl border border-slate-200 bg-white p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<h3 id="won-billing-title" className="text-lg font-semibold text-slate-900">
Mark deal as won — billing & PO
</h3>
<p className="mt-1 text-sm text-slate-600">
{dealName ? (
<>
<span className="font-medium text-slate-800">{dealName}</span>
<span> — complete the details below for accurate invoicing.</span>
</>
) : (
'Complete the details below for accurate invoicing.'
)}
</p>
<div
className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-950"
role="note"
>
Please double-check every field and line item so amounts, names, and PO match your customer
agreement. Errors here can cause invoice mistakes.
</div>
{gmailInvitesReady === false ? (
<p className="mt-3 text-xs text-amber-900 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
Workspace admins get an email when you mark a deal won. Sign in with Google again (consent
screen) or use{' '}
<a
href="/api/auth/google?reauth_gmail=1"
className="font-medium text-violet-700 underline"
>
Reconnect Google for invites
</a>
, then return here and submit again.
</p>
) : null}
<div className="mt-5 grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
PO number <span className="text-red-600">*</span>
</label>
<Input value={poNumber} onChange={(e) => setPoNumber(e.target.value)} className="bg-white" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
Customer legal name <span className="text-red-600">*</span>
</label>
<Input
value={customerLegalName}
onChange={(e) => setCustomerLegalName(e.target.value)}
className="bg-white"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium text-slate-600">
Customer address <span className="text-red-600">*</span>
</label>
<Textarea
value={customerAddress}
onChange={(e) => setCustomerAddress(e.target.value)}
className="min-h-[72px] bg-white text-sm"
rows={3}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
Contact person name <span className="text-red-600">*</span>
</label>
<Input
value={contactPersonName}
onChange={(e) => setContactPersonName(e.target.value)}
className="bg-white"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-600">
Channel partner name (if any)
</label>
<Input
value={channelPartnerName}
onChange={(e) => setChannelPartnerName(e.target.value)}
className="bg-white"
placeholder="Optional"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium text-slate-600">Note to customer</label>
<Textarea
value={noteToCustomer}
onChange={(e) => setNoteToCustomer(e.target.value)}
className="min-h-[64px] bg-white text-sm"
rows={2}
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium text-slate-600">
Note to our accounts <span className="text-red-600">*</span>
</label>
<Textarea
value={noteToAccounts}
onChange={(e) => setNoteToAccounts(e.target.value)}
className="min-h-[64px] bg-white text-sm"
rows={2}
/>
</div>
</div>
<div className="mt-6">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-semibold text-slate-800">Products / services</span>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" className="h-8" onClick={addLine}>
+ Add line
</Button>
<Button type="button" variant="ghost" size="sm" className="h-8 text-slate-600" onClick={clearAllLines}>
Clear all lines
</Button>
</div>
</div>
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="w-full min-w-[960px] border-collapse text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs font-semibold text-slate-600">
<th className="w-8 px-1 py-2" aria-hidden />
<th className="w-8 px-1 py-2 text-center">#</th>
<th className="min-w-[140px] px-2 py-2">Product / service</th>
<th className="min-w-[120px] px-2 py-2">
Description <span className="text-red-600">*</span>
</th>
<th className="min-w-[130px] px-2 py-2">
Billing interval <span className="text-red-600">*</span>
</th>
<th className="w-24 px-2 py-2">
Currency <span className="text-red-600">*</span>
</th>
<th className="w-20 px-2 py-2 text-right">Qty</th>
<th className="w-24 px-2 py-2 text-right">Rate</th>
<th className="w-28 px-2 py-2 text-right">Amount</th>
<th className="w-10 px-1 py-2" aria-hidden />
</tr>
</thead>
<tbody>
{lines.map((row, idx) => {
const amt = lineAmounts[idx];
return (
<tr key={row.id} className="border-b border-slate-100">
<td className="align-middle px-1 py-1">
<div className="flex flex-col items-center gap-0.5">
<GripVertical className="h-4 w-4 text-slate-300" aria-hidden />
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
disabled={idx === 0}
onClick={() => moveLine(idx, -1)}
aria-label="Move line up"
>
<ChevronUp className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
disabled={idx === lines.length - 1}
onClick={() => moveLine(idx, 1)}
aria-label="Move line down"
>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</div>
</td>
<td className="px-1 py-2 text-center text-xs text-slate-500 tabular-nums">
{idx + 1}
</td>
<td className="px-2 py-1">
<Input
value={row.product_service}
onChange={(e) =>
updateLine(row.id, { product_service: e.target.value })
}
className="h-8 bg-white text-xs"
placeholder="Product or service"
/>
</td>
<td className="px-2 py-1">
<Input
value={row.description}
onChange={(e) =>
updateLine(row.id, { description: e.target.value })
}
className="h-8 bg-white text-xs"
placeholder="Description"
/>
</td>
<td className="px-2 py-1">
<Select
value={row.billing_interval || 'monthly'}
onValueChange={(v) =>
updateLine(row.id, { billing_interval: v })
}
>
<SelectTrigger className="h-8 bg-white text-xs">
<span className="truncate">
{row.billing_interval === 'one_time'
? 'One-time'
: row.billing_interval === 'quarterly'
? 'Every 3 mo'
: row.billing_interval === 'annual'
? 'Every year'
: 'Every month'}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Every month</SelectItem>
<SelectItem value="quarterly">Every 3 months</SelectItem>
<SelectItem value="annual">Every year</SelectItem>
<SelectItem value="one_time">One-time</SelectItem>
</SelectContent>
</Select>
</td>
<td className="px-2 py-1">
<Select
value={row.currency || 'USD'}
onValueChange={(v) =>
updateLine(row.id, { currency: v })
}
>
<SelectTrigger className="h-8 bg-white text-xs">
{row.currency || 'USD'}
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="CAD">CAD</SelectItem>
</SelectContent>
</Select>
</td>
<td className="px-2 py-1">
<Input
value={row.qty}
onChange={(e) => updateLine(row.id, { qty: e.target.value })}
className="h-8 bg-white text-right text-xs tabular-nums"
inputMode="decimal"
/>
</td>
<td className="px-2 py-1">
<Input
value={row.rate}
onChange={(e) => updateLine(row.id, { rate: e.target.value })}
className="h-8 bg-white text-right text-xs tabular-nums"
inputMode="decimal"
/>
</td>
<td className="px-2 py-2 text-right text-xs font-medium tabular-nums text-slate-800">
{fmtMoneyAmount(amt)}
</td>
<td className="px-1 py-1 text-center">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-500 hover:text-red-600"
disabled={lines.length <= 1}
onClick={() => removeLine(row.id)}
aria-label="Remove line"
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-3 flex justify-end border-t border-slate-100 pt-2">
<div className="text-sm">
<span className="text-slate-500">Subtotal </span>
<span className="font-semibold tabular-nums text-slate-900">{fmtMoneyAmount(subtotal)}</span>
</div>
</div>
</div>
{localError ? (
<p className="mt-3 text-sm text-red-600" role="alert">
{localError}
</p>
) : null}
<div className="mt-6 flex flex-wrap justify-end gap-2">
<Button type="button" variant="outline" onClick={onCancel} disabled={busy}>
Cancel
</Button>
<Button
type="button"
className="bg-emerald-600 hover:bg-emerald-700"
onClick={handleSubmit}
disabled={busy || gmailInvitesReady === false}
>
{busy ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
Saving…
</>
) : (
'Submit & mark won'
)}
</Button>
</div>
</div>
</div>
);
}