This commit is contained in:
2026-07-23 17:48:44 -05:00
commit 5830cd7ab5
33 changed files with 6866 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(npm install *)",
"Bash(npm run *)",
"Bash(docker compose *)",
"Bash(DATABASE_URL=postgres://bizmatch:bizmatch@127.0.0.1:5432/bizmatch PORT=8099 NAS_ROOT=/tmp/nas-test node *)",
"Bash(mv \"/tmp/nas-test/AAA = SOLD\" \"/tmp/nas-test/AAA = SOLDX\")",
"Bash(curl -s -b /tmp/claude-1000/-home-aknuth-git-bizmatch-app/707e3054-047a-46c7-a85b-21fcee5afdd9/scratchpad/cj -X POST localhost:8099/api/businesses/scan -w ' [%{http_code}]')",
"Bash(pkill -f 'bizmatch-app/dist')",
"Bash(mv /tmp/nas-test-parked \"/tmp/nas-test/AAA = INACTIVE/Delta Print Shop\")",
"Bash(kill 128755)",
"Bash(echo \"exit=$?\")",
"Bash(npm view *)",
"Bash(echo \"--- exit $? ---\")"
]
}
}

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
web/node_modules
web/dist
.env
.git

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# For docker compose (optional, default: bizmatch)
DB_PASSWORD=bizmatch
# For dev mode (npm run dev) on the host:
DATABASE_URL=postgres://bizmatch:bizmatch@127.0.0.1:5432/bizmatch
PORT=8090
NAS_ROOT=/mnt/bizmatch-nas
# Directory names directly below NAS_ROOT, one per business status
NAS_DIR_ACTIVE="AAA = ACTIVE"
NAS_DIR_SOLD="AAA = SOLD"
NAS_DIR_INACTIVE="AAA = INACTIVE"

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
web/node_modules/
web/dist/
.env

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build && npm prune --omit=dev
FROM node:22-alpine AS web
WORKDIR /web
COPY web/package*.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=web /web/dist ./web/dist
COPY migrations ./migrations
COPY package.json ./
EXPOSE 8090
CMD ["node", "dist/server.js"]

129
README.md Normal file
View File

@@ -0,0 +1,129 @@
# BizMatch Phase 2 — Broker Workflow App
Module 1: foundation (Docker Compose, PostgreSQL, schema, migrations, login).
Module 2: business scan (NAS -> DB) and the first UI.
The UI and all domain constants are English.
## Setup on 192.168.100.99 (Ubuntu 24.04)
Prerequisite: Docker + Compose plugin (`sudo apt install docker.io docker-compose-v2`).
```bash
# Unpack the project, then:
cd bizmatch-app
cp .env.example .env # optionally adjust DB_PASSWORD
docker compose up -d --build
```
The app applies all migrations on start and then listens on
`http://192.168.100.99:8090`.
> **Upgrading from Module 1:** `001_init.sql` was rewritten in place (German
> constants -> English). Applying it needs a fresh database:
> `docker compose down -v && docker compose up -d --build`. The DB held no
> production data yet, so there is nothing to migrate.
First smoke test:
```bash
curl http://localhost:8090/api/health
# -> {"ok":true}
# Create the three staff members (adjust the names):
curl -X POST localhost:8090/api/staff -H 'content-type: application/json' -d '{"name":"Chris"}'
curl -X POST localhost:8090/api/staff -H 'content-type: application/json' -d '{"name":"..."}'
```
## Dev mode (without the app container)
```bash
docker compose up -d db # database only
npm install
set -a; source .env; set +a
npm run dev # tsx watch, migrations run on start
# second terminal — frontend with hot reload, /api is proxied to :8090
cd web && npm install && npm run dev
```
## NAS mount
Mount it on the host via NFS, e.g. in `/etc/fstab`:
```
<truenas-ip>:/mnt/<pool>/bizmatch /mnt/bizmatch-nas nfs ro,soft,timeo=100 0 0
```
The compose file already passes `NAS_ROOT` (default `/mnt/bizmatch-nas`) into the
app container. Write access (NDA filing) comes in module 6 — then replace `ro`
with `rw` and limit the permissions to the two write paths.
### Business directories
Directly below `NAS_ROOT` there are three status directories; every immediate
subdirectory of those is one business. The directory names are configurable
(they contain spaces and are treated as opaque strings):
| Env var | Default | Business status |
| ------------------ | -------------- | --------------- |
| `NAS_DIR_ACTIVE` | `AAA = ACTIVE` | ACTIVE |
| `NAS_DIR_SOLD` | `AAA = SOLD` | SOLD |
| `NAS_DIR_INACTIVE` | `AAA = INACTIVE` | INACTIVE |
The scan is idempotent: existing rows are matched by name and only updated when
`nas_path` or `status` changed. Businesses that exist in the DB but no longer on
disk are kept and only reported as a warning in the log. A missing configured
directory aborts the scan with an error naming the path.
## Moving to the AI machine (later)
1. `docker compose down` on .99
2. Take a dump: `docker compose exec db pg_dump -U bizmatch bizmatch > backup.sql`
3. Copy the project folder to the AI machine, run `docker compose up -d --build`
4. Restore the dump: `docker compose exec -T db psql -U bizmatch bizmatch < backup.sql`
## API (as of module 2)
| Method | Path | Purpose | Session |
| ------ | --------------------------- | ------------------------------------------------ | ------- |
| GET | /api/health | liveness incl. DB check | no |
| GET | /api/staff | staff list | no |
| POST | /api/staff | create staff member `{name}` | no |
| POST | /api/login | login via `{staff_id}` → session cookie | no |
| GET | /api/me | signed-in staff member | yes |
| POST | /api/logout | sign out | yes |
| POST | /api/businesses/scan | scan the NAS → `{scanned, inserted, updated, missing}` | yes |
| GET | /api/businesses | list `?status=&search=` + counts per status | yes |
| GET | /api/businesses/:id | single business incl. `nas_path` | yes |
| GET | /api/businesses/:id/files | live directory listing (PDFs first) | yes |
Everything except health, staff (GET+POST) and login requires the session
cookie; without it the API answers `401`.
## Frontend
`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,
search, "Scan NAS now") and business detail (status badge, live file list).
In dev, Vite proxies `/api` to `http://localhost:8090`. In production the
Fastify app serves `web/dist` via `@fastify/static` with an SPA fallback to
`index.html` for all non-`/api` routes; the Dockerfile builds the frontend in
its own stage and copies `web/dist` into the runtime image.
## Structure
```
migrations/ numbered SQL migrations (001_init.sql = full schema)
src/
config.ts env configuration
db.ts pg pool + query helpers
migrate.ts migration runner (transactional, advisory lock)
business-scan.ts NAS scan + directory listing
server.ts Fastify app (health, staff, login, businesses, static)
web/
src/api.ts typed API client
src/App.tsx session gate + view switch
src/views/ Login, Businesses, BusinessDetail
```

38
docker-compose.yml Normal file
View File

@@ -0,0 +1,38 @@
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: bizmatch
POSTGRES_USER: bizmatch
POSTGRES_PASSWORD: ${DB_PASSWORD:-bizmatch}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432" # local only, for psql / dev mode
healthcheck:
test: ["CMD-SHELL", "pg_isready -U bizmatch -d bizmatch"]
interval: 5s
timeout: 3s
retries: 10
app:
build: .
restart: unless-stopped
environment:
DATABASE_URL: postgres://bizmatch:${DB_PASSWORD:-bizmatch}@db:5432/bizmatch
PORT: "8090"
NAS_ROOT: ${NAS_ROOT:-/mnt/bizmatch-nas}
NAS_DIR_ACTIVE: ${NAS_DIR_ACTIVE:-AAA = ACTIVE}
NAS_DIR_SOLD: ${NAS_DIR_SOLD:-AAA = SOLD}
NAS_DIR_INACTIVE: ${NAS_DIR_INACTIVE:-AAA = INACTIVE}
ports:
- "8090:8090" # reachable on the LAN: http://192.168.100.99:8090
volumes:
- ${NAS_ROOT:-/mnt/bizmatch-nas}:${NAS_ROOT:-/mnt/bizmatch-nas} # host NFS mount
depends_on:
db:
condition: service_healthy
volumes:
pgdata:

196
migrations/001_init.sql Normal file
View File

