This commit is contained in:
2026-07-26 18:19:25 -05:00
parent b0e677d298
commit 32c8ccc3ed
15 changed files with 2679 additions and 36 deletions

View File

@@ -4,8 +4,10 @@ import {
businessFileUrl,
viewerUrl,
type Business,
type BusinessDeal,
type BusinessFile,
} from '../api.js';
import { DEAL_LABELS, StatusBadge, formatDay } from '../components.js';
function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
@@ -24,16 +26,28 @@ function formatDate(iso: string): string {
const isPdf = (filePath: string) => filePath.toLowerCase().endsWith('.pdf');
export default function BusinessDetail({ id, onBack }: { id: string; onBack: () => void }) {
export default function BusinessDetail({
id,
onBack,
onOpenBuyer,
}: {
id: string;
onBack: () => void;
onOpenBuyer: (buyerId: string) => void;
}) {
const [business, setBusiness] = useState<Business | null>(null);
const [files, setFiles] = useState<BusinessFile[] | null>(null);
const [deals, setDeals] = useState<BusinessDeal[] | null>(null);
const [dealsOpen, setDealsOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
setSelected(null);
setDealsOpen(false);
api.business(id).then(setBusiness).catch((err: Error) => setError(err.message));
api.businessFiles(id).then(setFiles).catch((err: Error) => setError(err.message));
api.businessDeals(id).then(setDeals).catch((err: Error) => setError(err.message));
}, [id]);
return (
@@ -56,6 +70,58 @@ export default function BusinessDetail({ id, onBack }: { id: string; onBack: ()
<p className="mt-1 font-mono text-xs text-gray-500">{business.nas_path}</p>
</div>
)}
{/* Collapsed by default so the master-detail split keeps the viewport. */}
<div className="mt-3 rounded border border-gray-200 bg-white">
<button
onClick={() => setDealsOpen(!dealsOpen)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wide text-gray-500"
>
<span className="text-gray-400">{dealsOpen ? '▾' : '▸'}</span>
Buyer activity ({deals?.length ?? 0})
</button>
{dealsOpen && (
<div className="max-h-48 overflow-auto border-t border-gray-200">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
<th className="px-3 py-1.5 font-medium">Buyer</th>
<th className="px-3 py-1.5 font-medium">Contact</th>
<th className="w-32 px-3 py-1.5 font-medium">Deal</th>
<th className="w-32 px-3 py-1.5 font-medium">Intro</th>
<th className="w-32 px-3 py-1.5 font-medium">NDA signed</th>
</tr>
</thead>
<tbody>
{deals?.map((deal) => (
<tr
key={deal.id}
onClick={() => onOpenBuyer(deal.buyer_id)}
className="cursor-pointer border-b border-gray-100 hover:bg-gray-50"
>
<td className="px-3 py-1.5 text-blue-600">
{deal.company_name ?? deal.contact_name ?? 'Buyer'}
</td>
<td className="px-3 py-1.5 text-gray-600">{deal.contact_name ?? '—'}</td>
<td className="px-3 py-1.5">
<StatusBadge status={deal.status} label={DEAL_LABELS[deal.status]} />
</td>
<td className="px-3 py-1.5 text-gray-500">{formatDay(deal.intro_date)}</td>
<td className="px-3 py-1.5 text-gray-500">{formatDay(deal.signed_at)}</td>
</tr>
))}
{deals && deals.length === 0 && (
<tr>
<td colSpan={5} className="px-3 py-3 text-gray-500">
No buyer has been introduced to this business yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Master-detail: file table left, PDF viewer right, together filling the viewport. */}

View File

@@ -0,0 +1,587 @@
import { useCallback, useEffect, useState } from 'react';
import {
api,
type Buyer,
type BusinessListItem,
type Contact,
type Deal,
type DealNote,
type DealStatus,
type NdaRound,
} from '../api.js';
import {
BusinessPicker,
DEAL_ACTIONS,
DEAL_FLOW,
DEAL_LABELS,
Dialog,
InlineField,
Panel,
StatusBadge,
TriState,
formatDay,
formatStamp,
} from '../components.js';
const ALL_DEAL_STATUSES = Object.keys(DEAL_LABELS) as DealStatus[];
/** The step the flow suggests next, plus "End deal" — everything else is a correction. */
function nextSteps(status: DealStatus): DealStatus[] {
const index = DEAL_FLOW.indexOf(status);
const forward = index >= 0 ? DEAL_FLOW[index + 1] : undefined;
return [...(forward ? [forward] : []), ...(status === 'ENDED' ? [] : ['ENDED' as DealStatus])];
}
export default function BuyerDetail({
id,
onBack,
onOpenBusiness,
}: {
id: string;
onBack: () => void;
onOpenBusiness: (businessId: string) => void;
}) {
const [buyer, setBuyer] = useState<Buyer | null>(null);
const [error, setError] = useState<string | null>(null);
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
const [endOpenDeals, setEndOpenDeals] = useState(true);
const reload = useCallback(
() =>
api
.buyer(id)
.then((res) => {
setBuyer(res);
setError(null);
})
.catch((err: Error) => setError(err.message)),
[id],
);
useEffect(() => {
void reload();
}, [reload]);
async function guard(action: () => Promise<unknown>) {
try {
await action();
await reload();
} catch (err) {
setError((err as Error).message);
}
}
if (!buyer) {
return (
<div>
<button onClick={onBack} className="text-sm text-blue-600 hover:underline">
Back to buyers
</button>
{error ? (
<p className="mt-2 text-sm text-red-600">{error}</p>
) : (
<p className="mt-2 text-sm text-gray-500">Loading</p>
)}
</div>
);
}
const primary = buyer.contacts.find((contact) => contact.is_primary) ?? buyer.contacts[0];
const openDeals = buyer.ndas.reduce(
(sum, nda) => sum + nda.deals.filter((deal) => deal.status !== 'ENDED').length,
0,
);
return (
<div className="flex flex-col gap-4">
<div>
<button onClick={onBack} className="text-sm text-blue-600 hover:underline">
Back to buyers
</button>
<div className="mt-2 flex items-center gap-3">
<h1 className="text-lg font-semibold">
{buyer.company_name ?? primary?.name ?? 'Buyer'}
</h1>
<StatusBadge status={buyer.status} />
{buyer.company_name && primary && (
<span className="text-sm text-gray-500">{primary.name}</span>
)}
<div className="flex-1" />
{buyer.status === 'ACTIVE' ? (
<button
onClick={() => setConfirmDeactivate(true)}
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm hover:bg-gray-100"
>
Deactivate
</button>
) : (
<button
onClick={() => guard(() => api.updateBuyer(buyer.id, { status: 'ACTIVE' }))}
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm hover:bg-gray-100"
>
Reactivate
</button>
)}
</div>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</div>
{confirmDeactivate && (
<Dialog
title="Deactivate this buyer?"
confirmLabel="Deactivate"
onCancel={() => setConfirmDeactivate(false)}
onConfirm={() => {
setConfirmDeactivate(false);
void guard(() =>
api.updateBuyer(buyer.id, {
status: 'DEACTIVATED',
end_open_deals: endOpenDeals,
}),
);
}}
>
<p className="text-sm text-gray-600">
{openDeals === 0
? 'This buyer has no open deals.'
: `${openDeals} open deal${openDeals === 1 ? '' : 's'} will be ended.`}
</p>
{openDeals > 0 && (
<label className="mt-2 flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={endOpenDeals}
onChange={(e) => setEndOpenDeals(e.target.checked)}
/>
End the open deals
</label>
)}
</Dialog>
)}
<Panel title="Identity">
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
<InlineField
label="Company name"
value={buyer.company_name}
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { company_name: next }))}
/>
<InlineField
label="State"
value={buyer.state}
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { state: next }))}
/>
<InlineField
label="Address"
value={buyer.address}
multiline
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { address: next }))}
/>
<InlineField
label="How they heard about us"
value={buyer.how_heard}
multiline
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { how_heard: next }))}
/>
<div className="col-span-2">
<InlineField
label="Background / experience"
value={buyer.background_experience}
multiline
onSave={(next) =>
guard(() => api.updateBuyer(buyer.id, { background_experience: next }))
}
/>
</div>
<div className="col-span-2">
<TriState
label="Interested in updates"
value={buyer.interested_in_updates}
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { interested_in_updates: next }))}
/>
</div>
</div>
</Panel>
<Contacts buyer={buyer} guard={guard} />
<Panel title="NDA rounds">
<div className="flex flex-col gap-4">
{buyer.ndas.map((nda) => (
<Round
key={nda.id}
nda={nda}
guard={guard}
onOpenBusiness={onOpenBusiness}
onError={setError}
/>
))}
{buyer.ndas.length === 0 && <p className="text-sm text-gray-500">No NDA rounds yet.</p>}
</div>
</Panel>
</div>
);
}
// ------------------------------------------------------------- Contacts
function Contacts({
buyer,
guard,
}: {
buyer: Buyer;
guard: (action: () => Promise<unknown>) => Promise<void>;
}) {
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState({ name: '', email: '', phone: '', cell: '' });
async function add() {
if (!draft.name.trim()) return;
await guard(() => api.addContact(buyer.id, draft));
setDraft({ name: '', email: '', phone: '', cell: '' });
setAdding(false);
}
const field = (contact: Contact, key: 'name' | 'email' | 'phone' | 'cell') => (
<InlineField
label=""
value={contact[key]}
onSave={(next) => guard(() => api.updateContact(contact.id, { [key]: next }))}
/>
);
return (
<Panel
title="Contacts"
action={
<button
onClick={() => setAdding(!adding)}
className="text-xs text-blue-600 hover:underline"
>
{adding ? 'Cancel' : 'Add contact'}
</button>
}
>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
<th className="w-8 px-2 py-1 font-medium" />
<th className="px-2 py-1 font-medium">Name</th>
<th className="px-2 py-1 font-medium">E-mail</th>
<th className="px-2 py-1 font-medium">Phone</th>
<th className="px-2 py-1 font-medium">Cell</th>
</tr>
</thead>
<tbody>
{buyer.contacts.map((contact) => (
<tr key={contact.id} className="border-b border-gray-100 align-top">
<td className="px-2 py-1">
<button
title={contact.is_primary ? 'Primary contact' : 'Make primary contact'}
onClick={() =>
!contact.is_primary &&
guard(() => api.updateContact(contact.id, { is_primary: true }))
}
className={contact.is_primary ? 'text-amber-500' : 'text-gray-300 hover:text-amber-400'}
>
</button>
</td>
<td className="px-2 py-1">{field(contact, 'name')}</td>
<td className="px-2 py-1">{field(contact, 'email')}</td>
<td className="px-2 py-1">{field(contact, 'phone')}</td>
<td className="px-2 py-1">{field(contact, 'cell')}</td>
</tr>
))}
{adding && (
<tr className="border-b border-gray-100">
<td className="px-2 py-1" />
{(['name', 'email', 'phone', 'cell'] as const).map((key) => (
<td key={key} className="px-2 py-1">
<input
autoFocus={key === 'name'}
value={draft[key]}
placeholder={key}
onChange={(e) => setDraft({ ...draft, [key]: e.target.value })}
onKeyDown={(e) => e.key === 'Enter' && add()}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</td>
))}
</tr>
)}
</tbody>
</table>
{adding && (
<button
onClick={add}
className="mt-2 rounded bg-gray-900 px-3 py-1 text-xs text-white"
>
Save contact
</button>
)}
</Panel>
);
}
// ------------------------------------------------------------ NDA round
function Round({
nda,
guard,
onOpenBusiness,
onError,
}: {
nda: NdaRound;
guard: (action: () => Promise<unknown>) => Promise<void>;
onOpenBusiness: (businessId: string) => void;
onError: (message: string) => void;
}) {
const [picking, setPicking] = useState(false);
const [business, setBusiness] = useState<BusinessListItem | null>(null);
async function addBusiness(picked: BusinessListItem) {
setBusiness(picked);
await guard(() => api.addDeal(nda.id, picked.id));
setBusiness(null);
setPicking(false);
}
return (
<div className="rounded border border-gray-200">
<div className="flex flex-wrap items-center gap-3 border-b border-gray-200 bg-gray-50 px-3 py-2">
<StatusBadge status={nda.status} label={nda.status === 'SIGNED' ? 'NDA signed' : 'NDA sent'} />
{nda.status === 'SIGNED' ? (
<span className="text-sm text-gray-600">on {formatDay(nda.signed_at)}</span>
) : (
<button
onClick={() => guard(() => api.updateNda(nda.id, { status: 'SIGNED' }))}
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs hover:bg-gray-100"
>
Mark signed
</button>
)}
<span className="text-xs text-gray-500">sent {formatDay(nda.sent_at)}</span>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 px-3 py-3 md:grid-cols-4">
<InlineField
label="Intro date"
type="date"
value={nda.intro_date}
onSave={(next) => guard(() => api.updateNda(nda.id, { intro_date: next }))}
/>
<InlineField
label="Signed date"
type="date"
value={nda.signed_at}
onSave={(next) => guard(() => api.updateNda(nda.id, { signed_at: next }))}
/>
<InlineField
label="Total purchase price"
value={nda.total_purchase_price}
onSave={(next) => guard(() => api.updateNda(nda.id, { total_purchase_price: next }))}
/>
<InlineField
label="Down payment"
value={nda.down_payment}
onSave={(next) => guard(() => api.updateNda(nda.id, { down_payment: next }))}
/>
<div className="col-span-2">
<InlineField
label="Preferred businesses"
value={nda.preferred_businesses_text}
multiline
onSave={(next) =>
guard(() => api.updateNda(nda.id, { preferred_businesses_text: next }))
}
/>
</div>
<div className="col-span-2">
<InlineField
label="NDA PDF on the NAS"
value={nda.nas_path}
onSave={(next) => guard(() => api.updateNda(nda.id, { nas_path: next }))}
/>
</div>
</div>
<div className="border-t border-gray-200">
{nda.deals.map((deal) => (
<DealRow
key={deal.id}
deal={deal}
guard={guard}
onOpenBusiness={onOpenBusiness}
onError={onError}
/>
))}
{nda.deals.length === 0 && (
<p className="px-3 py-2 text-sm text-gray-500">No businesses in this round.</p>
)}
<div className="px-3 py-2">
{picking ? (
<div className="flex flex-col gap-2">
<BusinessPicker
value={business}
onPick={(picked) => picked && addBusiness(picked)}
/>
<button
onClick={() => setPicking(false)}
className="self-start text-xs text-gray-500 hover:underline"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setPicking(true)}
className="text-xs text-blue-600 hover:underline"
>
+ Add business to this round
</button>
)}
</div>
</div>
</div>
);
}
// ----------------------------------------------------------------- Deal
function DealRow({
deal,
guard,
onOpenBusiness,
onError,
}: {
deal: Deal;
guard: (action: () => Promise<unknown>) => Promise<void>;
onOpenBusiness: (businessId: string) => void;
onError: (message: string) => void;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const [pending, setPending] = useState<DealStatus | null>(null);
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const [notes, setNotes] = useState<DealNote[] | null>(null);
const [notesOpen, setNotesOpen] = useState(false);
const forward = nextSteps(deal.status);
const corrections = ALL_DEAL_STATUSES.filter(
(status) => status !== deal.status && !forward.includes(status),
);
function pick(status: DealStatus) {
setMenuOpen(false);
setComment('');
setPending(status);
}
async function confirm() {
if (!pending) return;
setBusy(true);
await guard(() => api.setDealStatus(deal.id, pending, comment));
setBusy(false);
setPending(null);
}
async function toggleNotes() {
const next = !notesOpen;
setNotesOpen(next);
if (next) {
try {
setNotes(await api.dealNotes(deal.id));
} catch (err) {
onError((err as Error).message);
}
}
}
return (
<div className="border-b border-gray-100 last:border-0">
<div className="flex items-center gap-3 px-3 py-2">
<button
onClick={() => onOpenBusiness(deal.business.id)}
className="flex-1 truncate text-left text-sm text-blue-600 hover:underline"
>
{deal.business.name}
</button>
<StatusBadge status={deal.status} label={DEAL_LABELS[deal.status]} />
{deal.follow_up_at && (
<span className="text-xs text-gray-500">follow up {formatDay(deal.follow_up_at)}</span>
)}
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs hover:bg-gray-100"
>
Actions
</button>
{menuOpen && (
<div className="absolute right-0 z-10 mt-1 w-48 rounded border border-gray-200 bg-white py-1 shadow-lg">
{forward.map((status) => (
<button
key={status}
onClick={() => pick(status)}
className="block w-full px-3 py-1 text-left text-sm hover:bg-gray-100"
>
{DEAL_ACTIONS[status]}
</button>
))}
<div className="my-1 border-t border-gray-100 px-3 pt-1 text-xs text-gray-400">
Correct to
</div>
{corrections.map((status) => (
<button
key={status}
onClick={() => pick(status)}
className="block w-full px-3 py-1 text-left text-sm text-gray-600 hover:bg-gray-100"
>
{DEAL_LABELS[status]}
</button>
))}
</div>
)}
</div>
</div>
<div className="px-3 pb-2">
<button onClick={toggleNotes} className="text-xs text-gray-500 hover:underline">
{notesOpen ? '▾' : '▸'} Notes ({notes ? notes.length : deal.note_count})
</button>
{notesOpen && (
<ul className="mt-1 flex flex-col gap-1 border-l-2 border-gray-200 pl-3">
{notes?.map((note) => (
<li key={note.id} className="text-sm">
<span className="whitespace-pre-wrap">{note.text}</span>
<span className="ml-2 text-xs text-gray-400">
{note.author ?? 'unknown'} · {formatStamp(note.created_at)}
</span>
</li>
))}
{notes && notes.length === 0 && <li className="text-sm text-gray-500">No notes.</li>}
</ul>
)}
</div>
{pending && (
<Dialog
title={DEAL_ACTIONS[pending]}
confirmLabel="Confirm"
busy={busy}
onCancel={() => setPending(null)}
onConfirm={confirm}
>
<p className="mb-2 text-sm text-gray-600">
{deal.business.name}: {DEAL_LABELS[deal.status]} {DEAL_LABELS[pending]}
</p>
<textarea
rows={3}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Comment (optional) — saved as a note"
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</Dialog>
)}
</div>
);
}

118
web/src/views/Buyers.tsx Normal file
View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { api, type BuyerList, type BuyerStatus } from '../api.js';
import { StatusBadge } from '../components.js';
const CHIPS: { status: BuyerStatus; label: string }[] = [
{ status: 'ACTIVE', label: 'Active' },
{ status: 'DEACTIVATED', label: 'Deactivated' },
{ status: 'LEGACY', label: 'Legacy' },
];
export default function Buyers({
onOpen,
onNewInquiry,
}: {
onOpen: (id: string) => void;
onNewInquiry: () => void;
}) {
// No filter = every buyer; clicking the active chip again clears it, because
// the history of deactivated buyers matters as much as the active ones.
const [filter, setFilter] = useState<BuyerStatus | ''>('');
const [search, setSearch] = useState('');
const [data, setData] = useState<BuyerList | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.buyers(filter, search)
.then((res) => {
if (!cancelled) {
setData(res);
setError(null);
}
})
.catch((err: Error) => !cancelled && setError(err.message));
return () => {
cancelled = true;
};
}, [filter, search]);
return (
<div>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex gap-1">
{CHIPS.map((chip) => (
<button
key={chip.status}
onClick={() => setFilter(filter === chip.status ? '' : chip.status)}
className={`rounded px-3 py-1.5 text-sm ${
filter === chip.status
? 'bg-gray-900 text-white'
: 'border border-gray-300 bg-white hover:bg-gray-100'
}`}
>
{chip.label} ({data?.counts[chip.status] ?? 0})
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search company, contact, e-mail…"
className="w-64 rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
<button
onClick={onNewInquiry}
className="rounded bg-gray-900 px-3 py-1.5 text-sm text-white"
>
New inquiry
</button>
</div>
</div>
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
<table className="w-full border-collapse bg-white text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
<th className="px-3 py-2 font-medium">Company / name</th>
<th className="px-3 py-2 font-medium">Primary contact</th>
<th className="px-3 py-2 font-medium">E-mail</th>
<th className="w-24 px-3 py-2 text-right font-medium">NDA rounds</th>
<th className="w-24 px-3 py-2 text-right font-medium">Open deals</th>
<th className="w-32 px-3 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{data?.buyers.map((buyer) => (
<tr
key={buyer.id}
onClick={() => onOpen(buyer.id)}
className="cursor-pointer border-b border-gray-100 hover:bg-gray-50"
>
<td className="px-3 py-1.5">
{buyer.company_name ?? buyer.primary_contact?.name ?? '—'}
</td>
<td className="px-3 py-1.5 text-gray-600">{buyer.primary_contact?.name ?? '—'}</td>
<td className="px-3 py-1.5 text-gray-600">{buyer.primary_contact?.email ?? '—'}</td>
<td className="px-3 py-1.5 text-right text-gray-600">{buyer.nda_count}</td>
<td className="px-3 py-1.5 text-right text-gray-600">{buyer.open_deal_count}</td>
<td className="px-3 py-1.5">
<StatusBadge status={buyer.status} />
</td>
</tr>
))}
{data && data.buyers.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-4 text-gray-500">
No buyers.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,296 @@
import { useEffect, useState } from 'react';
import {
api,
type BusinessListItem,
type DealStatus,
type DuplicateCandidate,
type InquiryInput,
} from '../api.js';
import { BusinessPicker, DEAL_LABELS, StatusBadge, formatDay } from '../components.js';
const INPUT = 'w-full rounded border border-gray-300 px-2 py-1.5 text-sm';
const LABEL = 'mb-1 block text-xs uppercase tracking-wide text-gray-500';
/** A name shorter than this matches half the address book — not worth probing. */
const MIN_NAME_PROBE = 3;
export default function NewInquiry({
onCreated,
onCancel,
}: {
onCreated: (buyerId: string) => void;
onCancel: () => void;
}) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [cell, setCell] = useState('');
const [company, setCompany] = useState('');
const [business, setBusiness] = useState<BusinessListItem | null>(null);
// The e-mail is only probed once the field is left — probing every keystroke
// would fire on "a", "an", "ann@…" and never match anything useful.
const [emailProbe, setEmailProbe] = useState('');
const [candidates, setCandidates] = useState<DuplicateCandidate[]>([]);
const [dismissed, setDismissed] = useState(false);
const [locked, setLocked] = useState<DuplicateCandidate | null>(null);
const [backfillOpen, setBackfillOpen] = useState(false);
const [dealStatus, setDealStatus] = useState<DealStatus>('NEW');
const [ndaSigned, setNdaSigned] = useState(false);
const [signedAt, setSignedAt] = useState('');
const [ndaPath, setNdaPath] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (locked) return;
const nameProbe = name.trim().length >= MIN_NAME_PROBE ? name.trim() : '';
const phoneProbe = phone.trim() || cell.trim();
if (!nameProbe && !emailProbe && !phoneProbe) {
setCandidates([]);
return;
}
let cancelled = false;
const timer = setTimeout(() => {
api
.duplicates({ email: emailProbe, name: nameProbe, phone: phoneProbe })
.then((res) => !cancelled && setCandidates(res.candidates))
.catch(() => !cancelled && setCandidates([]));
}, 350);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [name, emailProbe, phone, cell, locked]);
async function submit() {
if (!name.trim()) return setError('A contact name is required.');
if (!business) return setError('Pick a business.');
setSubmitting(true);
setError(null);
const body: InquiryInput = {
contact: {
name: name.trim(),
email: email.trim(),
phone: phone.trim(),
cell: cell.trim(),
},
business_id: business.id,
};
if (locked) body.buyer_id = locked.buyer_id;
else if (company.trim()) body.company_name = company.trim();
if (backfillOpen) {
body.backfill = {
deal_status: dealStatus,
nda_status: ndaSigned ? 'SIGNED' : 'SENT',
signed_at: ndaSigned ? signedAt : '',
nda_nas_path: ndaPath.trim(),
};
}
try {
const res = await api.createInquiry(body);
onCreated(res.buyer_id);
} catch (err) {
setError((err as Error).message);
} finally {
setSubmitting(false);
}
}
const showWarning = !locked && !dismissed && candidates.length > 0;
return (
<div className="mx-auto w-full max-w-2xl">
<button onClick={onCancel} className="text-sm text-blue-600 hover:underline">
Back to buyers
</button>
<h1 className="mt-2 mb-4 text-lg font-semibold">New inquiry</h1>
{locked && (
<div className="mb-3 flex items-center gap-2 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-sm">
<span className="rounded-full border border-blue-300 bg-white px-2 py-0.5 text-xs text-blue-800">
existing buyer
</span>
<span className="flex-1">
{locked.company_name ?? locked.contact_name}
<span className="text-gray-500"> · {locked.nda_count} NDA round(s)</span>
</span>
<button
onClick={() => setLocked(null)}
className="text-xs text-blue-700 hover:underline"
>
Undo
</button>
</div>
)}
{showWarning && (
<div className="mb-3 rounded border border-amber-300 bg-amber-50 p-3">
<p className="text-sm font-medium text-amber-900">
{candidates.length === 1 ? 'A buyer already matches' : 'Existing buyers match'} this
contact
</p>
<ul className="mt-2 flex flex-col gap-2">
{candidates.map((candidate) => (
<li
key={candidate.buyer_id}
className="flex items-center gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-sm"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{candidate.company_name ?? candidate.contact_name}
</span>
<StatusBadge status={candidate.buyer_status} />
<span className="text-xs text-gray-500">
matched on {candidate.matched_on.join(', ')}
</span>
</div>
<div className="truncate text-xs text-gray-500">
{candidate.contact_name}
{candidate.contact_email ? ` · ${candidate.contact_email}` : ''} ·{' '}
{candidate.nda_count} round(s) · last {formatDay(candidate.last_date)}
</div>
</div>
<button
onClick={() => setLocked(candidate)}
className="shrink-0 rounded border border-amber-400 bg-white px-2 py-1 text-xs hover:bg-amber-100"
>
Use this buyer
</button>
</li>
))}
</ul>
<button
onClick={() => setDismissed(true)}
className="mt-2 text-xs text-amber-900 underline"
>
Create new buyer anyway
</button>
</div>
)}
<div className="grid grid-cols-2 gap-3 rounded border border-gray-200 bg-white p-3">
<div className="col-span-2">
<label className={LABEL}>Contact name *</label>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
className={INPUT}
/>
</div>
<div>
<label className={LABEL}>E-mail</label>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setEmailProbe(email.trim())}
className={INPUT}
/>
</div>
<div>
<label className={LABEL}>Company name</label>
<input
value={company}
onChange={(e) => setCompany(e.target.value)}
disabled={locked !== null}
className={`${INPUT} disabled:bg-gray-100`}
/>
</div>
<div>
<label className={LABEL}>Phone</label>
<input value={phone} onChange={(e) => setPhone(e.target.value)} className={INPUT} />
</div>
<div>
<label className={LABEL}>Cell</label>
<input value={cell} onChange={(e) => setCell(e.target.value)} className={INPUT} />
</div>
<div className="col-span-2">
<label className={LABEL}>Business *</label>
<BusinessPicker value={business} onPick={setBusiness} />
</div>
</div>
<div className="mt-3 rounded border border-gray-200 bg-white">
<button
onClick={() => setBackfillOpen(!backfillOpen)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm"
>
<span className="text-gray-400">{backfillOpen ? '▾' : '▸'}</span>
Backfill existing deal (paper records)
</button>
{backfillOpen && (
<div className="grid grid-cols-2 gap-3 border-t border-gray-200 p-3">
<div>
<label className={LABEL}>Deal status</label>
<select
value={dealStatus}
onChange={(e) => setDealStatus(e.target.value as DealStatus)}
className={INPUT}
>
{(Object.keys(DEAL_LABELS) as DealStatus[]).map((status) => (
<option key={status} value={status}>
{DEAL_LABELS[status]}
</option>
))}
</select>
</div>
<div>
<label className={LABEL}>NDA</label>
<label className="flex items-center gap-2 py-1.5 text-sm">
<input
type="checkbox"
checked={ndaSigned}
onChange={(e) => setNdaSigned(e.target.checked)}
/>
already signed
</label>
</div>
{ndaSigned && (
<div>
<label className={LABEL}>Signed date</label>
<input
type="date"
value={signedAt}
onChange={(e) => setSignedAt(e.target.value)}
className={INPUT}
/>
<p className="mt-1 text-xs text-gray-500">Empty = today.</p>
</div>
)}
<div className={ndaSigned ? '' : 'col-span-2'}>
<label className={LABEL}>NDA PDF path on the NAS</label>
<input
value={ndaPath}
onChange={(e) => setNdaPath(e.target.value)}
placeholder="e.g. NDA/2024/smith-nda.pdf"
className={`${INPUT} font-mono`}
/>
</div>
</div>
)}
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 flex gap-2">
<button
onClick={submit}
disabled={submitting}
className="rounded bg-gray-900 px-4 py-2 text-sm text-white disabled:opacity-50"
>
{submitting ? 'Creating…' : 'Create inquiry'}
</button>
<button
onClick={onCancel}
className="rounded border border-gray-300 px-4 py-2 text-sm hover:bg-gray-100"
>
Cancel
</button>
</div>
</div>
);
}