This commit is contained in:
2026-07-26 18:19:25 -05:00
parent b0e677d298
commit 32c8ccc3ed
15 changed files with 2679 additions and 36 deletions

View File

@@ -34,7 +34,12 @@
"Bash(kill 29669 29703 29714)", "Bash(kill 29669 29703 29714)",
"Bash(curl -s -m 2 -o /dev/null -w 'server: %{http_code}\\\\n' localhost:8090/api/health)", "Bash(curl -s -m 2 -o /dev/null -w 'server: %{http_code}\\\\n' localhost:8090/api/health)",
"Bash(git check-ignore *)", "Bash(git check-ignore *)",
"Bash(git add *)" "Bash(git add *)",
"Bash(curl -s -m 3 localhost:8090/api/health)",
"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)"
] ]
} }
} }

112
README.md
View File

@@ -4,6 +4,8 @@ Module 1: foundation (Docker Compose, PostgreSQL, schema, migrations, login).
Module 2: business scan (NAS -> DB) and the first UI. Module 2: business scan (NAS -> DB) and the first UI.
Module 3: recursive file listing, PDF streaming from the NAS and the ported Module 3: recursive file listing, PDF streaming from the NAS and the ported
pdf.js viewer. 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.
The UI and all domain constants are English. The UI and all domain constants are English.
@@ -26,6 +28,28 @@ The app applies all migrations on start and then listens on
> `docker compose down -v && docker compose up -d --build`. The DB held no > `docker compose down -v && docker compose up -d --build`. The DB held no
> production data yet, so there is nothing to migrate. > production data yet, so there is nothing to migrate.
### Schema notes
`001_init.sql` holds the full base schema (Buyer / Contact / NDA / Deal /
Business / Note / Todo / Document / ExtractionJob / Staff) and is never edited
again. `002_buyer_fields.sql` adds the fields the buyer side actually collects
and is purely additive, so it applies to an existing database:
| Table | Added |
| --------- | ------------------------------------------------------------------------------------ |
| `contact` | `cell` |
| `buyer` | `address`, `state`, `background_experience`, `how_heard`, `interested_in_updates` |
| `nda` | `total_purchase_price`, `down_payment`, `intro_date` |
`003_interested_in_updates_nullable.sql` then drops the `NOT NULL` and the
default from `buyer.interested_in_updates`: the value comes off scanned intake
sheets where the field is frequently blank, so `NULL` means "not answered" and
has to stay distinct from `false` ("explicitly no"). The buyer detail shows it
as a Yes / No / not answered control, and `PATCH /api/buyers/:id` accepts all
three. The two price fields stay `text` on purpose: the paper forms contain entries like "1.2M + inventory" that no
numeric type survives. The migration also adds the three index expressions the
duplicate check needs (`lower(btrim(name))` and the digits-only phone/cell).
First smoke test: First smoke test:
```bash ```bash
@@ -85,7 +109,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` 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` 4. Restore the dump: `docker compose exec -T db psql -U bizmatch bizmatch < backup.sql`
## API (as of module 3) ## API (as of module 4)
| Method | Path | Purpose | Session | | Method | Path | Purpose | Session |
| ------ | --------------------------- | ------------------------------------------------ | ------- | | ------ | --------------------------- | ------------------------------------------------ | ------- |
@@ -100,10 +124,58 @@ directory aborts the scan with an error naming the path.
| GET | /api/businesses/:id | single business incl. `nas_path` | yes | | GET | /api/businesses/:id | single business incl. `nas_path` | yes |
| GET | /api/businesses/:id/files | recursive listing, max depth 3 (PDFs first) | yes | | GET | /api/businesses/:id/files | recursive listing, max depth 3 (PDFs first) | yes |
| GET | /api/businesses/:id/file | stream one file, `?path=<relative>` | yes | | GET | /api/businesses/:id/file | stream one file, `?path=<relative>` | yes |
| GET | /api/businesses/:id/deals | buyer activity on one business, newest first | yes |
| GET | /api/buyers | list `?search=&status=` + counts per buyer status | yes |
| GET | /api/buyers/duplicates | candidates for `?email=&name=&phone=` | yes |
| POST | /api/inquiries | guided new-inquiry flow (one transaction) | yes |
| GET | /api/buyers/:id | buyer incl. `contacts[]` and `ndas[].deals[]` | yes |
| PATCH | /api/buyers/:id | identity fields + status (+ `end_open_deals`) | yes |
| POST | /api/buyers/:id/contacts | add a contact | yes |
| PATCH | /api/contacts/:id | edit a contact (incl. `is_primary`) | yes |
| PATCH | /api/ndas/:id | edit one NDA round | yes |
| 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 |
Everything except health, staff (GET+POST) and login requires the session Everything except health, staff (GET+POST) and login requires the session
cookie; without it the API answers `401`. cookie; without it the API answers `401`.
### Buyer side (module 4)
Domain rules, all enforced in the API:
* **buyer** is the buying party, **contact** are its 1..n people, **nda** is one
inquiry round (a returning buyer signs a *new* NDA), **deal** is
buyer↔business inside one round. There is deliberately no uniqueness on
`(buyer_id, business_id)` — a returning buyer gets a new round with new deals
and the history stays visible.
* Deal flow `NEW → INFO_SENT → DUE_DILIGENCE → LOI → CLOSING`, `ENDED` from
anywhere. `POST /api/deals/:id/status` rejects only a no-op (`409`);
everything else is allowed on purpose, because corrections have to be
possible. Entering `INFO_SENT` sets `follow_up_at = today + 14`, entering
`ENDED` clears it.
* When an NDA becomes `SIGNED` it gets a `signed_at` (default today) and its
buyer is set back to `ACTIVE`.
* Deactivating a buyer with `{"status":"DEACTIVATED","end_open_deals":true}`
ends all their non-`ENDED` deals; the response always carries
`open_deal_count` so the UI can warn first.
`GET /api/buyers/duplicates` matches exactly, never fuzzily: normalised e-mail
(`lower(btrim(…))`), case-insensitive contact name, and phone **or** cell
compared digits-only, so `(361) 555-0101` and `3615550101` are the same number.
Numbers with fewer than 7 digits are ignored. A candidate reports every reason
it matched in `matched_on`.
`POST /api/inquiries` is the guided flow and runs in one transaction. Without
`buyer_id` it creates buyer + primary contact; with `buyer_id` it reuses the
buyer and only adds the contact when no existing contact of that buyer has the
same normalised e-mail or the same name. It then creates the NDA round and one
deal, and returns
`{buyer_id, nda_id, deal_id, created:{buyer, contact}}`. The optional
`backfill` block (`deal_status`, `nda_status`, `signed_at`, `nda_nas_path`)
files a paper record in its real state — a backfilled `INFO_SENT` still arms
the 14-day follow-up, later statuses do not.
### File listing and streaming ### File listing and streaming
`/files` walks the business directory recursively (max depth 3), skipping `/files` walks the business directory recursively (max depth 3), skipping
@@ -127,9 +199,27 @@ alphabetical.
## Frontend ## Frontend
`web/` is a Vite + React + TypeScript app with Tailwind v4 (no router, no state `web/` is a Vite + React + TypeScript app with Tailwind v4 (no router, no state
library). Views: login ("Who is working?"), business list (tabs with counts, library). The header carries the two nav entries **Businesses** and **Buyers**;
search, "Scan NAS now") and business detail — a master-detail split filling the routing is a hand-rolled `{view, id}` state in `App.tsx`. Views:
viewport: file table left, PDF viewer right.
* login ("Who is working?")
* 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
* 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
phone a warning panel lists the duplicate candidates with "Use this buyer"
(locks the buyer, shown as a chip with an undo) or "Create new buyer anyway".
The collapsible "Backfill existing deal (paper records)" section files
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
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
per deal.
In dev, Vite proxies `/api` to `http://localhost:8090`. In production the 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 Fastify app serves `web/dist` via `@fastify/static` with an SPA fallback to
@@ -169,19 +259,25 @@ pdf.js fails the decoders silently and shows blank white canvases.
## Structure ## Structure
``` ```
migrations/ numbered SQL migrations (001_init.sql = full schema) 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
src/ src/
config.ts env configuration config.ts env configuration
db.ts pg pool + query helpers db.ts pg pool, query helpers, withTransaction
session.ts the staff-id cookie
migrate.ts migration runner (transactional, advisory lock) migrate.ts migration runner (transactional, advisory lock)
business-scan.ts NAS scan, recursive listing, safe file path resolution business-scan.ts NAS scan, recursive listing, safe file path resolution
server.ts Fastify app (health, staff, login, businesses, file, static) server.ts Fastify app (health, staff, login, businesses, file, static)
buyer-routes.ts buyers, contacts, NDA rounds, deals, the inquiry flow
web/ web/
scripts/copy-pdfjs.mjs pdfjs-dist -> public/pdfjs/ (predev + prebuild) scripts/copy-pdfjs.mjs pdfjs-dist -> public/pdfjs/ (predev + prebuild)
public/viewer/ standalone, unbundled pdf.js viewer page public/viewer/ standalone, unbundled pdf.js viewer page
public/pdfjs/ generated, git-ignored pdf.js runtime public/pdfjs/ generated, git-ignored pdf.js runtime
src/api.ts typed API client src/api.ts typed API client
src/App.tsx session gate + view switch src/App.tsx session gate + nav + view switch
src/views/ Login, Businesses, BusinessDetail src/components.tsx shared bits (badges, inline fields, business picker, dialog)
src/views/ Login, Businesses, BusinessDetail, Buyers, BuyerDetail, NewInquiry
viewer-phase1/ reference copy of the phase-1 desktop viewer viewer-phase1/ reference copy of the phase-1 desktop viewer
``` ```

View File

@@ -0,0 +1,30 @@
-- BizMatch Phase 2 — module 4: the fields the buyer side actually collects
-- (NDA form + intake sheet). 001_init.sql stays untouched.
-- -------------------------------------------------------------- contact
ALTER TABLE contact ADD COLUMN cell text;
-- ---------------------------------------------------------------- buyer
ALTER TABLE buyer
ADD COLUMN address text,
ADD COLUMN state text,
ADD COLUMN background_experience text,
ADD COLUMN how_heard text,
ADD COLUMN interested_in_updates boolean NOT NULL DEFAULT false;
-- ------------------------------------------------------------------ nda
-- Money stays text on purpose: the paper forms contain things like
-- "1.2M + inventory" or "TBD" that no numeric type survives.
ALTER TABLE nda
ADD COLUMN total_purchase_price text,
ADD COLUMN down_payment text,
ADD COLUMN intro_date date;
-- ------------------------------------------- dedup anchors (duplicates)
-- The duplicate check compares names case-insensitively and phone numbers
-- digits-only, so the indexes have to match those expressions exactly.
CREATE INDEX contact_name_lower_idx ON contact (lower(btrim(name)));
CREATE INDEX contact_phone_digits_idx
ON contact ((regexp_replace(phone, '[^0-9]', '', 'g'))) WHERE phone IS NOT NULL;
CREATE INDEX contact_cell_digits_idx
ON contact ((regexp_replace(cell, '[^0-9]', '', 'g'))) WHERE cell IS NOT NULL;

View File

