module 6a
This commit is contained in:
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { ApiError, api, type Staff } from './api.js';
|
||||
import Login from './views/Login.js';
|
||||
import Today from './views/Today.js';
|
||||
import NdaInbox from './views/NdaInbox.js';
|
||||
import Businesses from './views/Businesses.js';
|
||||
import BusinessDetail from './views/BusinessDetail.js';
|
||||
import Buyers from './views/Buyers.js';
|
||||
@@ -11,6 +12,7 @@ import NewInquiry from './views/NewInquiry.js';
|
||||
/** Hand-rolled routing: which view, and (for the detail views) which row. */
|
||||
type Route =
|
||||
| { view: 'today' }
|
||||
| { view: 'nda-inbox' }
|
||||
| { view: 'businesses' }
|
||||
| { view: 'business'; id: string }
|
||||
| { view: 'buyers' }
|
||||
@@ -19,6 +21,7 @@ type Route =
|
||||
|
||||
const NAV: { label: string; route: Route; active: Route['view'][] }[] = [
|
||||
{ label: 'Today', route: { view: 'today' }, active: ['today'] },
|
||||
{ label: 'NDA Inbox', route: { view: 'nda-inbox' }, active: ['nda-inbox'] },
|
||||
{ label: 'Businesses', route: { view: 'businesses' }, active: ['businesses', 'business'] },
|
||||
{ label: 'Buyers', route: { view: 'buyers' }, active: ['buyers', 'buyer', 'new-inquiry'] },
|
||||
];
|
||||
@@ -108,7 +111,12 @@ export default function App() {
|
||||
/>
|
||||
</main>
|
||||
) : (
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 overflow-auto px-6 py-6">
|
||||
<main
|
||||
// The inbox table carries eight columns and needs the extra width.
|
||||
className={`mx-auto w-full flex-1 overflow-auto px-6 py-6 ${
|
||||
route.view === 'nda-inbox' ? 'max-w-7xl' : 'max-w-5xl'
|
||||
}`}
|
||||
>
|
||||
{route.view === 'today' && (
|
||||
<Today
|
||||
staff={staff}
|
||||
@@ -117,6 +125,12 @@ export default function App() {
|
||||
onChanged={refreshBadge}
|
||||
/>
|
||||
)}
|
||||
{route.view === 'nda-inbox' && (
|
||||
<NdaInbox
|
||||
onOpenBuyer={(id) => setRoute({ view: 'buyer', id })}
|
||||
onChanged={refreshBadge}
|
||||
/>
|
||||
)}
|
||||
{route.view === 'businesses' && (
|
||||
<Businesses onOpen={(id) => setRoute({ view: 'business', id })} />
|
||||
)}
|
||||
|
||||
@@ -105,6 +105,11 @@ export interface NdaRound {
|
||||
preferred_businesses_text: string | null;
|
||||
total_purchase_price: string | null;
|
||||
down_payment: string | null;
|
||||
/** Set when the round came in through the NDA inbox. */
|
||||
dropbox_sign_id: string | null;
|
||||
signer_name: string | null;
|
||||
/** A declined round stays SENT — it was never signed. */
|
||||
declined: boolean;
|
||||
deals: Deal[];
|
||||
}
|
||||
|
||||
@@ -250,6 +255,53 @@ export interface Today {
|
||||
counts: TodayCounts;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- NDA inbox
|
||||
export type InboxStatus = 'pending' | 'signed' | 'declined';
|
||||
|
||||
export interface InboxRow {
|
||||
signature_request_id: string;
|
||||
title: string;
|
||||
/** The title with the prefix and the signer's name stripped out. */
|
||||
title_remainder: string;
|
||||
/** Full ISO timestamp: Dropbox orders by date *and* time. */
|
||||
created_at: string;
|
||||
status: InboxStatus;
|
||||
signer: { name: string; email: string };
|
||||
/** Full ISO timestamp as well — the second the signature came in. */
|
||||
signed_at: string | null;
|
||||
imported: { nda_id: string; buyer_id: string } | null;
|
||||
known_buyer: { buyer_id: string; company_name: string | null; contact_name: string } | null;
|
||||
business_suggestions: { id: string; name: string; status: BusinessStatus }[];
|
||||
}
|
||||
|
||||
export type RefreshState = 'idle' | 'running' | string;
|
||||
|
||||
export interface Inbox {
|
||||
requests: InboxRow[];
|
||||
/** ISO timestamp of the last completed background refresh, null if never. */
|
||||
last_refresh_at: string | null;
|
||||
refresh_state: RefreshState;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
buyer_id: string;
|
||||
nda_id: string;
|
||||
deal_id: string | null;
|
||||
created: { buyer: boolean; contact: boolean };
|
||||
status: InboxStatus;
|
||||
nas_path: string | null;
|
||||
/** Set when the round was imported but the PDF could not be filed. */
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
checked: number;
|
||||
signed: number;
|
||||
declined: number;
|
||||
failed: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(readonly status: number, message: string) {
|
||||
super(message);
|
||||
@@ -352,14 +404,37 @@ export const api = {
|
||||
business_id: businessId,
|
||||
path: filePath,
|
||||
}),
|
||||
|
||||
/** Reads the mirrored requests out of the database — never calls Dropbox. */
|
||||
ndaInbox: (since: string) => request<Inbox>(`/api/nda-inbox?${qs({ since })}`),
|
||||
/** Starts the background walk; resolves as soon as it is queued. */
|
||||
refreshInbox: (since: string) =>
|
||||
post<{ state: RefreshState }>(`/api/nda-inbox/refresh?${qs({ since })}`),
|
||||
importInbox: (signatureRequestId: string, buyerId?: string, businessId?: string) =>
|
||||
post<ImportResult>('/api/nda-inbox/import', {
|
||||
signature_request_id: signatureRequestId,
|
||||
buyer_id: buyerId,
|
||||
business_id: businessId,
|
||||
}),
|
||||
syncInbox: () => post<SyncResult>('/api/nda-inbox/sync'),
|
||||
};
|
||||
|
||||
/** Same-origin streaming URL of the PDF filed for one NDA round. */
|
||||
export function ndaFileUrl(ndaId: string): string {
|
||||
return `/api/ndas/${ndaId}/file`;
|
||||
}
|
||||
|
||||
/** Same-origin streaming URL of one file inside a business directory. */
|
||||
export function businessFileUrl(id: string, filePath: string): string {
|
||||
return `/api/businesses/${id}/file?path=${encodeURIComponent(filePath)}`;
|
||||
}
|
||||
|
||||
/** The standalone (unbundled) pdf.js viewer page, pointed at a business file. */
|
||||
export function viewerUrl(id: string, filePath: string): string {
|
||||
return `/viewer/index.html?file=${encodeURIComponent(businessFileUrl(id, filePath))}`;
|
||||
/** The standalone (unbundled) pdf.js viewer page, pointed at any /api/ file URL. */
|
||||
export function viewerUrlFor(apiPath: string): string {
|
||||
return `/viewer/index.html?file=${encodeURIComponent(apiPath)}`;
|
||||
}
|
||||
|
||||
/** The viewer, pointed at a business file. */
|
||||
export function viewerUrl(id: string, filePath: string): string {
|
||||
return viewerUrlFor(businessFileUrl(id, filePath));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,43 @@ export function formatDay(day: Day | null | undefined): string {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An instant split into its two halves, both in local time: "Jul 27, 2026"
|
||||
* and "3:02 PM". Kept apart so a narrow column can wrap the time under the
|
||||
* date instead of truncating it.
|
||||
*/
|
||||
export function formatDayTimeParts(iso: string | null): { day: string; time: string } {
|
||||
if (!iso) return { day: '—', time: '' };
|
||||
const at = new Date(iso);
|
||||
if (Number.isNaN(at.getTime())) return { day: iso, time: '' };
|
||||
return {
|
||||
day: at.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' }),
|
||||
time: at.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }),
|
||||
};
|
||||
}
|
||||
|
||||
/** "just now" / "12 minutes ago" / "3 days ago" — for the refresh line. */
|
||||
export function formatRelative(iso: string | null): string {
|
||||
if (!iso) return 'never';
|
||||
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (seconds < 60) return 'just now';
|
||||
const steps: [number, string][] = [
|
||||
[60, 'minute'],
|
||||
[3600, 'hour'],
|
||||
[86400, 'day'],
|
||||
];
|
||||
let unit = 'minute';
|
||||
let size = 60;
|
||||
for (const [step, name] of steps) {
|
||||
if (seconds >= step) {
|
||||
size = step;
|
||||
unit = name;
|
||||
}
|
||||
}
|
||||
const value = Math.floor(seconds / size);
|
||||
return `${value} ${unit}${value === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
export function formatStamp(iso: string): string {
|
||||
return new Date(iso).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
|
||||
@@ -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