314 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|