module 4
This commit is contained in:
@@ -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
112
README.md
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
30
migrations/002_buyer_fields.sql
Normal file
30
migrations/002_buyer_fields.sql
Normal 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;
|
||||||
8
migrations/003_interested_in_updates_nullable.sql
Normal file
8
migrations/003_interested_in_updates_nullable.sql
Normal 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
797
src/buyer-routes.ts
Normal 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],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
55
src/db.ts
55
src/db.ts
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
8
src/session.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
<button
|
<div className="flex items-center gap-6">
|
||||||
className="text-base font-semibold tracking-tight"
|
<span className="text-base font-semibold tracking-tight">BizMatch</span>
|
||||||
onClick={() => setView({ name: 'businesses' })}
|
<nav className="flex gap-1">
|
||||||
>
|
{NAV.map((entry) => (
|
||||||
BizMatch
|
<button
|
||||||
</button>
|
key={entry.label}
|
||||||
|
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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
</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>
|
||||||
|
|||||||
172
web/src/api.ts
172
web/src/api.ts
@@ -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
362
web/src/components.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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. */}
|
||||||
|
|||||||
587
web/src/views/BuyerDetail.tsx
Normal file
587
web/src/views/BuyerDetail.tsx
Normal 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
118
web/src/views/Buyers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
296
web/src/views/NewInquiry.tsx
Normal file
296
web/src/views/NewInquiry.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user