Files
bizmatch-app/web/src/views/NdaInbox.tsx
2026-07-27 17:48:40 -05:00

314 lines
11 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { api, type Inbox, type InboxRow, type InboxStatus, type SyncResult } from '../api.js';
import { formatDayTimeParts, formatRelative } from '../components.js';
const INPUT = 'rounded border border-gray-300 px-2 py-1.5 text-sm';
const STATUS_TONE: Record<InboxStatus, string> = {
pending: 'border-amber-300 bg-amber-50 text-amber-800',
signed: 'border-green-300 bg-green-50 text-green-800',
declined: 'border-red-300 bg-red-50 text-red-800',
};
const isoDaysAgo = (days: number) =>
new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
/**
* Date and time as two nowrap spans: they sit on one line when the column
* allows it, otherwise the time wraps under the date. The time matters —
* Dropbox orders to the second, and same-day rows are common.
*/
function DateTime({ iso }: { iso: string }) {
const { day, time } = formatDayTimeParts(iso);
return (
<>
<span className="whitespace-nowrap">{day}</span>{' '}
<span className="whitespace-nowrap text-gray-400">{time}</span>
</>
);
}
export default function NdaInbox({
onOpenBuyer,
onChanged,
}: {
onOpenBuyer: (buyerId: string) => void;
/** Imported rounds land in Today's pending list, so the badge follows. */
onChanged: () => void;
}) {
const [since, setSince] = useState(() => isoDaysAgo(90));
const [inbox, setInbox] = useState<Inbox | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<SyncResult | null>(null);
// Business chosen per row; '' means "import the NDA without a deal".
const [picked, setPicked] = useState<Record<string, string>>({});
const [busy, setBusy] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const poll = useRef<number | null>(null);
const rows: InboxRow[] | null = inbox?.requests ?? null;
const refreshing = inbox?.refresh_state === 'running';
const refreshError = inbox?.refresh_state?.startsWith('error:')
? inbox.refresh_state.slice('error:'.length)
: null;
/** Reads the mirror out of the database — this never talks to Dropbox. */
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.ndaInbox(since);
setInbox(res);
setError(null);
// Preselect the best suggestion, but never overwrite a manual choice.
setPicked((current) => {
const next = { ...current };
for (const row of res.requests) {
if (next[row.signature_request_id] === undefined) {
next[row.signature_request_id] = row.business_suggestions[0]?.id ?? '';
}
}
return next;
});
return res;
} catch (err) {
setError((err as Error).message);
return null;
} finally {
setLoading(false);
}
}, [since]);
useEffect(() => {
void load();
}, [load]);
// Whenever a walk is in flight — started here or elsewhere — watch it until
// it finishes, then show whatever it brought in.
useEffect(() => {
if (!refreshing) {
if (poll.current !== null) {
window.clearInterval(poll.current);
poll.current = null;
}
return;
}
if (poll.current !== null) return;
poll.current = window.setInterval(() => void load(), 3000);
return () => {
if (poll.current !== null) {
window.clearInterval(poll.current);
poll.current = null;
}
};
}, [refreshing, load]);
async function refresh() {
setError(null);
try {
await api.refreshInbox(since);
// Flip to "running" at once so the poller starts without a round trip.
setInbox((current) => (current ? { ...current, refresh_state: 'running' } : current));
} catch (err) {
setError((err as Error).message);
}
}
async function sync() {
setSyncing(true);
setSyncResult(null);
setError(null);
try {
setSyncResult(await api.syncInbox());
await load();
onChanged();
} catch (err) {
setError((err as Error).message);
} finally {
setSyncing(false);
}
}
async function importRow(row: InboxRow) {
setBusy(row.signature_request_id);
setNotice(null);
setError(null);
try {
const res = await api.importInbox(
row.signature_request_id,
row.known_buyer?.buyer_id,
picked[row.signature_request_id] || undefined,
);
setNotice(
res.warning ??
`Imported ${row.signer.name || row.signer.email}` +
`${res.created.buyer ? ' as a new buyer' : ' into the existing buyer'}` +
`${res.nas_path ? ' · PDF filed' : ''}.`,
);
await load();
onChanged();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(null);
}
}
return (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-sm text-gray-600">
Since
<input
type="date"
value={since}
onChange={(e) => setSince(e.target.value)}
className={INPUT}
/>
</label>
<span className="text-sm text-gray-500">
Last refreshed: {formatRelative(inbox?.last_refresh_at ?? null)}
{loading && !refreshing && ' · loading…'}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={refresh}
disabled={refreshing}
title="Fetches the signature requests from Dropbox Sign in the background"
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm hover:bg-gray-100 disabled:opacity-50"
>
{refreshing ? 'Refreshing…' : 'Refresh'}
</button>
<button
onClick={sync}
disabled={syncing}
className="rounded bg-gray-900 px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
{syncing ? 'Syncing…' : 'Sync signatures'}
</button>
</div>
</div>
{refreshing && (
<p className="mb-3 text-sm text-gray-600">
Fetching from Dropbox Sign in the background the table below stays usable.
</p>
)}
{refreshError && (
<p className="mb-3 text-sm text-red-600">Last refresh failed: {refreshError}</p>
)}
{syncResult && (
<p className="mb-3 text-sm text-gray-600">
Checked {syncResult.checked} · newly signed {syncResult.signed} · declined{' '}
{syncResult.declined} · failed {syncResult.failed}
{syncResult.warnings.length > 0 && (
<span className="text-amber-700"> · {syncResult.warnings.join('; ')}</span>
)}
</p>
)}
{notice && <p className="mb-3 text-sm text-gray-600">{notice}</p>}
{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="w-28 px-3 py-2 font-medium">Date</th>
<th className="px-3 py-2 font-medium">Signer</th>
<th className="px-3 py-2 font-medium">E-mail</th>
<th className="w-24 px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Title</th>
<th className="px-3 py-2 font-medium">Buyer</th>
<th className="px-3 py-2 font-medium">Deal</th>
<th className="w-24 px-3 py-2 font-medium" />
</tr>
</thead>
<tbody>
{rows?.map((row) => (
<tr key={row.signature_request_id} className="border-b border-gray-100 align-top">
<td className="px-3 py-1.5 text-gray-500">
<DateTime iso={row.created_at} />
</td>
<td className="px-3 py-1.5">{row.signer.name || '—'}</td>
<td className="px-3 py-1.5 text-gray-600">{row.signer.email || '—'}</td>
<td className="px-3 py-1.5">
<span
className={`rounded-full border px-2 py-0.5 text-xs whitespace-nowrap ${
STATUS_TONE[row.status]
}`}
>
{row.status}
</span>
</td>
<td className="px-3 py-1.5 text-gray-600">{row.title_remainder || '—'}</td>
<td className="px-3 py-1.5">
{row.known_buyer ? (
<button
onClick={() => row.known_buyer && onOpenBuyer(row.known_buyer.buyer_id)}
className="rounded-full border border-blue-300 bg-blue-50 px-2 py-0.5 text-xs text-blue-800"
>
Known: {row.known_buyer.company_name ?? row.known_buyer.contact_name}
</button>
) : (
<span className="text-xs text-gray-400">new</span>
)}
</td>
<td className="px-3 py-1.5">
{row.imported ? (
<span className="text-xs text-gray-400"></span>
) : (
<select
value={picked[row.signature_request_id] ?? ''}
onChange={(e) =>
setPicked({ ...picked, [row.signature_request_id]: e.target.value })
}
className="w-56 rounded border border-gray-300 px-1.5 py-1 text-sm"
>
<option value=""> no deal </option>
{row.business_suggestions.map((business) => (
<option key={business.id} value={business.id}>
{business.name}
</option>
))}
</select>
)}
</td>
<td className="px-3 py-1.5">
{row.imported ? (
<button
onClick={() => row.imported && onOpenBuyer(row.imported.buyer_id)}
className="text-sm text-green-700 hover:underline"
title="Already imported — open the buyer"
>
imported
</button>
) : (
<button
onClick={() => importRow(row)}
disabled={busy === row.signature_request_id}
className="rounded bg-gray-900 px-2 py-1 text-xs text-white disabled:opacity-50"
>
{busy === row.signature_request_id ? 'Importing…' : 'Import'}
</button>
)}
</td>
</tr>
))}
{rows && rows.length === 0 && (
<tr>
<td colSpan={8} className="px-3 py-4 text-gray-500">
{inbox?.last_refresh_at
? 'No NDA signature requests in this period.'
: 'Nothing fetched yet — press Refresh to load the signature requests.'}
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}