@@ -0,0 +1,196 @@
-- BizMatch Phase 2 — initial schema
-- Matches the agreed data model (Buyer / Contact / NDA / Deal / Business
-- / Note / Todo / Document / ExtractionJob / Staff)
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ---------------------------------------------------------------- staff
CREATE TABLE staff (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL UNIQUE,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
-- ------------------------------------------------------------- business
CREATE TABLE business (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
nas_path text NOT NULL UNIQUE,
status text NOT NULL CHECK (status IN ('ACTIVE', 'SOLD', 'INACTIVE')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX business_status_idx ON business (status);
CREATE TRIGGER business_updated BEFORE UPDATE ON business
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------- buyer
CREATE TABLE buyer (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
company_name text,
status text NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('ACTIVE', 'DEACTIVATED', 'LEGACY')),
legacy_json jsonb,
created_by uuid REFERENCES staff (id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX buyer_status_idx ON buyer (status);
CREATE TRIGGER buyer_updated BEFORE UPDATE ON buyer
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- -------------------------------------------------------------- contact
CREATE TABLE contact (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
buyer_id uuid NOT NULL REFERENCES buyer (id) ON DELETE CASCADE,
name text NOT NULL,
email text,
phone text,
is_primary boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX contact_buyer_idx ON contact (buyer_id);
-- Dedup anchor: normalized e-mail
CREATE INDEX contact_email_idx ON contact (lower(btrim(email))) WHERE email IS NOT NULL;
-- ------------------------------------------------------------------ nda
-- One "request round": for every new request the buyer fills in a new NDA.
CREATE TABLE nda (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
buyer_id uuid NOT NULL REFERENCES buyer (id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'SENT'
CHECK (status IN ('SENT', 'SIGNED')),
nas_path text,
sent_at date,
signed_at date,
preferred_businesses_text text,
created_by uuid REFERENCES staff (id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX nda_buyer_idx ON nda (buyer_id);
CREATE INDEX nda_open_idx ON nda (sent_at) WHERE status = 'SENT';
CREATE TRIGGER nda_updated BEFORE UPDATE ON nda
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- ----------------------------------------------------------------- deal
-- Relation buyer <-> business within one NDA round.
-- Deliberately NO unique constraint on (buyer_id, business_id):
-- a new NDA round => a new deal, the history stays visible.
CREATE TABLE deal (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
buyer_id uuid NOT NULL REFERENCES buyer (id) ON DELETE CASCADE,
business_id uuid NOT NULL REFERENCES business (id),
nda_id uuid REFERENCES nda (id),
status text NOT NULL DEFAULT 'NEW'
CHECK (status IN ('NEW', 'INFO_SENT', 'DUE_DILIGENCE',
'LOI', 'CLOSING', 'ENDED')),
follow_up_at date,
created_by uuid REFERENCES staff (id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX deal_buyer_idx ON deal (buyer_id);
CREATE INDEX deal_business_idx ON deal (business_id);
CREATE INDEX deal_status_idx ON deal (status);
CREATE INDEX deal_follow_up_idx ON deal (follow_up_at)
WHERE follow_up_at IS NOT NULL AND status <> 'ENDED';
CREATE TRIGGER deal_updated BEFORE UPDATE ON deal
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- ------------------------------------------------------------- document
-- Pure NAS reference; the NAS stays the source of truth.
CREATE TABLE document (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
nas_path text NOT NULL,
kind text NOT NULL DEFAULT 'OTHER'
CHECK (kind IN ('NDA', 'OVERVIEW', 'SUMMARY',
'SELLER_AGREEMENT', 'LOI', 'OTHER')),
buyer_id uuid REFERENCES buyer (id) ON DELETE CASCADE,
business_id uuid REFERENCES business (id) ON DELETE CASCADE,
deal_id uuid REFERENCES deal (id) ON DELETE CASCADE,
added_by uuid REFERENCES staff (id),
added_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT document_has_ref CHECK (
num_nonnulls(buyer_id, business_id, deal_id) >= 1
)
);
CREATE INDEX document_buyer_idx ON document (buyer_id) WHERE buyer_id IS NOT NULL;
CREATE INDEX document_business_idx ON document (business_id) WHERE business_id IS NOT NULL;
CREATE INDEX document_deal_idx ON document (deal_id) WHERE deal_id IS NOT NULL;
-- ----------------------------------------------------------------- note
-- Attached to exactly ONE reference object (buyer, deal, business or NDA).
CREATE TABLE note (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
text text NOT NULL,
highlight boolean NOT NULL DEFAULT false,
buyer_id uuid REFERENCES buyer (id) ON DELETE CASCADE,
deal_id uuid REFERENCES deal (id) ON DELETE CASCADE,
business_id uuid REFERENCES business (id) ON DELETE CASCADE,
nda_id uuid REFERENCES nda (id) ON DELETE CASCADE,
created_by uuid NOT NULL REFERENCES staff (id),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT note_exactly_one_ref CHECK (
num_nonnulls(buyer_id, deal_id, business_id, nda_id) = 1
)
);
CREATE INDEX note_buyer_idx ON note (buyer_id) WHERE buyer_id IS NOT NULL;
CREATE INDEX note_deal_idx ON note (deal_id) WHERE deal_id IS NOT NULL;
CREATE INDEX note_business_idx ON note (business_id) WHERE business_id IS NOT NULL;
CREATE INDEX note_nda_idx ON note (nda_id) WHERE nda_id IS NOT NULL;
-- ----------------------------------------------------------------- todo
-- kind TASK = normal task, REVIEW = request for proofreading/approval (with document_id).
-- At most ONE case reference; no reference at all is allowed (general task).
CREATE TABLE todo (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
text text NOT NULL,
kind text NOT NULL DEFAULT 'TASK' CHECK (kind IN ('TASK', 'REVIEW')),
assigned_to uuid NOT NULL REFERENCES staff (id),
due_at date,
status text NOT NULL DEFAULT 'OPEN' CHECK (status IN ('OPEN', 'DONE')),
done_by uuid REFERENCES staff (id),
done_at timestamptz,
buyer_id uuid REFERENCES buyer (id) ON DELETE CASCADE,
deal_id uuid REFERENCES deal (id) ON DELETE CASCADE,
business_id uuid REFERENCES business (id) ON DELETE CASCADE,
nda_id uuid REFERENCES nda (id) ON DELETE CASCADE,
document_id uuid REFERENCES document (id) ON DELETE SET NULL,
created_by uuid NOT NULL REFERENCES staff (id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT todo_max_one_ref CHECK (
num_nonnulls(buyer_id, deal_id, business_id, nda_id) <= 1
)
);
CREATE INDEX todo_assignee_idx ON todo (assigned_to, status);
CREATE INDEX todo_due_idx ON todo (due_at) WHERE status = 'OPEN';
CREATE TRIGGER todo_updated BEFORE UPDATE ON todo
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- ------------------------------------------------------- extraction_job
-- Queue for the asynchronous NDA extraction via vision_runner (worker polls QUEUED).
CREATE TABLE extraction_job (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
nda_id uuid NOT NULL REFERENCES nda (id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'QUEUED'
CHECK (status IN ('QUEUED', 'RUNNING', 'DONE', 'FAILED')),
attempts integer NOT NULL DEFAULT 0,
result_json jsonb,
error text,
created_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
finished_at timestamptz,
reviewed_by uuid REFERENCES staff (id),
reviewed_at timestamptz
);
CREATE INDEX extraction_job_queue_idx ON extraction_job (created_at)
WHERE status = 'QUEUED';
CREATE INDEX extraction_job_nda_idx ON extraction_job (nda_id);

1656
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "bizmatch-app",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"migrate": "tsx src/migrate-cli.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^10.1.2",
"fastify": "^5.2.1",
"pg": "^8.13.1"
},
"devDependencies": {
"@types/node": "^22.10.5",
"@types/pg": "^8.11.10",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

125
src/business-scan.ts Normal file
View File

@@ -0,0 +1,125 @@
import { readdir, stat } from 'node:fs/promises';
import path from 'node:path';
import { config } from './config.js';
import { query } from './db.js';
export type BusinessStatus = 'ACTIVE' | 'SOLD' | 'INACTIVE';
export interface ScanResult {
scanned: number;
inserted: number;
updated: number;
missing: number;
}
interface BusinessRow {
id: string;
name: string;
nas_path: string;
status: string;
}
/** The three status directories directly below NAS_ROOT. */
function statusDirs(): { status: BusinessStatus; dir: string }[] {
return [
{ status: 'ACTIVE', dir: config.nasDirActive },
{ status: 'SOLD', dir: config.nasDirSold },
{ status: 'INACTIVE', dir: config.nasDirInactive },
];
}
interface FoundBusiness {
name: string;
nasPath: string;
status: BusinessStatus;
}
/** Reads the three status directories; every immediate subdirectory is one business. */
async function readFromDisk(): Promise<FoundBusiness[]> {
const found: FoundBusiness[] = [];
for (const { status, dir } of statusDirs()) {
const full = path.join(config.nasRoot, dir);
let entries;
try {
entries = await readdir(full, { withFileTypes: true });
} catch (err) {
throw new Error(
`NAS directory not found or not readable: ${full} (${(err as Error).message})`,
);
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
found.push({ name: entry.name, nasPath: path.join(full, entry.name), status });
}
}
return found;
}
/**
* Scans the NAS and upserts the businesses. Idempotent: existing rows are matched
* by name and only touched when nas_path or status actually changed. Rows that no
* longer exist on disk are kept and only reported via the logger.
*/
export async function scanBusinesses(
log: { warn: (msg: string) => void } = console,
): Promise<ScanResult> {
const found = await readFromDisk();
const existing = await query<BusinessRow>('SELECT id, name, nas_path, status FROM business');
const byName = new Map(existing.map((row) => [row.name, row]));
let inserted = 0;
let updated = 0;
const seen = new Set<string>();
for (const business of found) {
seen.add(business.name);
const row = byName.get(business.name);
if (!row) {
await query(
'INSERT INTO business (name, nas_path, status) VALUES ($1, $2, $3)',
[business.name, business.nasPath, business.status],
);
inserted += 1;
} else if (row.nas_path !== business.nasPath || row.status !== business.status) {
await query('UPDATE business SET nas_path = $1, status = $2 WHERE id = $3', [
business.nasPath,
business.status,
row.id,
]);
updated += 1;
}
}
const missing = existing.filter((row) => !seen.has(row.name));
if (missing.length > 0) {
log.warn(
`[business-scan] ${missing.length} business(es) in the database no longer found on disk: ` +
missing.map((row) => row.name).join(', '),
);
}
return { scanned: found.length, inserted, updated, missing: missing.length };
}
export interface BusinessFile {
name: string;
size: number;
mtime: string;
}
/** Live, non-recursive listing of a business directory: PDFs first, then the rest, alphabetical. */
export async function listBusinessFiles(nasPath: string): Promise<BusinessFile[]> {
const entries = await readdir(nasPath, { withFileTypes: true });
const files: BusinessFile[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const info = await stat(path.join(nasPath, entry.name));
files.push({ name: entry.name, size: info.size, mtime: info.mtime.toISOString() });
}
const isPdf = (name: string) => name.toLowerCase().endsWith('.pdf');
return files.sort((a, b) => {
if (isPdf(a.name) !== isPdf(b.name)) return isPdf(a.name) ? -1 : 1;
return a.name.localeCompare(b.name);
});
}

17
src/config.ts Normal file
View File

@@ -0,0 +1,17 @@
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Environment variable ${name} is missing`);
return v;
}
export const config = {
databaseUrl: required('DATABASE_URL'),
port: Number(process.env.PORT ?? 8090),
host: process.env.HOST ?? '0.0.0.0',
/** Root of the NAS mount, e.g. /mnt/bizmatch-nas */
nasRoot: process.env.NAS_ROOT ?? '/mnt/bizmatch-nas',
/** Directory names directly below NAS_ROOT, one per business status */
nasDirActive: process.env.NAS_DIR_ACTIVE ?? 'AAA = ACTIVE',
nasDirSold: process.env.NAS_DIR_SOLD ?? 'AAA = SOLD',
nasDirInactive: process.env.NAS_DIR_INACTIVE ?? 'AAA = INACTIVE',
} as const;

24
src/db.ts Normal file
View File

@@ -0,0 +1,24 @@
import pg from 'pg';
import { config } from './config.js';
export const pool = new pg.Pool({
connectionString: config.databaseUrl,
max: 10,
});
/** Typed query helper */
export async function query<T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params: unknown[] = [],
): Promise<T[]> {
const res = await pool.query<T>(text, params);
return res.rows;
}
export async function queryOne<T extends pg.QueryResultRow = pg.QueryResultRow>(
text: string,
params: unknown[] = [],
): Promise<T | null> {
const rows = await query<T>(text, params);
return rows[0] ?? null;
}

8
src/migrate-cli.ts Normal file
View File

@@ -0,0 +1,8 @@
import { runMigrations } from './migrate.js';
import { pool } from './db.js';
try {
await runMigrations();
} finally {
await pool.end();
}

55
src/migrate.ts Normal file
View File

@@ -0,0 +1,55 @@
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { pool } from './db.js';
const MIGRATIONS_DIR = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'migrations',
);
/** Applies every not-yet-applied migrations/NNN_*.sql in order. */
export async function runMigrations(): Promise<void> {
const client = await pool.connect();
try {
// Prevents double execution if several processes start at the same time
await client.query('SELECT pg_advisory_lock(815001)');
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
)
`);
const applied = new Set(
(await client.query<{ name: string }>('SELECT name FROM schema_migrations')).rows.map(
(r) => r.name,
),
);
const files = (await readdir(MIGRATIONS_DIR))
.filter((f) => /^\d{3}_.+\.sql$/.test(f))
.sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = await readFile(path.join(MIGRATIONS_DIR, file), 'utf8');
console.log(`[migrate] applying: ${file}`);
try {
await client.query('BEGIN');
await client.query(sql);
await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [file]);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw new Error(`Migration ${file} failed: ${(err as Error).message}`);
}
}
console.log('[migrate] schema up to date');
} finally {
await client.query('SELECT pg_advisory_unlock(815001)').catch(() => {});
client.release();
}
}

187
src/server.ts Normal file
View File

@@ -0,0 +1,187 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import { config } from './config.js';
import { pool, query, queryOne } from './db.js';
import { runMigrations } from './migrate.js';
import { listBusinessFiles, scanBusinesses } from './business-scan.js';
interface Staff {
id: string;
name: string;
active: boolean;
}
interface Business {
id: string;
name: string;
nas_path: string;
status: string;
created_at: string;
updated_at: string;
}
const app = Fastify({ logger: true });
await app.register(cookie);
const COOKIE = 'bizmatch_staff';
function staffIdFromRequest(req: { cookies: Record<string, string | undefined> }): string | null {
return req.cookies[COOKIE] ?? null;
}
// ------------------------------------------------------------------ Auth
/** Endpoints reachable without a session cookie. */
const PUBLIC_ROUTES = new Set(['GET /api/health', 'GET /api/staff', 'POST /api/staff', 'POST /api/login']);
app.addHook('preHandler', async (req, reply) => {
const url = req.url.split('?')[0] ?? '';
if (!url.startsWith('/api/')) return; // static files / SPA
if (PUBLIC_ROUTES.has(`${req.method} ${url}`)) return;
if (!staffIdFromRequest(req)) return reply.code(401).send({ error: 'not signed in' });
});
// --------------------------------------------------------------- Health
app.get('/api/health', async () => {
await pool.query('SELECT 1');
return { ok: true };
});
// ---------------------------------------------------------------- Staff
app.get('/api/staff', async () => {
return query<Staff>('SELECT id, name, active FROM staff ORDER BY name');
});
app.post<{ Body: { name?: string } }>('/api/staff', async (req, reply) => {
const name = req.body?.name?.trim();
if (!name) return reply.code(400).send({ error: 'name is missing' });
const row = await queryOne<Staff>(
`INSERT INTO staff (name) VALUES ($1)
ON CONFLICT (name) DO UPDATE SET active = true
RETURNING id, name, active`,
[name],
);
return reply.code(201).send(row);
});
// ---------------------------------------------------------------- Login
app.post<{ Body: { staff_id?: string } }>('/api/login', async (req, reply) => {
const staffId = req.body?.staff_id;
if (!staffId) return reply.code(400).send({ error: 'staff_id is missing' });
const staff = await queryOne<Staff>(
'SELECT id, name, active FROM staff WHERE id = $1 AND active',
[staffId],
);
if (!staff) return reply.code(404).send({ error: 'staff member not found' });
reply.setCookie(COOKIE, staff.id, {
path: '/',
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365,
});
return { ok: true, staff };
});
app.get('/api/me', async (req, reply) => {
const id = staffIdFromRequest(req);
if (!id) return reply.code(401).send({ error: 'not signed in' });
const staff = await queryOne<Staff>(
'SELECT id, name, active FROM staff WHERE id = $1 AND active',
[id],
);
if (!staff) return reply.code(401).send({ error: 'not signed in' });
return staff;
});
app.post('/api/logout', async (_req, reply) => {
reply.clearCookie(COOKIE, { path: '/' });
return { ok: true };
});
// ----------------------------------------------------------- Businesses
app.post('/api/businesses/scan', async (_req, reply) => {
try {
return await scanBusinesses(app.log);
} catch (err) {
app.log.error((err as Error).message);
return reply.code(500).send({ error: (err as Error).message });
}
});
app.get<{ Querystring: { status?: string; search?: string } }>(
'/api/businesses',
async (req) => {
const search = req.query.search?.trim() ?? '';
const status = req.query.status?.trim() ?? '';
// The counts always cover every status, so the tab bar stays usable while filtering.
const countParams: unknown[] = [];
let where = '';
if (search) {
countParams.push(`%${search}%`);
where = `WHERE name ILIKE $${countParams.length}`;
}
const counts = await query<{ status: string; n: string }>(
`SELECT status, count(*)::text AS n FROM business ${where} GROUP BY status`,
countParams,
);
const listParams = [...countParams];
let listWhere = where;
if (status) {
listParams.push(status);
listWhere = `${listWhere ? `${listWhere} AND` : 'WHERE'} status = $${listParams.length}`;
}
const rows = await query<{ id: string; name: string; status: string }>(
`SELECT id, name, status FROM business ${listWhere} ORDER BY name`,
listParams,
);
return {
businesses: rows,
counts: {
ACTIVE: Number(counts.find((c) => c.status === 'ACTIVE')?.n ?? 0),
SOLD: Number(counts.find((c) => c.status === 'SOLD')?.n ?? 0),
INACTIVE: Number(counts.find((c) => c.status === 'INACTIVE')?.n ?? 0),
},
};
},
);
app.get<{ Params: { id: string } }>('/api/businesses/:id', async (req, reply) => {
const row = await queryOne<Business>('SELECT * FROM business WHERE id = $1', [req.params.id]);
if (!row) return reply.code(404).send({ error: 'business not found' });
return row;
});
app.get<{ Params: { id: string } }>('/api/businesses/:id/files', async (req, reply) => {
const row = await queryOne<Business>('SELECT * FROM business WHERE id = $1', [req.params.id]);
if (!row) return reply.code(404).send({ error: 'business not found' });
try {
return await listBusinessFiles(row.nas_path);
} catch (err) {
return reply
.code(502)
.send({ error: `directory not readable: ${row.nas_path} (${(err as Error).message})` });
}
});
// --------------------------------------------------------------- Static
// Serves the built frontend in production; SPA fallback for non-/api routes.
const WEB_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web', 'dist');
if (existsSync(WEB_DIST)) {
await app.register(fastifyStatic, { root: WEB_DIST });
app.setNotFoundHandler((req, reply) => {
if (req.url.startsWith('/api/')) return reply.code(404).send({ error: 'not found' });
return reply.sendFile('index.html');
});
} else {
app.log.warn(`[static] ${WEB_DIST} not found — serving the API only`);
}
// ---------------------------------------------------------------- Start
await runMigrations();
await app.listen({ port: config.port, host: config.host });

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}

362
viewer-phase1/app.js Normal file
View File

@@ -0,0 +1,362 @@
let state;
let groups = [];
let selectedIndex = -1;
let loadedIndex = -1; // which document the PDF viewer currently shows/loads
let pdfViewer = null;
let loadRequestId = 0;
const people = document.querySelector("#people");
const fields = document.querySelector("#fields");
const viewer = document.querySelector(".viewer");
const pdfContainer = document.querySelector("#pdfViewer");
const pdfMessage = document.querySelector("#pdfMessage");
const status = document.querySelector("#status");
const search = document.querySelector("#search");
const errorBanner = document.querySelector("#errorBanner");
const fieldDefs = [
["Name / Company", "name_company"],
["Prospective Buyer", "prospective_buyer"],
["Company", "company"],
null,
["Phone", "phone"],
["Cell", "cell"],
["Email", "email"],
null,
["Address", "address"],
["State", "state"],
null,
["Businesses from Notes", "notes_business_raw"],
["Types of Businesses", "types_of_business_raw"],
["Background Experience", "background_experience"],
null,
["How Did You Hear", "how_did_you_hear"],
["Interested in Updates", "interested_in_updates"],
["Down Payment", { key: "down_payment_raw", fallback: "down_payment" }],
["Total Purchase Price", "total_purchase_price"],
["Date of Introduction", "date_of_introduction"],
null,
["Notes Page", "_notes_page"],
["Buyer Info Page", "_info_page"],
["CA Page", "_ca_page"],
];
const esc = (value) =>
String(value ?? "").replace(/[&<>"']/g, (char) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[char]);
// Normalized person key from the vision-extracted buyer name: lowercase,
// punctuation stripped, name tokens sorted so "Zahoor Bilal" and
// "Bilal Zahoor" compare equal.
function personKey(group) {
const name = group.docs.map((d) => d.prospective_buyer).find((v) =>
typeof v === "string" && v.trim()
);
if (!name) return null;
const tokens = name.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ")
.trim().split(/\s+/).filter(Boolean).sort();
return tokens.length ? tokens.join(" ") : null;
}
function buildGroups(docs) {
const map = new Map();
docs.forEach((doc, index) => {
const key = doc.name_from_filename;
const group = map.get(key) || {
key,
displayName: doc.prospective_buyer || key,
docs: [],
text: "",
};
group.docs.push({ ...doc, index });
group.text += " " + [
key,
doc.prospective_buyer,
doc.types_of_business_raw,
doc.notes_business_raw,
doc.address,
].filter(Boolean).join(" ");
map.set(key, group);
});
// Second pass: merge filename-based groups that refer to the same person
// according to the extracted prospective_buyer. This catches typos in the
// scan filenames (e.g. "Zaboor, Bilal" vs "Zahoor, Bilal") which would
// otherwise show the same buyer twice. Groups without a prospective_buyer
// are never merged.
const byPerson = new Map();
const merged = [];
for (const group of map.values()) {
const pKey = personKey(group);
const target = pKey ? byPerson.get(pKey) : undefined;
if (target) {
target.docs.push(...group.docs);
target.text += " " + group.text;
continue;
}
if (pKey) byPerson.set(pKey, group);
merged.push(group);
}
for (const group of merged) {
group.docs.sort((a, b) => a.file_name.localeCompare(b.file_name));
const preferred = group.docs.find((d) =>
typeof d.prospective_buyer === "string" && d.prospective_buyer.trim()
);
if (preferred) group.displayName = preferred.prospective_buyer;
}
return merged.sort((a, b) => a.key.localeCompare(b.key));
}
function visibleGroups() {
const terms = search.value.toLowerCase().trim().split(/\s+/).filter(Boolean);
return groups.filter((group) =>
terms.every((term) => group.text.toLowerCase().includes(term))
);
}
function updateStatus(shownCount = visibleGroups().length) {
const source = state.dataSource === "sample" ? "sample data" : "JSON file";
status.textContent =
`${shownCount} people / ${state.documents.length} documents \u00b7 ${source}`;
status.className = "";
}
function showError(message) {
errorBanner.textContent = message;
errorBanner.hidden = !message;
}
function renderList() {
const shown = visibleGroups();
people.innerHTML = shown.map((group) => `
<div class="person">
<div class="person-title">${
esc(group.displayName)
} <span class="muted">(${group.docs.length})</span></div>
${
group.docs.map((doc) => `
<button class="doc ${
doc.index === selectedIndex ? "active" : ""
}" data-index="${doc.index}">
${esc(doc.file_name)}
</button>`).join("")
}
</div>
`).join("");
updateStatus(shown.length);
}
async function loadPdf(index) {
const doc = state.documents[index];
if (!doc) return;
// Deduplicate: don't reload the document that is already shown.
// NOTE: must compare against loadedIndex, NOT selectedIndex --
// select() updates selectedIndex before calling loadPdf(), so a
// selectedIndex comparison is always true and blocks every reload.
if (index === loadedIndex && pdfViewer) {
return;
}
loadedIndex = index;
const previousZoom = pdfViewer ? pdfViewer.zoom : 1;
if (pdfViewer) {
pdfViewer.destroy();
}
pdfViewer = new PdfViewer(pdfContainer, { initialZoom: previousZoom });
viewer.classList.remove("loaded");
pdfMessage.hidden = false;
pdfMessage.textContent = "Loading PDF\u2026";
const reqId = "pdf-" + (++loadRequestId);
console.log(
`[BizMatch QC] PDF ${reqId} selection: ${doc._letter}/${doc.file_name}`,
);
console.log(`[BizMatch QC] PDF ${reqId} prepare requested`);
const startTime = performance.now();
try {
const response = await fetch("/api/pdf/prepare", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ index, requestId: reqId }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `Prepare failed: HTTP ${response.status}`);
}
const data = await response.json();
const prepareMs = Math.round(performance.now() - startTime);
console.log(
`[BizMatch QC] PDF ${reqId} prepare: ${prepareMs}ms, disk cache ${data.cacheStatus}`,
);
if (index !== selectedIndex) {
// A newer selection happened while preparing, don't show stale result
return;
}
viewer.classList.add("loaded");
pdfMessage.hidden = true;
console.log(`[BizMatch QC] PDF ${reqId} viewer load started`);
await pdfViewer.load(data.url, data.size);
console.log(`[BizMatch QC] PDF ${reqId} rendering complete`);
} catch (error) {
if (index !== selectedIndex) return;
loadedIndex = -1; // allow retry: clicking the same document again reloads it
const message = error instanceof Error ? error.message : String(error);
pdfMessage.textContent = `Cannot open PDF: ${message}`;
pdfMessage.hidden = false;
}
}
function select(index) {
selectedIndex = index;
const doc = state.documents[index];
if (!doc) return;
let rowIdx = 0;
fields.innerHTML = `
<h2>${esc(doc.name_from_filename)}</h2>
<p class="muted">${esc(doc.file_name)} \u00b7 ${
esc(doc._doc_type || "unknown")
} \u00b7 ${esc(doc._pages_total ?? "?")} pages</p>
${
doc._vision_error
? `<p class="error">Vision error: ${esc(doc._vision_error)}</p>`
: ""
}
${
fieldDefs.map((def) => {
if (!def) return '<hr class="field-sep">';
const [label, keyOrObj] = def;
let value;
if (typeof keyOrObj === "object") {
value = esc(doc[keyOrObj.key] || doc[keyOrObj.fallback] || "\u2014");
} else {
value = esc(doc[keyOrObj] || "\u2014");
}
const bgClass = rowIdx % 2 === 0 ? "row-even" : "row-odd";
rowIdx++;
return `<div class="field ${bgClass}"><b>${label}</b><div>${value}</div></div>`;
}).join("")
}
`;
void loadPdf(index);
renderList();
}
people.addEventListener("click", (event) => {
const button = event.target.closest("[data-index]");
if (button) select(Number(button.dataset.index));
});
search.addEventListener("input", renderList);
document.addEventListener("keydown", (event) => {
if (["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
const delta = event.key === "ArrowDown" ? 1 : -1;
select(
Math.max(
0,
Math.min(
state.documents.length - 1,
selectedIndex < 0 ? 0 : selectedIndex + delta,
),
),
);
}
});
const dialog = document.querySelector("#settingsDialog");
const jsonPath = document.querySelector("#jsonPath");
const pdfBase = document.querySelector("#pdfBase");
const useAnonData = document.querySelector("#useAnonData");
const settingsError = document.querySelector("#settingsError");
const saveSettings = document.querySelector("#saveSettings");
document.querySelector("#settings").onclick = () => {
jsonPath.value = state.config.jsonPath || "";
pdfBase.value = state.config.pdfBaseDirectory || "";
useAnonData.checked = !!state.config.useAnonymousData;
settingsError.hidden = true;
settingsError.textContent = "";
dialog.showModal();
};
saveSettings.onclick = async (event) => {
event.preventDefault();
saveSettings.disabled = true;
settingsError.hidden = true;
try {
const response = await fetch("/api/config", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonPath: jsonPath.value,
pdfBaseDirectory: pdfBase.value,
useAnonymousData: useAnonData.checked,
}),
});
const body = await response.json();
if (!response.ok) throw new Error(body.error || "Could not save settings.");
dialog.close();
await load();
} catch (error) {
settingsError.textContent = error instanceof Error
? error.message
: String(error);
settingsError.hidden = false;
} finally {
saveSettings.disabled = false;
}
};
async function load() {
try {
const response = await fetch("/api/state", { cache: "no-store" });
if (!response.ok) {
throw new Error(`State request failed with HTTP ${response.status}.`);
}
state = await response.json();
loadedIndex = -1; // document set changed; index-based dedup is invalid now
groups = buildGroups(state.documents);
showError(state.loadError || "");
renderList();
if (state.documents.length) select(0);
} catch (error) {
showError(error instanceof Error ? error.message : String(error));
}
}
void load();
// ---- Window size reporting ----
// The backend's native size APIs are unreliable (see main.ts), so the
// webview reports its own viewport size. The first report, sent shortly
// after startup, calibrates the decoration offset on the backend; later
// reports track user resizes.
let metricsTimer = null;
function reportWindowMetrics() {
fetch("/api/window-metrics", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
innerWidth: globalThis.innerWidth,
innerHeight: globalThis.innerHeight,
}),
}).catch(() => {});
}
globalThis.addEventListener("resize", () => {
clearTimeout(metricsTimer);
metricsTimer = setTimeout(reportWindowMetrics, 250);
});
setTimeout(reportWindowMetrics, 800); // calibration report