@@ -0,0 +1,8 @@
-- BizMatch Phase 2 — module 4 follow-up
-- The value comes from scanned intake sheets where the field is frequently
-- left blank. NULL therefore has to mean "not answered" and stay distinct
-- from false, which means "explicitly no".
ALTER TABLE buyer
ALTER COLUMN interested_in_updates DROP NOT NULL,
ALTER COLUMN interested_in_updates DROP DEFAULT;

797
src/buyer-routes.ts Normal file
View File

@@ -0,0 +1,797 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import { query, queryOne, withTransaction, type Tx } from './db.js';
import { staffIdFromRequest } from './session.js';
export type BuyerStatus = 'ACTIVE' | 'DEACTIVATED' | 'LEGACY';
export type NdaStatus = 'SENT' | 'SIGNED';
export type DealStatus = 'NEW' | 'INFO_SENT' | 'DUE_DILIGENCE' | 'LOI' | 'CLOSING' | 'ENDED';
const BUYER_STATUSES = ['ACTIVE', 'DEACTIVATED', 'LEGACY'] as const;
const NDA_STATUSES = ['SENT', 'SIGNED'] as const;
const DEAL_STATUSES = [
'NEW',
'INFO_SENT',
'DUE_DILIGENCE',
'LOI',
'CLOSING',
'ENDED',
] as const;
/** Entering INFO_SENT arms the reminder; nothing else touches follow_up_at. */
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.
* Idempotent, so every path that can set status = SIGNED just calls it.
*/
async function applySignedRule(tx: Tx, ndaId: string): Promise<void> {
const nda = await tx.queryOne<{ buyer_id: string }>(
`UPDATE nda SET signed_at = coalesce(signed_at, CURRENT_DATE)
WHERE id = $1 AND status = 'SIGNED'
RETURNING buyer_id`,
[ndaId],
);
if (!nda) return; // still SENT — nothing to do
await tx.query(`UPDATE buyer SET status = 'ACTIVE' WHERE id = $1 AND status <> 'ACTIVE'`, [
nda.buyer_id,
]);
}
/** `is_primary` is a single-winner flag per buyer. */
async function clearPrimaryFlag(tx: Tx, buyerId: string, keepContactId: string): Promise<void> {
await tx.query(
'UPDATE contact SET is_primary = false WHERE buyer_id = $1 AND id <> $2 AND is_primary',
[buyerId, keepContactId],
);
}
const openDealCount = async (buyerId: string): Promise<number> =>
(
await queryOne<{ n: number }>(
`SELECT count(*)::int AS n FROM deal WHERE buyer_id = $1 AND status <> 'ENDED'`,
[buyerId],
)
)?.n ?? 0;
// ------------------------------------------------------------------ Rows
interface BuyerListRow {
id: string;
company_name: string | null;
status: BuyerStatus;
contact_name: string | null;
contact_email: string | null;
contact_phone: string | null;
nda_count: number;
open_deal_count: number;
}
interface DuplicateRow {
buyer_id: string;
company_name: string | null;
buyer_status: BuyerStatus;
contact_name: string;
contact_email: string | null;
m_email: boolean;
m_name: boolean;
m_phone: boolean;
}
interface DealRow {
id: string;
nda_id: string | null;
status: DealStatus;
follow_up_at: string | null;
note_count: number;
business_id: string;
business_name: string;
business_status: string;
}
interface InquiryBody {
buyer_id?: string;
company_name?: string;
contact?: { name?: string; email?: string; phone?: string; cell?: string };
business_id?: string;
backfill?: {
deal_status?: string;
nda_status?: string;
signed_at?: string;
nda_nas_path?: string;
};
}
/**
* Everything on the buyer side: buyers, contacts, NDA rounds, deals and the
* guided inquiry flow. Registered on the main app instance, so the global
* session hook in server.ts covers all of it.
*/
export function registerBuyerRoutes(app: FastifyInstance): void {
// ------------------------------------------------------------- Buyers
app.get<{ Querystring: { search?: string; status?: string } }>(
'/api/buyers',
async (req) => {
const search = req.query.search?.trim() ?? '';
const status = req.query.status?.trim() ?? '';
// Like the business list: the counts always cover every status, so the
// filter chips stay usable while a filter is active.
const countParams: unknown[] = [];
let where = '';
if (search) {
countParams.push(`%${search}%`);
const p = `$${countParams.length}`;
where = `WHERE (b.company_name ILIKE ${p} OR EXISTS (
SELECT 1 FROM contact c WHERE c.buyer_id = b.id
AND (c.name ILIKE ${p} OR c.email ILIKE ${p})))`;
}
const counts = await query<{ status: string; n: number }>(
`SELECT b.status, count(*)::int AS n FROM buyer b ${where} GROUP BY b.status`,
countParams,
);
const listParams = [...countParams];
let listWhere = where;
if (status) {
listParams.push(status);
listWhere = `${listWhere ? `${listWhere} AND` : 'WHERE'} b.status = $${listParams.length}`;
}
const rows = await query<BuyerListRow>(
`SELECT b.id, b.company_name, b.status,
c.name AS contact_name, c.email AS contact_email, c.phone AS contact_phone,
n.nda_count, d.open_deal_count
FROM buyer b
LEFT JOIN LATERAL (
SELECT name, email, phone FROM contact
WHERE buyer_id = b.id ORDER BY is_primary DESC, created_at LIMIT 1
) c ON true
LEFT JOIN LATERAL (
SELECT count(*)::int AS nda_count FROM nda WHERE buyer_id = b.id
) n ON true
LEFT JOIN LATERAL (
SELECT count(*)::int AS open_deal_count FROM deal
WHERE buyer_id = b.id AND status <> 'ENDED'
) d ON true
${listWhere}
ORDER BY b.created_at DESC`,
listParams,
);
return {
buyers: rows.map((row) => ({
id: row.id,
company_name: row.company_name,
status: row.status,
primary_contact: row.contact_name
? { name: row.contact_name, email: row.contact_email, phone: row.contact_phone }
: null,
nda_count: row.nda_count,
open_deal_count: row.open_deal_count,
})),
counts: {
ACTIVE: counts.find((c) => c.status === 'ACTIVE')?.n ?? 0,
DEACTIVATED: counts.find((c) => c.status === 'DEACTIVATED')?.n ?? 0,
LEGACY: counts.find((c) => c.status === 'LEGACY')?.n ?? 0,
},
};
},
);
/**
* Duplicate candidates for the inquiry flow: same e-mail, same name or the
* same phone number. Deliberately exact matches (after normalising) — a
* fuzzy match would cry wolf on every "John Smith" typo.
*/
app.get<{ Querystring: { email?: string; name?: string; phone?: string } }>(
'/api/buyers/duplicates',
async (req) => {
const email = trimmed(req.query.email).toLowerCase();
const name = trimmed(req.query.name).toLowerCase();
const phone = digits(trimmed(req.query.phone));
const phoneKey = phone.length >= MIN_PHONE_DIGITS ? phone : '';
if (!email && !name && !phoneKey) return { candidates: [] };
const rows = await query<DuplicateRow>(
`SELECT c.buyer_id, b.company_name, b.status AS buyer_status,
c.name AS contact_name, c.email AS contact_email,
($1 <> '' AND lower(btrim(c.email)) = $1) AS m_email,
($2 <> '' AND lower(btrim(c.name)) = $2) AS m_name,
($3 <> '' AND $3 IN (regexp_replace(coalesce(c.phone, ''), '[^0-9]', '', 'g'),
regexp_replace(coalesce(c.cell, ''), '[^0-9]', '', 'g')))
AS m_phone
FROM contact c
JOIN buyer b ON b.id = c.buyer_id
WHERE ($1 <> '' AND lower(btrim(c.email)) = $1)
OR ($2 <> '' AND lower(btrim(c.name)) = $2)
OR ($3 <> '' AND $3 IN (regexp_replace(coalesce(c.phone, ''), '[^0-9]', '', 'g'),
regexp_replace(coalesce(c.cell, ''), '[^0-9]', '', 'g')))
ORDER BY c.is_primary DESC, c.created_at`,
[email, name, phoneKey],
);
if (rows.length === 0) return { candidates: [] };
const buyerIds = [...new Set(rows.map((row) => row.buyer_id))];
const rounds = await query<{ buyer_id: string; nda_count: number; last_date: string | null }>(
`SELECT buyer_id, count(*)::int AS nda_count,
max(coalesce(signed_at, intro_date, sent_at)) AS last_date
FROM nda WHERE buyer_id = ANY($1) GROUP BY buyer_id`,
[buyerIds],
);
// Several contacts of one buyer can match; report the buyer once with
// the union of the reasons.
const candidates = buyerIds.map((buyerId) => {
const matches = rows.filter((row) => row.buyer_id === buyerId);
const first = matches[0]!;
const matchedOn: string[] = [];
if (matches.some((row) => row.m_email)) matchedOn.push('email');
if (matches.some((row) => row.m_name)) matchedOn.push('name');
if (matches.some((row) => row.m_phone)) matchedOn.push('phone');
const round = rounds.find((r) => r.buyer_id === buyerId);
return {
buyer_id: buyerId,
company_name: first.company_name,
buyer_status: first.buyer_status,
matched_on: matchedOn,
contact_name: first.contact_name,
contact_email: first.contact_email,
nda_count: round?.nda_count ?? 0,
last_date: round?.last_date ?? null,
};
});
return { candidates };
},
);
/**
* The guided "New inquiry" flow — one transaction so a half-created buyer
* can never survive. Returns which objects were actually created, because
* the UI says "added to existing buyer" vs "new buyer" afterwards.
*/
app.post<{ Body: InquiryBody }>('/api/inquiries', async (req, reply) => {
const staffId = staffIdFromRequest(req);
if (!staffId) return reply.code(401).send({ error: 'not signed in' });
const body = req.body ?? {};
const contactName = trimmed(body.contact?.name);
if (!contactName) return reply.code(400).send({ error: 'contact.name is missing' });
const businessId = trimmed(body.business_id);
if (!businessId) return reply.code(400).send({ error: 'business_id is missing' });
const email = trimmed(body.contact?.email);
const phone = trimmed(body.contact?.phone);
const cell = trimmed(body.contact?.cell);
const companyName = trimmed(body.company_name) || null;
const buyerId = trimmed(body.buyer_id) || null;
// Backfill of a paper record: the round may already be signed and the deal
// may already be well past NEW.
let dealStatus: DealStatus = 'NEW';
let ndaStatus: NdaStatus = 'SENT';
let signedAt: string | null = null;
let ndaPath: string | null = null;
try {
const backfill = body.backfill;
if (backfill) {
if (trimmed(backfill.deal_status)) {
dealStatus = asOneOf(DEAL_STATUSES)(backfill.deal_status);
}
if (trimmed(backfill.nda_status)) {
ndaStatus = asOneOf(NDA_STATUSES)(backfill.nda_status);
}
signedAt = asDate(backfill.signed_at);
ndaPath = asText(backfill.nda_nas_path);
}
} catch (err) {
if (err instanceof InputError) return reply.code(400).send({ error: err.message });
throw err;
}
if (!UUID_RE.test(businessId)) return reply.code(404).send({ error: 'business not found' });
if (!(await queryOne('SELECT id FROM business WHERE id = $1', [businessId]))) {
return reply.code(404).send({ error: 'business not found' });
}
if (buyerId) {
if (!UUID_RE.test(buyerId)) return reply.code(404).send({ error: 'buyer not found' });
if (!(await queryOne('SELECT id FROM buyer WHERE id = $1', [buyerId]))) {
return reply.code(404).send({ error: 'buyer not found' });
}
}
const result = await withTransaction(async (tx) => {
let targetBuyer = buyerId;
let createdBuyer = false;
let createdContact = false;
if (targetBuyer) {
// Same person, new round: reuse the contact instead of piling up
// near-identical rows under one buyer.
const existing = await tx.queryOne<{ id: string }>(
`SELECT id FROM contact
WHERE buyer_id = $1
AND (($2 <> '' AND lower(btrim(email)) = $2) OR lower(btrim(name)) = $3)
ORDER BY is_primary DESC, created_at LIMIT 1`,
[targetBuyer, email.toLowerCase(), contactName.toLowerCase()],
);
if (!existing) {
await tx.query(
'INSERT INTO contact (buyer_id, name, email, phone, cell) VALUES ($1, $2, $3, $4, $5)',
[targetBuyer, contactName, email || null, phone || null, cell || null],
);
createdContact = true;
}
} else {
const buyer = await tx.queryRow<{ id: string }>(
'INSERT INTO buyer (company_name, created_by) VALUES ($1, $2) RETURNING id',
[companyName, staffId],
);
targetBuyer = buyer.id;
createdBuyer = true;
await tx.query(
`INSERT INTO contact (buyer_id, name, email, phone, cell, is_primary)
VALUES ($1, $2, $3, $4, $5, true)`,
[targetBuyer, contactName, email || null, phone || null, cell || null],
);
createdContact = true;
}
const nda = await tx.queryRow<{ id: string }>(
`INSERT INTO nda (buyer_id, status, sent_at, signed_at, nas_path, created_by)
VALUES ($1, $2, CURRENT_DATE, $3, $4, $5) RETURNING id`,
[targetBuyer, ndaStatus, signedAt, ndaPath, staffId],
);
await applySignedRule(tx, nda.id);
// A backfilled INFO_SENT still needs its reminder — the follow-up is due
// 14 days from when we learn about it, not from the paper date.
const deal = await tx.queryRow<{ id: string }>(
`INSERT INTO deal (buyer_id, business_id, nda_id, status, follow_up_at, created_by)
VALUES ($1, $2, $3, $4,
CASE WHEN $4 = 'INFO_SENT' THEN CURRENT_DATE + ${FOLLOW_UP_DAYS} END, $5)
RETURNING id`,
[targetBuyer, businessId, nda.id, dealStatus, staffId],
);
return {
buyer_id: targetBuyer,
nda_id: nda.id,
deal_id: deal.id,
created: { buyer: createdBuyer, contact: createdContact },
};
});
return reply.code(201).send(result);
});
// -------------------------------------------------------- Buyer detail
app.get<{ Params: { id: string } }>('/api/buyers/:id', async (req, reply) => {
const { id } = req.params;
if (badId(id, reply, 'buyer')) return reply;
const buyer = await queryOne('SELECT * FROM buyer WHERE id = $1', [id]);
if (!buyer) return reply.code(404).send({ error: 'buyer not found' });
const contacts = await query(
`SELECT id, name, email, phone, cell, is_primary, created_at FROM contact
WHERE buyer_id = $1 ORDER BY is_primary DESC, created_at`,
[id],
);
const ndas = await query<{ id: string }>(
`SELECT id, status, nas_path, sent_at, signed_at, intro_date,
preferred_businesses_text, total_purchase_price, down_payment, created_at
FROM nda WHERE buyer_id = $1 ORDER BY created_at DESC`,
[id],
);
const deals = await query<DealRow>(
`SELECT d.id, d.nda_id, d.status, d.follow_up_at,
b.id AS business_id, b.name AS business_name, b.status AS business_status,
(SELECT count(*)::int FROM note WHERE deal_id = d.id) AS note_count
FROM deal d JOIN business b ON b.id = d.business_id
WHERE d.buyer_id = $1 ORDER BY d.created_at`,
[id],
);
const dealsOfRound = (ndaId: string) =>
deals
.filter((deal) => deal.nda_id === ndaId)
.map((deal) => ({
id: deal.id,
status: deal.status,
follow_up_at: deal.follow_up_at,
note_count: deal.note_count,
business: {
id: deal.business_id,
name: deal.business_name,
status: deal.business_status,
},
}));
return {
...buyer,
contacts,
ndas: ndas.map((nda) => ({ ...nda, deals: dealsOfRound(nda.id) })),
};
});
app.patch<{ Params: { id: string }; Body: Record<string, unknown> }>(
'/api/buyers/:id',
async (req, reply) => {
const { id } = req.params;
if (badId(id, reply, 'buyer')) return reply;
const body = req.body ?? {};
let patch;
try {
patch = buildPatch(body, {
company_name: asText,
address: asText,
state: asText,
background_experience: asText,
how_heard: asText,
interested_in_updates: asNullableBool,
status: asOneOf(BUYER_STATUSES),
});
} catch (err) {
if (err instanceof InputError) return reply.code(400).send({ error: err.message });
throw err;
}
const endOpenDeals = body.status === 'DEACTIVATED' && body.end_open_deals === true;
if (patch.sets.length === 0 && !endOpenDeals) {
return reply.code(400).send({ error: 'nothing to update' });
}
const buyer = await withTransaction(async (tx) => {
const row =
patch.sets.length === 0
? await tx.queryOne('SELECT * FROM buyer WHERE id = $1', [id])
: await tx.queryOne(
`UPDATE buyer SET ${patch.sets.join(', ')}
WHERE id = $${patch.params.length + 1} RETURNING *`,
[...patch.params, id],
);
if (!row) return null;
// Deactivating a buyer who still has running deals would leave those
// deals in the follow-up lists forever, so the caller can end them.
if (endOpenDeals) {
await tx.query(
`UPDATE deal SET status = 'ENDED', follow_up_at = NULL
WHERE buyer_id = $1 AND status <> 'ENDED'`,
[id],
);
}
return row;
});
if (!buyer) return reply.code(404).send({ error: 'buyer not found' });
return { ...buyer, open_deal_count: await openDealCount(id) };
},
);
// ------------------------------------------------------------ Contacts
app.post<{ Params: { id: string }; Body: Record<string, unknown> }>(
'/api/buyers/:id/contacts',
async (req, reply) => {
const { id } = req.params;
if (badId(id, reply, 'buyer')) return reply;
const body = req.body ?? {};
let name: string;
try {
name = asRequiredText(body.name);
} catch {
return reply.code(400).send({ error: 'name is missing' });
}
if (!(await queryOne('SELECT id FROM buyer WHERE id = $1', [id]))) {
return reply.code(404).send({ error: 'buyer not found' });
}
const contact = await withTransaction(async (tx) => {
const row = await tx.queryRow<{ id: string }>(
`INSERT INTO contact (buyer_id, name, email, phone, cell, is_primary)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, email, phone, cell, is_primary, created_at`,
[id, name, asText(body.email), asText(body.phone), asText(body.cell), asBool(body.is_primary)],
);
if (asBool(body.is_primary)) await clearPrimaryFlag(tx, id, row.id);
return row;
});
return reply.code(201).send(contact);
},
);
app.patch<{ Params: { id: string }; Body: Record<string, unknown> }>(
'/api/contacts/:id',
async (req, reply) => {
const { id } = req.params;
if (badId(id, reply, 'contact')) return reply;
const body = req.body ?? {};
let patch;
try {
patch = buildPatch(body, {
name: asRequiredText,
email: asText,
phone: asText,
cell: asText,
is_primary: asBool,
});
} catch (err) {
if (err instanceof InputError) return reply.code(400).send({ error: err.message });
throw err;
}
if (patch.sets.length === 0) return reply.code(400).send({ error: 'nothing to update' });
const contact = await withTransaction(async (tx) => {
const row = await tx.queryOne<{ id: string; buyer_id: string; is_primary: boolean }>(
`UPDATE contact SET ${patch.sets.join(', ')}
WHERE id = $${patch.params.length + 1}
RETURNING id, buyer_id, name, email, phone, cell, is_primary, created_at`,
[...patch.params, id],
);
if (!row) return null;
if (row.is_primary) await clearPrimaryFlag(tx, row.buyer_id, row.id);
return row;
});
if (!contact) return reply.code(404).send({ error: 'contact not found' });
return contact;
},
);
// ---------------------------------------------------------- NDA rounds
app.patch<{ Params: { id: string }; Body: Record<string, unknown> }>(
'/api/ndas/:id',
async (req, reply) => {
const { id } = req.params;
if (badId(id, reply, 'nda')) return reply;
let patch;
try {
patch = buildPatch(req.body ?? {}, {
status: asOneOf(NDA_STATUSES),
sent_at: asDate,
signed_at: asDate,
nas_path: asText,
preferred_businesses_text: asText,
total_purchase_price: asText,
down_payment: asText,
intro_date: asDate,
});
} catch (err) {
if (err instanceof InputError) return reply.code(400).send({ error: err.message });
throw err;
}
if (patch.sets.length === 0) return reply.code(400).send({ error: 'nothing to update' });
const nda = await withTransaction(async (tx) => {
const row = await tx.queryOne<{ id: string }>(
`UPDATE nda SET ${patch.sets.join(', ')}
WHERE id = $${patch.params.length + 1} RETURNING id`,
[...patch.params, id],
);
if (!row) return null;
await applySignedRule(tx, row.id);
return tx.queryOne(
`SELECT id, buyer_id, status, nas_path, sent_at, signed_at, intro_date,
preferred_businesses_text, total_purchase_price, down_payment, created_at
FROM nda WHERE id = $1`,
[row.id],
);
});
if (!nda) return reply.code(404).send({ error: 'nda not found' });
return nda;
},
);
/** Another business inside an existing round — no new NDA needed. */
app.post<{ Params: { id: string }; Body: { business_id?: string } }>(
'/api/ndas/:id/deals',
async (req, reply) => {
const { id } = req.params;
if (badId(id, reply, 'nda')) return reply;
const staffId = staffIdFromRequest(req);
if (!staffId) return reply.code(401).send({ error: 'not signed in' });
const businessId = trimmed(req.body?.business_id);
if (!businessId) return reply.code(400).send({ error: 'business_id is missing' });
if (!UUID_RE.test(businessId)) return reply.code(404).send({ error: 'business not found' });
const nda = await queryOne<{ buyer_id: string }>('SELECT buyer_id FROM nda WHERE id = $1', [
id,
]);
if (!nda) return reply.code(404).send({ error: 'nda not found' });
if (!(await queryOne('SELECT id FROM business WHERE id = $1', [businessId]))) {
return reply.code(404).send({ error: 'business not found' });
}
// A repeat within the same round is a mis-click; a repeat in a *new*
// round is legitimate and stays allowed.
if (
await queryOne('SELECT id FROM deal WHERE nda_id = $1 AND business_id = $2', [
id,
businessId,
])
) {
return reply.code(409).send({ error: 'this business is already part of this NDA round' });
}
const deal = await queryOne(
`INSERT INTO deal (buyer_id, business_id, nda_id, created_by)
VALUES ($1, $2, $3, $4) RETURNING id, status, follow_up_at`,
[nda.buyer_id, businessId, id, staffId],
);
return reply.code(201).send(deal);
},
);
// --------------------------------------------------------------- Deals
/**
* Status change. Only a no-op is rejected: the team is small and every
* correction — including going back a step — has to stay possible.
*/
app.post<{ Params: { id: string }; Body: { status?: string; comment?: string } }>(
'/api/deals/:id/status',
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' });
let status: DealStatus;
try {
status = asOneOf(DEAL_STATUSES)(req.body?.status);
} catch (err) {
if (err instanceof InputError) return reply.code(400).send({ error: err.message });
throw err;
}
const comment = trimmed(req.body?.comment);
const current = await queryOne<{ status: DealStatus }>('SELECT status FROM deal WHERE id = $1', [
id,
]);
if (!current) return reply.code(404).send({ error: 'deal not found' });
if (current.status === status) {
return reply.code(409).send({ error: `deal is already ${status}` });
}
const deal = await withTransaction(async (tx) => {
const row = await tx.queryRow(
`UPDATE deal
SET status = $2,
follow_up_at = CASE
WHEN $2 = 'INFO_SENT' THEN CURRENT_DATE + ${FOLLOW_UP_DAYS}
WHEN $2 = 'ENDED' THEN NULL
ELSE follow_up_at END
WHERE id = $1
RETURNING id, status, follow_up_at`,
[id, status],
);
if (comment) {
await tx.query('INSERT INTO note (text, deal_id, created_by) VALUES ($1, $2, $3)', [
comment,
id,
staffId,
]);
}
return row;
});
return deal;
},
);
/** Read-only; the full notes UI follows in module 5. */
app.get<{ Params: { id: string } }>('/api/deals/:id/notes', async (req, reply) => {
const { id } = req.params;
if (badId(id, reply, 'deal')) return reply;
return query(
`SELECT n.id, n.text, n.highlight, n.created_at, s.name AS author
FROM note n LEFT JOIN staff s ON s.id = n.created_by
WHERE n.deal_id = $1 ORDER BY n.created_at DESC`,
[id],
);
});
/** Buyer activity on a business page — the other end of the deal list. */
app.get<{ Params: { id: string } }>('/api/businesses/:id/deals', async (req, reply) => {
const { id } = req.params;
if (badId(id, reply, 'business')) return reply;
return query(
`SELECT d.id, d.status, d.follow_up_at, d.created_at,
b.id AS buyer_id, b.company_name, b.status AS buyer_status,
c.name AS contact_name,
n.intro_date, n.signed_at
FROM deal d
JOIN buyer b ON b.id = d.buyer_id
LEFT JOIN nda n ON n.id = d.nda_id
LEFT JOIN LATERAL (
SELECT name FROM contact
WHERE buyer_id = b.id ORDER BY is_primary DESC, created_at LIMIT 1
) c ON true
WHERE d.business_id = $1
ORDER BY d.created_at DESC`,
[id],
);
});
}

