module5
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
86
README.md
86
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 <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
|
||||
```
|
||||
|
||||
@@ -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}
|
||||
|
||||
10
migrations/004_reset_interested_in_updates.sql
Normal file
10
migrations/004_reset_interested_in_updates.sql
Normal 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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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
121
src/http.ts
Normal 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 };
|
||||
}
|
||||
@@ -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
555
src/workflow-routes.ts
Normal 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],
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
123
web/src/api.ts
123
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<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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}) & 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
384
web/src/views/Today.tsx
Normal 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
511
web/src/workflow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user