63
viewer-phase1/index.html Normal file
View File

@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>BizMatch QC</title>
<link rel="stylesheet" href="/styles.css">
<script type="module">
console.log(
"[BizMatch QC] Map.getOrInsertComputed supported:",
typeof Map.prototype.getOrInsertComputed === "function",
);
console.log("[BizMatch QC] PDF.js frontend build: legacy");
import * as pdfjsLib from "/pdfjs/legacy/pdf.min.mjs";
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdfjs/legacy/pdf.worker.min.mjs";
globalThis.pdfjsLib = pdfjsLib;
</script>
<script type="module" src="/pdf_viewer.js"></script>
</head>
<body>
<header>
<strong>BizMatch QC</strong>
<input id="search" placeholder="Search name, business, or address">
<button id="settings">Settings</button>
<span id="status"></span>
</header>
<div id="errorBanner" class="error-banner" hidden></div>
<main>
<aside>
<div id="people"></div>
</aside>
<section class="details">
<div id="fields"></div>
</section>
<section class="viewer">
<div id="pdfViewer"></div>
<div id="pdfMessage">Select a document</div>
</section>
</main>
<dialog id="settingsDialog">
<form method="dialog">
<h2>Settings</h2>
<label class="checkbox-label">
<input type="checkbox" id="useAnonData">
Use anonymized sample data
</label>
<label>buyers_vision.json
<input id="jsonPath" autocomplete="off">
</label>
<label>PDF base directory
<input id="pdfBase" autocomplete="off">
</label>
<div id="settingsError" class="dialog-error" hidden></div>
<div class="actions">
<button value="cancel">Cancel</button>
<button id="saveSettings" value="default">Save</button>
</div>
<p class="hint">Full PDF path: base directory / _letter / file_name</p>
</form>
</dialog>
<script type="module" src="/app.js"></script>
</body>
</html>

