| 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); |
| } |
|
|
| |
| |
| |
| |
| 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> |
| ); |
| } |
|
|