module 6a
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
api,
|
||||
ndaFileUrl,
|
||||
viewerUrlFor,
|
||||
type Buyer,
|
||||
type BusinessListItem,
|
||||
type Contact,
|
||||
@@ -375,6 +377,26 @@ function Round({
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">sent {formatDay(nda.sent_at)}</span>
|
||||
{/* A declined round keeps status SENT, so it needs its own badge. */}
|
||||
{nda.declined && (
|
||||
<span className="rounded-full border border-red-300 bg-red-50 px-2 py-0.5 text-xs text-red-800">
|
||||
Declined
|
||||
</span>
|
||||
)}
|
||||
{nda.signer_name && (
|
||||
<span className="text-xs text-gray-500">signed by {nda.signer_name}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{nda.nas_path && (
|
||||
<a
|
||||
href={viewerUrlFor(ndaFileUrl(nda.id))}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs hover:bg-gray-100"
|
||||
>
|
||||
View PDF
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 px-3 py-3 md:grid-cols-4">
|
||||
|
||||
313
web/src/views/NdaInbox.tsx
Normal file
313
web/src/views/NdaInbox.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user