385
viewer-phase1/pdf_viewer.js Normal file
View File

@@ -0,0 +1,385 @@
// PDF.js viewer using pdfjs-dist 6.1.200 legacy build.
// Depends on global pdfjsLib (loaded in index.html).
const ZOOM_MIN = 0.25;
const ZOOM_MAX = 5;
const ZOOM_STEP = 1.2; // multiplicative
class PdfViewer {
constructor(container, options = {}) {
this.container = container;
this.generation = 0;
this.loadingTask = null;
this.doc = null;
this.numPages = 0;
this.renderTasks = new Set();
this.lastUrl = null;
this.lastSize = null;
// multiplier on fit-to-width (1 = fit width); can be seeded from the
// previous viewer instance so zoom survives switching documents
this.zoom = Math.min(
ZOOM_MAX,
Math.max(ZOOM_MIN, options.initialZoom ?? 1),
);
this._lastRenderWidth = 0;
this._resizeTimer = null;
this._zoomTimer = null;
this.resizeObserver = null;
this._createDOM();
this._updateZoomLabel();
this._observeResize();
}
_createDOM() {
// The pages container is always visible (never hidden).
// Loading and error states are overlays on top.
this.container.innerHTML = `
<div class="pdf-v-toolbar" hidden>
<button class="pdf-v-zoom-out" title="Verkleinern (Strg+Mausrad)">\u2212</button>
<button class="pdf-v-zoom-label" title="Auf Seitenbreite einpassen">100%</button>
<button class="pdf-v-zoom-in" title="Vergr\u00f6\u00dfern (Strg+Mausrad)">+</button>
</div>
<div class="pdf-v-pages"></div>
<div class="pdf-v-overlay pdf-v-loading" hidden>Loading PDF\u2026</div>
<div class="pdf-v-overlay pdf-v-error" hidden>
<div class="pdf-v-error-msg"></div>
<button class="pdf-v-retry">Retry</button>
</div>
`;
this.toolbar = this.container.querySelector(".pdf-v-toolbar");
this.zoomLabel = this.container.querySelector(".pdf-v-zoom-label");
this.pagesDiv = this.container.querySelector(".pdf-v-pages");
this.loadingDiv = this.container.querySelector(".pdf-v-loading");
this.errorDiv = this.container.querySelector(".pdf-v-error");
this.errorMsg = this.container.querySelector(".pdf-v-error-msg");
this.retryBtn = this.container.querySelector(".pdf-v-retry");
this.retryBtn.addEventListener("click", () => {
if (this.lastUrl != null) {
this._setState("loading");
this._loadInternal(this.lastUrl, this.lastSize);
}
});
this.container.querySelector(".pdf-v-zoom-in").addEventListener(
"click",
() => this.setZoom(this.zoom * ZOOM_STEP),
);
this.container.querySelector(".pdf-v-zoom-out").addEventListener(
"click",
() => this.setZoom(this.zoom / ZOOM_STEP),
);
// Clicking the percentage resets to fit-width.
this.zoomLabel.addEventListener("click", () => this.setZoom(1));
// Ctrl + mouse wheel zoom, like in browsers / PDF readers.
this.pagesDiv.addEventListener("wheel", (e) => {
if (!e.ctrlKey) return;
e.preventDefault();
this.setZoom(this.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
}, { passive: false });
}
// ---- Zoom ----
setZoom(zoom) {
const clamped = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom));
if (Math.abs(clamped - this.zoom) < 0.001) return;
this.zoom = clamped;
this._updateZoomLabel();
if (!this.doc) return;
// Debounce so repeated +/+/+ or wheel ticks trigger one re-render.
clearTimeout(this._zoomTimer);
this._zoomTimer = setTimeout(() => this._rerender(), 120);
}
_updateZoomLabel() {
this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
}
// ---- Resize ----
_observeResize() {
if (typeof ResizeObserver === "undefined") return;
this.resizeObserver = new ResizeObserver(() => {
if (!this.doc) return;
const width = this._renderWidth();
if (Math.abs(width - this._lastRenderWidth) < 2) return;
clearTimeout(this._resizeTimer);
this._resizeTimer = setTimeout(() => {
console.log(
`[BizMatch QC] PDF viewer resized, re-rendering at ${this._renderWidth()}px`,
);
this._rerender();
}, 150);
});
this.resizeObserver.observe(this.container);
}
_renderWidth() {
return Math.max(Math.round(this._getViewportWidth() * this.zoom), 100);
}
// Re-render all pages of the already-loaded document (zoom / resize),
// preserving the relative scroll position.
async _rerender() {
if (!this.doc) return;
const generation = ++this.generation;
for (const rt of this.renderTasks) {
try {
rt.cancel();
} catch { /* ok */ }
}
this.renderTasks.clear();
const scrollRatio = this.pagesDiv.scrollHeight > 0
? this.pagesDiv.scrollTop / this.pagesDiv.scrollHeight
: 0;
await this._renderAllPages(this.doc, generation, false);
if (generation !== this.generation) return;
this.pagesDiv.scrollTop = scrollRatio * this.pagesDiv.scrollHeight;
}
// ---- Loading ----
async load(url, byteSize) {
this.lastUrl = url;
this.lastSize = byteSize;
this._setState("loading");
await this._loadInternal(url, byteSize);
}
async _loadInternal(url, _byteSize) {
const generation = ++this.generation;
console.log(
`[BizMatch QC] PDF viewer load started: generation ${generation}`,
);
// Await cleanup of previous document (promises may be involved)
await this._disposeCurrentDocument();
if (generation !== this.generation) return;
this.pagesDiv.innerHTML = "";
const loadStart = performance.now();
try {
const task = pdfjsLib.getDocument({
url,
rangeChunkSize: 65536,
disableAutoFetch: false,
// PDF.js >= 5 decodes CCITT-G4/JBIG2 (B/W scans), JPEG2000 and ICC
// color via WASM modules fetched from wasmUrl. Without these URLs
// the decoders fail silently (ignoreErrors default) and scanned
// pages render as blank white canvases of the correct size.
wasmUrl: "/pdfjs/wasm/",
standardFontDataUrl: "/pdfjs/standard_fonts/",
iccUrl: "/pdfjs/iccs/",
});
this.loadingTask = task;
const doc = await task.promise;
if (generation !== this.generation) {
doc.destroy();
return;
}
this.doc = doc;
this.numPages = doc.numPages;
const loadMs = Math.round(performance.now() - loadStart);
console.log(
`[BizMatch QC] PDF document loaded: ${this.numPages} pages in ${loadMs}ms`,
);
this.toolbar.hidden = false;
this._updateZoomLabel();
await this._renderAllPages(doc, generation, true);
if (generation !== this.generation) return;
const totalMs = Math.round(performance.now() - loadStart);
console.log(
`[BizMatch QC] PDF viewer load completed: generation ${generation}`,
);
console.log(
`[BizMatch QC] PDF fully rendered: ${this.numPages} pages in ${totalMs}ms`,
);
} catch (err) {
if (generation !== this.generation) return;
// Distinguish cancellation from real errors
if (isCancellationError(err)) {
console.log(
`[BizMatch QC] PDF generation ${generation} cancelled`,
);
return;
}
const msg = errorMsg(err);
console.error(`[BizMatch QC] PDF load failed:`, msg);
this.errorMsg.textContent = `Unable to open PDF: ${msg}`;
this._setState("error");
}
}
// Shared render loop for initial load, zoom and resize.
// hideLoadingAfterFirstPage: true on initial load (removes the overlay
// as soon as page 1 is visible).
async _renderAllPages(doc, generation, hideLoadingAfterFirstPage) {
this.pagesDiv.innerHTML = "";
const viewerWidth = this._renderWidth();
this._lastRenderWidth = viewerWidth;
console.log(`[BizMatch QC] PDF render width: ${viewerWidth}px`);
for (let p = 1; p <= this.numPages; p++) {
if (generation !== this.generation) return;
const pStart = performance.now();
await this._renderPage(doc, p, viewerWidth, generation);
if (generation !== this.generation) return;
if (p === 1 && hideLoadingAfterFirstPage) {
this._setState(null); // Remove loading overlay
}
console.log(
`[BizMatch QC] PDF page ${p} rendered in ${
Math.round(performance.now() - pStart)
}ms`,
);
await new Promise((r) => requestAnimationFrame(r));
}
}
_setState(state) {
this.loadingDiv.hidden = true;
this.errorDiv.hidden = true;
if (state === "loading") this.loadingDiv.hidden = false;
if (state === "error") this.errorDiv.hidden = false;
this._viewerState = state;
}
_getViewportWidth() {
const style = globalThis.getComputedStyle
? getComputedStyle(this.pagesDiv)
: null;
let padding = 24; // default guess
if (style) {
padding = (parseFloat(style.paddingLeft) || 0) +
(parseFloat(style.paddingRight) || 0);
}
const raw = Math.round(this.pagesDiv.clientWidth || 600);
return Math.max(raw - padding, 200);
}
async _disposeCurrentDocument() {
for (const rt of this.renderTasks) {
try {
rt.cancel();
} catch { /* ok */ }
}
this.renderTasks.clear();
if (this.loadingTask) {
try {
await this.loadingTask.destroy();
} catch { /* ok */ }
this.loadingTask = null;
}
if (this.doc) {
try {
await this.doc.destroy();
} catch { /* ok */ }
this.doc = null;
this.numPages = 0;
}
}
async _renderPage(doc, pageNum, viewerWidth, generation) {
const pageDiv = document.createElement("div");
pageDiv.className = "pdf-v-page";
pageDiv.dataset.page = String(pageNum);
pageDiv.dataset.renderState = "idle";
const canvas = document.createElement("canvas");
pageDiv.appendChild(canvas);
this.pagesDiv.appendChild(pageDiv);
pageDiv.dataset.renderState = "rendering";
try {
const page = await doc.getPage(pageNum);
if (generation !== this.generation) return;
const vp1 = page.getViewport({ scale: 1 });
const fitScale = viewerWidth / vp1.width;
const viewport = page.getViewport({ scale: fitScale });
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
canvas.width = Math.floor(viewport.width * dpr);
canvas.height = Math.floor(viewport.height * dpr);
canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
const ctx = canvas.getContext("2d");
const transform = dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined;
const renderTask = page.render({
canvasContext: ctx,
viewport,
transform,
});
this.renderTasks.add(renderTask);
await renderTask.promise;
this.renderTasks.delete(renderTask);
if (generation !== this.generation) return;
pageDiv.dataset.renderState = "rendered";
} catch (err) {
if (generation !== this.generation) return;
if (isCancellationError(err)) return;
pageDiv.dataset.renderState = "error";
console.error(
`[BizMatch QC] Failed to render PDF page ${pageNum}`,
err,
);
if (pageNum === 1) {
this.errorMsg.textContent = `Unable to open PDF: ${errorMsg(err)}`;
this._setState("error");
} else {
pageDiv.textContent = `Error rendering page ${pageNum}`;
pageDiv.style.padding = "20px";
pageDiv.style.color = "#f88";
pageDiv.style.textAlign = "center";
}
}
}
destroy() {
this.generation = 0;
clearTimeout(this._resizeTimer);
clearTimeout(this._zoomTimer);
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
this._disposeCurrentDocument().catch(() => {});
this.pagesDiv.innerHTML = "";
this.toolbar.hidden = true;
this._setState("loading");
}
}
function isCancellationError(err) {
if (!(err instanceof Error)) return false;
const msg = err.message || "";
return msg.includes("cancelled") ||
msg.includes("canceled") ||
msg.includes("destroyed") ||
msg.includes("Worker was destroyed");
}
function errorMsg(err) {
return err instanceof Error ? err.message : String(err);
}
globalThis.PdfViewer = PdfViewer;

