init
This commit is contained in:
18
.claude/settings.local.json
Normal file
18
.claude/settings.local.json
Normal 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
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
web/node_modules
|
||||
web/dist
|
||||
.env
|
||||
.git
|
||||
12
.env.example
Normal file
12
.env.example
Normal 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
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
.env
|
||||
25
Dockerfile
Normal file
25
Dockerfile
Normal 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
129
README.md
Normal 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
38
docker-compose.yml
Normal 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
196
migrations/001_init.sql
Normal 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
1656
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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
125
src/business-scan.ts
Normal 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
17
src/config.ts
Normal 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
24
src/db.ts
Normal 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
8
src/migrate-cli.ts
Normal 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
55
src/migrate.ts
Normal 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
187
src/server.ts
Normal 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
17
tsconfig.json
Normal 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
362
viewer-phase1/app.js
Normal 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) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[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
63
viewer-phase1/index.html
Normal 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
385
viewer-phase1/pdf_viewer.js
Normal 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
283
viewer-phase1/styles.css
Normal 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
12
web/index.html
Normal 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
2771
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
web/package.json
Normal file
26
web/package.json
Normal 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
62
web/src/App.tsx
Normal 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
75
web/src/api.ts
Normal 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
1
web/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
76
web/src/views/BusinessDetail.tsx
Normal file
76
web/src/views/BusinessDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
web/src/views/Businesses.tsx
Normal file
119
web/src/views/Businesses.tsx
Normal 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
46
web/src/views/Login.tsx
Normal 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
17
web/tsconfig.json
Normal 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
12
web/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user