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