283
viewer-phase1/styles.css Normal file
View File

@@ -0,0 +1,283 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font: 14px system-ui, sans-serif;
color: #202124;
}
header {
height: 52px;
display: flex;
align-items: center;
gap: 14px;
padding: 8px 14px;
border-bottom: 1px solid #ddd;
}
header strong {
font-size: 18px;
}
header input {
flex: 1;
max-width: 620px;
padding: 8px;
}
header span {
margin-left: auto;
color: #666;
}
main {
height: calc(100vh - 52px);
display: grid;
grid-template-columns: 330px 430px minmax(500px, 1fr);
}
aside,
.details {
overflow: auto;
border-right: 1px solid #ddd;
}
.person {
border-bottom: 1px solid #ddd;
}
.person-title {
font-weight: 650;
padding: 10px 12px;
background: #f6f7f8;
}
.doc {
display: block;
width: 100%;
border: 0;
border-top: 1px solid #eee;
background: white;
text-align: left;
padding: 8px 14px;
cursor: pointer;
}
.doc:hover,
.doc.active {
background: #e9f1ff;
}
.details {
padding: 14px;
}
.field {
margin-bottom: 13px;
}
.field b {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 3px;
text-transform: uppercase;
}
.field div {
white-space: pre-wrap;
}
.viewer {
position: relative;
background: #555;
overflow: hidden;
}
.viewer #pdfViewer {
position: relative;
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
background: #525659;
}
.viewer #pdfMessage {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: white;
pointer-events: none;
z-index: 2;
}
.viewer.loaded #pdfMessage {
display: none;
}
dialog {
width: min(720px, 90vw);
}
dialog label {
display: block;
margin: 12px 0;
font-weight: 600;
}
dialog input {
display: block;
width: 100%;
padding: 8px;
margin-top: 5px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.hint {
color: #666;
}
.error {
color: #a40000;
}
.muted {
color: #777;
}
.error-banner {
padding: 10px 16px;
background: #fff1f1;
border-bottom: 1px solid #c62828;
color: #9b1c1c;
white-space: pre-wrap;
}
.dialog-error {
margin-top: 12px;
padding: 10px;
border: 1px solid #c62828;
background: #fff1f1;
color: #9b1c1c;
white-space: pre-wrap;
}
#pdfMessage {
white-space: pre-wrap;
padding: 20px;
color: #8a1c1c;
}
.person-title {
color: #1a56db;
font-size: 15px;
}
.field {
margin-bottom: 0;
padding: 7px 6px;
border-radius: 3px;
}
.field b {
display: block;
font-size: 11px;
color: #666;
margin-bottom: 2px;
text-transform: uppercase;
}
.field div {
white-space: pre-wrap;
}
.field.row-even {
background: #fff;
}
.field.row-odd {
background: #eef2f6;
}
hr.field-sep {
border: none;
border-top: 2px solid #9ca3af;
margin: 14px 0;
}
.checkbox-label {
display: flex !important;
align-items: center;
gap: 8px;
font-weight: 400 !important;
}
.checkbox-label input {
display: inline;
width: auto;
margin-top: 0;
}
/* PDF.js viewer */
.pdf-v-overlay {
position: absolute;
inset: 0;
z-index: 1;
}
.pdf-v-loading {
padding: 40px 20px;
color: #aaa;
text-align: center;
font-size: 15px;
}
.pdf-v-error {
padding: 30px 20px;
text-align: center;
}
.pdf-v-error-msg {
color: #f88;
font-size: 16px;
margin-bottom: 12px;
white-space: pre-wrap;
}
.pdf-v-retry {
padding: 8px 24px;
font-size: 14px;
cursor: pointer;
border: 1px solid #888;
background: #444;
color: #eee;
border-radius: 4px;
}
.pdf-v-retry:hover {
background: #555;
}
.pdf-v-pages {
box-sizing: border-box;
width: 100%;
height: 100%;
overflow-y: scroll;
overflow-x: auto;
scrollbar-gutter: stable;
display: flex;
flex-direction: column;
align-items: center;
align-items: safe center; /* keeps left edge reachable when zoomed wider than the pane */
gap: 16px;
padding: 12px;
}
.pdf-v-page {
background: white;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.35);
flex-shrink: 0;
}
.pdf-v-page canvas {
display: block;
}
/* Zoom toolbar */
.pdf-v-toolbar {
position: absolute;
top: 10px;
right: 24px;
z-index: 3;
display: flex;
align-items: center;
gap: 2px;
background: rgba(35, 35, 38, 0.88);
border: 1px solid #666;
border-radius: 6px;
padding: 3px 4px;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);
}
.pdf-v-toolbar button {
border: 0;
background: transparent;
color: #e5e5e5;
font-size: 15px;
line-height: 1;
padding: 5px 9px;
cursor: pointer;
border-radius: 4px;
}
.pdf-v-toolbar button:hover {
background: #555;
}
.pdf-v-zoom-label {
min-width: 52px;
text-align: center;
font-size: 12px !important;
color: #ccc !important;
}

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BizMatch</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2771
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
web/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "bizmatch-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"pdfjs-dist": "6.1.200",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}