View File

@@ -1,6 +1,11 @@
import pg from 'pg'; import pg from 'pg';
import { config } from './config.js'; import { config } from './config.js';
// `date` columns are calendar days, not instants. Left to node-pg they become
// Date objects at local midnight and shift by a day on the way to JSON, so we
// keep them as the plain 'YYYY-MM-DD' string Postgres already sends.
pg.types.setTypeParser(pg.types.builtins.DATE, (value) => value);
export const pool = new pg.Pool({ export const pool = new pg.Pool({
connectionString: config.databaseUrl, connectionString: config.databaseUrl,
max: 10, max: 10,
@@ -22,3 +27,53 @@ export async function queryOne<T extends pg.QueryResultRow = pg.QueryResultRow>(
const rows = await query<T>(text, params); const rows = await query<T>(text, params);
return rows[0] ?? null; return rows[0] ?? null;
} }
/** Same helpers, bound to one connection inside a transaction. */
export interface Tx {
query<T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params?: unknown[],
): Promise<T[]>;
queryOne<T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params?: unknown[],
): Promise<T | null>;
/** For statements that must produce a row (INSERT … RETURNING, UPDATE of a known id). */
queryRow<T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params?: unknown[],
): Promise<T>;
}
/**
* Runs `fn` inside BEGIN/COMMIT on a single connection; any throw rolls back.
* The guided inquiry flow creates buyer + contact + NDA + deal together, so it
* must be all-or-nothing.
*/
export async function withTransaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
const client = await pool.connect();
const run = async <R extends pg.QueryResultRow>(text: string, params: unknown[] = []) =>
(await client.query<R>(text, params)).rows;
const tx: Tx = {
query: run,
async queryOne<T extends pg.QueryResultRow>(text: string, params: unknown[] = []) {
return (await run<T>(text, params))[0] ?? null;
},
async queryRow<T extends pg.QueryResultRow>(text: string, params: unknown[] = []) {
const row = (await run<T>(text, params))[0];
if (!row) throw new Error('statement returned no row');
return row;
},
};
try {
await client.query('BEGIN');
const result = await fn(tx);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK').catch(() => {});
throw err;
} finally {
client.release();
}
}

