From 6cb13650edac9d59e83894501f72efe17044852b Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Mon, 27 Jul 2026 15:26:17 -0500 Subject: [PATCH] module5 --- .claude/settings.local.json | 4 +- .env.example | 3 + README.md | 86 ++- docker-compose.yml | 1 + .../004_reset_interested_in_updates.sql | 10 + src/buyer-routes.ts | 98 +--- src/config.ts | 2 + src/http.ts | 121 ++++ src/server.ts | 4 + src/workflow-routes.ts | 555 ++++++++++++++++++ web/src/App.tsx | 44 +- web/src/api.ts | 123 +++- web/src/views/BusinessDetail.tsx | 56 +- web/src/views/BuyerDetail.tsx | 70 ++- web/src/views/Today.tsx | 384 ++++++++++++ web/src/workflow.tsx | 511 ++++++++++++++++ 16 files changed, 1924 insertions(+), 148 deletions(-) create mode 100644 migrations/004_reset_interested_in_updates.sql create mode 100644 src/http.ts create mode 100644 src/workflow-routes.ts create mode 100644 web/src/views/Today.tsx create mode 100644 web/src/workflow.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 32cd5e1..b49c782 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -39,7 +39,9 @@ "Bash(bash acceptance.sh)", "Bash(pkill -f \"tsx src/server.ts\")", "Bash(curl -s localhost:8090/api/health)", - "Bash(curl -s -m 3 localhost:8091/api/health)" + "Bash(curl -s -m 3 localhost:8091/api/health)", + "Bash(bash /tmp/claude-1000/-home-aknuth-git-bizmatch-app/0d38fdce-4b18-4159-86e7-135dadf9386d/scratchpad/acceptance5.sh 2>&1)", + "Bash(npm --prefix web run typecheck)" ] } } diff --git a/.env.example b/.env.example index 6577840..265d05d 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ DATABASE_URL=postgres://bizmatch:bizmatch@127.0.0.1:5432/bizmatch PORT=8090 NAS_ROOT=/mnt/bizmatch-nas +# An NDA that is still SENT after this many days is reported in the Today view +NDA_REMINDER_DAYS=14 + # Directory names directly below NAS_ROOT, one per business status NAS_DIR_ACTIVE="AAA = ACTIVE" NAS_DIR_SOLD="AAA = SOLD" diff --git a/README.md b/README.md index 5d239fb..82853f2 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Module 3: recursive file listing, PDF streaming from the NAS and the ported pdf.js viewer. Module 4: the buyer side — buyers, contacts, NDA rounds, deals, the guided "New inquiry" flow with duplicate detection and the deal status transitions. +Module 5: notes, todos and the "Today" view with the follow-up workflow. The UI and all domain constants are English. @@ -109,7 +110,7 @@ directory aborts the scan with an error naming the path. 3. Copy the project folder to the AI machine, run `docker compose up -d --build` 4. Restore the dump: `docker compose exec -T db psql -U bizmatch bizmatch < backup.sql` -## API (as of module 4) +## API (as of module 5) | Method | Path | Purpose | Session | | ------ | --------------------------- | ------------------------------------------------ | ------- | @@ -136,6 +137,18 @@ directory aborts the scan with an error naming the path. | POST | /api/ndas/:id/deals | add a business to an existing round | yes | | POST | /api/deals/:id/status | `{status, comment?}` — transition + note | yes | | GET | /api/deals/:id/notes | notes of one deal, newest first | yes | +| POST | /api/deals/:id/follow-up-sent | `{comment?, rearm}` — note + re-arm or clear | yes | +| POST | /api/notes | create a note on exactly one reference object | yes | +| GET | /api/notes | notes of one object, `?…_id=` (+`include_related`) | yes | +| PATCH | /api/notes/:id | edit `text` / `highlight` | yes | +| DELETE | /api/notes/:id | delete a note | yes | +| POST | /api/todos | create a todo | yes | +| GET | /api/todos | `?assigned_to=&status=` + one ref id as scope | yes | +| PATCH | /api/todos/:id | text, due_at, assigned_to, kind, document_id | yes | +| POST | /api/todos/:id/done | close it (`done_by`/`done_at` = session, now) | yes | +| POST | /api/todos/:id/reopen | reopen it and clear both | yes | +| POST | /api/documents | pin a business file so a REVIEW todo can link it | yes | +| GET | /api/today | `?staff_id=` — the day's work + nav counts | yes | Everything except health, staff (GET+POST) and login requires the session cookie; without it the API answers `401`. @@ -196,17 +209,62 @@ alphabetical. * `.pdf` is served as `application/pdf` (inline), anything else as `application/octet-stream` with `Content-Disposition: attachment`. +### Notes, todos and Today (module 5) + +A **note** hangs off exactly one object (buyer, deal, business or NDA round), a +**todo** off at most one — the DB enforces both, and the API mirrors it so a +wrong body gets a `400` instead of a constraint violation. Every note and todo +row carries a `context` object `{type, id, label, buyer_id, business_id}`: the +label is the business name, the buyer's company/contact name or +`Round `, and the two ids let the UI link straight to the right page. +`GET /api/notes?buyer_id=…&include_related=true` additionally folds in the +notes of that buyer's rounds and deals, which is what the buyer page shows. + +`GET /api/today` is the daily workqueue, narrowed to one person with +`?staff_id=` and covering the whole team without it: + +* **todos** — OPEN, `due_at <= today`, each flagged `overdue` when + `due_at < today` +* **follow_ups** — deals with `follow_up_at <= today` that are not `ENDED`; + "mine" means `created_by` +* **pending_ndas** — rounds still `SENT` after `NDA_REMINDER_DAYS` (env, + default 14); "mine" means `created_by` +* **counts** — the numbers behind the nav badge + +Follow-ups and pending NDAs are **virtual**: they are derived from the deal and +nda rows on every request and never materialise as todo rows, so there is +nothing to keep in sync or clean up. Answering one is +`POST /api/deals/:id/follow-up-sent`, which always writes a note and then +either re-arms the reminder for another 14 days (`rearm: true`) or clears +`follow_up_at` — `409` on a deal that is already ENDED. Giving up on the deal +instead is the normal `POST /api/deals/:id/status` with `ENDED`. + +`POST /api/documents` is a stopgap for the REVIEW todo's file picker: it pins +one business file (validated through the same resolver the file streaming uses) +as a `document` row so `todo.document_id` can point at it. Real document +management follows in module 6. + ## Frontend `web/` is a Vite + React + TypeScript app with Tailwind v4 (no router, no state -library). The header carries the two nav entries **Businesses** and **Buyers**; -routing is a hand-rolled `{view, id}` state in `App.tsx`. Views: +library). The header carries the nav entries **Today**, **Businesses** and +**Buyers**; routing is a hand-rolled `{view, id}` state in `App.tsx`. Today is +the landing view and its nav entry shows a red badge with the signed-in user's +overdue + due todos. There is no polling, so the badge is refreshed on mount, +on every view change and after any action that can move an item off the list. +Views: * login ("Who is working?") +* today — "My day" / "Team" tabs (Team groups todos by assignee and follow-ups + by their creator) over three sections: todos (overdue in red, checkbox to + complete, context chip opens the buyer or business), follow-ups due (with + "Follow-up sent…" → comment + "wait another 14 days" / "stop waiting", and + "End deal…" → the status dialog preset to ENDED) and NDA signatures pending. + Empty state: "Nothing due. Enjoy your coffee." * business list (tabs with counts, search, "Scan NAS now") * business detail — a master-detail split filling the viewport: file table - left, PDF viewer right, plus a collapsed "Buyer activity" panel linking to - the buyers who were introduced to this business + left, PDF viewer right, plus collapsed "Buyer activity", "Notes" and "Todos" + panels above it * buyer list (status chips with counts, search over company/contact/e-mail, "New inquiry") * new inquiry — contact + business picker; while typing a known name, e-mail or @@ -216,11 +274,16 @@ routing is a hand-rolled `{view, id}` state in `App.tsx`. Views: historic deals in their real state. * buyer detail — status header with Deactivate/Reactivate (warns about the open deals it would end), inline-editable identity panel, contacts with a primary - star, and the NDA rounds newest first: editable round fields, the deals of + star, a notes panel covering the buyer *and* their rounds and deals, a todos + panel, and the NDA rounds newest first: editable round fields, the deals of the round with an action menu (next step, "End deal", plus a "Correct to…" - section) that opens a comment dialog, and a collapsed read-only notes list + section) that opens a comment dialog, and a collapsed "Notes & todos" section per deal. +Notes are written in a composer at the top of every notes panel; the red flag +button marks a note as important, and flagged notes get a red left border and a +light red background wherever they appear. + In dev, Vite proxies `/api` to `http://localhost:8090`. In production the Fastify app serves `web/dist` via `@fastify/static` with an SPA fallback to `index.html` for all non-`/api` routes; the Dockerfile builds the frontend in @@ -263,21 +326,26 @@ migrations/ numbered SQL migrations 001_init.sql full schema 002_buyer_fields.sql buyer-side fields from the NDA form + intake sheet 003_…_nullable.sql interested_in_updates becomes tri-state + 004_reset_….sql one-time reset of that column to NULL src/ config.ts env configuration db.ts pg pool, query helpers, withTransaction session.ts the staff-id cookie + http.ts input coercion + PATCH/reference helpers for the routes migrate.ts migration runner (transactional, advisory lock) business-scan.ts NAS scan, recursive listing, safe file path resolution server.ts Fastify app (health, staff, login, businesses, file, static) buyer-routes.ts buyers, contacts, NDA rounds, deals, the inquiry flow + workflow-routes.ts notes, todos, documents, the Today view, follow-ups web/ scripts/copy-pdfjs.mjs pdfjs-dist -> public/pdfjs/ (predev + prebuild) public/viewer/ standalone, unbundled pdf.js viewer page public/pdfjs/ generated, git-ignored pdf.js runtime src/api.ts typed API client - src/App.tsx session gate + nav + view switch + src/App.tsx session gate + nav (with the Today badge) + view switch src/components.tsx shared bits (badges, inline fields, business picker, dialog) - src/views/ Login, Businesses, BusinessDetail, Buyers, BuyerDetail, NewInquiry + src/workflow.tsx notes panel, todo list and the add-todo dialog + src/views/ Login, Today, Businesses, BusinessDetail, Buyers, + BuyerDetail, NewInquiry viewer-phase1/ reference copy of the phase-1 desktop viewer ``` diff --git a/docker-compose.yml b/docker-compose.yml index 1b34a20..5fe736c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: DATABASE_URL: postgres://bizmatch:${DB_PASSWORD:-bizmatch}@db:5432/bizmatch PORT: "8090" NAS_ROOT: ${NAS_ROOT:-/mnt/bizmatch-nas} + NDA_REMINDER_DAYS: ${NDA_REMINDER_DAYS:-14} NAS_DIR_ACTIVE: ${NAS_DIR_ACTIVE:-AAA = ACTIVE} NAS_DIR_SOLD: ${NAS_DIR_SOLD:-AAA = SOLD} NAS_DIR_INACTIVE: ${NAS_DIR_INACTIVE:-AAA = INACTIVE} diff --git a/migrations/004_reset_interested_in_updates.sql b/migrations/004_reset_interested_in_updates.sql new file mode 100644 index 0000000..43b71ef --- /dev/null +++ b/migrations/004_reset_interested_in_updates.sql @@ -0,0 +1,10 @@ +-- BizMatch Phase 2 — module 5 +-- One-time reset of buyer.interested_in_updates. +-- +-- Every existing row got its `false` from the DEFAULT that 002 shipped with, +-- i.e. before 003 made the column tri-state. Under the new semantics that +-- `false` reads as "explicitly said no", which nobody ever recorded. All rows +-- predate the tri-state semantics and the table holds test data only, so the +-- honest value for every one of them is NULL = "not answered". + +UPDATE buyer SET interested_in_updates = NULL; diff --git a/src/buyer-routes.ts b/src/buyer-routes.ts index ed6bcf4..76c0587 100644 --- a/src/buyer-routes.ts +++ b/src/buyer-routes.ts @@ -1,6 +1,19 @@ -import type { FastifyInstance, FastifyReply } from 'fastify'; +import type { FastifyInstance } from 'fastify'; import { query, queryOne, withTransaction, type Tx } from './db.js'; import { staffIdFromRequest } from './session.js'; +import { + InputError, + UUID_RE, + asBool, + asDate, + asNullableBool, + asOneOf, + asRequiredText, + asText, + badId, + buildPatch, + trimmed, +} from './http.js'; export type BuyerStatus = 'ACTIVE' | 'DEACTIVATED' | 'LEGACY'; export type NdaStatus = 'SENT' | 'SIGNED'; @@ -17,91 +30,18 @@ const DEAL_STATUSES = [ 'ENDED', ] as const; -/** Entering INFO_SENT arms the reminder; nothing else touches follow_up_at. */ -const FOLLOW_UP_DAYS = 14; +/** + * Entering INFO_SENT arms the reminder, and so does answering one (see + * workflow-routes.ts); nothing else touches follow_up_at. + */ +export const FOLLOW_UP_DAYS = 14; /** Below this many digits a phone number is not distinctive enough to match on. */ const MIN_PHONE_DIGITS = 7; -// ----------------------------------------------------------------- Input -/** Thrown by the coercers below; the routes turn it into a 400. */ -class InputError extends Error {} - -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; - -const trimmed = (value: unknown): string => (typeof value === 'string' ? value.trim() : ''); - /** Digits only — the duplicate check ignores every kind of phone formatting. */ const digits = (value: string): string => value.replace(/\D/g, ''); -/** Empty strings become NULL, so "cleared in the UI" and "never filled in" agree. */ -const asText = (value: unknown): string | null => trimmed(value) || null; - -const asRequiredText = (value: unknown): string => { - const text = trimmed(value); - if (!text) throw new InputError('value must not be empty'); - return text; -}; - -const asBool = (value: unknown): boolean => value === true; - -/** - * Tri-state: null is "not answered", which the scanned intake sheets leave - * blank often enough that it must stay distinct from an explicit false. - */ -const asNullableBool = (value: unknown): boolean | null => { - if (value === null || value === undefined || value === '') return null; - if (typeof value !== 'boolean') { - throw new InputError(`invalid value: ${String(value)} (expected true, false or null)`); - } - return value; -}; - -const asDate = (value: unknown): string | null => { - if (value === null || value === undefined || value === '') return null; - if (typeof value !== 'string' || !DATE_RE.test(value)) { - throw new InputError(`invalid date: ${String(value)} (expected YYYY-MM-DD)`); - } - return value; -}; - -const asOneOf = - (allowed: readonly T[]) => - (value: unknown): T => { - if (typeof value !== 'string' || !allowed.includes(value as T)) { - throw new InputError(`invalid value: ${String(value)}`); - } - return value as T; - }; - -type Coerce = (value: unknown) => unknown; - -/** - * Turns the present keys of a PATCH body into `col = $n` fragments. Keys the - * caller did not send stay untouched — every PATCH here is a partial update. - */ -function buildPatch( - body: Record, - fields: Record, -): { sets: string[]; params: unknown[] } { - const sets: string[] = []; - const params: unknown[] = []; - for (const [column, coerce] of Object.entries(fields)) { - if (!(column in body)) continue; - params.push(coerce(body[column])); - sets.push(`${column} = $${params.length}`); - } - return { sets, params }; -} - -/** Path ids come straight from the URL; a non-uuid can never match a row. */ -function badId(id: string, reply: FastifyReply, what: string): boolean { - if (UUID_RE.test(id)) return false; - reply.code(404).send({ error: `${what} not found` }); - return true; -} - // ------------------------------------------------------------ Domain bits /** * A signed NDA always carries a date, and signing puts the buyer back in play. diff --git a/src/config.ts b/src/config.ts index f4295dc..a0c4342 100644 --- a/src/config.ts +++ b/src/config.ts @@ -10,6 +10,8 @@ export const config = { host: process.env.HOST ?? '0.0.0.0', /** Root of the NAS mount, e.g. /mnt/bizmatch-nas */ nasRoot: process.env.NAS_ROOT ?? '/mnt/bizmatch-nas', + /** After this many days an NDA that is still SENT shows up in the Today view */ + ndaReminderDays: Number(process.env.NDA_REMINDER_DAYS ?? 14), /** Directory names directly below NAS_ROOT, one per business status */ nasDirActive: process.env.NAS_DIR_ACTIVE ?? 'AAA = ACTIVE', nasDirSold: process.env.NAS_DIR_SOLD ?? 'AAA = SOLD', diff --git a/src/http.ts b/src/http.ts new file mode 100644 index 0000000..5b4c0b4 --- /dev/null +++ b/src/http.ts @@ -0,0 +1,121 @@ +import type { FastifyReply } from 'fastify'; + +/** + * Input coercion shared by the route modules. Every coercer either returns the + * value ready for SQL or throws InputError, which the routes answer with 400. + */ +export class InputError extends Error {} + +export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +export const trimmed = (value: unknown): string => + typeof value === 'string' ? value.trim() : ''; + +/** Empty strings become NULL, so "cleared in the UI" and "never filled in" agree. */ +export const asText = (value: unknown): string | null => trimmed(value) || null; + +export const asRequiredText = (value: unknown): string => { + const text = trimmed(value); + if (!text) throw new InputError('value must not be empty'); + return text; +}; + +export const asBool = (value: unknown): boolean => value === true; + +/** + * Tri-state: null is "not answered", which the scanned intake sheets leave + * blank often enough that it must stay distinct from an explicit false. + */ +export const asNullableBool = (value: unknown): boolean | null => { + if (value === null || value === undefined || value === '') return null; + if (typeof value !== 'boolean') { + throw new InputError(`invalid value: ${String(value)} (expected true, false or null)`); + } + return value; +}; + +export const asDate = (value: unknown): string | null => { + if (value === null || value === undefined || value === '') return null; + if (typeof value !== 'string' || !DATE_RE.test(value)) { + throw new InputError(`invalid date: ${String(value)} (expected YYYY-MM-DD)`); + } + return value; +}; + +export const asOneOf = + (allowed: readonly T[]) => + (value: unknown): T => { + if (typeof value !== 'string' || !allowed.includes(value as T)) { + throw new InputError(`invalid value: ${String(value)}`); + } + return value as T; + }; + +/** A uuid that must exist; empty/garbage is rejected rather than sent to Postgres. */ +export const asUuid = (value: unknown): string => { + const id = trimmed(value); + if (!UUID_RE.test(id)) throw new InputError(`invalid id: ${String(value)}`); + return id; +}; + +export const asNullableUuid = (value: unknown): string | null => { + if (value === null || value === undefined || value === '') return null; + return asUuid(value); +}; + +type Coerce = (value: unknown) => unknown; + +/** + * Turns the present keys of a PATCH body into `col = $n` fragments. Keys the + * caller did not send stay untouched — every PATCH here is a partial update. + */ +export function buildPatch( + body: Record, + fields: Record, +): { sets: string[]; params: unknown[] } { + const sets: string[] = []; + const params: unknown[] = []; + for (const [column, coerce] of Object.entries(fields)) { + if (!(column in body)) continue; + params.push(coerce(body[column])); + sets.push(`${column} = $${params.length}`); + } + return { sets, params }; +} + +/** Path ids come straight from the URL; a non-uuid can never match a row. */ +export function badId(id: string, reply: FastifyReply, what: string): boolean { + if (UUID_RE.test(id)) return false; + reply.code(404).send({ error: `${what} not found` }); + return true; +} + +/** Wraps a handler body so InputError becomes a 400 instead of a 500. */ +export function badInput(err: unknown, reply: FastifyReply): boolean { + if (!(err instanceof InputError)) return false; + reply.code(400).send({ error: err.message }); + return true; +} + +/** + * The four objects a note or todo can hang off. The DB enforces "exactly one" + * for notes and "at most one" for todos; these helpers mirror it in the API so + * the caller gets a 400 instead of a constraint violation. + */ +export const REF_COLUMNS = ['buyer_id', 'deal_id', 'business_id', 'nda_id'] as const; +export type RefColumn = (typeof REF_COLUMNS)[number]; + +export interface Refs { + column: RefColumn | null; + id: string | null; +} + +export function readRefs(source: Record): Refs { + const present = REF_COLUMNS.filter((column) => trimmed(source[column]) !== ''); + if (present.length > 1) { + throw new InputError(`only one of ${REF_COLUMNS.join(', ')} may be set`); + } + const column = present[0] ?? null; + return { column, id: column ? asUuid(source[column]) : null }; +} diff --git a/src/server.ts b/src/server.ts index cd7b9d5..bdffa49 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,7 @@ import { scanBusinesses, } from './business-scan.js'; import { registerBuyerRoutes } from './buyer-routes.js'; +import { registerWorkflowRoutes } from './workflow-routes.js'; import { COOKIE, staffIdFromRequest } from './session.js'; interface Staff { @@ -251,6 +252,9 @@ app.get<{ Params: { id: string }; Querystring: { path?: string } }>( // Registered on this instance, so the session hook above covers them too. registerBuyerRoutes(app); +// ------------------------------------------- Notes / todos / the Today view +registerWorkflowRoutes(app); + // --------------------------------------------------------------- Static // Serves the built frontend in production; SPA fallback for non-/api routes. const WEB_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web', 'dist'); diff --git a/src/workflow-routes.ts b/src/workflow-routes.ts new file mode 100644 index 0000000..671a3ff --- /dev/null +++ b/src/workflow-routes.ts @@ -0,0 +1,555 @@ +import path from 'node:path'; +import type { FastifyInstance } from 'fastify'; +import { config } from './config.js'; +import { query, queryOne, withTransaction } from './db.js'; +import { staffIdFromRequest } from './session.js'; +import { FOLLOW_UP_DAYS } from './buyer-routes.js'; +import { resolveBusinessFile } from './business-scan.js'; +import { + REF_COLUMNS, + asBool, + asDate, + asNullableUuid, + asOneOf, + asRequiredText, + asText, + asUuid, + badId, + badInput, + buildPatch, + readRefs, + trimmed, +} from './http.js'; + +const TODO_KINDS = ['TASK', 'REVIEW'] as const; +const TODO_STATUSES = ['OPEN', 'DONE'] as const; + +/** + * Label for the object a note or todo hangs off. Deals are labelled by their + * business and NDAs by their round date, because that is how the team refers + * to them out loud. + */ +const ROUND_LABEL = `'Round ' || to_char( + coalesce(nda.sent_at, nda.intro_date, nda.signed_at, nda.created_at::date), 'YYYY-MM-DD')`; + +/** A buyer is named by its company, or by its primary contact when it has none. */ +const buyerLabel = (alias: string) => `coalesce(${alias}.company_name, ( + SELECT name FROM contact WHERE buyer_id = ${alias}.id + ORDER BY is_primary DESC, created_at LIMIT 1), 'Buyer')`; + +/** The joins every context label needs; `$src` is the note/todo table alias. */ +const contextJoins = (src: string) => ` + LEFT JOIN buyer ON buyer.id = ${src}.buyer_id + LEFT JOIN nda ON nda.id = ${src}.nda_id + LEFT JOIN deal ON deal.id = ${src}.deal_id + LEFT JOIN business ON business.id = coalesce(${src}.business_id, deal.business_id)`; + +/** + * One `context` object per row: what it hangs off, plus the ids the UI needs to + * turn it into a link. Null when the row has no reference at all. + */ +const CONTEXT_SELECT = ` + CASE + WHEN deal.id IS NOT NULL THEN 'deal' + WHEN nda.id IS NOT NULL THEN 'nda' + WHEN business.id IS NOT NULL THEN 'business' + WHEN buyer.id IS NOT NULL THEN 'buyer' + END AS context_type, + coalesce(deal.id, nda.id, business.id, buyer.id) AS context_id, + CASE + WHEN deal.id IS NOT NULL THEN 'Deal: ' || business.name + WHEN nda.id IS NOT NULL THEN ${ROUND_LABEL} + WHEN business.id IS NOT NULL THEN business.name + WHEN buyer.id IS NOT NULL THEN ${buyerLabel('buyer')} + END AS context_label, + coalesce(buyer.id, nda.buyer_id, deal.buyer_id) AS context_buyer_id, + coalesce(business.id, deal.business_id) AS context_business_id`; + +interface ContextRow { + context_type: string | null; + context_id: string | null; + context_label: string | null; + context_buyer_id: string | null; + context_business_id: string | null; +} + +function takeContext(row: T) { + const { + context_type, + context_id, + context_label, + context_buyer_id, + context_business_id, + ...rest + } = row; + return { + ...rest, + context: context_type + ? { + type: context_type, + id: context_id, + label: context_label, + buyer_id: context_buyer_id, + business_id: context_business_id, + } + : null, + }; +} + +interface NoteRow extends ContextRow { + id: string; + text: string; + highlight: boolean; + created_at: string; + author_id: string | null; + author_name: string | null; +} + +const shapeNote = (row: NoteRow) => { + const { author_id, author_name, ...rest } = takeContext(row); + return { ...rest, author: author_id ? { id: author_id, name: author_name } : null }; +}; + +const NOTE_SELECT = ` + SELECT note.id, note.text, note.highlight, note.created_at, + staff.id AS author_id, staff.name AS author_name, + ${CONTEXT_SELECT} + FROM note + LEFT JOIN staff ON staff.id = note.created_by + ${contextJoins('note')}`; + +interface TodoRow extends ContextRow { + id: string; + text: string; + kind: string; + status: string; + due_at: string | null; + document_id: string | null; + document_path: string | null; + assignee_id: string; + assignee_name: string; + author_id: string | null; + author_name: string | null; + done_by_name: string | null; + done_at: string | null; +} + +const shapeTodo = (row: TodoRow) => { + const { + assignee_id, + assignee_name, + author_id, + author_name, + document_path, + ...rest + } = takeContext(row); + return { + ...rest, + /** Basename only — the full NAS path is noise in a list. */ + document_name: document_path ? path.basename(document_path) : null, + assigned_to: { id: assignee_id, name: assignee_name }, + author: author_id ? { id: author_id, name: author_name } : null, + }; +}; + +const TODO_SELECT = ` + SELECT todo.id, todo.text, todo.kind, todo.status, todo.due_at, todo.document_id, + todo.done_at, document.nas_path AS document_path, + assignee.id AS assignee_id, assignee.name AS assignee_name, + author.id AS author_id, author.name AS author_name, + doner.name AS done_by_name, + ${CONTEXT_SELECT} + FROM todo + JOIN staff assignee ON assignee.id = todo.assigned_to + LEFT JOIN staff author ON author.id = todo.created_by + LEFT JOIN staff doner ON doner.id = todo.done_by + LEFT JOIN document ON document.id = todo.document_id + ${contextJoins('todo')}`; + +/** + * Notes, todos and the Today view — the daily workflow on top of the buyer + * data. Registered on the main app instance, so the session hook covers it. + */ +export function registerWorkflowRoutes(app: FastifyInstance): void { + // --------------------------------------------------------------- Notes + app.post<{ Body: Record }>('/api/notes', async (req, reply) => { + const staffId = staffIdFromRequest(req); + if (!staffId) return reply.code(401).send({ error: 'not signed in' }); + const body = req.body ?? {}; + + let text: string; + let refs; + try { + text = asRequiredText(body.text); + refs = readRefs(body); + } catch (err) { + if (badInput(err, reply)) return reply; + throw err; + } + // Mirrors note_exactly_one_ref, so the caller sees 400 instead of a 500. + if (!refs.column) { + return reply.code(400).send({ error: `one of ${REF_COLUMNS.join(', ')} is required` }); + } + + const created = await queryOne<{ id: string }>( + `INSERT INTO note (text, highlight, ${refs.column}, created_by) + VALUES ($1, $2, $3, $4) RETURNING id`, + [text, asBool(body.highlight), refs.id, staffId], + ); + const row = await queryOne(`${NOTE_SELECT} WHERE note.id = $1`, [created?.id]); + return reply.code(201).send(row ? shapeNote(row) : null); + }); + + /** + * `include_related=true` on a buyer pulls in the notes of that buyer's rounds + * and deals as well — the buyer page is where you want the whole story. + */ + app.get<{ Querystring: Record }>('/api/notes', async (req, reply) => { + let refs; + try { + refs = readRefs(req.query); + } catch (err) { + if (badInput(err, reply)) return reply; + throw err; + } + if (!refs.column) { + return reply.code(400).send({ error: `one of ${REF_COLUMNS.join(', ')} is required` }); + } + + const related = refs.column === 'buyer_id' && trimmed(req.query.include_related) === 'true'; + const where = related + ? 'WHERE note.buyer_id = $1 OR nda.buyer_id = $1 OR deal.buyer_id = $1' + : `WHERE note.${refs.column} = $1`; + const rows = await query( + `${NOTE_SELECT} ${where} ORDER BY note.created_at DESC`, + [refs.id], + ); + return rows.map(shapeNote); + }); + + // Any signed-in staff may edit or delete: three people, no roles by design. + app.patch<{ Params: { id: string }; Body: Record }>( + '/api/notes/:id', + async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'note')) return reply; + + let patch; + try { + patch = buildPatch(req.body ?? {}, { text: asRequiredText, highlight: asBool }); + } catch (err) { + if (badInput(err, reply)) return reply; + throw err; + } + if (patch.sets.length === 0) return reply.code(400).send({ error: 'nothing to update' }); + + const updated = await queryOne<{ id: string }>( + `UPDATE note SET ${patch.sets.join(', ')} + WHERE id = $${patch.params.length + 1} RETURNING id`, + [...patch.params, id], + ); + if (!updated) return reply.code(404).send({ error: 'note not found' }); + const row = await queryOne(`${NOTE_SELECT} WHERE note.id = $1`, [id]); + return row ? shapeNote(row) : null; + }, + ); + + app.delete<{ Params: { id: string } }>('/api/notes/:id', async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'note')) return reply; + const row = await queryOne<{ id: string }>('DELETE FROM note WHERE id = $1 RETURNING id', [id]); + if (!row) return reply.code(404).send({ error: 'note not found' }); + return { ok: true }; + }); + + // --------------------------------------------------------------- Todos + app.post<{ Body: Record }>('/api/todos', async (req, reply) => { + const staffId = staffIdFromRequest(req); + if (!staffId) return reply.code(401).send({ error: 'not signed in' }); + const body = req.body ?? {}; + + let text: string; + let assignedTo: string; + let kind: string; + let dueAt: string | null; + let documentId: string | null; + let refs; + try { + text = asRequiredText(body.text); + assignedTo = asUuid(body.assigned_to); + kind = body.kind === undefined || body.kind === '' ? 'TASK' : asOneOf(TODO_KINDS)(body.kind); + dueAt = asDate(body.due_at); + documentId = asNullableUuid(body.document_id); + refs = readRefs(body); // todo_max_one_ref: no reference at all is fine + } catch (err) { + if (badInput(err, reply)) return reply; + throw err; + } + if (!(await queryOne('SELECT id FROM staff WHERE id = $1 AND active', [assignedTo]))) { + return reply.code(404).send({ error: 'assignee not found' }); + } + + const columns = ['text', 'kind', 'assigned_to', 'due_at', 'document_id', 'created_by']; + const values: unknown[] = [text, kind, assignedTo, dueAt, documentId, staffId]; + if (refs.column) { + columns.push(refs.column); + values.push(refs.id); + } + const created = await queryOne<{ id: string }>( + `INSERT INTO todo (${columns.join(', ')}) + VALUES (${values.map((_, i) => `$${i + 1}`).join(', ')}) RETURNING id`, + values, + ); + const row = await queryOne(`${TODO_SELECT} WHERE todo.id = $1`, [created?.id]); + return reply.code(201).send(row ? shapeTodo(row) : null); + }); + + /** Filters: assigned_to, status and the scope, given as one of the ref ids. */ + app.get<{ Querystring: Record }>('/api/todos', async (req, reply) => { + let refs; + let status: string | null = null; + let assignedTo: string | null = null; + try { + refs = readRefs(req.query); + if (trimmed(req.query.status)) status = asOneOf(TODO_STATUSES)(req.query.status); + if (trimmed(req.query.assigned_to)) assignedTo = asUuid(req.query.assigned_to); + } catch (err) { + if (badInput(err, reply)) return reply; + throw err; + } + + const params: unknown[] = []; + const where: string[] = []; + if (refs.column) { + params.push(refs.id); + where.push(`todo.${refs.column} = $${params.length}`); + } + if (status) { + params.push(status); + where.push(`todo.status = $${params.length}`); + } + if (assignedTo) { + params.push(assignedTo); + where.push(`todo.assigned_to = $${params.length}`); + } + const rows = await query( + `${TODO_SELECT} + ${where.length ? `WHERE ${where.join(' AND ')}` : ''} + ORDER BY todo.status, todo.due_at NULLS LAST, todo.created_at`, + params, + ); + return rows.map(shapeTodo); + }); + + app.patch<{ Params: { id: string }; Body: Record }>( + '/api/todos/:id', + async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'todo')) return reply; + + let patch; + try { + patch = buildPatch(req.body ?? {}, { + text: asRequiredText, + due_at: asDate, + assigned_to: asUuid, + kind: asOneOf(TODO_KINDS), + document_id: asNullableUuid, + }); + } catch (err) { + if (badInput(err, reply)) return reply; + throw err; + } + if (patch.sets.length === 0) return reply.code(400).send({ error: 'nothing to update' }); + + const updated = await queryOne<{ id: string }>( + `UPDATE todo SET ${patch.sets.join(', ')} + WHERE id = $${patch.params.length + 1} RETURNING id`, + [...patch.params, id], + ); + if (!updated) return reply.code(404).send({ error: 'todo not found' }); + const row = await queryOne(`${TODO_SELECT} WHERE todo.id = $1`, [id]); + return row ? shapeTodo(row) : null; + }, + ); + + for (const action of ['done', 'reopen'] as const) { + app.post<{ Params: { id: string } }>(`/api/todos/:id/${action}`, async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'todo')) return reply; + const staffId = staffIdFromRequest(req); + if (!staffId) return reply.code(401).send({ error: 'not signed in' }); + + const set = + action === 'done' + ? `status = 'DONE', done_by = $2, done_at = now()` + : `status = 'OPEN', done_by = NULL, done_at = NULL`; + const params = action === 'done' ? [id, staffId] : [id]; + const updated = await queryOne<{ id: string }>( + `UPDATE todo SET ${set} WHERE id = $1 RETURNING id`, + params, + ); + if (!updated) return reply.code(404).send({ error: 'todo not found' }); + const row = await queryOne(`${TODO_SELECT} WHERE todo.id = $1`, [id]); + return row ? shapeTodo(row) : null; + }); + } + + /** + * Minimal document registration, used by the REVIEW todo's file picker: the + * NAS stays the source of truth, this only pins a path so a todo can point + * at it. The proper document management arrives with module 6. + */ + app.post<{ Body: Record }>('/api/documents', async (req, reply) => { + const staffId = staffIdFromRequest(req); + if (!staffId) return reply.code(401).send({ error: 'not signed in' }); + + let businessId: string; + let relPath: string; + try { + businessId = asUuid(req.body?.business_id); + relPath = asRequiredText(req.body?.path); + } catch (err) { + if (badInput(err, reply)) return reply; + throw err; + } + const business = await queryOne<{ nas_path: string }>( + 'SELECT nas_path FROM business WHERE id = $1', + [businessId], + ); + if (!business) return reply.code(404).send({ error: 'business not found' }); + + // Reuses the business file resolver, so a document can never point outside + // the business directory. + let file; + try { + file = await resolveBusinessFile(business.nas_path, relPath); + } catch (err) { + return reply.code(400).send({ error: (err as Error).message }); + } + + const existing = await queryOne<{ id: string; nas_path: string }>( + 'SELECT id, nas_path FROM document WHERE nas_path = $1 AND business_id = $2', + [file.absPath, businessId], + ); + if (existing) return existing; + const row = await queryOne( + `INSERT INTO document (nas_path, kind, business_id, added_by) + VALUES ($1, $2, $3, $4) RETURNING id, nas_path`, + [file.absPath, trimmed(req.body?.kind) || 'OTHER', businessId, staffId], + ); + return reply.code(201).send(row); + }); + + // --------------------------------------------------------------- Today + /** + * Everything that wants attention today. `staff_id` narrows it to one + * person ("My day"); without it you get the whole team. + * + * Follow-ups and pending NDAs are virtual: they are derived from deal and + * nda rows on every request and never materialise as todos, so nothing can + * go stale or need cleaning up. + */ + app.get<{ Querystring: { staff_id?: string } }>('/api/today', async (req, reply) => { + let staffId: string | null = null; + try { + if (trimmed(req.query.staff_id)) staffId = asUuid(req.query.staff_id); + } catch (err) { + if (badInput(err, reply)) return reply; + throw err; + } + + // Wrapped as a subquery so the shared TODO_SELECT stays untouched and can + // still contribute the overdue flag the UI colours red. + const todos = ( + await query( + `SELECT sub.*, (sub.due_at < CURRENT_DATE) AS overdue + FROM (${TODO_SELECT} + WHERE todo.status = 'OPEN' AND todo.due_at <= CURRENT_DATE + AND ($1::uuid IS NULL OR todo.assigned_to = $1)) sub + ORDER BY sub.due_at, sub.id`, + [staffId], + ) + ).map((row) => ({ ...shapeTodo(row), overdue: row.overdue })); + + const followUps = await query( + `SELECT d.id, d.status, d.follow_up_at, + b.id AS buyer_id, ${buyerLabel('b')} AS buyer_name, + bus.id AS business_id, bus.name AS business_name, + s.id AS created_by_id, s.name AS created_by_name + FROM deal d + JOIN buyer b ON b.id = d.buyer_id + JOIN business bus ON bus.id = d.business_id + LEFT JOIN staff s ON s.id = d.created_by + WHERE d.follow_up_at <= CURRENT_DATE AND d.status <> 'ENDED' + AND ($1::uuid IS NULL OR d.created_by = $1) + ORDER BY d.follow_up_at, bus.name`, + [staffId], + ); + + const pendingNdas = await query( + `SELECT n.id, n.sent_at, + b.id AS buyer_id, ${buyerLabel('b')} AS buyer_name, + s.id AS created_by_id, s.name AS created_by_name + FROM nda n + JOIN buyer b ON b.id = n.buyer_id + LEFT JOIN staff s ON s.id = n.created_by + WHERE n.status = 'SENT' AND n.sent_at <= CURRENT_DATE - $2::int + AND ($1::uuid IS NULL OR n.created_by = $1) + ORDER BY n.sent_at`, + [staffId, config.ndaReminderDays], + ); + + const overdue = todos.filter((todo) => todo.overdue).length; + return { + todos, + follow_ups: followUps, + pending_ndas: pendingNdas, + counts: { + overdue_todos: overdue, + due_todos: todos.length - overdue, + follow_ups: followUps.length, + pending_ndas: pendingNdas.length, + total: todos.length + followUps.length + pendingNdas.length, + }, + }; + }); + + /** + * Answering a follow-up: always leaves a note, then either re-arms the + * reminder for another two weeks or stops waiting. Ending the deal instead + * goes through the normal POST /api/deals/:id/status with ENDED. + */ + app.post<{ Params: { id: string }; Body: { comment?: string; rearm?: boolean } }>( + '/api/deals/:id/follow-up-sent', + async (req, reply) => { + const { id } = req.params; + if (badId(id, reply, 'deal')) return reply; + const staffId = staffIdFromRequest(req); + if (!staffId) return reply.code(401).send({ error: 'not signed in' }); + + const deal = await queryOne<{ status: string }>('SELECT status FROM deal WHERE id = $1', [id]); + if (!deal) return reply.code(404).send({ error: 'deal not found' }); + if (deal.status === 'ENDED') { + return reply.code(409).send({ error: 'deal is already ended' }); + } + + const comment = trimmed(req.body?.comment); + const rearm = asBool(req.body?.rearm); + return withTransaction(async (tx) => { + await tx.query('INSERT INTO note (text, deal_id, created_by) VALUES ($1, $2, $3)', [ + comment ? `Follow-up sent — ${comment}` : 'Follow-up sent', + id, + staffId, + ]); + return tx.queryRow( + `UPDATE deal + SET follow_up_at = CASE WHEN $2 THEN CURRENT_DATE + ${FOLLOW_UP_DAYS} END + WHERE id = $1 + RETURNING id, status, follow_up_at`, + [id, rearm], + ); + }); + }, + ); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index e42208b..484e531 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from 'react'; +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 Businesses from './views/Businesses.js'; import BusinessDetail from './views/BusinessDetail.js'; import Buyers from './views/Buyers.js'; @@ -9,6 +10,7 @@ import NewInquiry from './views/NewInquiry.js'; /** Hand-rolled routing: which view, and (for the detail views) which row. */ type Route = + | { view: 'today' } | { view: 'businesses' } | { view: 'business'; id: string } | { view: 'buyers' } @@ -16,18 +18,16 @@ type Route = | { view: 'new-inquiry' }; const NAV: { label: string; route: Route; active: Route['view'][] }[] = [ + { label: 'Today', route: { view: 'today' }, active: ['today'] }, { label: 'Businesses', route: { view: 'businesses' }, active: ['businesses', 'business'] }, - { - label: 'Buyers', - route: { view: 'buyers' }, - active: ['buyers', 'buyer', 'new-inquiry'], - }, + { label: 'Buyers', route: { view: 'buyers' }, active: ['buyers', 'buyer', 'new-inquiry'] }, ]; export default function App() { const [staff, setStaff] = useState(null); const [loading, setLoading] = useState(true); - const [route, setRoute] = useState({ view: 'businesses' }); + const [route, setRoute] = useState({ view: 'today' }); + const [badge, setBadge] = useState(0); useEffect(() => { api @@ -39,10 +39,24 @@ export default function App() { .finally(() => setLoading(false)); }, []); + // There is no polling in this app, so the badge is refreshed on mount, on + // every view change and after any action that can move an item off the list. + const refreshBadge = useCallback(() => { + if (!staff) return; + api + .today(staff.id) + .then((res) => setBadge(res.counts.overdue_todos + res.counts.due_todos)) + .catch(() => setBadge(0)); + }, [staff]); + + useEffect(() => { + refreshBadge(); + }, [refreshBadge, route.view]); + async function signOut() { await api.logout(); setStaff(null); - setRoute({ view: 'businesses' }); + setRoute({ view: 'today' }); } if (loading) return
Loading…
; @@ -60,13 +74,16 @@ export default function App() { ))} @@ -92,6 +109,14 @@ export default function App() { ) : (
+ {route.view === 'today' && ( + setRoute({ view: 'buyer', id })} + onOpenBusiness={(id) => setRoute({ view: 'business', id })} + onChanged={refreshBadge} + /> + )} {route.view === 'businesses' && ( setRoute({ view: 'business', id })} /> )} @@ -112,6 +137,7 @@ export default function App() { id={route.id} onBack={() => setRoute({ view: 'buyers' })} onOpenBusiness={(id) => setRoute({ view: 'business', id })} + onChanged={refreshBadge} /> )}
diff --git a/web/src/api.ts b/web/src/api.ts index 5e10e6a..dcd7241 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -164,6 +164,92 @@ export interface InquiryResult { created: { buyer: boolean; contact: boolean }; } +// ------------------------------------------------- Notes / todos / Today +export type TodoKind = 'TASK' | 'REVIEW'; +export type TodoStatus = 'OPEN' | 'DONE'; + +export interface Author { + id: string; + name: string; +} + +/** What a note or todo hangs off, with the ids needed to link to it. */ +export interface RefContext { + type: 'buyer' | 'deal' | 'business' | 'nda'; + id: string; + label: string; + buyer_id: string | null; + business_id: string | null; +} + +/** Exactly one of these for a note, at most one for a todo. */ +export interface Ref { + buyer_id?: string; + deal_id?: string; + business_id?: string; + nda_id?: string; +} + +export interface Note { + id: string; + text: string; + highlight: boolean; + created_at: string; + author: Author | null; + context: RefContext | null; +} + +export interface Todo { + id: string; + text: string; + kind: TodoKind; + status: TodoStatus; + due_at: Day | null; + document_id: string | null; + document_name: string | null; + done_at: string | null; + done_by_name: string | null; + assigned_to: Author; + author: Author | null; + context: RefContext | null; +} + +export interface FollowUp { + id: string; + status: DealStatus; + follow_up_at: Day; + buyer_id: string; + buyer_name: string; + business_id: string; + business_name: string; + created_by_id: string | null; + created_by_name: string | null; +} + +export interface PendingNda { + id: string; + sent_at: Day; + buyer_id: string; + buyer_name: string; + created_by_id: string | null; + created_by_name: string | null; +} + +export interface TodayCounts { + overdue_todos: number; + due_todos: number; + follow_ups: number; + pending_ndas: number; + total: number; +} + +export interface Today { + todos: (Todo & { overdue: boolean })[]; + follow_ups: FollowUp[]; + pending_ndas: PendingNda[]; + counts: TodayCounts; +} + export class ApiError extends Error { constructor(readonly status: number, message: string) { super(message); @@ -189,9 +275,12 @@ function send(method: 'POST' | 'PATCH', path: string, body?: unknown): Promis const post = (path: string, body?: unknown) => send('POST', path, body); const patch = (path: string, body: unknown) => send('PATCH', path, body); +const del = (path: string) => request(path, { method: 'DELETE' }); -const qs = (params: Record) => - new URLSearchParams(params).toString(); +const qs = (params: Record) => + new URLSearchParams( + Object.entries(params).filter(([, value]) => value) as [string, string][], + ).toString(); export const api = { me: () => request('/api/me'), @@ -233,6 +322,36 @@ export const api = { { status, comment }, ), dealNotes: (id: string) => request(`/api/deals/${id}/notes`), + + notes: (ref: Ref, includeRelated = false) => + request( + `/api/notes?${qs({ ...ref, include_related: includeRelated ? 'true' : undefined })}`, + ), + createNote: (ref: Ref, text: string, highlight: boolean) => + post('/api/notes', { ...ref, text, highlight }), + updateNote: (id: string, body: { text?: string; highlight?: boolean }) => + patch(`/api/notes/${id}`, body), + deleteNote: (id: string) => del<{ ok: boolean }>(`/api/notes/${id}`), + + todos: (params: Ref & { assigned_to?: string; status?: TodoStatus }) => + request(`/api/todos?${qs({ ...params })}`), + createTodo: (body: Ref & Record) => post('/api/todos', body), + updateTodo: (id: string, body: Record) => patch(`/api/todos/${id}`, body), + todoDone: (id: string) => post(`/api/todos/${id}/done`), + todoReopen: (id: string) => post(`/api/todos/${id}/reopen`), + + today: (staffId?: string) => request(`/api/today?${qs({ staff_id: staffId })}`), + followUpSent: (dealId: string, comment: string, rearm: boolean) => + post<{ id: string; status: DealStatus; follow_up_at: Day | null }>( + `/api/deals/${dealId}/follow-up-sent`, + { comment, rearm }, + ), + /** Pins a business file so a REVIEW todo can point at it. */ + createDocument: (businessId: string, filePath: string) => + post<{ id: string; nas_path: string }>('/api/documents', { + business_id: businessId, + path: filePath, + }), }; /** Same-origin streaming URL of one file inside a business directory. */ diff --git a/web/src/views/BusinessDetail.tsx b/web/src/views/BusinessDetail.tsx index 7fa8c5c..15f2586 100644 --- a/web/src/views/BusinessDetail.tsx +++ b/web/src/views/BusinessDetail.tsx @@ -8,6 +8,7 @@ import { type BusinessFile, } from '../api.js'; import { DEAL_LABELS, StatusBadge, formatDay } from '../components.js'; +import { NotesPanel, TodosPanel } from '../workflow.js'; function formatSize(bytes: number): string { if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; @@ -26,6 +27,28 @@ function formatDate(iso: string): string { const isPdf = (filePath: string) => filePath.toLowerCase().endsWith('.pdf'); +/** + * Header panels stay collapsed and capped in height: this page's job is the + * file table plus the viewer, and they must keep the viewport. + */ +function Collapsible({ label, children }: { label: string; children: React.ReactNode }) { + const [open, setOpen] = useState(false); + return ( +
+ + {open && ( +
{children}
+ )} +
+ ); +} + export default function BusinessDetail({ id, onBack, @@ -72,16 +95,17 @@ export default function BusinessDetail({ )} {/* Collapsed by default so the master-detail split keeps the viewport. */} -
- - {dealsOpen && ( -
+
+
+ + {dealsOpen && ( +
@@ -119,8 +143,16 @@ export default function BusinessDetail({ )}
-
- )} +
+ )} +
+ + + + + + +
diff --git a/web/src/views/BuyerDetail.tsx b/web/src/views/BuyerDetail.tsx index 73a5330..92ffc78 100644 --- a/web/src/views/BuyerDetail.tsx +++ b/web/src/views/BuyerDetail.tsx @@ -5,10 +5,10 @@ import { type BusinessListItem, type Contact, type Deal, - type DealNote, type DealStatus, type NdaRound, } from '../api.js'; +import { NotesPanel, TodosPanel } from '../workflow.js'; import { BusinessPicker, DEAL_ACTIONS, @@ -20,7 +20,6 @@ import { StatusBadge, TriState, formatDay, - formatStamp, } from '../components.js'; const ALL_DEAL_STATUSES = Object.keys(DEAL_LABELS) as DealStatus[]; @@ -36,15 +35,20 @@ export default function BuyerDetail({ id, onBack, onOpenBusiness, + onChanged, }: { id: string; onBack: () => void; onOpenBusiness: (businessId: string) => void; + /** Deal changes here can add or remove follow-ups, so the nav badge follows. */ + onChanged: () => void; }) { const [buyer, setBuyer] = useState(null); const [error, setError] = useState(null); const [confirmDeactivate, setConfirmDeactivate] = useState(false); const [endOpenDeals, setEndOpenDeals] = useState(true); + // Status changes and follow-ups write notes; bumping this refetches the panels. + const [noteEpoch, setNoteEpoch] = useState(0); const reload = useCallback( () => @@ -66,6 +70,8 @@ export default function BuyerDetail({ try { await action(); await reload(); + setNoteEpoch((n) => n + 1); + onChanged(); } catch (err) { setError((err as Error).message); } @@ -205,6 +211,15 @@ export default function BuyerDetail({ + {/* Everything the buyer ever produced, including their rounds and deals. */} + + + + + + + +
{buyer.ndas.map((nda) => ( @@ -213,7 +228,7 @@ export default function BuyerDetail({ nda={nda} guard={guard} onOpenBusiness={onOpenBusiness} - onError={setError} + noteEpoch={noteEpoch} /> ))} {buyer.ndas.length === 0 &&

No NDA rounds yet.

} @@ -328,12 +343,12 @@ function Round({ nda, guard, onOpenBusiness, - onError, + noteEpoch, }: { nda: NdaRound; guard: (action: () => Promise) => Promise; onOpenBusiness: (businessId: string) => void; - onError: (message: string) => void; + noteEpoch: number; }) { const [picking, setPicking] = useState(false); const [business, setBusiness] = useState(null); @@ -411,7 +426,7 @@ function Round({ deal={deal} guard={guard} onOpenBusiness={onOpenBusiness} - onError={onError} + noteEpoch={noteEpoch} /> ))} {nda.deals.length === 0 && ( @@ -450,19 +465,18 @@ function DealRow({ deal, guard, onOpenBusiness, - onError, + noteEpoch, }: { deal: Deal; guard: (action: () => Promise) => Promise; onOpenBusiness: (businessId: string) => void; - onError: (message: string) => void; + noteEpoch: number; }) { const [menuOpen, setMenuOpen] = useState(false); const [pending, setPending] = useState(null); const [comment, setComment] = useState(''); const [busy, setBusy] = useState(false); - const [notes, setNotes] = useState(null); - const [notesOpen, setNotesOpen] = useState(false); + const [detailOpen, setDetailOpen] = useState(false); const forward = nextSteps(deal.status); const corrections = ALL_DEAL_STATUSES.filter( @@ -483,18 +497,6 @@ function DealRow({ setPending(null); } - async function toggleNotes() { - const next = !notesOpen; - setNotesOpen(next); - if (next) { - try { - setNotes(await api.dealNotes(deal.id)); - } catch (err) { - onError((err as Error).message); - } - } - } - return (
@@ -544,21 +546,17 @@ function DealRow({
- - {notesOpen && ( -
    - {notes?.map((note) => ( -
  • - {note.text} - - {note.author ?? 'unknown'} · {formatStamp(note.created_at)} - -
  • - ))} - {notes && notes.length === 0 &&
  • No notes.
  • } -
+ {detailOpen && ( +
+ + +
)}
diff --git a/web/src/views/Today.tsx b/web/src/views/Today.tsx new file mode 100644 index 0000000..091ccee --- /dev/null +++ b/web/src/views/Today.tsx @@ -0,0 +1,384 @@ +import { useCallback, useEffect, useState } from 'react'; +import { + api, + type FollowUp, + type PendingNda, + type Staff, + type Today as TodayData, + type Todo, +} from '../api.js'; +import { DEAL_LABELS, Dialog, Panel, StatusBadge, formatDay } from '../components.js'; +import { TodoRow } from '../workflow.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(rows: T[], name: (row: T) => string): [string, T[]][] { + const groups = new Map(); + for (const row of rows) { + const key = name(row); + groups.set(key, [...(groups.get(key) ?? []), row]); + } + return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0])); +} + +export default function Today({ + staff, + onOpenBuyer, + onOpenBusiness, + onChanged, +}: { + staff: Staff; + onOpenBuyer: (id: string) => void; + onOpenBusiness: (id: string) => void; + /** Lets the nav badge follow along after every action. */ + onChanged: () => void; +}) { + const [team, setTeam] = useState(false); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [followUp, setFollowUp] = useState(null); + const [ending, setEnding] = useState(null); + + const load = useCallback( + () => + api + .today(team ? undefined : staff.id) + .then((res) => { + setData(res); + setError(null); + }) + .catch((err: Error) => setError(err.message)), + [team, staff.id], + ); + + useEffect(() => { + void load(); + }, [load]); + + async function refresh() { + await load(); + onChanged(); + } + + const empty = + data && + data.todos.length === 0 && + data.follow_ups.length === 0 && + data.pending_ndas.length === 0; + + return ( +
+
+
+ {[ + { label: 'My day', value: false }, + { label: 'Team', value: true }, + ].map((tab) => ( + + ))} +
+ {data && ( + + {data.counts.overdue_todos > 0 && ( + {data.counts.overdue_todos} overdue + )} + {data.counts.overdue_todos > 0 && ' · '} + {data.counts.total} item{data.counts.total === 1 ? '' : 's'} + + )} +
+ + {error &&

{error}

} + + {empty && ( +
+ Nothing due. Enjoy your coffee. +
+ )} + + {data && data.todos.length > 0 && ( + + {team ? ( + groupBy(data.todos, (todo) => todo.assigned_to.name).map(([name, todos]) => ( +
+

{name}

+ +
+ )) + ) : ( + + )} +
+ )} + + {data && data.follow_ups.length > 0 && ( + + {team ? ( + groupBy(data.follow_ups, (row) => row.created_by_name ?? 'unassigned').map( + ([name, rows]) => ( +
+

{name}

+ {rows.map((row) => ( + + ))} +
+ ), + ) + ) : ( + data.follow_ups.map((row) => ( + + )) + )} +
+ )} + + {data && data.pending_ndas.length > 0 && ( + + {data.pending_ndas.map((row) => ( + + ))} + + )} + + {followUp && ( + setFollowUp(null)} + onDone={() => { + setFollowUp(null); + void refresh(); + }} + /> + )} + {ending && ( + setEnding(null)} + onDone={() => { + setEnding(null); + void refresh(); + }} + /> + )} +
+ ); +} + +function TodoList({ + todos, + onChanged, + open, +}: { + todos: (Todo & { overdue?: boolean })[]; + onChanged: () => void; + open: { onOpenBuyer: (id: string) => void; onOpenBusiness: (id: string) => void }; +}) { + return ( +
    + {todos.map((todo) => ( + { + // Deals and rounds live on the buyer page, so that wins when both exist. + if (context.buyer_id) open.onOpenBuyer(context.buyer_id); + else if (context.business_id) open.onOpenBusiness(context.business_id); + }} + /> + ))} +
+ ); +} + +function FollowUpRow({ + row, + onOpenBuyer, + onOpenBusiness, + onFollowUp, + onEnd, +}: { + row: FollowUp; + onOpenBuyer: (id: string) => void; + onOpenBusiness: (id: string) => void; + onFollowUp: (row: FollowUp) => void; + onEnd: (row: FollowUp) => void; +}) { + return ( +
+ + {' '} + about{' '} + + , info sent {formatDay(row.follow_up_at)} + + + + +
+ ); +} + +function PendingNdaRow({ + row, + team, + onOpenBuyer, +}: { + row: PendingNda; + team: boolean; + onOpenBuyer: (id: string) => void; +}) { + return ( +
+ + + , sent {formatDay(row.sent_at)} + + {team && {row.created_by_name ?? '—'}} +
+ ); +} + +/** "Follow-up sent" always writes a note; the choice is whether to keep waiting. */ +function FollowUpDialog({ + row, + onClose, + onDone, +}: { + row: FollowUp; + onClose: () => void; + onDone: () => void; +}) { + const [comment, setComment] = useState(''); + const [rearm, setRearm] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function confirm() { + setBusy(true); + try { + await api.followUpSent(row.id, comment, rearm); + onDone(); + } catch (err) { + setError((err as Error).message); + setBusy(false); + } + } + + return ( + +

+ {row.buyer_name} about {row.business_name} +

+