62
web/src/App.tsx Normal file
View File

@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react';
import { ApiError, api, type Staff } from './api.js';
import Login from './views/Login.js';
import Businesses from './views/Businesses.js';
import BusinessDetail from './views/BusinessDetail.js';
type View = { name: 'businesses' } | { name: 'business'; id: string };
export default function App() {
const [staff, setStaff] = useState<Staff | null>(null);
const [loading, setLoading] = useState(true);
const [view, setView] = useState<View>({ name: 'businesses' });
useEffect(() => {
api
.me()
.then(setStaff)
.catch((err: unknown) => {
if (!(err instanceof ApiError) || err.status !== 401) console.error(err);
})
.finally(() => setLoading(false));
}, []);
async function signOut() {
await api.logout();
setStaff(null);
setView({ name: 'businesses' });
}
if (loading) return <div className="p-8 text-sm text-gray-500">Loading</div>;
if (!staff) return <Login onLogin={setStaff} />;
return (
<div className="min-h-screen bg-gray-50 text-gray-900">
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
<button
className="text-base font-semibold tracking-tight"
onClick={() => setView({ name: 'businesses' })}
>
BizMatch
</button>
<div className="flex items-center gap-3 text-sm">
<span className="text-gray-600">{staff.name}</span>
<button
className="rounded border border-gray-300 px-2 py-1 text-xs hover:bg-gray-100"
onClick={signOut}
>
Sign out
</button>
</div>
</header>
<main className="mx-auto max-w-5xl px-6 py-6">
{view.name === 'businesses' ? (
<Businesses onOpen={(id) => setView({ name: 'business', id })} />
) : (
<BusinessDetail id={view.id} onBack={() => setView({ name: 'businesses' })} />
)}
</main>
</div>
);
}