View File

@@ -13,6 +13,8 @@ import {
resolveBusinessFile, resolveBusinessFile,
scanBusinesses, scanBusinesses,
} from './business-scan.js'; } from './business-scan.js';
import { registerBuyerRoutes } from './buyer-routes.js';
import { COOKIE, staffIdFromRequest } from './session.js';
interface Staff { interface Staff {
id: string; id: string;
@@ -32,12 +34,6 @@ interface Business {
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
await app.register(cookie); await app.register(cookie);
const COOKIE = 'bizmatch_staff';
function staffIdFromRequest(req: { cookies: Record<string, string | undefined> }): string | null {
return req.cookies[COOKIE] ?? null;
}
// ------------------------------------------------------------------ Auth // ------------------------------------------------------------------ Auth
/** Endpoints reachable without a session cookie. */ /** Endpoints reachable without a session cookie. */
const PUBLIC_ROUTES = new Set(['GET /api/health', 'GET /api/staff', 'POST /api/staff', 'POST /api/login']); const PUBLIC_ROUTES = new Set(['GET /api/health', 'GET /api/staff', 'POST /api/staff', 'POST /api/login']);
@@ -251,6 +247,10 @@ app.get<{ Params: { id: string }; Querystring: { path?: string } }>(
}, },
); );
// ---------------------------------------------------- Buyers / NDAs / deals
// Registered on this instance, so the session hook above covers them too.
registerBuyerRoutes(app);
// --------------------------------------------------------------- Static // --------------------------------------------------------------- Static
// Serves the built frontend in production; SPA fallback for non-/api routes. // 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'); const WEB_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web', 'dist');

8
src/session.ts Normal file
View File

@@ -0,0 +1,8 @@
/** The session is just the staff id in a cookie — one small team, one LAN. */
export const COOKIE = 'bizmatch_staff';
export function staffIdFromRequest(req: {
cookies: Record<string, string | undefined>;
}): string | null {
return req.cookies[COOKIE] ?? null;
}

View File

