From 96a37a495e55f842ab81315ad7e731682554d17b Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Wed, 29 Jul 2026 14:50:11 -0500 Subject: [PATCH] actual state --- README.md | 35 ++++++- src/buyer-routes.ts | 20 +++- src/dropbox-sign.ts | 53 ++++++++++ src/nda-inbox-routes.ts | 53 +++++++++- src/nda-refresh.ts | 180 ++++++++++++++++++++++++++++------ web/src/App.tsx | 5 +- web/src/api.ts | 20 +++- web/src/views/BuyerDetail.tsx | 172 ++++++++++++++++++++------------ web/src/views/Login.tsx | 78 +++++++++++++-- web/src/views/NdaInbox.tsx | 62 +++++++++++- 10 files changed, 561 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index c61f72a..5069d87 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,7 @@ directory aborts the scan with an error naming the path. | POST | /api/nda-inbox/import | import one request into buyer/contact/nda(/deal) | yes | | POST | /api/nda-inbox/sync | background: re-check pending NDAs + stale mirror rows | yes | | POST | /api/nda-inbox/backfill-fields | retrofit form fields onto signed rounds | yes | +| DELETE | /api/deals/:id | delete a deal; notes/todos cascade | yes | | GET | /api/ndas/:id/file | stream the filed NDA PDF (Range + ETag) | yes | Everything except health, staff (GET+POST) and login requires the session @@ -307,6 +308,28 @@ it is never committed and never logged. Only requests whose title starts with **`Buyer Forms - NDA`** are considered. +**The template was renamed around 2026-06-29.** Requests created before that +are titled `Buyer Forms - - `, without the ` - NDA`. The +refresh therefore runs **two legs**: + +| leg | query | when | +| --- | --- | --- | +| current | `title:"Buyer Forms - NDA" AND created:{ TO *}` | always | +| pre-rename | `title:"Buyer Forms" AND created:{ TO 2026-06-30}` | only when the window starts before `RENAME_DATE` | + +Both feed the same upsert, so the inbox needs no notion of the two formats — +rows are rows. An incremental refresh never pays for the second leg: its +cutoff is days old, well past the rename. + +The legacy query is deliberately **`Buyer Forms`, not `Buyer Forms -`**. The +API does not treat the trailing punctuation as part of the phrase: with the +hyphen it returns 70 requests for May/June, without it 535, and ~87% of the +wider set are genuine pre-rename NDAs. Precision comes from the code-side +safety net instead — `/^Buyer Forms -(?!.*NDA)/` plus a signer — which is why +the loose phrase is safe here. Unbounded it would not be: `title:"Buyer Forms"` +matches 11,877 of the account's 13,264 requests, so the date bound is what +makes it selective. + **The inbox is DB-backed.** Proxying the list endpoint on every view mount did not survive contact with the real account: 700+ requests in a 90-day window means 7–8 paged calls, ~74s of latency, throttling (Dropbox answers `409` as @@ -317,7 +340,17 @@ are mirrored into `ds_request` by a background task, and unaffected by tab switches. It returns the same per-request shape as before (status, signer, `imported`, `known_buyer` by exact normalised e-mail, up to five `business_suggestions` by word overlap with the title remainder) plus - `last_refresh_at` and `refresh_state`. + `last_refresh_at`, `refresh_state` and `covers_from`. +* Filtering is **DB-side**: `?status=pending|signed|declined` and `?q=` over + signer name and e-mail. The per-status counts describe the whole matching + set (search applied, status not), so the chips stay meaningful while one is + active — the same rule the business and buyer lists follow. +* `covers_from` is how far back the mirror actually reaches. A walk stopped by + the `MAX_PAGES` cap covers less than it was asked for, so it records the + oldest date it got to and the inbox says "showing data from …" instead of + presenting a short list as complete. Coverage only ever widens: an + incremental walk reaching back two days does not un-mirror what a full reload + fetched last week. * `POST /api/nda-inbox/refresh` starts the walk and returns `202` immediately, or `409` when one is already running — the slot is claimed with a conditional upsert on `app_meta`, so two clicks cannot start two walks. A `running` state diff --git a/src/buyer-routes.ts b/src/buyer-routes.ts index 36e8839..537fd40 100644 --- a/src/buyer-routes.ts +++ b/src/buyer-routes.ts @@ -176,6 +176,7 @@ interface DealRow { status: DealStatus; follow_up_at: string | null; note_count: number; + todo_count: number; business_id: string; business_name: string; business_status: string; @@ -456,7 +457,10 @@ export function registerBuyerRoutes(app: FastifyInstance): void { const deals = await query( `SELECT d.id, d.nda_id, d.status, d.follow_up_at, b.id AS business_id, b.name AS business_name, b.status AS business_status, - (SELECT count(*)::int FROM note WHERE deal_id = d.id) AS note_count + (SELECT count(*)::int FROM note WHERE deal_id = d.id) AS note_count, + -- Both counts feed the delete confirmation: the user should know + -- what disappears with the deal before agreeing to it. + (SELECT count(*)::int FROM todo WHERE deal_id = d.id) AS todo_count FROM deal d JOIN business b ON b.id = d.business_id WHERE d.buyer_id = $1 ORDER BY d.created_at`, [id], @@ -470,6 +474,7 @@ export function registerBuyerRoutes(app: FastifyInstance): void { status: deal.status, follow_up_at: deal.follow_up_at, note_count: deal.note_count, + todo_count: deal.todo_count, business: { id: deal.business_id, name: deal.business_name, @@ -777,6 +782,19 @@ export function registerBuyerRoutes(app: FastifyInstance): void { }, ); + /** + * Removes one deal. Its notes, todos and documents go with it through the + * ON DELETE CASCADE on their deal_id; the round and the buyer are untouched, + * which is what the confirmation in the UI promises. + */ + app.delete<{ Params: { id: string } }>('/api/deals/:id', async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'deal')) return reply; + const row = await queryOne<{ id: string }>('DELETE FROM deal WHERE id = $1 RETURNING id', [id]); + if (!row) return reply.code(404).send({ error: 'deal not found' }); + return { ok: true }; + }); + /** Read-only; the full notes UI follows in module 5. */ app.get<{ Params: { id: string } }>('/api/deals/:id/notes', async (req, reply) => { const { id } = req.params; diff --git a/src/dropbox-sign.ts b/src/dropbox-sign.ts index 99ba7ee..668f95b 100644 --- a/src/dropbox-sign.ts +++ b/src/dropbox-sign.ts @@ -118,6 +118,59 @@ export function ndaSearchQuery(since: Date): string { return `title:"${NDA_TITLE_PREFIX}" AND created:{${day} TO *}`; } +/** + * Before this date the NDA template was called plain `Buyer Forms - - + * `; it was renamed to add the ` - NDA` around 2026-06-29. Requests + * older than the rename therefore do not match NDA_TITLE_PREFIX at all, and + * are fetched by a second, date-bounded query instead. + * + * The bound is deliberately a day past the rename so nothing can slip through + * the seam; the small overlap re-reads a few requests, which is harmless + * because both legs feed the same idempotent upsert. + */ +export const RENAME_DATE = new Date('2026-06-30T00:00:00Z'); + +/** + * The phrase the legacy leg searches for. + * + * Deliberately without the trailing hyphen: `title:"Buyer Forms -"` matches + * only 70 requests in May/June where `title:"Buyer Forms"` matches 535, and + * ~87% of that wider set are genuine pre-rename NDAs. The API evidently does + * not treat the trailing punctuation as part of the phrase, so the hyphen + * costs recall and buys nothing. Precision is restored by + * isLegacyNdaRequest() below, which is what the safety net is for. + */ +export const LEGACY_TITLE_QUERY = 'Buyer Forms'; + +/** + * `title:"Buyer Forms"` on its own matches 11,877 of the account's 13,264 + * requests, so it is useless unbounded. Restricting it to the period before + * the rename is what makes it selective — after that date the strict prefix + * covers everything anyway. + */ +export function legacyNdaSearchQuery(since: Date): string { + const from = since.toISOString().slice(0, 10); + const to = RENAME_DATE.toISOString().slice(0, 10); + return `title:"${LEGACY_TITLE_QUERY}" AND created:{${from} TO ${to}}`; +} + +/** + * Safety net for the legacy leg: the old title, no "NDA" anywhere in it (that + * belongs to the other leg), and an actual signer. Anything else the loose + * query drags in is logged and skipped. + * + * The hyphen after "Buyer Forms" is optional because a handful of requests + * were typed without it ("Buyer Forms Jack Cahn - …") and are perfectly good + * NDAs. The trailing \S is what still rejects the bare "Buyer Forms" rows, + * which carry neither a name nor a business. + */ +export function isLegacyNdaRequest(request: SignatureRequest): boolean { + const title = request.title ?? ''; + if (!/^Buyer Forms\s*-?\s*(?!.*NDA)\S/.test(title)) return false; + const signature = request.signatures?.[0]; + return Boolean(signature?.signer_name?.trim() || signature?.signer_email_address?.trim()); +} + /** * One page of the list endpoint, newest first. Paging is driven by the caller * (see nda-refresh.ts) so it can pace the calls and handle throttling. diff --git a/src/nda-inbox-routes.ts b/src/nda-inbox-routes.ts index 8acd434..3fe5e03 100644 --- a/src/nda-inbox-routes.ts +++ b/src/nda-inbox-routes.ts @@ -13,6 +13,7 @@ import { statusOf, } from './dropbox-sign.js'; import { + META_COVERS_FROM, META_LAST_REFRESH, OVERLAP_MS, REFRESH_KEYS, @@ -349,7 +350,9 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void { * Reads ds_request only — never Dropbox. Mounting the view is one indexed * query, so switching tabs costs nothing and the list is always there. */ - app.get<{ Querystring: { since?: string } }>('/api/nda-inbox', async (req, reply) => { + app.get<{ Querystring: { since?: string; status?: string; q?: string } }>( + '/api/nda-inbox', + async (req, reply) => { if (!isConfigured()) return notConfigured(reply); const sinceParam = trimmed(req.query.since); @@ -360,6 +363,37 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void { return reply.code(400).send({ error: `invalid since: ${sinceParam}` }); } + // Filtering happens here rather than in the browser: the mirror holds + // hundreds of rows and grows, and the status counts have to describe the + // whole matching set, not the slice that was shipped. + const status = trimmed(req.query.status); + if (status && !['pending', 'signed', 'declined'].includes(status)) { + return reply.code(400).send({ error: `invalid status: ${status}` }); + } + const search = trimmed(req.query.q); + + const baseParams: unknown[] = [since.toISOString()]; + let baseWhere = 'WHERE r.created_at >= $1'; + if (search) { + baseParams.push(`%${search}%`); + const p = `$${baseParams.length}`; + baseWhere += ` AND (r.signer_name ILIKE ${p} OR r.signer_email ILIKE ${p})`; + } + + // Counts cover every status so the chips stay usable while one is active, + // exactly like the business and buyer lists. + const counts = await query<{ status: string; n: number }>( + `SELECT r.status, count(*)::int AS n FROM ds_request r ${baseWhere} GROUP BY r.status`, + baseParams, + ); + + const listParams = [...baseParams]; + let listWhere = baseWhere; + if (status) { + listParams.push(status); + listWhere += ` AND r.status = $${listParams.length}`; + } + // Full timestamps, not calendar days: Dropbox orders strictly by date and // time, and dropping the time made same-day rows look arbitrarily ordered. const rows = await query<{ @@ -389,9 +423,9 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void { n.id AS nda_id, n.buyer_id FROM ds_request r LEFT JOIN nda n ON n.dropbox_sign_id = r.signature_request_id - WHERE r.created_at >= $1 + ${listWhere} ORDER BY coalesce(r.signed_at, r.created_at) DESC, r.signature_request_id DESC`, - [since.toISOString()], + listParams, ); const emails = [ @@ -445,8 +479,15 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void { readTaskState(REFRESH_KEYS), readTaskState(SYNC_KEYS), ]); + const coversFrom = await getMeta(META_COVERS_FROM); return { requests, + counts: { + pending: counts.find((c) => c.status === 'pending')?.n ?? 0, + signed: counts.find((c) => c.status === 'signed')?.n ?? 0, + declined: counts.find((c) => c.status === 'declined')?.n ?? 0, + all: counts.reduce((sum, c) => sum + c.n, 0), + }, last_refresh_at: refresh.last_at, refresh_state: refresh.state, /** {pages, seen, stored} of the last completed walk, null before the first. */ @@ -454,8 +495,12 @@ export function registerNdaInboxRoutes(app: FastifyInstance): void { last_sync_at: sync.last_at, sync_state: sync.state, last_sync_result: sync.last_result, + /** How far back the mirror actually reaches; the UI warns when it is later + * than the requested window. */ + covers_from: coversFrom, }; - }); + }, + ); /** * Starts the background walk. 202 when it was started, 409 when one is diff --git a/src/nda-refresh.ts b/src/nda-refresh.ts index f82085a..d3a29f3 100644 --- a/src/nda-refresh.ts +++ b/src/nda-refresh.ts @@ -2,13 +2,16 @@ import type { FastifyBaseLogger } from 'fastify'; import { withTransaction } from './db.js'; import { NDA_TITLE_PREFIX, + RENAME_DATE, type SignatureRequest, + isLegacyNdaRequest, isRateLimited, + legacyNdaSearchQuery, listPage, ndaSearchQuery, statusOf, } from './dropbox-sign.js'; -import { type TaskKeys, clearStaleTask, getMeta, startTask } from './background-task.js'; +import { type TaskKeys, clearStaleTask, getMeta, setMeta, startTask } from './background-task.js'; /** * Mirrors the Dropbox Sign signature requests into ds_request in the @@ -35,6 +38,14 @@ export const REFRESH_KEYS: TaskKeys = { }; export const META_LAST_REFRESH = REFRESH_KEYS.lastAt; +/** + * The earliest date the mirror is known to be complete from. Normally this is + * just the widest window ever walked, but a walk stopped by MAX_PAGES covers + * less than it was asked for, and the inbox has to say so rather than quietly + * show a short list. + */ +export const META_COVERS_FROM = 'ds_mirror_covers_from'; + /** * How far back a follow-up walk reaches beyond the previous refresh. Generous * on purpose: a request created just before the last run, or a clock skew @@ -89,6 +100,24 @@ export interface RefreshResult { pages: number; stored: number; seen: number; + /** Of `stored`, how many came from the pre-rename title format. */ + legacy_stored: number; + /** Earliest creation date this walk actually reached, ISO date. */ + covers_from: string; + /** True when MAX_PAGES ended the walk before it reached the cutoff. */ + truncated: boolean; +} + +interface LegResult { + pages: number; + seen: number; + stored: number; + /** Rows the query returned that the safety net rejected. */ + skipped: number; + /** Oldest created_at seen, unix seconds; Infinity when nothing came back. */ + oldest: number; + /** False when MAX_PAGES cut it short. */ + complete: boolean; } /** Fetches one page, backing off and retrying while Dropbox throttles us. */ @@ -140,59 +169,146 @@ export async function refreshCutoff(since: Date, explicitSince: boolean): Promis } /** - * Walks the NDA requests created since `cutoffAt` and mirrors them. + * Pages through one search, upserting what survives the safety net. * - * The filtering happens server-side via the search query, so the pages this - * walks are already ours: `seen` and `stored` should come out roughly equal. - * The two client-side filters below are safety nets — if the query were ever - * ignored or changed, they keep the mirror correct and say so in the log. + * The filtering happens server-side via the query, so the pages this walks are + * already ours; `accept` is the belt-and-braces check that keeps the mirror + * correct if a query is ever ignored or widened, and says so in the log. */ -async function walk(cutoffAt: Date, log: FastifyBaseLogger): Promise { - const cutoff = Math.floor(cutoffAt.getTime() / 1000); - const query = ndaSearchQuery(cutoffAt); - const loggedBody = { done: false }; +async function walkLeg( + label: string, + query: string, + cutoff: number, + accept: (request: SignatureRequest) => boolean, + log: FastifyBaseLogger, + loggedBody: { done: boolean }, +): Promise { let stored = 0; let seen = 0; - let foreign = 0; + let skipped = 0; + let oldest = Number.POSITIVE_INFINITY; + let complete = false; let page = 1; for (; page <= MAX_PAGES; page += 1) { if (page > 1) await sleep(PAGE_PAUSE_MS); const result = await fetchPageWithBackoff(page, query, log, loggedBody); - if (result.requests.length === 0) break; + if (result.requests.length === 0) { + complete = true; + break; + } seen += result.requests.length; - // Safety net 1: a title the search should have excluded. - const wrongTitle = result.requests.length - result.ndaRequests.length; - if (wrongTitle > 0) { - foreign += wrongTitle; - if (foreign === wrongTitle) { - const example = result.requests.find( - (request) => !request.title?.startsWith(NDA_TITLE_PREFIX), - ); + const wanted = result.requests.filter(accept); + const rejected = result.requests.length - wanted.length; + if (rejected > 0) { + if (skipped === 0) { + const example = result.requests.find((request) => !accept(request)); log.warn( - `[nda-refresh] the search returned ${wrongTitle} request(s) with a foreign title on ` + - `page ${page} — filtering them out here; e.g. ${JSON.stringify(example?.title ?? '')}`, + `[nda-refresh] ${label}: the search returned ${rejected} request(s) this leg does not ` + + `accept on page ${page} — skipping them; e.g. ${JSON.stringify(example?.title ?? '')}`, ); } + skipped += rejected; } - // Safety net 2: the date clause is day-granular, so a row from the cutoff - // day itself can come back slightly too old. - const inWindow = result.ndaRequests.filter((request) => request.created_at >= cutoff); + // The date clause is day-granular, so a row from the cutoff day itself can + // come back slightly too old. + const inWindow = wanted.filter((request) => request.created_at >= cutoff); await upsertRequests(inWindow); stored += inWindow.length; - if (result.page >= result.numPages) break; + for (const request of result.requests) { + if (request.created_at < oldest) oldest = request.created_at; + } + + if (result.page >= result.numPages) { + complete = true; + break; + } } - const pages = Math.min(page, MAX_PAGES); - log.info( - `[nda-refresh] walked ${pages} page(s) with query since ${cutoffAt.toISOString().slice(0, 10)}, ` + - `saw ${seen}, stored ${stored} NDA request(s)` + - (foreign > 0 ? `, discarded ${foreign} foreign title(s)` : ''), + return { pages: Math.min(page, MAX_PAGES), seen, stored, skipped, oldest, complete }; +} + +/** + * Mirrors every NDA request created since `cutoffAt`. + * + * Two legs, because the Dropbox template was renamed on ~2026-06-29: the + * current `Buyer Forms - NDA …` title, and — only when the window reaches + * before the rename — the older `Buyer Forms -…` one, bounded to the period + * where that looser prefix is still selective. + */ +async function walk(cutoffAt: Date, log: FastifyBaseLogger): Promise { + const cutoff = Math.floor(cutoffAt.getTime() / 1000); + const loggedBody = { done: false }; + + const current = await walkLeg( + 'current format', + ndaSearchQuery(cutoffAt), + cutoff, + (request) => Boolean(request.title?.startsWith(NDA_TITLE_PREFIX)), + log, + loggedBody, ); - return { pages, stored, seen }; + + const needsLegacy = cutoffAt < RENAME_DATE; + const legacy = needsLegacy + ? await walkLeg( + 'pre-rename format', + legacyNdaSearchQuery(cutoffAt), + cutoff, + isLegacyNdaRequest, + log, + loggedBody, + ) + : null; + + const pages = current.pages + (legacy?.pages ?? 0); + const seen = current.seen + (legacy?.seen ?? 0); + const stored = current.stored + (legacy?.stored ?? 0); + const complete = current.complete && (legacy?.complete ?? true); + const oldest = Math.min(current.oldest, legacy?.oldest ?? Number.POSITIVE_INFINITY); + + // A complete walk covers everything back to its cutoff. A truncated one only + // covers back to the oldest row it managed to read. + const reached = complete || !Number.isFinite(oldest) ? cutoffAt : new Date(oldest * 1000); + if (!complete) { + log.warn( + `[nda-refresh] stopped at the ${MAX_PAGES}-page cap before reaching ` + + `${cutoffAt.toISOString().slice(0, 10)} — the mirror only covers back to ` + + `${reached.toISOString().slice(0, 10)}`, + ); + } + await widenCoverage(reached); + + log.info( + `[nda-refresh] walked ${pages} page(s) since ${cutoffAt.toISOString().slice(0, 10)}, ` + + `saw ${seen}, stored ${stored}` + + (legacy ? ` (${legacy.stored} pre-rename)` : '') + + (current.skipped + (legacy?.skipped ?? 0) > 0 + ? `, skipped ${current.skipped + (legacy?.skipped ?? 0)} non-matching` + : ''), + ); + return { + pages, + stored, + seen, + legacy_stored: legacy?.stored ?? 0, + covers_from: reached.toISOString().slice(0, 10), + truncated: !complete, + }; +} + +/** + * Coverage only ever improves: an incremental walk reaching back two days does + * not un-mirror what a full reload fetched last week. + */ +async function widenCoverage(reached: Date): Promise { + const current = await getMeta(META_COVERS_FROM); + const currentAt = current ? new Date(current) : null; + if (currentAt && !Number.isNaN(currentAt.getTime()) && currentAt <= reached) return; + await setMeta(META_COVERS_FROM, reached.toISOString()); } /** diff --git a/web/src/App.tsx b/web/src/App.tsx index 763c0ca..ca48520 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -113,9 +113,10 @@ export default function App() { ) : (
{route.view === 'today' && ( diff --git a/web/src/api.ts b/web/src/api.ts index b8b0b58..c09a017 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -92,6 +92,7 @@ export interface Deal { status: DealStatus; follow_up_at: Day | null; note_count: number; + todo_count: number; business: { id: string; name: string; status: BusinessStatus }; } @@ -287,10 +288,21 @@ export interface RefreshResult { pages: number; seen: number; stored: number; + /** Of `stored`, how many used the pre-rename title format. */ + legacy_stored: number; +} + +export interface InboxCounts { + pending: number; + signed: number; + declined: number; + all: number; } export interface Inbox { requests: InboxRow[]; + /** Per status over the whole matching set, not just the rows shipped. */ + counts: InboxCounts; /** ISO timestamp of the last completed background refresh, null if never. */ last_refresh_at: string | null; refresh_state: RefreshState; @@ -300,6 +312,8 @@ export interface Inbox { last_sync_at: string | null; sync_state: RefreshState; last_sync_result: SyncResult | null; + /** ISO date the mirror actually reaches back to, null if never walked. */ + covers_from: string | null; } export interface ImportResult { @@ -370,6 +384,8 @@ const qs = (params: Record) => export const api = { me: () => request('/api/me'), staff: () => request('/api/staff'), + /** An existing name is reactivated rather than rejected. */ + createStaff: (name: string) => post('/api/staff', { name }), login: (staffId: string) => post<{ ok: boolean; staff: Staff }>('/api/login', { staff_id: staffId }), logout: () => post<{ ok: boolean }>('/api/logout'), businesses: (status: BusinessStatus | '', search: string) => @@ -401,6 +417,7 @@ export const api = { patch(`/api/ndas/${id}`, body), addDeal: (ndaId: string, businessId: string) => post(`/api/ndas/${ndaId}/deals`, { business_id: businessId }), + deleteDeal: (id: string) => del<{ ok: boolean }>(`/api/deals/${id}`), setDealStatus: (id: string, status: DealStatus, comment?: string) => post<{ id: string; status: DealStatus; follow_up_at: Day | null }>( `/api/deals/${id}/status`, @@ -439,7 +456,8 @@ export const api = { }), /** Reads the mirrored requests out of the database — never calls Dropbox. */ - ndaInbox: (since: string) => request(`/api/nda-inbox?${qs({ since })}`), + ndaInbox: (since: string, status?: InboxStatus | '', q?: string) => + request(`/api/nda-inbox?${qs({ since, status, q })}`), /** * 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 diff --git a/web/src/views/BuyerDetail.tsx b/web/src/views/BuyerDetail.tsx index 1cc6082..166081f 100644 --- a/web/src/views/BuyerDetail.tsx +++ b/web/src/views/BuyerDetail.tsx @@ -167,75 +167,85 @@ export default function BuyerDetail({ )} - -
- guard(() => api.updateBuyer(buyer.id, { company_name: next }))} - /> - guard(() => api.updateBuyer(buyer.id, { state: next }))} - /> - guard(() => api.updateBuyer(buyer.id, { address: next }))} - /> - guard(() => api.updateBuyer(buyer.id, { how_heard: next }))} - /> -
- - guard(() => api.updateBuyer(buyer.id, { background_experience: next })) - } - /> -
-
- guard(() => api.updateBuyer(buyer.id, { interested_in_updates: next }))} - /> -
+ {/* Two columns from lg up: who they are on the left, what is happening + with them on the right. Below lg it collapses to the old single + column. min-w-0 on both so long values wrap instead of forcing the + page to scroll sideways. */} +
+
+ +
+ guard(() => api.updateBuyer(buyer.id, { company_name: next }))} + /> + guard(() => api.updateBuyer(buyer.id, { state: next }))} + /> + guard(() => api.updateBuyer(buyer.id, { address: next }))} + /> + guard(() => api.updateBuyer(buyer.id, { how_heard: next }))} + /> +
+ + guard(() => api.updateBuyer(buyer.id, { background_experience: next })) + } + /> +
+
+ guard(() => api.updateBuyer(buyer.id, { interested_in_updates: next }))} + /> +
+
+
+ + + + {/* Everything the buyer ever produced, including their rounds and deals. */} + + +
- - +
+ +
+ {buyer.ndas.map((nda) => ( + + ))} + {buyer.ndas.length === 0 &&

No NDA rounds yet.

} +
+
- {/* Everything the buyer ever produced, including their rounds and deals. */} - - - - - - - - - -
- {buyer.ndas.map((nda) => ( - - ))} - {buyer.ndas.length === 0 &&

No NDA rounds yet.

} + + +
-
+
); } @@ -517,6 +527,7 @@ function DealRow({ }) { const [menuOpen, setMenuOpen] = useState(false); const [pending, setPending] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(false); const [comment, setComment] = useState(''); const [busy, setBusy] = useState(false); const [detailOpen, setDetailOpen] = useState(false); @@ -583,6 +594,16 @@ function DealRow({ {DEAL_LABELS[status]} ))} +
+
)}
@@ -603,6 +624,27 @@ function DealRow({ )} + {confirmDelete && ( + setConfirmDelete(false)} + onConfirm={async () => { + setBusy(true); + await guard(() => api.deleteDeal(deal.id)); + setBusy(false); + setConfirmDelete(false); + }} + > +

+ This removes {deal.business.name} and its{' '} + {deal.note_count} note{deal.note_count === 1 ? '' : 's'} and {deal.todo_count} todo + {deal.todo_count === 1 ? '' : 's'}. The NDA round and the buyer stay. +

+
+ )} + {pending && ( void }) { const [staff, setStaff] = useState([]); const [error, setError] = useState(null); + const [adding, setAdding] = useState(false); + const [name, setName] = useState(''); + const [busy, setBusy] = useState(false); + + const load = useCallback( + () => + api + .staff() + .then((list) => setStaff(list.filter((s) => s.active))) + .catch((err: Error) => setError(err.message)), + [], + ); useEffect(() => { - api - .staff() - .then((list) => setStaff(list.filter((s) => s.active))) - .catch((err: Error) => setError(err.message)); - }, []); + void load(); + }, [load]); async function pick(member: Staff) { try { @@ -21,6 +31,28 @@ export default function Login({ onLogin }: { onLogin: (staff: Staff) => void }) } } + /** + * Adding a person here means a new colleague can sign in without anyone + * running curl. An existing name is not an error: the endpoint reactivates + * that person, which is exactly what you want when someone comes back. + */ + async function add() { + const trimmed = name.trim(); + if (!trimmed || busy) return; + setBusy(true); + setError(null); + try { + await api.createStaff(trimmed); + setName(''); + setAdding(false); + await load(); + } catch (err) { + setError((err as Error).message); + } finally { + setBusy(false); + } + } + return (
@@ -33,13 +65,45 @@ export default function Login({ onLogin }: { onLogin: (staff: Staff) => void }) className="rounded border border-gray-300 px-3 py-2 text-left text-sm hover:bg-gray-100" onClick={() => pick(member)} > - {member.name} + ))} {staff.length === 0 && !error && (

No staff members yet.

)}
+ +
+ {adding ? ( +
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') void add(); + if (e.key === 'Escape') setAdding(false); + }} + placeholder="Name" + className="w-full rounded border border-gray-300 px-2 py-1 text-sm" + /> + +
+ ) : ( + + )} +
); diff --git a/web/src/views/NdaInbox.tsx b/web/src/views/NdaInbox.tsx index e3b675b..b04caa6 100644 --- a/web/src/views/NdaInbox.tsx +++ b/web/src/views/NdaInbox.tsx @@ -41,6 +41,10 @@ export default function NdaInbox({ // 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 [status, setStatus] = useState(''); + // Typed straight into the box; `search` is the debounced value that queries. + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); const [inbox, setInbox] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -66,11 +70,18 @@ export default function NdaInbox({ .find((state) => state?.startsWith('error:')) ?.slice('error:'.length); + // Debounce the search box: filtering is a DB query, not a client-side pass + // over rows that happen to be loaded. + useEffect(() => { + const timer = setTimeout(() => setSearch(searchInput.trim()), 300); + return () => clearTimeout(timer); + }, [searchInput]); + /** 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); + const res = await api.ndaInbox(since, status, search); setInbox(res); setError(null); // Deliberately no preselection: a suggestion is a guess, and an import @@ -83,7 +94,7 @@ export default function NdaInbox({ } finally { setLoading(false); } - }, [since]); + }, [since, status, search]); useEffect(() => { void load(); @@ -94,10 +105,12 @@ export default function NdaInbox({ useEffect(() => { if (!refreshing && watchingRefresh.current && inbox?.last_refresh_result) { watchingRefresh.current = false; - const { pages, seen, stored } = inbox.last_refresh_result; + const { pages, seen, stored, legacy_stored: legacy } = inbox.last_refresh_result; setRefreshNotice( `Refreshed: ${stored} request${stored === 1 ? '' : 's'} stored ` + - `from ${seen} seen over ${pages} page${pages === 1 ? '' : 's'}.`, + `from ${seen} seen over ${pages} page${pages === 1 ? '' : 's'}` + + (legacy > 0 ? ` · ${legacy} in the pre-rename title format` : '') + + '.', ); } if (!syncing && watchingSync.current && inbox?.last_sync_result) { @@ -241,6 +254,47 @@ export default function NdaInbox({ {refreshNotice &&

{refreshNotice}

} {syncNotice &&

{syncNotice}

} + + {/* The mirror can reach back less far than the picker asks for — say so + rather than showing a short list as if it were complete. */} + {inbox?.covers_from && inbox.covers_from.slice(0, 10) > since && ( +

+ Showing data from {formatDayTimeParts(inbox.covers_from).day} — the mirror does not reach + back to {formatDayTimeParts(since).day} yet. Change the date and press Reload window to + fetch the rest. +

+ )} + +
+
+ {( + [ + { value: '', label: 'All' }, + { value: 'pending', label: 'Pending' }, + { value: 'signed', label: 'Signed' }, + { value: 'declined', label: 'Declined' }, + ] as { value: InboxStatus | ''; label: string }[] + ).map((chip) => ( + + ))} +
+ setSearchInput(e.target.value)} + placeholder="Search signer or e-mail…" + className={`${INPUT} w-64`} + /> +
{notice &&

{notice}

} {error &&

{error}

}