75
web/src/api.ts Normal file
View File

@@ -0,0 +1,75 @@
export interface Staff {
id: string;
name: string;
active: boolean;
}
export type BusinessStatus = 'ACTIVE' | 'SOLD' | 'INACTIVE';
export interface BusinessListItem {
id: string;
name: string;
status: BusinessStatus;
}
export interface BusinessList {
businesses: BusinessListItem[];
counts: Record<BusinessStatus, number>;
}
export interface Business {
id: string;
name: string;
nas_path: string;
status: BusinessStatus;
}
export interface BusinessFile {
name: string;
size: number;
mtime: string;
}
export interface ScanResult {
scanned: number;
inserted: number;
updated: number;
missing: number;
}
export class ApiError extends Error {
constructor(readonly status: number, message: string) {
super(message);
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { credentials: 'same-origin', ...init });
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new ApiError(res.status, body?.error ?? `Request failed (${res.status})`);
}
return (await res.json()) as T;
}
function post<T>(path: string, body?: unknown): Promise<T> {
return request<T>(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body ?? {}),
});
}
export const api = {
me: () => request<Staff>('/api/me'),
staff: () => request<Staff[]>('/api/staff'),
login: (staffId: string) => post<{ ok: boolean; staff: Staff }>('/api/login', { staff_id: staffId }),
logout: () => post<{ ok: boolean }>('/api/logout'),
businesses: (status: BusinessStatus, search: string) =>
request<BusinessList>(
`/api/businesses?status=${encodeURIComponent(status)}&search=${encodeURIComponent(search)}`,
),
business: (id: string) => request<Business>(`/api/businesses/${id}`),
businessFiles: (id: string) => request<BusinessFile[]>(`/api/businesses/${id}/files`),
scan: () => post<ScanResult>('/api/businesses/scan'),
};

