This commit is contained in:
2026-07-27 15:26:17 -05:00
parent 32c8ccc3ed
commit 6cb13650ed
16 changed files with 1924 additions and 148 deletions

View File

@@ -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)"
]
}
}

View File

@@ -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"

View File

@@ -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 <date>`, 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
```

View File

@@ -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}

View File

@@ -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;

View File

@@ -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 =
<T extends string>(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<string, unknown>,
fields: Record<string, Coerce>,
): { 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.

View File

@@ -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',

121
src/http.ts Normal file
View File

@@ -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 =
<T extends string>(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<string, unknown>,
fields: Record<string, Coerce>,
): { 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<string, unknown>): 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 };
}

View File

@@ -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');

555
src/workflow-routes.ts Normal file
View File

@@ -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<T extends ContextRow>(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<string, unknown> }>('/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<NoteRow>(`${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<string, string> }>('/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<NoteRow>(
`${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<string, unknown> }>(
'/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<NoteRow>(`${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<string, unknown> }>('/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<TodoRow>(`${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<string, string> }>('/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<TodoRow>(
`${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<string, unknown> }>(
'/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<TodoRow>(`${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<TodoRow>(`${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<string, unknown> }>('/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<TodoRow & { overdue: boolean }>(
`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],
);
});
},
);
}

View File