@@ -3,13 +3,31 @@ import { ApiError, api, type Staff } from './api.js';
import Login from './views/Login.js'; import Login from './views/Login.js';
import Businesses from './views/Businesses.js'; import Businesses from './views/Businesses.js';
import BusinessDetail from './views/BusinessDetail.js'; import BusinessDetail from './views/BusinessDetail.js';
import Buyers from './views/Buyers.js';
import BuyerDetail from './views/BuyerDetail.js';
import NewInquiry from './views/NewInquiry.js';
type View = { name: 'businesses' } | { name: 'business'; id: string }; /** Hand-rolled routing: which view, and (for the detail views) which row. */
type Route =
| { view: 'businesses' }
| { view: 'business'; id: string }
| { view: 'buyers' }
| { view: 'buyer'; id: string }
| { view: 'new-inquiry' };
const NAV: { label: string; route: Route; active: Route['view'][] }[] = [
{ label: 'Businesses', route: { view: 'businesses' }, active: ['businesses', 'business'] },
{
label: 'Buyers',
route: { view: 'buyers' },
active: ['buyers', 'buyer', 'new-inquiry'],
},
];
export default function App() { export default function App() {
const [staff, setStaff] = useState<Staff | null>(null); const [staff, setStaff] = useState<Staff | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [view, setView] = useState<View>({ name: 'businesses' }); const [route, setRoute] = useState<Route>({ view: 'businesses' });
useEffect(() => { useEffect(() => {
api api
@@ -24,7 +42,7 @@ export default function App() {
async function signOut() { async function signOut() {
await api.logout(); await api.logout();
setStaff(null); setStaff(null);
setView({ name: 'businesses' }); setRoute({ view: 'businesses' });
} }
if (loading) return <div className="p-8 text-sm text-gray-500">Loading</div>; if (loading) return <div className="p-8 text-sm text-gray-500">Loading</div>;
@@ -35,12 +53,24 @@ export default function App() {
// fill exactly the remaining viewport height. // fill exactly the remaining viewport height.
<div className="flex h-screen flex-col bg-gray-50 text-gray-900"> <div className="flex h-screen flex-col bg-gray-50 text-gray-900">
<header className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-white px-6 py-3"> <header className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
<div className="flex items-center gap-6">
<span className="text-base font-semibold tracking-tight">BizMatch</span>
<nav className="flex gap-1">
{NAV.map((entry) => (
<button <button
className="text-base font-semibold tracking-tight" key={entry.label}
onClick={() => setView({ name: 'businesses' })} onClick={() => setRoute(entry.route)}
className={`rounded px-3 py-1 text-sm ${
entry.active.includes(route.view)
? 'bg-gray-900 text-white'
: 'hover:bg-gray-100'
}`}
> >
BizMatch {entry.label}
</button> </button>
))}
</nav>
</div>
<div className="flex items-center gap-3 text-sm"> <div className="flex items-center gap-3 text-sm">
<span className="text-gray-600">{staff.name}</span> <span className="text-gray-600">{staff.name}</span>
<button <button
@@ -52,13 +82,38 @@ export default function App() {
</div> </div>
</header> </header>
{view.name === 'businesses' ? ( {route.view === 'business' ? (
<main className="mx-auto w-full max-w-5xl flex-1 overflow-auto px-6 py-6"> <main className="flex min-h-0 flex-1 flex-col px-6 py-4">
<Businesses onOpen={(id) => setView({ name: 'business', id })} /> <BusinessDetail
id={route.id}
onBack={() => setRoute({ view: 'businesses' })}
onOpenBuyer={(id) => setRoute({ view: 'buyer', id })}
/>
</main> </main>
) : ( ) : (
<main className="flex min-h-0 flex-1 flex-col px-6 py-4"> <main className="mx-auto w-full max-w-5xl flex-1 overflow-auto px-6 py-6">
<BusinessDetail id={view.id} onBack={() => setView({ name: 'businesses' })} /> {route.view === 'businesses' && (
<Businesses onOpen={(id) => setRoute({ view: 'business', id })} />
)}
{route.view === 'buyers' && (
<Buyers
onOpen={(id) => setRoute({ view: 'buyer', id })}
onNewInquiry={() => setRoute({ view: 'new-inquiry' })}
/>
)}
{route.view === 'new-inquiry' && (
<NewInquiry
onCreated={(id) => setRoute({ view: 'buyer', id })}
onCancel={() => setRoute({ view: 'buyers' })}
/>
)}
{route.view === 'buyer' && (
<BuyerDetail
id={route.id}
onBack={() => setRoute({ view: 'buyers' })}
onOpenBusiness={(id) => setRoute({ view: 'business', id })}
/>
)}
</main> </main>
)} )}
</div> </div>

View File

@@ -38,6 +38,132 @@ export interface ScanResult {
missing: number; missing: number;
} }
// ------------------------------------------------------------ Buyer side
export type BuyerStatus = 'ACTIVE' | 'DEACTIVATED' | 'LEGACY';
export type NdaStatus = 'SENT' | 'SIGNED';
export type DealStatus = 'NEW' | 'INFO_SENT' | 'DUE_DILIGENCE' | 'LOI' | 'CLOSING' | 'ENDED';
/** Calendar day as 'YYYY-MM-DD' — the API never sends date columns as instants. */
export type Day = string;
export interface PrimaryContact {
name: string;
email: string | null;
phone: string | null;
}
export interface BuyerListItem {
id: string;
company_name: string | null;
status: BuyerStatus;
primary_contact: PrimaryContact | null;
nda_count: number;
open_deal_count: number;
}
export interface BuyerList {
buyers: BuyerListItem[];
counts: Record<BuyerStatus, number>;
}
export interface DuplicateCandidate {
buyer_id: string;
company_name: string | null;
buyer_status: BuyerStatus;
/** Any of 'email' | 'name' | 'phone' — a buyer can match on several at once. */
matched_on: string[];
contact_name: string;
contact_email: string | null;
nda_count: number;
last_date: Day | null;
}
export interface Contact {
id: string;
name: string;
email: string | null;
phone: string | null;
cell: string | null;
is_primary: boolean;
}
export interface Deal {
id: string;
status: DealStatus;
follow_up_at: Day | null;
note_count: number;
business: { id: string; name: string; status: BusinessStatus };
}
export interface NdaRound {
id: string;
status: NdaStatus;
nas_path: string | null;
sent_at: Day | null;
signed_at: Day | null;
intro_date: Day | null;
preferred_businesses_text: string | null;
total_purchase_price: string | null;
down_payment: string | null;
deals: Deal[];
}
export interface Buyer {
id: string;
company_name: string | null;
status: BuyerStatus;
address: string | null;
state: string | null;
background_experience: string | null;
how_heard: string | null;
/** null = not answered on the intake sheet, distinct from an explicit false. */
interested_in_updates: boolean | null;
contacts: Contact[];
ndas: NdaRound[];
/** Only present on the PATCH response. */
open_deal_count?: number;
}
export interface DealNote {
id: string;
text: string;
highlight: boolean;
created_at: string;
author: string | null;
}
export interface BusinessDeal {
id: string;
status: DealStatus;
buyer_id: string;
company_name: string | null;
buyer_status: BuyerStatus;
contact_name: string | null;
intro_date: Day | null;
signed_at: Day | null;
created_at: string;
}
export interface InquiryInput {
buyer_id?: string;
company_name?: string;
contact: { name: string; email?: string; phone?: string; cell?: string };
business_id: string;
backfill?: {
deal_status: DealStatus;
nda_status: NdaStatus;
signed_at?: string;
nda_nas_path?: string;
};
}
export interface InquiryResult {
buyer_id: string;
nda_id: string;
deal_id: string;
created: { buyer: boolean; contact: boolean };
}
export class ApiError extends Error { export class ApiError extends Error {
constructor(readonly status: number, message: string) { constructor(readonly status: number, message: string) {
super(message); super(message);
@@ -53,26 +179,60 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
return (await res.json()) as T; return (await res.json()) as T;
} }
function post<T>(path: string, body?: unknown): Promise<T> { function send<T>(method: 'POST' | 'PATCH', path: string, body?: unknown): Promise<T> {
return request<T>(path, { return request<T>(path, {
method: 'POST', method,
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify(body ?? {}), body: JSON.stringify(body ?? {}),
}); });
} }
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 qs = (params: Record<string, string>) =>
new URLSearchParams(params).toString();
export const api = { export const api = {
me: () => request<Staff>('/api/me'), me: () => request<Staff>('/api/me'),
staff: () => request<Staff[]>('/api/staff'), staff: () => request<Staff[]>('/api/staff'),
login: (staffId: string) => post<{ ok: boolean; staff: Staff }>('/api/login', { staff_id: staffId }), login: (staffId: string) => post<{ ok: boolean; staff: Staff }>('/api/login', { staff_id: staffId }),
logout: () => post<{ ok: boolean }>('/api/logout'), logout: () => post<{ ok: boolean }>('/api/logout'),
businesses: (status: BusinessStatus, search: string) => businesses: (status: BusinessStatus | '', search: string) =>
request<BusinessList>( request<BusinessList>(`/api/businesses?${qs({ status, search })}`),
`/api/businesses?status=${encodeURIComponent(status)}&search=${encodeURIComponent(search)}`,
),
business: (id: string) => request<Business>(`/api/businesses/${id}`), business: (id: string) => request<Business>(`/api/businesses/${id}`),
businessFiles: (id: string) => request<BusinessFile[]>(`/api/businesses/${id}/files`), businessFiles: (id: string) => request<BusinessFile[]>(`/api/businesses/${id}/files`),
businessDeals: (id: string) => request<BusinessDeal[]>(`/api/businesses/${id}/deals`),
scan: () => post<ScanResult>('/api/businesses/scan'), scan: () => post<ScanResult>('/api/businesses/scan'),
buyers: (status: BuyerStatus | '', search: string) =>
request<BuyerList>(`/api/buyers?${qs({ status, search })}`),
buyer: (id: string) => request<Buyer>(`/api/buyers/${id}`),
updateBuyer: (id: string, body: Record<string, unknown>) =>
patch<Buyer>(`/api/buyers/${id}`, body),
duplicates: (probe: { email?: string; name?: string; phone?: string }) =>
request<{ candidates: DuplicateCandidate[] }>(
`/api/buyers/duplicates?${qs({
email: probe.email ?? '',
name: probe.name ?? '',
phone: probe.phone ?? '',
})}`,
),
createInquiry: (body: InquiryInput) => post<InquiryResult>('/api/inquiries', body),
addContact: (buyerId: string, body: Record<string, unknown>) =>
post<Contact>(`/api/buyers/${buyerId}/contacts`, body),
updateContact: (id: string, body: Record<string, unknown>) =>
patch<Contact>(`/api/contacts/${id}`, body),
updateNda: (id: string, body: Record<string, unknown>) =>
patch<NdaRound>(`/api/ndas/${id}`, body),
addDeal: (ndaId: string, businessId: string) =>
post<Deal>(`/api/ndas/${ndaId}/deals`, { business_id: businessId }),
setDealStatus: (id: string, status: DealStatus, comment?: string) =>
post<{ id: string; status: DealStatus; follow_up_at: Day | null }>(
`/api/deals/${id}/status`,
{ status, comment },
),
dealNotes: (id: string) => request<DealNote[]>(`/api/deals/${id}/notes`),
}; };
/** Same-origin streaming URL of one file inside a business directory. */ /** Same-origin streaming URL of one file inside a business directory. */

362
web/src/components.tsx Normal file
View File

@@ -0,0 +1,362 @@
import { useEffect, useRef, useState } from 'react';
import {
api,
type BusinessListItem,
type BusinessStatus,
type BuyerStatus,
type Day,
type DealStatus,
type NdaStatus,
} from './api.js';
/** 'YYYY-MM-DD' is parsed by hand — new Date('…') would shift the day by the UTC offset. */
export function formatDay(day: Day | null | undefined): string {
if (!day) return '—';
const [year = 0, month = 1, dayOfMonth = 1] = day.split('-').map(Number);
return new Date(year, month - 1, dayOfMonth).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit',
});
}
export function formatStamp(iso: string): string {
return new Date(iso).toLocaleString('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export const DEAL_LABELS: Record<DealStatus, string> = {
NEW: 'New',
INFO_SENT: 'Info sent',
DUE_DILIGENCE: 'Due diligence',
LOI: 'LOI',
CLOSING: 'Closing',
ENDED: 'Ended',
};
/** The label of the *action* that puts a deal into each status. */
export const DEAL_ACTIONS: Record<DealStatus, string> = {
NEW: 'Back to new',
INFO_SENT: 'Send info',
DUE_DILIGENCE: 'Start due diligence',
LOI: 'LOI',
CLOSING: 'Closing',
ENDED: 'End deal',
};
/** The happy path; ENDED is reachable from everywhere and therefore not in it. */
export const DEAL_FLOW: DealStatus[] = ['NEW', 'INFO_SENT', 'DUE_DILIGENCE', 'LOI', 'CLOSING'];
const TONES: Record<string, string> = {
ACTIVE: 'border-green-300 bg-green-50 text-green-800',
SIGNED: 'border-green-300 bg-green-50 text-green-800',
CLOSING: 'border-green-300 bg-green-50 text-green-800',
SENT: 'border-amber-300 bg-amber-50 text-amber-800',
NEW: 'border-blue-300 bg-blue-50 text-blue-800',
INFO_SENT: 'border-blue-300 bg-blue-50 text-blue-800',
DUE_DILIGENCE: 'border-indigo-300 bg-indigo-50 text-indigo-800',
LOI: 'border-indigo-300 bg-indigo-50 text-indigo-800',
DEACTIVATED: 'border-gray-300 bg-gray-100 text-gray-600',
ENDED: 'border-gray-300 bg-gray-100 text-gray-600',
LEGACY: 'border-gray-300 bg-gray-100 text-gray-600',
};
export function StatusBadge({
status,
label,
}: {
status: BuyerStatus | NdaStatus | DealStatus | BusinessStatus | string;
label?: string;
}) {
const tone = TONES[status] ?? 'border-gray-300 bg-white text-gray-600';
return (
<span className={`rounded-full border px-2 py-0.5 text-xs whitespace-nowrap ${tone}`}>
{label ?? status}
</span>
);
}
export function Panel({
title,
action,
children,
}: {
title: string;
action?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<section className="rounded border border-gray-200 bg-white">
<div className="flex items-center justify-between border-b border-gray-200 px-3 py-2">
<h2 className="text-xs font-medium uppercase tracking-wide text-gray-500">{title}</h2>
{action}
</div>
<div className="p-3">{children}</div>
</section>
);
}
const INPUT = 'rounded border border-gray-300 px-2 py-1 text-sm';
/**
* Click the value to edit it: Enter (or blur) saves, Escape cancels. Multiline
* fields get explicit buttons because Enter has to stay a newline there.
*/
export function InlineField({
label,
value,
multiline,
type = 'text',
placeholder,
onSave,
}: {
label: string;
value: string | null;
multiline?: boolean;
type?: 'text' | 'date';
placeholder?: string;
onSave: (next: string) => Promise<unknown> | void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value ?? '');
const [busy, setBusy] = useState(false);
function start() {
setDraft(value ?? '');
setEditing(true);
}
async function save() {
if (busy) return;
if (draft === (value ?? '')) return setEditing(false);
setBusy(true);
try {
await onSave(draft);
setEditing(false);
} finally {
setBusy(false);
}
}
const shown = type === 'date' ? formatDay(value) : value;
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs uppercase tracking-wide text-gray-500">{label}</span>
{editing ? (
multiline ? (
<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">
<button onClick={save} disabled={busy} className="text-xs text-blue-600 hover:underline">
Save
</button>
<button onClick={() => setEditing(false)} className="text-xs text-gray-500 hover:underline">
Cancel
</button>
</div>
</div>
) : (
<input
autoFocus
type={type}
value={draft}
placeholder={placeholder}
onChange={(e) => setDraft(e.target.value)}
onBlur={save}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
if (e.key === 'Escape') setEditing(false);
}}
className={`${INPUT} w-full`}
/>
)
) : (
<button
onClick={start}
title="Click to edit"
className="min-h-[1.5rem] whitespace-pre-wrap rounded px-1 py-0.5 text-left text-sm hover:bg-gray-100"
>
{shown && shown !== '—' ? shown : <span className="text-gray-400"> add </span>}
</button>
)}
</div>
);
}
/**
* Yes / No / not answered. The third state is not a nicety: the intake sheets
* are often blank there, and "we never asked" must not read as "they said no".
*/
export function TriState({
label,
value,
onSave,
}: {
label: string;
value: boolean | null;
onSave: (next: boolean | null) => Promise<unknown> | void;
}) {
const options: { value: boolean | null; label: string }[] = [
{ value: true, label: 'Yes' },
{ value: false, label: 'No' },
{ value: null, label: 'not answered' },
];
return (
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-wide text-gray-500">{label}</span>
<div className="flex overflow-hidden rounded border border-gray-300">
{options.map((option) => (
<button
key={String(option.value)}
onClick={() => option.value !== value && onSave(option.value)}
className={`border-r border-gray-300 px-2 py-0.5 text-xs last:border-r-0 ${
option.value === value
? 'bg-gray-900 text-white'
: 'bg-white text-gray-600 hover:bg-gray-100'
}`}
>
{option.label}
</button>
))}
</div>
</div>
);
}
/**
* Searchable business picker over the normal business list endpoint. Defaults
* to ACTIVE because that is what buyers get shown, but sold/inactive stay
* reachable for backfilled paper records.
*/
export function BusinessPicker({
value,
onPick,
}: {
value: BusinessListItem | null;
onPick: (business: BusinessListItem | null) => void;
}) {
const [search, setSearch] = useState('');
const [status, setStatus] = useState<BusinessStatus | ''>('ACTIVE');
const [list, setList] = useState<BusinessListItem[]>([]);
useEffect(() => {
if (value) return;
let cancelled = false;
const timer = setTimeout(() => {
api
.businesses(status, search)
.then((res) => !cancelled && setList(res.businesses))
.catch(() => !cancelled && setList([]));
}, 200);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [search, status, value]);
if (value) {
return (
<div className="flex items-center gap-2 rounded border border-gray-300 bg-gray-50 px-2 py-1.5 text-sm">
<span className="flex-1 truncate">{value.name}</span>
<StatusBadge status={value.status} />
<button onClick={() => onPick(null)} className="text-xs text-blue-600 hover:underline">
Change
</button>
</div>
);
}
return (
<div>
<div className="flex gap-2">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search business…"
className={`${INPUT} flex-1`}
/>
<select
value={status}
onChange={(e) => setStatus(e.target.value as BusinessStatus | '')}
className={INPUT}
>
<option value="ACTIVE">Active</option>
<option value="SOLD">Sold</option>
<option value="INACTIVE">Inactive</option>
<option value="">All</option>
</select>
</div>
<div className="mt-1 max-h-48 overflow-auto rounded border border-gray-200">
{list.map((business) => (
<button
key={business.id}
onClick={() => onPick(business)}
className="flex w-full items-center gap-2 border-b border-gray-100 px-2 py-1 text-left text-sm last:border-0 hover:bg-blue-50"
>
<span className="flex-1 truncate">{business.name}</span>
<span className="text-xs text-gray-400">{business.status}</span>
</button>
))}
{list.length === 0 && <p className="px-2 py-2 text-sm text-gray-500">No matches.</p>}
</div>
</div>
);
}
/** Small centred modal — used for the status-change comment. */
export function Dialog({
title,
confirmLabel,
busy,
onConfirm,
onCancel,
children,
}: {
title: string;
confirmLabel: string;
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
children: React.ReactNode;
}) {
const box = useRef<HTMLDivElement>(null);
useEffect(() => {
box.current?.querySelector('textarea')?.focus();
}, []);
return (
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/30 p-4">
<div ref={box} className="w-96 rounded-lg border border-gray-200 bg-white p-4 shadow-lg">
<h3 className="mb-3 text-sm font-semibold">{title}</h3>
{children}
<div className="mt-4 flex justify-end gap-2">
<button
onClick={onCancel}
className="rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-100"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={busy}
className="rounded bg-gray-900 px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -4,8 +4,10 @@ import {
businessFileUrl, businessFileUrl,
viewerUrl, viewerUrl,
type Business, type Business,
type BusinessDeal,
type BusinessFile, type BusinessFile,
} from '../api.js'; } from '../api.js';
import { DEAL_LABELS, StatusBadge, formatDay } from '../components.js';
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
@@ -24,16 +26,28 @@ function formatDate(iso: string): string {
const isPdf = (filePath: string) => filePath.toLowerCase().endsWith('.pdf'); const isPdf = (filePath: string) => filePath.toLowerCase().endsWith('.pdf');
export default function BusinessDetail({ id, onBack }: { id: string; onBack: () => void }) { export default function BusinessDetail({
id,
onBack,
onOpenBuyer,
}: {
id: string;
onBack: () => void;
onOpenBuyer: (buyerId: string) => void;
}) {
const [business, setBusiness] = useState<Business | null>(null); const [business, setBusiness] = useState<Business | null>(null);
const [files, setFiles] = useState<BusinessFile[] | null>(null); const [files, setFiles] = useState<BusinessFile[] | null>(null);
const [deals, setDeals] = useState<BusinessDeal[] | null>(null);
const [dealsOpen, setDealsOpen] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setSelected(null); setSelected(null);
setDealsOpen(false);
api.business(id).then(setBusiness).catch((err: Error) => setError(err.message)); api.business(id).then(setBusiness).catch((err: Error) => setError(err.message));
api.businessFiles(id).then(setFiles).catch((err: Error) => setError(err.message)); api.businessFiles(id).then(setFiles).catch((err: Error) => setError(err.message));
api.businessDeals(id).then(setDeals).catch((err: Error) => setError(err.message));
}, [id]); }, [id]);
return ( return (
@@ -56,6 +70,58 @@ export default function BusinessDetail({ id, onBack }: { id: string; onBack: ()
<p className="mt-1 font-mono text-xs text-gray-500">{business.nas_path}</p> <p className="mt-1 font-mono text-xs text-gray-500">{business.nas_path}</p>
</div> </div>
)} )}
{/* 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">
<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">
<th className="px-3 py-1.5 font-medium">Buyer</th>
<th className="px-3 py-1.5 font-medium">Contact</th>
<th className="w-32 px-3 py-1.5 font-medium">Deal</th>
<th className="w-32 px-3 py-1.5 font-medium">Intro</th>
<th className="w-32 px-3 py-1.5 font-medium">NDA signed</th>
</tr>
</thead>
<tbody>
{deals?.map((deal) => (
<tr
key={deal.id}
onClick={() => onOpenBuyer(deal.buyer_id)}
className="cursor-pointer border-b border-gray-100 hover:bg-gray-50"
>
<td className="px-3 py-1.5 text-blue-600">
{deal.company_name ?? deal.contact_name ?? 'Buyer'}
</td>
<td className="px-3 py-1.5 text-gray-600">{deal.contact_name ?? '—'}</td>
<td className="px-3 py-1.5">
<StatusBadge status={deal.status} label={DEAL_LABELS[deal.status]} />
</td>
<td className="px-3 py-1.5 text-gray-500">{formatDay(deal.intro_date)}</td>
<td className="px-3 py-1.5 text-gray-500">{formatDay(deal.signed_at)}</td>
</tr>
))}
{deals && deals.length === 0 && (
<tr>
<td colSpan={5} className="px-3 py-3 text-gray-500">
No buyer has been introduced to this business yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div> </div>
{/* Master-detail: file table left, PDF viewer right, together filling the viewport. */} {/* Master-detail: file table left, PDF viewer right, together filling the viewport. */}

View File

@@ -0,0 +1,587 @@
import { useCallback, useEffect, useState } from 'react';
import {
api,
type Buyer,
type BusinessListItem,
type Contact,
type Deal,
type DealNote,
type DealStatus,
type NdaRound,
} from '../api.js';
import {
BusinessPicker,
DEAL_ACTIONS,
DEAL_FLOW,
DEAL_LABELS,
Dialog,
InlineField,
Panel,
StatusBadge,
TriState,
formatDay,
formatStamp,
} from '../components.js';
const ALL_DEAL_STATUSES = Object.keys(DEAL_LABELS) as DealStatus[];
/** The step the flow suggests next, plus "End deal" — everything else is a correction. */
function nextSteps(status: DealStatus): DealStatus[] {
const index = DEAL_FLOW.indexOf(status);
const forward = index >= 0 ? DEAL_FLOW[index + 1] : undefined;
return [...(forward ? [forward] : []), ...(status === 'ENDED' ? [] : ['ENDED' as DealStatus])];
}
export default function BuyerDetail({
id,
onBack,
onOpenBusiness,
}: {
id: string;
onBack: () => void;
onOpenBusiness: (businessId: string) => 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);
const reload = useCallback(
() =>
api
.buyer(id)
.then((res) => {
setBuyer(res);
setError(null);
})
.catch((err: Error) => setError(err.message)),
[id],
);
useEffect(() => {
void reload();
}, [reload]);
async function guard(action: () => Promise<unknown>) {
try {
await action();
await reload();
} catch (err) {
setError((err as Error).message);
}
}
if (!buyer) {
return (
<div>
<button onClick={onBack} className="text-sm text-blue-600 hover:underline">
Back to buyers
</button>
{error ? (
<p className="mt-2 text-sm text-red-600">{error}</p>
) : (
<p className="mt-2 text-sm text-gray-500">Loading</p>
)}
</div>
);
}
const primary = buyer.contacts.find((contact) => contact.is_primary) ?? buyer.contacts[0];
const openDeals = buyer.ndas.reduce(
(sum, nda) => sum + nda.deals.filter((deal) => deal.status !== 'ENDED').length,
0,
);
return (
<div className="flex flex-col gap-4">
<div>
<button onClick={onBack} className="text-sm text-blue-600 hover:underline">
Back to buyers
</button>
<div className="mt-2 flex items-center gap-3">
<h1 className="text-lg font-semibold">
{buyer.company_name ?? primary?.name ?? 'Buyer'}
</h1>
<StatusBadge status={buyer.status} />
{buyer.company_name && primary && (
<span className="text-sm text-gray-500">{primary.name}</span>
)}
<div className="flex-1" />
{buyer.status === 'ACTIVE' ? (
<button
onClick={() => setConfirmDeactivate(true)}
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm hover:bg-gray-100"
>
Deactivate
</button>
) : (
<button
onClick={() => guard(() => api.updateBuyer(buyer.id, { status: 'ACTIVE' }))}
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm hover:bg-gray-100"
>
Reactivate
</button>
)}
</div>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</div>
{confirmDeactivate && (
<Dialog
title="Deactivate this buyer?"
confirmLabel="Deactivate"
onCancel={() => setConfirmDeactivate(false)}
onConfirm={() => {
setConfirmDeactivate(false);
void guard(() =>
api.updateBuyer(buyer.id, {
status: 'DEACTIVATED',
end_open_deals: endOpenDeals,
}),
);
}}
>
<p className="text-sm text-gray-600">
{openDeals === 0
? 'This buyer has no open deals.'
: `${openDeals} open deal${openDeals === 1 ? '' : 's'} will be ended.`}
</p>
{openDeals > 0 && (
<label className="mt-2 flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={endOpenDeals}
onChange={(e) => setEndOpenDeals(e.target.checked)}
/>
End the open deals
</label>
)}
</Dialog>
)}
<Panel title="Identity">
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
<InlineField
label="Company name"
value={buyer.company_name}
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { company_name: next }))}
/>
<InlineField
label="State"
value={buyer.state}
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { state: next }))}
/>
<InlineField
label="Address"
value={buyer.address}
multiline
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { address: next }))}
/>
<InlineField
label="How they heard about us"
value={buyer.how_heard}
multiline
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { how_heard: next }))}
/>
<div className="col-span-2">
<InlineField
label="Background / experience"
value={buyer.background_experience}
multiline
onSave={(next) =>
guard(() => api.updateBuyer(buyer.id, { background_experience: next }))
}
/>
</div>
<div className="col-span-2">
<TriState
label="Interested in updates"
value={buyer.interested_in_updates}
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { interested_in_updates: next }))}
/>
</div>
</div>
</Panel>
<Contacts buyer={buyer} guard={guard} />
<Panel title="NDA rounds">
<div className="flex flex-col gap-4">
{buyer.ndas.map((nda) => (
<Round
key={nda.id}
nda={nda}
guard={guard}
onOpenBusiness={onOpenBusiness}
onError={setError}
/>
))}
{buyer.ndas.length === 0 && <p className="text-sm text-gray-500">No NDA rounds yet.</p>}
</div>
</Panel>
</div>
);
}
// ------------------------------------------------------------- Contacts
function Contacts({
buyer,
guard,
}: {
buyer: Buyer;
guard: (action: () => Promise<unknown>) => Promise<void>;
}) {
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState({ name: '', email: '', phone: '', cell: '' });
async function add() {
if (!draft.name.trim()) return;
await guard(() => api.addContact(buyer.id, draft));
setDraft({ name: '', email: '', phone: '', cell: '' });
setAdding(false);
}
const field = (contact: Contact, key: 'name' | 'email' | 'phone' | 'cell') => (
<InlineField
label=""
value={contact[key]}
onSave={(next) => guard(() => api.updateContact(contact.id, { [key]: next }))}
/>
);
return (
<Panel
title="Contacts"
action={
<button
onClick={() => setAdding(!adding)}
className="text-xs text-blue-600 hover:underline"
>
{adding ? 'Cancel' : 'Add contact'}
</button>
}
>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
<th className="w-8 px-2 py-1 font-medium" />
<th className="px-2 py-1 font-medium">Name</th>
<th className="px-2 py-1 font-medium">E-mail</th>
<th className="px-2 py-1 font-medium">Phone</th>
<th className="px-2 py-1 font-medium">Cell</th>
</tr>
</thead>
<tbody>
{buyer.contacts.map((contact) => (
<tr key={contact.id} className="border-b border-gray-100 align-top">
<td className="px-2 py-1">
<button
title={contact.is_primary ? 'Primary contact' : 'Make primary contact'}
onClick={() =>
!contact.is_primary &&
guard(() => api.updateContact(contact.id, { is_primary: true }))
}
className={contact.is_primary ? 'text-amber-500' : 'text-gray-300 hover:text-amber-400'}
>
</button>
</td>
<td className="px-2 py-1">{field(contact, 'name')}</td>
<td className="px-2 py-1">{field(contact, 'email')}</td>
<td className="px-2 py-1">{field(contact, 'phone')}</td>
<td className="px-2 py-1">{field(contact, 'cell')}</td>
</tr>
))}
{adding && (
<tr className="border-b border-gray-100">
<td className="px-2 py-1" />
{(['name', 'email', 'phone', 'cell'] as const).map((key) => (
<td key={key} className="px-2 py-1">
<input
autoFocus={key === 'name'}
value={draft[key]}
placeholder={key}
onChange={(e) => setDraft({ ...draft, [key]: e.target.value })}
onKeyDown={(e) => e.key === 'Enter' && add()}
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</td>
))}
</tr>
)}
</tbody>
</table>
{adding && (
<button
onClick={add}
className="mt-2 rounded bg-gray-900 px-3 py-1 text-xs text-white"
>
Save contact
</button>
)}
</Panel>
);
}
// ------------------------------------------------------------ NDA round
function Round({
nda,
guard,
onOpenBusiness,
onError,
}: {
nda: NdaRound;
guard: (action: () => Promise<unknown>) => Promise<void>;
onOpenBusiness: (businessId: string) => void;
onError: (message: string) => void;
}) {
const [picking, setPicking] = useState(false);
const [business, setBusiness] = useState<BusinessListItem | null>(null);
async function addBusiness(picked: BusinessListItem) {
setBusiness(picked);
await guard(() => api.addDeal(nda.id, picked.id));
setBusiness(null);
setPicking(false);
}
return (
<div className="rounded border border-gray-200">
<div className="flex flex-wrap items-center gap-3 border-b border-gray-200 bg-gray-50 px-3 py-2">
<StatusBadge status={nda.status} label={nda.status === 'SIGNED' ? 'NDA signed' : 'NDA sent'} />
{nda.status === 'SIGNED' ? (
<span className="text-sm text-gray-600">on {formatDay(nda.signed_at)}</span>
) : (
<button
onClick={() => guard(() => api.updateNda(nda.id, { status: 'SIGNED' }))}
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs hover:bg-gray-100"
>
Mark signed
</button>
)}
<span className="text-xs text-gray-500">sent {formatDay(nda.sent_at)}</span>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 px-3 py-3 md:grid-cols-4">
<InlineField
label="Intro date"
type="date"
value={nda.intro_date}
onSave={(next) => guard(() => api.updateNda(nda.id, { intro_date: next }))}
/>
<InlineField
label="Signed date"
type="date"
value={nda.signed_at}
onSave={(next) => guard(() => api.updateNda(nda.id, { signed_at: next }))}
/>
<InlineField
label="Total purchase price"
value={nda.total_purchase_price}
onSave={(next) => guard(() => api.updateNda(nda.id, { total_purchase_price: next }))}
/>
<InlineField
label="Down payment"
value={nda.down_payment}
onSave={(next) => guard(() => api.updateNda(nda.id, { down_payment: next }))}
/>
<div className="col-span-2">
<InlineField
label="Preferred businesses"
value={nda.preferred_businesses_text}
multiline
onSave={(next) =>
guard(() => api.updateNda(nda.id, { preferred_businesses_text: next }))
}
/>
</div>
<div className="col-span-2">
<InlineField
label="NDA PDF on the NAS"
value={nda.nas_path}
onSave={(next) => guard(() => api.updateNda(nda.id, { nas_path: next }))}
/>
</div>
</div>
<div className="border-t border-gray-200">
{nda.deals.map((deal) => (
<DealRow
key={deal.id}
deal={deal}
guard={guard}
onOpenBusiness={onOpenBusiness}
onError={onError}
/>
))}
{nda.deals.length === 0 && (
<p className="px-3 py-2 text-sm text-gray-500">No businesses in this round.</p>
)}
<div className="px-3 py-2">
{picking ? (
<div className="flex flex-col gap-2">
<BusinessPicker
value={business}
onPick={(picked) => picked && addBusiness(picked)}
/>
<button
onClick={() => setPicking(false)}
className="self-start text-xs text-gray-500 hover:underline"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setPicking(true)}
className="text-xs text-blue-600 hover:underline"
>
+ Add business to this round
</button>
)}
</div>
</div>
</div>
);
}
// ----------------------------------------------------------------- Deal
function DealRow({
deal,
guard,
onOpenBusiness,
onError,
}: {
deal: Deal;
guard: (action: () => Promise<unknown>) => Promise<void>;
onOpenBusiness: (businessId: string) => void;
onError: (message: string) => void;
}) {
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 forward = nextSteps(deal.status);
const corrections = ALL_DEAL_STATUSES.filter(
(status) => status !== deal.status && !forward.includes(status),
);
function pick(status: DealStatus) {
setMenuOpen(false);
setComment('');
setPending(status);
}
async function confirm() {
if (!pending) return;
setBusy(true);
await guard(() => api.setDealStatus(deal.id, pending, comment));
setBusy(false);
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">
<button
onClick={() => onOpenBusiness(deal.business.id)}
className="flex-1 truncate text-left text-sm text-blue-600 hover:underline"
>
{deal.business.name}
</button>
<StatusBadge status={deal.status} label={DEAL_LABELS[deal.status]} />
{deal.follow_up_at && (
<span className="text-xs text-gray-500">follow up {formatDay(deal.follow_up_at)}</span>
)}
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs hover:bg-gray-100"
>
Actions
</button>
{menuOpen && (
<div className="absolute right-0 z-10 mt-1 w-48 rounded border border-gray-200 bg-white py-1 shadow-lg">
{forward.map((status) => (
<button
key={status}
onClick={() => pick(status)}
className="block w-full px-3 py-1 text-left text-sm hover:bg-gray-100"
>
{DEAL_ACTIONS[status]}
</button>
))}
<div className="my-1 border-t border-gray-100 px-3 pt-1 text-xs text-gray-400">
Correct to
</div>
{corrections.map((status) => (
<button
key={status}
onClick={() => pick(status)}
className="block w-full px-3 py-1 text-left text-sm text-gray-600 hover:bg-gray-100"
>
{DEAL_LABELS[status]}
</button>
))}
</div>
)}
</div>
</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>
{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>
)}
</div>
{pending && (
<Dialog
title={DEAL_ACTIONS[pending]}
confirmLabel="Confirm"
busy={busy}
onCancel={() => setPending(null)}
onConfirm={confirm}
>
<p className="mb-2 text-sm text-gray-600">
{deal.business.name}: {DEAL_LABELS[deal.status]} {DEAL_LABELS[pending]}
</p>
<textarea
rows={3}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Comment (optional) — saved as a note"
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
</Dialog>
)}
</div>
);
}