1
web/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import 'tailwindcss';

10
web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.js';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react';
import { api, type Business, type BusinessFile } from '../api.js';
function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export default function BusinessDetail({ id, onBack }: { id: string; onBack: () => void }) {
const [business, setBusiness] = useState<Business | null>(null);
const [files, setFiles] = useState<BusinessFile[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.business(id).then(setBusiness).catch((err: Error) => setError(err.message));
api.businessFiles(id).then(setFiles).catch((err: Error) => setError(err.message));
}, [id]);
return (
<div>
<button onClick={onBack} className="mb-4 text-sm text-blue-600 hover:underline">
Back to businesses
</button>
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
{business && (
<div className="mb-5">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">{business.name}</h1>
<span className="rounded-full border border-gray-300 bg-white px-2 py-0.5 text-xs text-gray-600">
{business.status}
</span>
</div>
<p className="mt-1 font-mono text-xs text-gray-500">{business.nas_path}</p>
</div>
)}
<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">File</th>
<th className="w-24 px-3 py-2 font-medium">Size</th>
<th className="w-48 px-3 py-2 font-medium">Modified</th>
</tr>
</thead>
<tbody>
{files?.map((f) => (
<tr key={f.name} className="border-b border-gray-100">
<td className="px-3 py-1.5">{f.name}</td>
<td className="px-3 py-1.5 text-gray-500">{formatSize(f.size)}</td>
<td className="px-3 py-1.5 text-gray-500">{formatDate(f.mtime)}</td>
</tr>
))}
{files && files.length === 0 && (
<tr>
<td colSpan={3} className="px-3 py-4 text-gray-500">
No files in this directory.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useState } from 'react';
import { api, type BusinessList, type BusinessStatus } from '../api.js';
const TABS: { status: BusinessStatus; label: string }[] = [
{ status: 'ACTIVE', label: 'Active' },
{ status: 'SOLD', label: 'Sold' },
{ status: 'INACTIVE', label: 'Inactive' },
];
export default function Businesses({ onOpen }: { onOpen: (id: string) => void }) {
const [tab, setTab] = useState<BusinessStatus>('ACTIVE');
const [search, setSearch] = useState('');
const [data, setData] = useState<BusinessList | null>(null);
const [error, setError] = useState<string | null>(null);
const [scanning, setScanning] = useState(false);
const [scanMsg, setScanMsg] = useState<string | null>(null);
const [reload, setReload] = useState(0);
useEffect(() => {
let cancelled = false;
api
.businesses(tab, search)
.then((res) => {
if (!cancelled) {
setData(res);
setError(null);
}
})
.catch((err: Error) => !cancelled && setError(err.message));
return () => {
cancelled = true;
};
}, [tab, search, reload]);
async function scan() {
setScanning(true);
setScanMsg(null);
try {
const res = await api.scan();
setScanMsg(
`Scanned ${res.scanned} · inserted ${res.inserted} · updated ${res.updated} · missing ${res.missing}`,
);
setReload((n) => n + 1);
setTimeout(() => setScanMsg(null), 8000);
} catch (err) {
setScanMsg((err as Error).message);
} finally {
setScanning(false);
}
}
return (
<div>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex gap-1">
{TABS.map((t) => (
<button
key={t.status}
onClick={() => setTab(t.status)}
className={`rounded px-3 py-1.5 text-sm ${
tab === t.status
? 'bg-gray-900 text-white'
: 'border border-gray-300 bg-white hover:bg-gray-100'
}`}
>
{t.label} ({data?.counts[t.status] ?? 0})
</button>
))}
</div>
<div className="flex items-center gap-2">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search name…"
className="w-56 rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
<button
onClick={scan}
disabled={scanning}
className="rounded bg-gray-900 px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
{scanning ? 'Scanning…' : 'Scan NAS now'}
</button>
</div>
</div>
{scanMsg && <p className="mb-3 text-sm text-gray-600">{scanMsg}</p>}
{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">Name</th>
<th className="w-32 px-3 py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{data?.businesses.map((b) => (
<tr
key={b.id}
onClick={() => onOpen(b.id)}
className="cursor-pointer border-b border-gray-100 hover:bg-gray-50"
>
<td className="px-3 py-1.5">{b.name}</td>
<td className="px-3 py-1.5 text-gray-500">{b.status}</td>
</tr>
))}
{data && data.businesses.length === 0 && (
<tr>
<td colSpan={2} className="px-3 py-4 text-gray-500">
No businesses.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

46
web/src/views/Login.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
import { api, type Staff } from '../api.js';
export default function Login({ onLogin }: { onLogin: (staff: Staff) => void }) {
const [staff, setStaff] = useState<Staff[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api
.staff()
.then((list) => setStaff(list.filter((s) => s.active)))
.catch((err: Error) => setError(err.message));
}, []);
async function pick(member: Staff) {
try {
const res = await api.login(member.id);
onLogin(res.staff);
} catch (err) {
setError((err as Error).message);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-80 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h1 className="mb-4 text-lg font-semibold">Who is working?</h1>
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
<div className="flex flex-col gap-2">
{staff.map((member) => (
<button
key={member.id}
className="rounded border border-gray-300 px-3 py-2 text-left text-sm hover:bg-gray-100"
onClick={() => pick(member)}
>
{member.name}
</button>
))}
{staff.length === 0 && !error && (
<p className="text-sm text-gray-500">No staff members yet.</p>
)}
</div>
</div>
</div>
);
}

17
web/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src", "vite.config.ts"]
}

12
web/vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:8090',
},
},
});