@@ -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<Staff | null>(null);
const [loading, setLoading] = useState(true);
const [route, setRoute] = useState<Route>({ view: 'businesses' });
const [route, setRoute] = useState<Route>({ 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 <div className="p-8 text-sm text-gray-500">Loading</div>;
@@ -60,13 +74,16 @@ export default function App() {
<button
key={entry.label}
onClick={() => setRoute(entry.route)}
className={`rounded px-3 py-1 text-sm ${
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm ${
entry.active.includes(route.view)
? 'bg-gray-900 text-white'
: 'hover:bg-gray-100'
}`}
>
{entry.label}
{entry.label === 'Today' && badge > 0 && (
<span className="rounded-full bg-red-600 px-1.5 text-xs text-white">{badge}</span>
)}
</button>
))}
</nav>
@@ -92,6 +109,14 @@ export default function App() {
</main>
) : (
<main className="mx-auto w-full max-w-5xl flex-1 overflow-auto px-6 py-6">
{route.view === 'today' && (
<Today
staff={staff}
onOpenBuyer={(id) => setRoute({ view: 'buyer', id })}
onOpenBusiness={(id) => setRoute({ view: 'business', id })}
onChanged={refreshBadge}
/>
)}
{route.view === 'businesses' && (
<Businesses onOpen={(id) => 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}
/>
)}
</main>

View File

@@ -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<T>(method: 'POST' | 'PATCH', path: string, body?: unknown): Promis
const post = <T>(path: string, body?: unknown) => send<T>('POST', path, body);
const patch = <T>(path: string, body: unknown) => send<T>('PATCH', path, body);
const del = <T>(path: string) => request<T>(path, { method: 'DELETE' });
const qs = (params: Record<string, string>) =>
new URLSearchParams(params).toString();
const qs = (params: Record<string, string | undefined>) =>
new URLSearchParams(
Object.entries(params).filter(([, value]) => value) as [string, string][],
).toString();
export const api = {
me: () => request<Staff>('/api/me'),
@@ -233,6 +322,36 @@ export const api = {
{ status, comment },
),
dealNotes: (id: string) => request<DealNote[]>(`/api/deals/${id}/notes`),
notes: (ref: Ref, includeRelated = false) =>
request<Note[]>(
`/api/notes?${qs({ ...ref, include_related: includeRelated ? 'true' : undefined })}`,
),
createNote: (ref: Ref, text: string, highlight: boolean) =>
post<Note>('/api/notes', { ...ref, text, highlight }),
updateNote: (id: string, body: { text?: string; highlight?: boolean }) =>
patch<Note>(`/api/notes/${id}`, body),
deleteNote: (id: string) => del<{ ok: boolean }>(`/api/notes/${id}`),
todos: (params: Ref & { assigned_to?: string; status?: TodoStatus }) =>
request<Todo[]>(`/api/todos?${qs({ ...params })}`),
createTodo: (body: Ref & Record<string, unknown>) => post<Todo>('/api/todos', body),
updateTodo: (id: string, body: Record<string, unknown>) => patch<Todo>(`/api/todos/${id}`, body),
todoDone: (id: string) => post<Todo>(`/api/todos/${id}/done`),
todoReopen: (id: string) => post<Todo>(`/api/todos/${id}/reopen`),
today: (staffId?: string) => request<Today>(`/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. */

View File

@@ -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 (
<div className="rounded border border-gray-200 bg-white">
<button
onClick={() => setOpen(!open)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wide text-gray-500"
>
<span className="text-gray-400">{open ? '▾' : '▸'}</span>
{label}
</button>
{open && (
<div className="max-h-64 overflow-auto border-t border-gray-200 p-3">{children}</div>
)}
</div>
);
}
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. */}
<div className="mt-3 rounded border border-gray-200 bg-white">
<button
onClick={() => setDealsOpen(!dealsOpen)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wide text-gray-500"
>
<span className="text-gray-400">{dealsOpen ? '▾' : '▸'}</span>
Buyer activity ({deals?.length ?? 0})
</button>
{dealsOpen && (
<div className="max-h-48 overflow-auto border-t border-gray-200">
<div className="mt-3 flex flex-col gap-2">
<div className="rounded border border-gray-200 bg-white">
<button
onClick={() => setDealsOpen(!dealsOpen)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wide text-gray-500"
>
<span className="text-gray-400">{dealsOpen ? '▾' : '▸'}</span>
Buyer activity ({deals?.length ?? 0})
</button>
{dealsOpen && (
<div className="max-h-48 overflow-auto border-t border-gray-200">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
@@ -119,8 +143,16 @@ export default function BusinessDetail({
)}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
<Collapsible label="Notes">
<NotesPanel target={{ business_id: id }} />
</Collapsible>
<Collapsible label="Todos">
<TodosPanel target={{ business_id: id }} businessId={id} />
</Collapsible>
</div>
</div>

View File

@@ -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<Buyer | null>(null);
const [error, setError] = useState<string | null>(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({
<Contacts buyer={buyer} guard={guard} />
{/* Everything the buyer ever produced, including their rounds and deals. */}
<Panel title="Notes">
<NotesPanel target={{ buyer_id: buyer.id }} includeRelated reloadKey={noteEpoch} />
</Panel>
<Panel title="Todos">
<TodosPanel target={{ buyer_id: buyer.id }} />
</Panel>
<Panel title="NDA rounds">
<div className="flex flex-col gap-4">
{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 && <p className="text-sm text-gray-500">No NDA rounds yet.</p>}
@@ -328,12 +343,12 @@ function Round({
nda,
guard,
onOpenBusiness,
onError,
noteEpoch,
}: {
nda: NdaRound;
guard: (action: () => Promise<unknown>) => Promise<void>;
onOpenBusiness: (businessId: string) => void;
onError: (message: string) => void;
noteEpoch: number;
}) {
const [picking, setPicking] = useState(false);
const [business, setBusiness] = useState<BusinessListItem | null>(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<unknown>) => Promise<void>;
onOpenBusiness: (businessId: string) => void;
onError: (message: string) => void;
noteEpoch: number;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const [pending, setPending] = useState<DealStatus | null>(null);
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const [notes, setNotes] = useState<DealNote[] | null>(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 (
<div className="border-b border-gray-100 last:border-0">
<div className="flex items-center gap-3 px-3 py-2">
@@ -544,21 +546,17 @@ function DealRow({
</div>
<div className="px-3 pb-2">
<button onClick={toggleNotes} className="text-xs text-gray-500 hover:underline">
{notesOpen ? '▾' : '▸'} Notes ({notes ? notes.length : deal.note_count})
<button
onClick={() => setDetailOpen(!detailOpen)}
className="text-xs text-gray-500 hover:underline"
>
{detailOpen ? '▾' : '▸'} Notes ({deal.note_count}) &amp; todos
</button>
{notesOpen && (
<ul className="mt-1 flex flex-col gap-1 border-l-2 border-gray-200 pl-3">
{notes?.map((note) => (
<li key={note.id} className="text-sm">
<span className="whitespace-pre-wrap">{note.text}</span>
<span className="ml-2 text-xs text-gray-400">
{note.author ?? 'unknown'} · {formatStamp(note.created_at)}
</span>
</li>
))}
{notes && notes.length === 0 && <li className="text-sm text-gray-500">No notes.</li>}
</ul>
{detailOpen && (
<div className="mt-2 flex flex-col gap-3 border-l-2 border-gray-200 pl-3">
<NotesPanel target={{ deal_id: deal.id }} reloadKey={noteEpoch} />
<TodosPanel target={{ deal_id: deal.id }} businessId={deal.business.id} />
</div>
)}
</div>

384
web/src/views/Today.tsx Normal file
View File

@@ -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<T>(rows: T[], name: (row: T) => string): [string, T[]][] {
const groups = new Map<string, T[]>();
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<TodayData | null>(null);
const [error, setError] = useState<string | null>(null);
const [followUp, setFollowUp] = useState<FollowUp | null>(null);
const [ending, setEnding] = useState<FollowUp | null>(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 (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex gap-1">
{[
{ label: 'My day', value: false },
{ label: 'Team', value: true },
].map((tab) => (
<button
key={tab.label}
onClick={() => setTeam(tab.value)}
className={`rounded px-3 py-1.5 text-sm ${
team === tab.value
? 'bg-gray-900 text-white'
: 'border border-gray-300 bg-white hover:bg-gray-100'
}`}
>
{tab.label}
</button>
))}
</div>
{data && (
<span className="text-sm text-gray-500">
{data.counts.overdue_todos > 0 && (
<span className="font-medium text-red-600">{data.counts.overdue_todos} overdue</span>
)}
{data.counts.overdue_todos > 0 && ' · '}
{data.counts.total} item{data.counts.total === 1 ? '' : 's'}
</span>
)}
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{empty && (
<div className="rounded border border-gray-200 bg-white px-4 py-10 text-center text-sm text-gray-500">
Nothing due. Enjoy your coffee.
</div>
)}
{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 }} />
</div>
))
) : (
<TodoList todos={data.todos} onChanged={refresh} open={{ onOpenBuyer, onOpenBusiness }} />
)}
</Panel>
)}
{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) => (
<FollowUpRow
key={row.id}
row={row}
onOpenBuyer={onOpenBuyer}
onOpenBusiness={onOpenBusiness}
onFollowUp={setFollowUp}
onEnd={setEnding}
/>
))}
</div>
),
)
) : (
data.follow_ups.map((row) => (
<FollowUpRow
key={row.id}
row={row}
onOpenBuyer={onOpenBuyer}
onOpenBusiness={onOpenBusiness}
onFollowUp={setFollowUp}
onEnd={setEnding}
/>
))
)}
</Panel>
)}
{data && data.pending_ndas.length > 0 && (
<Panel title="NDA signatures pending">
{data.pending_ndas.map((row) => (
<PendingNdaRow key={row.id} row={row} team={team} onOpenBuyer={onOpenBuyer} />
))}
</Panel>
)}
{followUp && (
<FollowUpDialog
row={followUp}
onClose={() => setFollowUp(null)}
onDone={() => {
setFollowUp(null);
void refresh();
}}
/>
)}
{ending && (
<EndDealDialog
row={ending}
onClose={() => setEnding(null)}
onDone={() => {
setEnding(null);
void refresh();
}}
/>
)}
</div>
);
}
function TodoList({
todos,
onChanged,
open,
}: {
todos: (Todo & { overdue?: boolean })[];
onChanged: () => void;
open: { onOpenBuyer: (id: string) => void; onOpenBusiness: (id: string) => void };
}) {
return (
<ul className="flex flex-col">
{todos.map((todo) => (
<TodoRow
key={todo.id}
todo={todo}
onChanged={onChanged}
onOpenContext={(context) => {
// 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);
}}
/>
))}
</ul>
);
}
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 (
<div className="flex flex-wrap items-center gap-2 border-b border-gray-100 py-2 text-sm last:border-0">
<span className="min-w-0 flex-1">
<button onClick={() => onOpenBuyer(row.buyer_id)} className="text-blue-600 hover:underline">
{row.buyer_name}
</button>{' '}
about{' '}
<button
onClick={() => onOpenBusiness(row.business_id)}
className="text-blue-600 hover:underline"
>
{row.business_name}
</button>
, info sent <span className="text-gray-500">{formatDay(row.follow_up_at)}</span>
</span>
<StatusBadge status={row.status} label={DEAL_LABELS[row.status]} />
<button
onClick={() => onFollowUp(row)}
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs hover:bg-gray-100"
>
Follow-up sent
</button>
<button
onClick={() => onEnd(row)}
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs hover:bg-gray-100"
>
End deal
</button>
</div>
);
}
function PendingNdaRow({
row,
team,
onOpenBuyer,
}: {
row: PendingNda;
team: boolean;
onOpenBuyer: (id: string) => void;
}) {
return (
<div className="flex items-center gap-2 border-b border-gray-100 py-2 text-sm last:border-0">
<span className="flex-1">
<button onClick={() => onOpenBuyer(row.buyer_id)} className="text-blue-600 hover:underline">
{row.buyer_name}
</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>}
</div>
);
}
/** "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<string | null>(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 (
<Dialog title="Follow-up sent" confirmLabel="Save" busy={busy} onConfirm={confirm} onCancel={onClose}>
<p className="mb-2 text-sm text-gray-600">
{row.buyer_name} about {row.business_name}
</p>
<textarea
rows={3}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Comment (optional) — saved as a note"
className={INPUT}
/>
<div className="mt-2 flex flex-col gap-1 text-sm">
{[
{ value: true, label: 'Wait another 14 days' },
{ value: false, label: 'Stop waiting' },
].map((option) => (
<label key={String(option.value)} className="flex items-center gap-2">
<input
type="radio"
checked={rearm === option.value}
onChange={() => setRearm(option.value)}
/>
{option.label}
</label>
))}
</div>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</Dialog>
);
}
/** The normal status change, preset to ENDED. */
function EndDealDialog({
row,
onClose,
onDone,
}: {
row: FollowUp;
onClose: () => void;
onDone: () => void;
}) {
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function confirm() {
setBusy(true);
try {
await api.setDealStatus(row.id, 'ENDED', comment);
onDone();
} catch (err) {
setError((err as Error).message);
setBusy(false);
}
}
return (
<Dialog title="End deal" confirmLabel="Confirm" busy={busy} onConfirm={confirm} onCancel={onClose}>
<p className="mb-2 text-sm text-gray-600">
{row.business_name}: {DEAL_LABELS[row.status]} {DEAL_LABELS.ENDED}
</p>
<textarea
rows={3}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Comment (optional) — saved as a note"
className={INPUT}
/>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</Dialog>
);
}

511
web/src/workflow.tsx Normal file
View File

@@ -0,0 +1,511 @@
import { useCallback, useEffect, useState } from 'react';
import {
api,
type BusinessFile,
type Note,
type Ref,
type Staff,
type Todo,
type TodoKind,
} from './api.js';
import { Dialog, formatDay, formatStamp } from './components.js';
const INPUT = 'rounded border border-gray-300 px-2 py-1 text-sm';
// ----------------------------------------------------------------- Notes
/** A note's own text, plus the "which round / which deal" label when related. */
function NoteRow({
note,
showContext,
onChanged,
onError,
}: {
note: Note;
showContext: boolean;
onChanged: () => void;
onError: (message: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(note.text);
const [confirmDelete, setConfirmDelete] = useState(false);
async function run(action: () => Promise<unknown>) {
try {
await action();
onChanged();
} catch (err) {
onError((err as Error).message);
}
}
return (
<li
className={`rounded border-l-2 px-2 py-1.5 ${
note.highlight ? 'border-red-500 bg-red-50' : 'border-gray-200 bg-white'
}`}
>
{editing ? (
<div>
<textarea
autoFocus
rows={3}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Escape' && setEditing(false)}
className={`${INPUT} w-full`}
/>
<div className="mt-1 flex gap-2 text-xs">
<button
onClick={() =>
run(async () => {
await api.updateNote(note.id, { text: draft });
setEditing(false);
})
}
className="text-blue-600 hover:underline"
>
Save
</button>
<button onClick={() => setEditing(false)} className="text-gray-500 hover:underline">
Cancel
</button>
</div>
</div>
) : (
<>
<p className="whitespace-pre-wrap text-sm">{note.text}</p>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-gray-400">
{showContext && note.context && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-gray-600">
{note.context.label}
</span>
)}
<span>
{note.author?.name ?? 'unknown'} · {formatStamp(note.created_at)}
</span>
<button
onClick={() => run(() => api.updateNote(note.id, { highlight: !note.highlight }))}
title={note.highlight ? 'Remove the flag' : 'Flag this note'}
className={note.highlight ? 'text-red-500' : 'hover:text-red-400'}
>
</button>
<button
onClick={() => {
setDraft(note.text);
setEditing(true);
}}
className="hover:text-gray-600 hover:underline"
>
Edit
</button>
{confirmDelete ? (
<>
<button
onClick={() => run(() => api.deleteNote(note.id))}
className="text-red-600 hover:underline"
>
Really delete
</button>
<button
onClick={() => setConfirmDelete(false)}
className="hover:text-gray-600 hover:underline"
>
Cancel
</button>
</>
) : (
<button
onClick={() => setConfirmDelete(true)}
className="hover:text-gray-600 hover:underline"
>
Delete
</button>
)}
</div>
</>
)}
</li>
);
}
/**
* Composer plus list for one reference object. `includeRelated` is only
* meaningful on a buyer, where it folds in the notes of that buyer's rounds
* and deals.
*/
export function NotesPanel({
target,
includeRelated,
reloadKey,
}: {
target: Ref;
includeRelated?: boolean;
/** Bump to refetch from the outside (e.g. after a status change wrote a note). */
reloadKey?: number;
}) {
const [notes, setNotes] = useState<Note[] | null>(null);
const [text, setText] = useState('');
const [highlight, setHighlight] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const key = JSON.stringify(target);
const load = useCallback(() => {
api
.notes(JSON.parse(key) as Ref, includeRelated)
.then((rows) => {
setNotes(rows);
setError(null);
})
.catch((err: Error) => setError(err.message));
}, [key, includeRelated]);
useEffect(() => {
load();
}, [load, reloadKey]);
async function add() {
if (!text.trim() || busy) return;
setBusy(true);
try {
await api.createNote(target, text.trim(), highlight);
setText('');
setHighlight(false);
load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
return (
<div>
<div className="flex items-start gap-2">
<textarea
rows={2}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a note…"
className={`${INPUT} flex-1`}
/>
<div className="flex flex-col gap-1">
<button
onClick={() => setHighlight(!highlight)}
title="Flag this note as important"
className={`rounded border px-2 py-1 text-sm ${
highlight
? 'border-red-400 bg-red-50 text-red-600'
: 'border-gray-300 bg-white text-gray-400 hover:text-red-400'
}`}
>
</button>
<button
onClick={add}
disabled={busy || !text.trim()}
className="rounded bg-gray-900 px-2 py-1 text-xs text-white disabled:opacity-40"
>
Save
</button>
</div>
</div>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
<ul className="mt-2 flex flex-col gap-1.5">
{notes?.map((note) => (
<NoteRow
key={note.id}
note={note}
showContext={Boolean(includeRelated)}
onChanged={load}
onError={setError}
/>
))}
{notes && notes.length === 0 && <li className="text-sm text-gray-500">No notes yet.</li>}
</ul>
</div>
);
}
// ----------------------------------------------------------------- Todos
const KIND_TONE: Record<TodoKind, string> = {
TASK: 'border-gray-300 bg-white text-gray-600',
REVIEW: 'border-purple-300 bg-purple-50 text-purple-800',
};
export function TodoRow({
todo,
onChanged,
onOpenContext,
}: {
todo: Todo & { overdue?: boolean };
onChanged: () => void;
onOpenContext?: (context: NonNullable<Todo['context']>) => void;
}) {
const [busy, setBusy] = useState(false);
const done = todo.status === 'DONE';
async function toggle() {
setBusy(true);
try {
await (done ? api.todoReopen(todo.id) : api.todoDone(todo.id));
onChanged();
} finally {
setBusy(false);
}
}
return (
<li className="flex items-start gap-2 border-b border-gray-100 py-1.5 last:border-0">
<input
type="checkbox"
checked={done}
disabled={busy}
onChange={toggle}
className="mt-1"
title={done ? `Done by ${todo.done_by_name ?? '?'}` : 'Mark as done'}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className={`text-sm ${done ? 'text-gray-400 line-through' : ''}`}>{todo.text}</span>
{todo.kind === 'REVIEW' && (
<span className={`rounded-full border px-2 py-0.5 text-xs ${KIND_TONE.REVIEW}`}>
Review
</span>
)}
{todo.document_name && (
<span className="font-mono text-xs text-gray-500">{todo.document_name}</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-400">
<span>{todo.assigned_to.name}</span>
{todo.due_at && (
<span className={todo.overdue && !done ? 'font-medium text-red-600' : ''}>
due {formatDay(todo.due_at)}
</span>
)}
{todo.context && (
<button
onClick={() => todo.context && onOpenContext?.(todo.context)}
disabled={!onOpenContext}
className="rounded bg-gray-100 px-1.5 py-0.5 text-gray-600 enabled:hover:bg-gray-200"
>
{todo.context.label}
</button>
)}
{done && todo.done_at && (
<span title={`${todo.done_by_name ?? 'unknown'} · ${formatStamp(todo.done_at)}`}>
done {formatStamp(todo.done_at)}
</span>
)}
</div>
</div>
</li>
);
}
/** Scoped todo list with its own "Add todo" button. */
export function TodosPanel({
target,
businessId,
onOpenContext,
}: {
target: Ref;
/** Enables the REVIEW document picker; only known where a business is in play. */
businessId?: string;
onOpenContext?: (context: NonNullable<Todo['context']>) => void;
}) {
const [todos, setTodos] = useState<Todo[] | null>(null);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const key = JSON.stringify(target);
const load = useCallback(() => {
api
.todos(JSON.parse(key) as Ref)
.then((rows) => {
setTodos(rows);
setError(null);
})
.catch((err: Error) => setError(err.message));
}, [key]);
useEffect(() => {
load();
}, [load]);
const open = todos?.filter((todo) => todo.status === 'OPEN') ?? [];
const done = todos?.filter((todo) => todo.status === 'DONE') ?? [];
return (
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-gray-500">
{open.length} open{done.length > 0 ? ` · ${done.length} done` : ''}
</span>
<button onClick={() => setAdding(true)} className="text-xs text-blue-600 hover:underline">
+ Add todo
</button>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<ul className="flex flex-col">
{[...open, ...done].map((todo) => (
<TodoRow key={todo.id} todo={todo} onChanged={load} onOpenContext={onOpenContext} />
))}
{todos && todos.length === 0 && <li className="py-1 text-sm text-gray-500">No todos.</li>}
</ul>
{adding && (
<AddTodoDialog
target={target}
businessId={businessId}
onClose={() => setAdding(false)}
onCreated={() => {
setAdding(false);
load();
}}
/>
)}
</div>
);
}
export function AddTodoDialog({
target,
businessId,
onClose,
onCreated,
}: {
target: Ref;
businessId?: string;
onClose: () => void;
onCreated: () => void;
}) {
const [staff, setStaff] = useState<Staff[]>([]);
const [text, setText] = useState('');
const [assignee, setAssignee] = useState('');
const [dueAt, setDueAt] = useState('');
const [kind, setKind] = useState<TodoKind>('TASK');
const [files, setFiles] = useState<BusinessFile[]>([]);
const [documentPath, setDocumentPath] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api
.staff()
.then((list) => {
const active = list.filter((member) => member.active);
setStaff(active);
setAssignee((current) => current || active[0]?.id || '');
})
.catch((err: Error) => setError(err.message));
}, []);
// Only fetched once REVIEW is picked — the listing walks the NAS.
useEffect(() => {
if (kind !== 'REVIEW' || !businessId || files.length > 0) return;
api.businessFiles(businessId).then(setFiles).catch(() => setFiles([]));
}, [kind, businessId, files.length]);
async function save() {
if (!text.trim() || !assignee) return setError('Text and assignee are required.');
setBusy(true);
setError(null);
try {
let documentId: string | undefined;
if (kind === 'REVIEW' && businessId && documentPath) {
documentId = (await api.createDocument(businessId, documentPath)).id;
}
await api.createTodo({
...target,
text: text.trim(),
kind,
assigned_to: assignee,
due_at: dueAt,
document_id: documentId,
});
onCreated();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
return (
<Dialog title="Add todo" confirmLabel="Create" busy={busy} onConfirm={save} onCancel={onClose}>
<div className="flex flex-col gap-2">
<textarea
autoFocus
rows={2}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What needs to happen?"
className={`${INPUT} w-full`}
/>
<div className="flex gap-2">
<label className="flex-1 text-xs uppercase tracking-wide text-gray-500">
Assignee
<select
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
className={`${INPUT} mt-0.5 w-full`}
>
{staff.map((member) => (
<option key={member.id} value={member.id}>
{member.name}
</option>
))}
</select>
</label>
<label className="flex-1 text-xs uppercase tracking-wide text-gray-500">
Due
<input
type="date"
value={dueAt}
onChange={(e) => setDueAt(e.target.value)}
className={`${INPUT} mt-0.5 w-full`}
/>
</label>
</div>
<label className="text-xs uppercase tracking-wide text-gray-500">
Kind
<select
value={kind}
onChange={(e) => setKind(e.target.value as TodoKind)}
className={`${INPUT} mt-0.5 w-full`}
>
<option value="TASK">Task</option>
<option value="REVIEW">Review</option>
</select>
</label>
{kind === 'REVIEW' && businessId && (
<label className="text-xs uppercase tracking-wide text-gray-500">
Document to review (optional)
<select
value={documentPath}
onChange={(e) => setDocumentPath(e.target.value)}
className={`${INPUT} mt-0.5 w-full`}
>
<option value=""> none </option>
{files.map((file) => (
<option key={file.path} value={file.path}>
{file.path}
</option>
))}
</select>
</label>
)}
{kind === 'REVIEW' && !businessId && (
<p className="text-xs text-gray-500">
Documents can only be picked where a business is in context.
</p>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
</Dialog>
);
}