118
web/src/views/Buyers.tsx Normal file
View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { api, type BuyerList, type BuyerStatus } from '../api.js';
import { StatusBadge } from '../components.js';
const CHIPS: { status: BuyerStatus; label: string }[] = [
{ status: 'ACTIVE', label: 'Active' },
{ status: 'DEACTIVATED', label: 'Deactivated' },
{ status: 'LEGACY', label: 'Legacy' },
];
export default function Buyers({
onOpen,
onNewInquiry,
}: {
onOpen: (id: string) => void;
onNewInquiry: () => void;
}) {
// No filter = every buyer; clicking the active chip again clears it, because
// the history of deactivated buyers matters as much as the active ones.
const [filter, setFilter] = useState<BuyerStatus | ''>('');
const [search, setSearch] = useState('');
const [data, setData] = useState<BuyerList | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api
.buyers(filter, search)
.then((res) => {
if (!cancelled) {
setData(res);
setError(null);
}
})
.catch((err: Error) => !cancelled && setError(err.message));
return () => {
cancelled = true;
};
}, [filter, search]);
return (
<div>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex gap-1">
{CHIPS.map((chip) => (
<button
key={chip.status}
onClick={() => setFilter(filter === chip.status ? '' : chip.status)}
className={`rounded px-3 py-1.5 text-sm ${
filter === chip.status
? 'bg-gray-900 text-white'
: 'border border-gray-300 bg-white hover:bg-gray-100'
}`}
>
{chip.label} ({data?.counts[chip.status] ?? 0})
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search company, contact, e-mail…"
className="w-64 rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
<button
onClick={onNewInquiry}
className="rounded bg-gray-900 px-3 py-1.5 text-sm text-white"
>
New inquiry
</button>
</div>
</div>
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
<table className="w-full border-collapse bg-white text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
<th className="px-3 py-2 font-medium">Company / name</th>
<th className="px-3 py-2 font-medium">Primary contact</th>
<th className="px-3 py-2 font-medium">E-mail</th>
<th className="w-24 px-3 py-2 text-right font-medium">NDA rounds</th>
<th className="w-24 px-3 py-2 text-right font-medium">Open deals</th>
<th className="w-32 px-3 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{data?.buyers.map((buyer) => (
<tr
key={buyer.id}
onClick={() => onOpen(buyer.id)}
className="cursor-pointer border-b border-gray-100 hover:bg-gray-50"
>
<td className="px-3 py-1.5">
{buyer.company_name ?? buyer.primary_contact?.name ?? '—'}
</td>
<td className="px-3 py-1.5 text-gray-600">{buyer.primary_contact?.name ?? '—'}</td>
<td className="px-3 py-1.5 text-gray-600">{buyer.primary_contact?.email ?? '—'}</td>
<td className="px-3 py-1.5 text-right text-gray-600">{buyer.nda_count}</td>
<td className="px-3 py-1.5 text-right text-gray-600">{buyer.open_deal_count}</td>
<td className="px-3 py-1.5">
<StatusBadge status={buyer.status} />
</td>
</tr>
))}
{data && data.buyers.length === 0 && (
<tr>
<td colSpan={6} className="px-3 py-4 text-gray-500">
No buyers.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,296 @@
import { useEffect, useState } from 'react';
import {
api,
type BusinessListItem,
type DealStatus,
type DuplicateCandidate,
type InquiryInput,
} from '../api.js';
import { BusinessPicker, DEAL_LABELS, StatusBadge, formatDay } from '../components.js';
const INPUT = 'w-full rounded border border-gray-300 px-2 py-1.5 text-sm';
const LABEL = 'mb-1 block text-xs uppercase tracking-wide text-gray-500';
/** A name shorter than this matches half the address book — not worth probing. */
const MIN_NAME_PROBE = 3;
export default function NewInquiry({
onCreated,
onCancel,
}: {
onCreated: (buyerId: string) => void;
onCancel: () => void;
}) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [cell, setCell] = useState('');
const [company, setCompany] = useState('');
const [business, setBusiness] = useState<BusinessListItem | null>(null);
// The e-mail is only probed once the field is left — probing every keystroke
// would fire on "a", "an", "ann@…" and never match anything useful.
const [emailProbe, setEmailProbe] = useState('');
const [candidates, setCandidates] = useState<DuplicateCandidate[]>([]);
const [dismissed, setDismissed] = useState(false);
const [locked, setLocked] = useState<DuplicateCandidate | null>(null);
const [backfillOpen, setBackfillOpen] = useState(false);
const [dealStatus, setDealStatus] = useState<DealStatus>('NEW');
const [ndaSigned, setNdaSigned] = useState(false);
const [signedAt, setSignedAt] = useState('');
const [ndaPath, setNdaPath] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (locked) return;
const nameProbe = name.trim().length >= MIN_NAME_PROBE ? name.trim() : '';
const phoneProbe = phone.trim() || cell.trim();
if (!nameProbe && !emailProbe && !phoneProbe) {
setCandidates([]);
return;
}
let cancelled = false;
const timer = setTimeout(() => {
api
.duplicates({ email: emailProbe, name: nameProbe, phone: phoneProbe })
.then((res) => !cancelled && setCandidates(res.candidates))
.catch(() => !cancelled && setCandidates([]));
}, 350);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [name, emailProbe, phone, cell, locked]);
async function submit() {
if (!name.trim()) return setError('A contact name is required.');
if (!business) return setError('Pick a business.');
setSubmitting(true);
setError(null);
const body: InquiryInput = {
contact: {
name: name.trim(),
email: email.trim(),
phone: phone.trim(),
cell: cell.trim(),
},
business_id: business.id,
};
if (locked) body.buyer_id = locked.buyer_id;
else if (company.trim()) body.company_name = company.trim();
if (backfillOpen) {
body.backfill = {
deal_status: dealStatus,
nda_status: ndaSigned ? 'SIGNED' : 'SENT',
signed_at: ndaSigned ? signedAt : '',
nda_nas_path: ndaPath.trim(),
};
}
try {
const res = await api.createInquiry(body);
onCreated(res.buyer_id);
} catch (err) {
setError((err as Error).message);
} finally {
setSubmitting(false);
}
}
const showWarning = !locked && !dismissed && candidates.length > 0;
return (
<div className="mx-auto w-full max-w-2xl">
<button onClick={onCancel} className="text-sm text-blue-600 hover:underline">
Back to buyers
</button>
<h1 className="mt-2 mb-4 text-lg font-semibold">New inquiry</h1>
{locked && (
<div className="mb-3 flex items-center gap-2 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-sm">
<span className="rounded-full border border-blue-300 bg-white px-2 py-0.5 text-xs text-blue-800">
existing buyer
</span>
<span className="flex-1">
{locked.company_name ?? locked.contact_name}
<span className="text-gray-500"> · {locked.nda_count} NDA round(s)</span>
</span>
<button
onClick={() => setLocked(null)}
className="text-xs text-blue-700 hover:underline"
>
Undo
</button>
</div>
)}
{showWarning && (
<div className="mb-3 rounded border border-amber-300 bg-amber-50 p-3">
<p className="text-sm font-medium text-amber-900">
{candidates.length === 1 ? 'A buyer already matches' : 'Existing buyers match'} this
contact
</p>
<ul className="mt-2 flex flex-col gap-2">
{candidates.map((candidate) => (
<li
key={candidate.buyer_id}
className="flex items-center gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-sm"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{candidate.company_name ?? candidate.contact_name}
</span>
<StatusBadge status={candidate.buyer_status} />
<span className="text-xs text-gray-500">
matched on {candidate.matched_on.join(', ')}
</span>
</div>
<div className="truncate text-xs text-gray-500">
{candidate.contact_name}
{candidate.contact_email ? ` · ${candidate.contact_email}` : ''} ·{' '}
{candidate.nda_count} round(s) · last {formatDay(candidate.last_date)}
</div>
</div>
<button
onClick={() => setLocked(candidate)}
className="shrink-0 rounded border border-amber-400 bg-white px-2 py-1 text-xs hover:bg-amber-100"
>
Use this buyer
</button>
</li>
))}
</ul>
<button
onClick={() => setDismissed(true)}
className="mt-2 text-xs text-amber-900 underline"
>
Create new buyer anyway
</button>
</div>
)}
<div className="grid grid-cols-2 gap-3 rounded border border-gray-200 bg-white p-3">
<div className="col-span-2">
<label className={LABEL}>Contact name *</label>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
className={INPUT}
/>
</div>
<div>
<label className={LABEL}>E-mail</label>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setEmailProbe(email.trim())}
className={INPUT}
/>
</div>
<div>
<label className={LABEL}>Company name</label>
<input
value={company}
onChange={(e) => setCompany(e.target.value)}
disabled={locked !== null}
className={`${INPUT} disabled:bg-gray-100`}
/>
</div>
<div>
<label className={LABEL}>Phone</label>
<input value={phone} onChange={(e) => setPhone(e.target.value)} className={INPUT} />
</div>
<div>
<label className={LABEL}>Cell</label>
<input value={cell} onChange={(e) => setCell(e.target.value)} className={INPUT} />
</div>
<div className="col-span-2">
<label className={LABEL}>Business *</label>
<BusinessPicker value={business} onPick={setBusiness} />
</div>
</div>
<div className="mt-3 rounded border border-gray-200 bg-white">
<button
onClick={() => setBackfillOpen(!backfillOpen)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm"
>
<span className="text-gray-400">{backfillOpen ? '▾' : '▸'}</span>
Backfill existing deal (paper records)
</button>
{backfillOpen && (
<div className="grid grid-cols-2 gap-3 border-t border-gray-200 p-3">
<div>
<label className={LABEL}>Deal status</label>
<select
value={dealStatus}
onChange={(e) => setDealStatus(e.target.value as DealStatus)}
className={INPUT}
>
{(Object.keys(DEAL_LABELS) as DealStatus[]).map((status) => (
<option key={status} value={status}>
{DEAL_LABELS[status]}
</option>
))}
</select>
</div>
<div>
<label className={LABEL}>NDA</label>
<label className="flex items-center gap-2 py-1.5 text-sm">
<input
type="checkbox"
checked={ndaSigned}
onChange={(e) => setNdaSigned(e.target.checked)}
/>
already signed
</label>
</div>
{ndaSigned && (
<div>
<label className={LABEL}>Signed date</label>
<input
type="date"
value={signedAt}
onChange={(e) => setSignedAt(e.target.value)}
className={INPUT}
/>
<p className="mt-1 text-xs text-gray-500">Empty = today.</p>
</div>
)}
<div className={ndaSigned ? '' : 'col-span-2'}>
<label className={LABEL}>NDA PDF path on the NAS</label>
<input
value={ndaPath}
onChange={(e) => setNdaPath(e.target.value)}
placeholder="e.g. NDA/2024/smith-nda.pdf"
className={`${INPUT} font-mono`}
/>
</div>
</div>
)}
</div>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<div className="mt-4 flex gap-2">
<button
onClick={submit}
disabled={submitting}
className="rounded bg-gray-900 px-4 py-2 text-sm text-white disabled:opacity-50"
>
{submitting ? 'Creating…' : 'Create inquiry'}
</button>
<button
onClick={onCancel}
className="rounded border border-gray-300 px-4 py-2 text-sm hover:bg-gray-100"
>
Cancel
</button>
</div>
</div>
);
}