nda improvements

This commit is contained in:
2026-07-28 17:35:44 -05:00
parent 4653f53ff3
commit c9202d0b80
16 changed files with 35467 additions and 237 deletions

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { ApiError, api, type Staff } from './api.js';
import { StaffName } from './staff-color.js';
import Login from './views/Login.js';
import Today from './views/Today.js';
import NdaInbox from './views/NdaInbox.js';
@@ -92,7 +93,7 @@ export default function App() {
</nav>
</div>
<div className="flex items-center gap-3 text-sm">
<span className="text-gray-600">{staff.name}</span>
<StaffName id={staff.id} name={staff.name} />
<button
className="rounded border border-gray-300 px-2 py-1 text-xs hover:bg-gray-100"
onClick={signOut}

View File

@@ -283,11 +283,23 @@ export interface InboxRow {
export type RefreshState = 'idle' | 'running' | string;
export interface RefreshResult {
pages: number;
seen: number;
stored: number;
}
export interface Inbox {
requests: InboxRow[];
/** ISO timestamp of the last completed background refresh, null if never. */
last_refresh_at: string | null;
refresh_state: RefreshState;
/** What the last completed walk did, null before the first one. */
last_refresh_result: RefreshResult | null;
/** The sync runs in the background too, with the same state pattern. */
last_sync_at: string | null;
sync_state: RefreshState;
last_sync_result: SyncResult | null;
}
export interface ImportResult {
@@ -314,7 +326,13 @@ export interface SyncResult {
signed: number;
declined: number;
failed: number;
/** Old pending mirror rows re-fetched one by one, and how many had moved on. */
mirror_candidates: number;
mirror_rechecked: number;
mirror_changed: number;
mirror_failed: number;
warnings: string[];
warnings_omitted: number;
}
export class ApiError extends Error {
@@ -422,8 +440,12 @@ export const api = {
/** 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) =>
/**
* Starts the background walk; resolves as soon as it is queued. Passing
* `since` makes the server re-read that whole window — leave it out for the
* incremental catch-up, which is what a plain Refresh should do.
*/
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', {
@@ -431,7 +453,7 @@ export const api = {
buyer_id: buyerId,
business_id: businessId,
}),
syncInbox: () => post<SyncResult>('/api/nda-inbox/sync'),
syncInbox: () => post<{ state: RefreshState }>('/api/nda-inbox/sync'),
backfillFields: () => post<BackfillResult>('/api/nda-inbox/backfill-fields'),
};

71
web/src/staff-color.tsx Normal file
View File

@@ -0,0 +1,71 @@
/**
* A stable colour per staff member, so "who did this" is readable at a glance
* without reading the name. Derived from the id, which means it survives
* reloads, sessions and renames, and needs nothing stored anywhere.
*/
/**
* Ten hues that stay apart from each other and from the greys the app uses.
* Written out in full because Tailwind only ships classes it can see in the
* source — a generated `text-${hue}-700` would be purged.
*/
export interface StaffColor {
text: string;
dot: string;
}
const PALETTE: StaffColor[] = [
{ text: 'text-blue-700', dot: 'bg-blue-500' },
{ text: 'text-emerald-700', dot: 'bg-emerald-500' },
{ text: 'text-violet-700', dot: 'bg-violet-500' },
{ text: 'text-amber-700', dot: 'bg-amber-500' },
{ text: 'text-rose-700', dot: 'bg-rose-500' },
{ text: 'text-cyan-700', dot: 'bg-cyan-500' },
{ text: 'text-lime-700', dot: 'bg-lime-600' },
{ text: 'text-fuchsia-700', dot: 'bg-fuchsia-500' },
{ text: 'text-orange-700', dot: 'bg-orange-500' },
{ text: 'text-teal-700', dot: 'bg-teal-600' },
];
/** FNV-1a — short, stable, and well spread over ten buckets. */
function hash(value: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < value.length; i += 1) {
h ^= value.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return Math.abs(h);
}
/** Grey stands in for "nobody" — a deleted staff member or an unassigned row. */
const UNKNOWN: StaffColor = { text: 'text-gray-500', dot: 'bg-gray-400' };
export function staffColor(id: string | null | undefined): StaffColor {
if (!id) return UNKNOWN;
return PALETTE[hash(id) % PALETTE.length] ?? UNKNOWN;
}
/**
* A staff member's name in their colour. Deliberately understated — a dot and
* tinted text, never a filled pill, so a list of names does not turn into a
* traffic light.
*/
export function StaffName({
id,
name,
dot = true,
className = '',
}: {
id: string | null | undefined;
name: string | null | undefined;
dot?: boolean;
className?: string;
}) {
const color = staffColor(id);
return (
<span className={`inline-flex items-center gap-1 ${color.text} ${className}`}>
{dot && <span className={`h-1.5 w-1.5 shrink-0 rounded-full ${color.dot}`} />}
{name ?? 'unknown'}
</span>
);
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api, type Inbox, type InboxRow, type InboxStatus, type SyncResult } from '../api.js';
import { api, type Inbox, type InboxRow, type InboxStatus } from '../api.js';
import { formatDayTimeParts, formatRelative } from '../components.js';
const INPUT = 'rounded border border-gray-300 px-2 py-1.5 text-sm';
@@ -37,22 +37,34 @@ export default function NdaInbox({
onChanged: () => void;
}) {
const [since, setSince] = useState(() => isoDaysAgo(90));
// Sending ?since= tells the server to re-read the whole window, which is a
// minute of paging. A plain Refresh must not do that — only a date the user
// actually moved since the last run asks for the wide reload, once.
const [sinceChanged, setSinceChanged] = useState(false);
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);
// Which row is mid-import, if any.
const [importing, setImporting] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const poll = useRef<number | null>(null);
// Set when a task started *here* finishes, so its result is reported once
// rather than on every mount.
const [refreshNotice, setRefreshNotice] = useState<string | null>(null);
const [syncNotice, setSyncNotice] = useState<string | null>(null);
const watchingRefresh = useRef(false);
const watchingSync = useRef(false);
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;
const syncing = inbox?.sync_state === 'running';
// While either task runs the table keeps polling; both report when done.
const busy = refreshing || syncing;
const taskError = [inbox?.refresh_state, inbox?.sync_state]
.find((state) => state?.startsWith('error:'))
?.slice('error:'.length);
/** Reads the mirror out of the database — this never talks to Dropbox. */
const load = useCallback(async () => {
@@ -61,16 +73,9 @@ export default function NdaInbox({
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;
});
// Deliberately no preselection: a suggestion is a guess, and an import
// must never attach a deal the user did not pick. Anything already
// chosen by hand is kept across reloads.
return res;
} catch (err) {
setError((err as Error).message);
@@ -87,7 +92,31 @@ export default function NdaInbox({
// 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 (!refreshing && watchingRefresh.current && inbox?.last_refresh_result) {
watchingRefresh.current = false;
const { pages, seen, stored } = inbox.last_refresh_result;
setRefreshNotice(
`Refreshed: ${stored} request${stored === 1 ? '' : 's'} stored ` +
`from ${seen} seen over ${pages} page${pages === 1 ? '' : 's'}.`,
);
}
if (!syncing && watchingSync.current && inbox?.last_sync_result) {
watchingSync.current = false;
const r = inbox.last_sync_result;
setSyncNotice(
`Synced: ${r.checked} NDA(s) checked, ${r.signed} newly signed, ` +
`${r.declined} declined` +
(r.failed > 0 ? `, ${r.failed} failed` : '') +
` · ${r.mirror_rechecked}/${r.mirror_candidates} older request(s) re-read, ` +
`${r.mirror_changed} had moved on` +
(r.mirror_failed > 0 ? `, ${r.mirror_failed} unreadable` : '') +
(r.warnings.length > 0
? ` · ${r.warnings.join('; ')}${r.warnings_omitted > 0 ? ` (+${r.warnings_omitted} more)` : ''}`
: ''),
);
}
if (!busy) {
if (poll.current !== null) {
window.clearInterval(poll.current);
poll.current = null;
@@ -102,12 +131,16 @@ export default function NdaInbox({
poll.current = null;
}
};
}, [refreshing, load]);
}, [busy, refreshing, syncing, load, inbox?.last_refresh_result, inbox?.last_sync_result]);
async function refresh() {
setError(null);
setRefreshNotice(null);
try {
await api.refreshInbox(since);
// Only a moved date asks for the full window; then back to incremental.
await api.refreshInbox(sinceChanged ? since : undefined);
setSinceChanged(false);
watchingRefresh.current = true;
// Flip to "running" at once so the poller starts without a round trip.
setInbox((current) => (current ? { ...current, refresh_state: 'running' } : current));
} catch (err) {
@@ -116,22 +149,20 @@ export default function NdaInbox({
}
async function sync() {
setSyncing(true);
setSyncResult(null);
setError(null);
setSyncNotice(null);
try {
setSyncResult(await api.syncInbox());
await load();
onChanged();
await api.syncInbox();
watchingSync.current = true;
// Flip to "running" at once so the poller starts without a round trip.
setInbox((current) => (current ? { ...current, sync_state: 'running' } : current));
} catch (err) {
setError((err as Error).message);
} finally {
setSyncing(false);
}
}
async function importRow(row: InboxRow) {
setBusy(row.signature_request_id);
setImporting(row.signature_request_id);
setNotice(null);
setError(null);
try {
@@ -151,7 +182,7 @@ export default function NdaInbox({
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(null);
setImporting(null);
}
}
@@ -164,7 +195,11 @@ export default function NdaInbox({
<input
type="date"
value={since}
onChange={(e) => setSince(e.target.value)}
onChange={(e) => {
setSince(e.target.value);
setSinceChanged(true);
}}
title="Changing the date makes the next Refresh re-read that whole window"
className={INPUT}
/>
</label>
@@ -177,10 +212,14 @@ export default function NdaInbox({
<button
onClick={refresh}
disabled={refreshing}
title="Fetches the signature requests from Dropbox Sign in the background"
title={
sinceChanged
? 'Reload the whole window from the changed date'
: 'Refresh (new since last check)'
}
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'}
{refreshing ? 'Refreshing…' : sinceChanged ? 'Reload window' : 'Refresh'}
</button>
<button
onClick={sync}
@@ -192,24 +231,16 @@ export default function NdaInbox({
</div>
</div>
{refreshing && (
{busy && (
<p className="mb-3 text-sm text-gray-600">
Fetching from Dropbox Sign in the background the table below stays usable.
{refreshing ? 'Fetching from' : 'Re-checking with'} 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>
)}
{taskError && <p className="mb-3 text-sm text-red-600">Last run failed: {taskError}</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>
)}
{refreshNotice && <p className="mb-3 text-sm text-gray-600">{refreshNotice}</p>}
{syncNotice && <p className="mb-3 text-sm text-gray-600">{syncNotice}</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>}
@@ -289,10 +320,10 @@ export default function NdaInbox({
) : (
<button
onClick={() => importRow(row)}
disabled={busy === row.signature_request_id}
disabled={importing === 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'}
{importing === row.signature_request_id ? 'Importing…' : 'Import'}
</button>
)}
</td>

View File

@@ -9,17 +9,30 @@ import {
} from '../api.js';
import { DEAL_LABELS, Dialog, Panel, StatusBadge, formatDay } from '../components.js';
import { TodoRow } from '../workflow.js';
import { StaffName } from '../staff-color.js';
const INPUT = 'w-full rounded border border-gray-300 px-2 py-1 text-sm';
/** Groups anything with a person attached, for the team tab. */
function groupBy<T>(rows: T[], name: (row: T) => string): [string, T[]][] {
const groups = new Map<string, T[]>();
interface Group<T> {
id: string | null;
name: string;
rows: T[];
}
/**
* Groups anything with a person attached, for the team tab. Keyed by staff id
* rather than name, so the group heading can carry that person's colour.
*/
function groupByStaff<T>(rows: T[], who: (row: T) => { id: string | null; name: string }): Group<T>[] {
const groups = new Map<string, Group<T>>();
for (const row of rows) {
const key = name(row);
groups.set(key, [...(groups.get(key) ?? []), row]);
const { id, name } = who(row);
const key = id ?? name;
const group = groups.get(key) ?? { id, name, rows: [] };
group.rows.push(row);
groups.set(key, group);
}
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
return [...groups.values()].sort((a, b) => a.name.localeCompare(b.name));
}
export default function Today({
@@ -110,10 +123,16 @@ export default function Today({
{data && data.todos.length > 0 && (
<Panel title="Todos">
{team ? (
groupBy(data.todos, (todo) => todo.assigned_to.name).map(([name, todos]) => (
<div key={name} className="mb-2 last:mb-0">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{name}</p>
<TodoList todos={todos} onChanged={refresh} open={{ onOpenBuyer, onOpenBusiness }} />
groupByStaff(data.todos, (todo) => todo.assigned_to).map((group) => (
<div key={group.id ?? group.name} className="mb-2 last:mb-0">
<p className="text-xs font-medium uppercase tracking-wide">
<StaffName id={group.id} name={group.name} />
</p>
<TodoList
todos={group.rows}
onChanged={refresh}
open={{ onOpenBuyer, onOpenBusiness }}
/>
</div>
))
) : (
@@ -125,11 +144,16 @@ export default function Today({
{data && data.follow_ups.length > 0 && (
<Panel title="Follow-ups due">
{team ? (
groupBy(data.follow_ups, (row) => row.created_by_name ?? 'unassigned').map(
([name, rows]) => (
<div key={name} className="mb-2 last:mb-0">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{name}</p>
{rows.map((row) => (
groupByStaff(data.follow_ups, (row) => ({
id: row.created_by_id,
name: row.created_by_name ?? 'unassigned',
})).map(
(group) => (
<div key={group.id ?? group.name} className="mb-2 last:mb-0">
<p className="text-xs font-medium uppercase tracking-wide">
<StaffName id={group.id} name={group.name} />
</p>
{group.rows.map((row) => (
<FollowUpRow
key={row.id}
row={row}
@@ -278,7 +302,11 @@ function PendingNdaRow({
</button>
, sent <span className="text-gray-500">{formatDay(row.sent_at)}</span>
</span>
{team && <span className="text-xs text-gray-500">{row.created_by_name ?? '—'}</span>}
{team && (
<span className="text-xs">
<StaffName id={row.created_by_id} name={row.created_by_name ?? '—'} />
</span>
)}
</div>
);
}

View File

@@ -9,6 +9,7 @@ import {
type TodoKind,
} from './api.js';
import { Dialog, formatDay, formatStamp } from './components.js';
import { StaffName } from './staff-color.js';
const INPUT = 'rounded border border-gray-300 px-2 py-1 text-sm';
@@ -80,8 +81,9 @@ function NoteRow({
{note.context.label}
</span>
)}
<span>
{note.author?.name ?? 'unknown'} · {formatStamp(note.created_at)}
<span className="inline-flex items-center gap-1">
<StaffName id={note.author?.id} name={note.author?.name} />·{' '}
{formatStamp(note.created_at)}
</span>
<button
onClick={() => run(() => api.updateNote(note.id, { highlight: !note.highlight }))}
@@ -281,7 +283,7 @@ export function TodoRow({
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-400">
<span>{todo.assigned_to.name}</span>
<StaffName id={todo.assigned_to.id} name={todo.assigned_to.name} />
{todo.due_at && (
<span className={todo.overdue && !done ? 'font-medium text-red-600' : ''}>
due {formatDay(todo.due_at)}