diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d9b4301 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,252 @@ +# CLAUDE.md + +Persistent context for Claude / Copilot sessions on this repository. +Read this **first** before making changes. + +--- + +## 1. Project Idea + +**CRM OMT — Cash Collection Management System** is a multi-shop POS / cash +control / reconciliation web app aimed at the typical Lebanese cell-phone / +mixed-retail shop. + +### The real-world problem we're solving + +Most cell shops in Lebanon don't just sell phones and do repairs — they also +operate an **OMT counter and/or a Whish counter** (plus Alfa/Touch recharge, +EDL bill payments, FX swap between USD and LBP, etc.) as a side service for +walk-in customers. That side service moves a lot of cash through the till +every day, in two currencies, across multiple employees and shifts. + +What owners actually struggle with: + +- **Cash shortages / "incompatible cash" at end of shift** — the drawer doesn't + match what the system says it should hold. +- **No clear accountability per cashier / per shift** — when money is missing, + it's not obvious *whose* shift it disappeared on. +- **Mixed streams in one drawer** — repair income, item sales, OMT send/receive, + Whish in/out, recharges, bill payments, FX swaps — all flowing through the + same physical cash, in USD *and* LBP, with no separation. +- **Carry-forward shortfalls** — a cashier comes up short one day; the owner + needs that shortfall to roll forward and be cleared by future deposits, not + silently forgotten. +- **Owner has no live visibility** — they want to know, at any moment, how much + cash *should* be in each till and in the safe, who owes what, and where the + variance is. + +This app addresses exactly that: it gives the **shop owner** a tool to track +every money movement (repair, sale, OMT, Whish, recharge, bill, FX, deposit, +withdrawal, refund, void) per **employee** and per **shift**, enforce the +accounting rules in the database (atomic sale coupling, sign guards, void +reverses movements, idempotency), and surface variance + outstanding balances +so losses are caught the same day instead of weeks later. + +### Feature areas + +- **Shift control** — open/close shifts, opening floats, midday drops, end-of-day variance. +- **POS / Transaction entry** — record service sales, fees, FX swaps, customer KYC. +- **Cashier tools** — deposits, withdrawals, refunds, voids, overrides. +- **Manager console** — approvals, fee schedule, fx rates, safe & bank ledger, seed data. +- **User management** — roles (admin / manager / cashier), assignments per shop & shift. +- **Reporting** — outstanding employee balances (carry-forward shortfall logic), + detailed employee payment reports, reconciliation views. + +The accounting model is enforced server-side in PostgreSQL: atomic sale coupling, +cash movement sign guard, void reverses movements, idempotency keys, RLS by +shop/role, etc. (See `supabase/migrations/00xx_*.sql`.) + +--- + +## 2. Origin Note — Supabase ➜ self-hosted Postgres + +> The project was originally scaffolded on **Supabase** (Lovable / `vite_react_shadcn_ts` +> template, `@supabase/supabase-js` client, `auth.users`, RLS using `request.jwt.claim.*`). +> +> It has since been **transformed into a self-hosted PostgreSQL stack**: +> +> - **DB:** local `postgres:16-alpine` via `docker-compose.yml`, data in named volume `dbdata`. +> Schema is the original Supabase migrations under [`supabase/migrations/`](supabase/migrations/), +> replayed by [`server/db/init/01_run_migrations.sh`](server/db/init/01_run_migrations.sh). +> - **Auth shim:** [`server/db/init/00_auth_shim.sql`](server/db/init/00_auth_shim.sql) +> recreates the `auth.users` table + `auth.uid()` / `auth.role()` / `auth.jwt()` SQL +> helpers that the migrations expect, so the original RLS policies keep working. +> - **API:** a small Express server at [`server/src/index.js`](server/src/index.js) +> replaces PostgREST + GoTrue. It issues JWTs via `bcrypt` + `jsonwebtoken`, +> then on every request opens a pooled connection and runs: +> ```sql +> SELECT set_config('request.jwt.claim.sub', $user_id, true); +> SELECT set_config('request.jwt.claim.role', $role, true); +> SELECT set_config('request.jwt.claims', $claims, true); +> SET LOCAL ROLE authenticated; +> ``` +> so RLS continues to evaluate exactly as it did on Supabase. +> - **Frontend shim:** [`src/integrations/supabase/client.ts`](src/integrations/supabase/client.ts) +> is no longer the real Supabase JS client — it is a **drop-in shim** backed by +> [`src/lib/api.ts`](src/lib/api.ts) that exposes the same surface +> (`supabase.auth.*`, `supabase.rpc(...)`, `supabase.from(view).select().eq(...)`). +> This is why existing components keep importing `@/integrations/supabase/client` +> even though there is no Supabase anymore. +- **`@supabase/supabase-js`** has been removed from the project. The shim at + `@/integrations/supabase/client` is now the only "supabase" surface. +> - **Seed admin** is created by [`server/db/init/99_seed_admin.sh`](server/db/init/99_seed_admin.sh) +> from `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_NAME` env vars in `docker-compose.yml`. + +**Implication for any future work:** treat `supabase/*` as the **source of truth +for the schema only**. Do not reintroduce calls to a hosted Supabase. New +endpoints must be added to `server/src/index.js` (and, if used as RPCs, exposed +via `app.(...)` SQL functions so they go through the generic `/rpc/:fn` route). + +--- + +## 3. Repository Layout + +``` +cash-collection-management-system/ +├── docker-compose.yml # postgres:16-alpine + volume + init scripts +├── index.html # Vite entry +├── vite.config.ts +├── package.json # frontend (Vite + React 18 + TS + shadcn/ui + tailwind) +├── server/ +│ ├── package.json # express, pg, bcrypt, jsonwebtoken, cors, dotenv +│ ├── .env(.example) # DATABASE_URL, JWT_SECRET, CORS_ORIGIN, PORT +│ ├── src/index.js # the entire Express API (auth, /rpc/:fn, /from/:view, employees…) +│ └── db/init/ # postgres docker-entrypoint-initdb.d +│ ├── 00_auth_shim.sql # recreates auth.users + auth.* helpers +│ ├── 01_run_migrations.sh # replays /sql/migrations/*.sql in order +│ ├── 50_employee_payments.sql # extra app-layer tables for the payment report +│ └── 99_seed_admin.sh # creates first admin from env vars +├── supabase/ +│ ├── config.toml # legacy, unused at runtime +│ └── migrations/ # 0001…0026 — schema + RLS + RPCs (source of truth) +├── src/ +│ ├── main.tsx, App.tsx, index.css +│ ├── pages/ # Index.tsx (tabbed shell), NotFound.tsx +│ ├── components/ # Feature components (see §4) +│ │ └── ui/ # shadcn primitives — only the ones actually used +│ ├── hooks/ +│ │ ├── useAuth.tsx +│ │ ├── useSupabaseEmployeeData.ts # primary data hook (reads/writes via api shim) +│ │ ├── useEmployeeData.ts # legacy adapter, kept for EmployeePaymentReport +│ │ └── use-toast.ts +│ ├── integrations/supabase/ +│ │ └── client.ts # SHIM over src/lib/api.ts — NOT real supabase-js +│ └── lib/ +│ ├── api.ts # fetch wrapper around the Express server +│ ├── services.ts # POS service catalogue (OMT_SEND, WHISH_SEND, …) +│ ├── currency.ts # USD/LBP conversion + formatting +│ └── utils.ts # cn() helper +└── docs/ + └── THREAT_MODEL.md +``` + +--- + +## 4. Feature Components → DB + +| Component | Talks to | +| ------------------------------------- | ---------------------------------------------------------- | +| `LoginPage` | `POST /auth/login` (Express) → JWT in localStorage | +| `OwnerOverview` | `v_owner_dashboard`, `v_z_report`, `v_employee_scorecard_30d`, `alerts` (read), `ack_alert` RPC | +| `ShiftControl` | `supabase.rpc(...)` shift open/declare/finalize + Expected/Counted/Δ panel from `finalize_close` | +| `TransactionEntry` | `supabase.rpc(...)` atomic sale + cash movement RPCs | +| `CashierTools` | `supabase.rpc(...)` deposits / withdrawals / refunds | +| `ManagerConsole` | RPCs for fee schedule, fx rates, safe/bank ledger, seeds | +| `UserManagement` | `useSupabaseEmployeeData` + admin RPCs (`get_shop_users`) | +| `OutstandingReportDashboard` | `useSupabaseEmployeeData` (`/employees`, `/employee_transactions`) | +| `DetailedEmployeePaymentReport` | same | +| `EmployeePaymentReport` | `useEmployeeData` (legacy adapter over the same data) | +| `AdminDataEntryModal` | `useSupabaseEmployeeData.addTransaction(...)` | + +--- + +## 5. How to Run + +```bash +# one-time +cp server/.env.example server/.env # edit JWT_SECRET if exposed +npm install +npm --prefix server install + +# day-to-day (DB + API + Web all together) +npm run dev:all +# DB → docker container crm_omt_db on :5432 +# API → node server on :4000 +# WEB → vite on :5173 (or :8080) + +# build frontend +npm run build + +# wipe & rebuild DB (re-runs all migrations + seeds admin) +npm run db:reset +``` + +Default seed admin (override via env in `docker-compose.yml`): +- email: `admin@local.test` +- password: `ChangeMe123!` + +--- + +## 6. Conventions / Gotchas + +- **Don't import `@supabase/supabase-js` directly.** Use `@/integrations/supabase/client` + (the shim) or `@/lib/api` (raw). The package will be removed. +- **Don't put business logic in the Express server.** All money-touching logic + must live in SQL functions under the `app.*` schema (see migrations 0018–0026) + and be invoked through `/rpc/:fn`. The Express layer only authenticates and + forwards args. +- **RLS depends on JWT claims being set per-connection.** Any new endpoint that + hits a tenant-scoped table must use `withUserClient(req, ...)` in + `server/src/index.js`, not `pool.query` directly. +- **New views exposed via `supabase.from(view)`** must be added to `ALLOWED_VIEWS` + in `server/src/index.js`. +- **shadcn/ui:** only the components actually imported by feature code live in + `src/components/ui/`. If you need another primitive, add it back from + https://ui.shadcn.com — don't restore a kitchen-sink set. +- **Currencies:** USD is the canonical store; LBP is derived via + `getUsdToLbpRate()` in `src/lib/currency.ts`. + +--- + +## 7. Recent Cleanup (2026-05) + +**Cleanup pass:** + +- Root one-off codegen scripts: `rewrite_pos.py`, `update_assign_shift.py`, + `update_shift_control.py`, `update_shiftcontrol_rpc.py`. +- `bun.lockb` (project uses npm). +- `src/App.css` (Vite template leftover, not imported). +- `src/integrations/supabase/types.ts` (Supabase-generated types, unused since the shim). +- `src/hooks/use-mobile.tsx` (only consumed by the now-removed `sidebar` UI). +- Unused shadcn primitives in `src/components/ui/`: + `accordion, alert, alert-dialog, aspect-ratio, avatar, badge, breadcrumb, + carousel, chart, checkbox, collapsible, command, context-menu, drawer, + dropdown-menu, form, hover-card, input-otp, menubar, navigation-menu, + pagination, progress, radio-group, resizable, scroll-area, separator, + sheet, sidebar, skeleton, slider, switch, toggle, toggle-group`. +- `@supabase/supabase-js` uninstalled. + +**Owner-facing additions (against the stated problem):** + +- `ShiftControl` now displays an **Expected / Counted / Δ** panel right after + finalize-close, in USD and LBP, color-coded by short / over / match. +- New **`OwnerOverview`** component, wired as the default tab for `admin` + users. Reads: + - `app.v_owner_dashboard` — per-shop open shifts, open alerts, today's gross. + - `app.v_z_report` — recent closed-shift variances. + - `app.v_employee_scorecard_30d` — 30-day per-cashier variance + voids. + - `app.alerts` — open alerts with one-click acknowledge via `app.ack_alert`. +- Exposed those views/tables in `ALLOWED_VIEWS` in `server/src/index.js`. + RLS continues to scope rows. + +**Known follow-ups still on the list:** + +- Add `WHISH_RECEIVE` (needs new migration: service row + record-receive RPC + + details table or a generic receive path). Today only `WHISH_SEND` is wired. +- Roll real shift variance into the per-cashier outstanding/carry-forward + ledger so the Outstanding Report reflects POS reality, not just manual entries. +- Cashier-side "live drawer" widget while a shift is open + (expected-vs-recorded by stream). Needs a small `app.live_drawer(p_shift_id)` RPC. +- Inventory + repair-ticket UI (DB seeds exist via `0014_product_catalog.sql`). + +`npm run build` is green. diff --git a/README.md b/README.md index b67305d..a83a584 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,90 @@ +# CRM OMT — Cash Collection Management System + +A multi-shop POS / cash control / reconciliation web app for the typical +Lebanese cell-phone or mixed-retail shop that also runs an OMT and/or Whish +counter alongside repair and item sales. + +## The problem + +Most Lebanese cell shops don't just sell phones and do repairs — they also act +as **OMT and/or Whish agents**, sell **Alfa / Touch / Ogero** recharges, take +**EDL** bill payments, and swap between USD and LBP. All of that money flows +through one drawer, in two currencies, across multiple employees and shifts, +mixed with goods and repair income. + +The result for the owner is the recurring "incompatible cash" pain: at the end +of the day the drawer doesn't match what the system *should* hold, and there +is no clear accountability per cashier or per shift. + +## What this app gives the owner + +- **Per-employee, per-shift accountability** with a blind close: the cashier + declares the cash count, the system computes what was expected, and any + variance is recorded against that exact shift. +- **Live variance panel** at end-of-shift (Expected / Counted / Δ in USD and LBP). +- **Owner overview tab** — open shifts, open alerts, today's gross, recent + closed-shift variances, and a 30-day cashier scorecard. +- **All money streams in one ledger** — OMT send/receive, Whish, Alfa/Touch/Ogero + recharge, EDL bill, FX swap, deposits/withdrawals, refunds, voids, mid-day + safe drops, repair, goods sale. +- **DB-enforced accounting**: atomic sale coupling, cash-movement sign guards, + voids reverse movements, idempotency keys, RLS by shop and role. +- **Alerts pipeline** for chronic shorts, void spikes, override spikes, + reconciliation backlog, after-hours activity, voucher write-offs, stock + shrinkage. + +## Architecture + +The schema started life on Supabase but the app **no longer uses Supabase at +runtime**. It runs against a self-hosted Postgres + a thin Express API: + +- **DB:** `postgres:16-alpine` via [docker-compose.yml](docker-compose.yml). + The Supabase migrations under [`supabase/migrations/`](supabase/migrations/) + are replayed on first boot by [`server/db/init/01_run_migrations.sh`](server/db/init/01_run_migrations.sh), + preceded by an `auth.users` shim ([`00_auth_shim.sql`](server/db/init/00_auth_shim.sql)) + so the original `auth.uid()` / RLS policies keep working. +- **API:** [`server/src/index.js`](server/src/index.js) — Express. Issues JWTs + with `bcrypt` + `jsonwebtoken`, opens a pooled connection per request, sets + `request.jwt.claim.*` and `SET LOCAL ROLE authenticated` so RLS evaluates + against the caller. Generic `/rpc/:fn` route forwards to `app.(...)` + SQL functions; `/from/:view` exposes an allow-listed set of read views. +- **Frontend:** Vite + React 18 + TypeScript + shadcn/ui + Tailwind. The + module at [`src/integrations/supabase/client.ts`](src/integrations/supabase/client.ts) + is **a drop-in shim** over [`src/lib/api.ts`](src/lib/api.ts) — same + `.auth`, `.rpc`, `.from(...)` surface, but talks to the Express server. + +See [CLAUDE.md](CLAUDE.md) for the full design notes and conventions. + +## Run it + +```bash +# one-time +cp server/.env.example server/.env # set JWT_SECRET, etc. +npm install +npm --prefix server install + +# day-to-day (DB + API + Web all together) +npm run dev:all +# DB → docker container crm_omt_db on :5432 +# API → node server on :4000 +# WEB → vite on :5173 + +# build the frontend +npm run build + +# wipe & rebuild the DB (reruns migrations + reseeds the admin) +npm run db:reset +``` + +Default seed admin (override via env in [docker-compose.yml](docker-compose.yml)): + +- email: `admin@local.test` +- password: `ChangeMe123!` + +## Tech stack + +React 18, TypeScript, Vite, shadcn/ui, Tailwind, TanStack Query, react-hook-form, +zod · Express 4, pg, bcrypt, jsonwebtoken · PostgreSQL 16 · Docker Compose. 💼 Employee Payment Reconciliation Dashboard A mini web application built with React and Supabase that tracks daily collection vs deposit transactions for employees and enforces a carry-forward balance logic. If an employee's deposit on a given day is less than the collection amount, the shortfall is rolled over and must be cleared by future deposits. The app processes transaction data, maintains a running balance, and generates an intuitive dashboard to visualize employee payment behavior. diff --git a/bun.lockb b/bun.lockb deleted file mode 100644 index 160304d..0000000 Binary files a/bun.lockb and /dev/null differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aca31b0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +services: + db: + image: postgres:16-alpine + container_name: crm_omt_db + restart: unless-stopped + ports: + - "5432:5432" + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: crm_omt + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@local.test} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-ChangeMe123!} + ADMIN_NAME: ${ADMIN_NAME:-Local Admin} + volumes: + - dbdata:/var/lib/postgresql/data + - ./supabase/migrations:/sql/migrations:ro + - ./server/db/init:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d crm_omt"] + interval: 5s + timeout: 5s + retries: 20 + +volumes: + dbdata: diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 0000000..13dbf21 --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,78 @@ +# Threat Model — OMT / Recharge Cell Shop + +> **Purpose.** Document, before any code is written, every realistic way an +> employee (or a colluding pair) can steal money or goods from the shop, and +> the specific control the system must enforce to block each path. Every later +> migration, RLS policy, trigger, and UI rule must trace back to a row in this +> table. +> +> This file is **append-only** in spirit: when a new theft vector is +> discovered, add a row; do not delete history. + +## Actors + +| Actor | Description | +| ---------- | -------------------------------------------------------------- | +| `cashier` | Operates a till during a shift. Highest fraud-risk role. | +| `manager` | Approves voids, refunds, overrides. Can collude with cashier. | +| `owner` | Read-everything. Surprise inspections. Sets prices/fees. | +| `auditor` | Read-only third party (accountant). | +| `customer` | May be an accomplice (fake refund, fake cancellation). | + +## Trust boundaries + +1. **Browser/POS ↔ Supabase**: client is hostile. Never trust client-supplied + prices, fees, FX rates, timestamps, user IDs, or shift IDs. +2. **App role ↔ Postgres**: even the service role must not be able to `DELETE` + from the ledger or rewrite hashes. Enforce with table grants + triggers. +3. **Shop ↔ Provider (OMT / Alfa / touch / Bank)**: the provider's statement + is the source of truth for reconciliation. Any local row not present on the + provider statement is suspect. + +## Theft vectors and required controls + +| # | Vector | Control (must exist before go-live) | Enforced in | +| -- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | --------------- | +| 1 | Pocket cash, never enter the transaction | Mandatory printed receipt, sequential `reference_no` per shop, customer SMS with reference, gap-detection report | DB + server | +| 2 | Enter transaction, then edit / delete it after customer leaves | Append-only ledger; `UPDATE`/`DELETE` revoked; void requires reason + manager PIN; row hash chain | DB triggers | +| 3 | Recharge own/friend's number flagged as "test" | No "test" flag exists; every recharge debits stock or e-float | Schema | +| 4 | Sell a scratch card, keep cash, claim "lost card" | Voucher serial scanned on intake **and** on sale; loss requires manager approval; variance assigned to cashier | DB + UI | +| 5 | Use shop OMT terminal to send to themselves at zero / reduced fee | Fee schedule server-side and immutable per cashier; OMT statement reconciliation; alert on cashier as sender or beneficiary | DB + recon job | +| 6 | Manipulate FX rate (e.g. "I gave 89,000 LBP/USD instead of 90,000") | `fx_rates` populated by scheduled job; cashier selects, never types; rate stamped on each txn | DB + cron | +| 7 | Skim cash from drawer, blame "shortfall" | Blind closing count (declared before expected revealed); per-cashier variance trend; chronic shortage alert | UI + report | +| 8 | Collect customer cash, "deposit later", never deposit | Forced shift close before leaving; aging-outstanding alert > N hours; bank-deposit reconciliation | DB + recon | +| 9 | Share login with a colleague | Per-user PIN re-prompt, device binding, session timeout, no shared accounts | Auth | +| 10 | Print fake receipts on a second printer | Receipt carries server-signed QR (JWT of `txn_id`); spot-scan by owner returns DB row or "FAKE" | Server | +| 11 | Refund/void to themselves | Void/refund requires manager PIN on same device; cashier cannot self-approve; daily void report per cashier | DB + UI | +| 12 | Sell recharge below price ("friend discount") | Price list server-controlled; cashier UI has no price field; override only by manager and logged | DB + UI | +| 13 | Take goods (phones/accessories) without sale | Per-shift inventory count; CCTV ↔ txn time sync; variance report | UI + ops | +| 14 | Tamper with the closing count | Declared count entered first and locked; expected revealed only after; both stored | DB | +| 15 | Accomplice customer "cancels" after cash handed | Cancellations require manager + reason + photo of voided receipt; CCTV cross-check | UI + ops | +| 16 | Structure large transfers under multiple fake walk-in identities | KYC threshold per (customer, day) and (beneficiary, week); customer record mandatory ≥ threshold | DB + AML report | +| 17 | Off-hours transaction when nobody is watching | Shift hours per shop; after-hours flag + alert | DB + alert | +| 18 | Manager–cashier collusion to mass-void real sales | Void rate per (cashier, manager) pair trended; owner-only weekly review; voids count against shift variance | Report | +| 19 | Replay an old OMT receipt to a new customer | `external_ref` unique per provider; duplicate detection on insert | DB constraint | +| 20 | Cashier opens a second, undeclared till on the same device | One open shift per `till_id`; device fingerprint pinned to till | DB + auth | +| 21 | Cashier marks recharge "failed at provider" and keeps cash | Provider e-recharge response stored as `external_ref` and reconciled; "failed" requires provider failure id | DB + recon | +| 22 | Cashier "exchanges currency" at a worse rate than recorded, pocketing the spread | FX swap is a typed `cash_movement` with both legs at the system rate; deviation requires manager override | DB | +| 23 | Cashier deletes their browser data to "lose" pending offline transactions | Offline queue persisted with server-issued idempotency key; missing key sequences flagged on reconnect | Client + server | +| 24 | Insider (developer/DBA) silently edits the database | Hash chain on ledger; daily hash anchor exported off-site; restricted DB roles; audit of all DDL and privileged SQL | DB + ops | +| 25 | Backdated transaction to fit a doctored shift count | `occurred_at` server-side `now()`; cashier cannot set; backdate only by owner role with reason | DB | + +## Non-negotiables (no go-live without these) + +1. RLS on every table; no table is publicly readable or writable. +2. `INSERT`-only ledger with row hash chain; `DELETE` revoked from every role. +3. All money writes go through `SECURITY DEFINER` Postgres functions, not raw + table writes. +4. Server-controlled prices, fees, and FX rates. No cashier-typed money rules. +5. Blind cash close per shift, with declared-vs-expected variance stored. +6. External reconciliation (OMT, Alfa, touch, bank) before any month-close. +7. Per-user account, device-bound, with PIN re-prompt for sensitive ops. +8. Receipt with signed QR linking back to the ledger row. + +## Review cadence + +- Every new feature PR must reference at least one row above (or add one). +- Quarterly walk-through: pick 5 random rows, demonstrate the control still + works on a staging environment. diff --git a/package-lock.json b/package-lock.json index a830128..2390995 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,6 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", - "@supabase/supabase-js": "^2.49.8", "@tanstack/react-query": "^5.56.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -67,6 +66,7 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", + "concurrently": "^9.1.0", "eslint": "^9.9.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.9", @@ -83,7 +83,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -815,7 +814,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -833,7 +831,6 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/set-array": "^1.2.1", @@ -848,7 +845,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -858,7 +854,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -868,14 +863,12 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -886,7 +879,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -900,7 +892,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -910,7 +901,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -924,7 +914,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -2554,80 +2543,6 @@ "win32" ] }, - "node_modules/@supabase/auth-js": { - "version": "2.69.1", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.69.1.tgz", - "integrity": "sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/functions-js": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz", - "integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/node-fetch": { - "version": "2.6.15", - "resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz", - "integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - } - }, - "node_modules/@supabase/postgrest-js": { - "version": "1.19.4", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz", - "integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/realtime-js": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.2.tgz", - "integrity": "sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.14", - "@types/phoenix": "^1.5.4", - "@types/ws": "^8.5.10", - "ws": "^8.18.0" - } - }, - "node_modules/@supabase/storage-js": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz", - "integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==", - "license": "MIT", - "dependencies": { - "@supabase/node-fetch": "^2.6.14" - } - }, - "node_modules/@supabase/supabase-js": { - "version": "2.49.8", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.49.8.tgz", - "integrity": "sha512-zzBQLgS/jZs7btWcIAc7V5yfB+juG7h0AXxKowMJuySsO5vK+F7Vp+HCa07Z+tu9lZtr3sT9fofkc86bdylmtw==", - "license": "MIT", - "dependencies": { - "@supabase/auth-js": "2.69.1", - "@supabase/functions-js": "2.4.4", - "@supabase/node-fetch": "2.6.15", - "@supabase/postgrest-js": "1.19.4", - "@supabase/realtime-js": "2.11.2", - "@supabase/storage-js": "2.7.1" - } - }, "node_modules/@swc/core": { "version": "1.7.39", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.39.tgz", @@ -2989,29 +2904,24 @@ "version": "22.7.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.9.tgz", "integrity": "sha512-jrTfRC7FM6nChvU7X2KqcrgquofrWLFDeYC1hKfwNWomVvrn7JIksqf344WN2X/y8xrgqBd2dJATZV4GbatBfg==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.19.2" } }, - "node_modules/@types/phoenix": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", - "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", - "license": "MIT" - }, "node_modules/@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.12", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -3022,21 +2932,12 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/react": "*" } }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.11.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", @@ -3323,7 +3224,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -3336,7 +3236,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -3352,14 +3251,12 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, "license": "MIT" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -3373,7 +3270,6 @@ "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -3437,14 +3333,12 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3468,7 +3362,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -3524,7 +3417,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -3572,7 +3464,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -3597,7 +3488,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -3617,6 +3507,84 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4008,7 +3976,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4021,14 +3988,12 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -4041,11 +4006,51 @@ "dev": true, "license": "MIT" }, + "node_modules/concurrently": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", + "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.3", + "supports-color": "8.1.1", + "tree-kill": "1.2.2", + "yargs": "17.7.2" + }, + "bin": { + "conc": "dist/bin/concurrently.js", + "concurrently": "dist/bin/concurrently.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -4059,7 +4064,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -4246,14 +4250,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, "license": "Apache-2.0" }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, "license": "MIT" }, "node_modules/dom-helpers": { @@ -4270,7 +4272,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/electron-to-chromium": { @@ -4312,7 +4313,6 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/esbuild": { @@ -4591,7 +4591,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -4608,7 +4607,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -4635,7 +4633,6 @@ "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -4658,7 +4655,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -4709,7 +4705,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.0", @@ -4740,7 +4735,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4755,12 +4749,21 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4774,7 +4777,6 @@ "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -4795,7 +4797,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -4808,7 +4809,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -4818,7 +4818,6 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -4864,7 +4863,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4942,7 +4940,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -4955,7 +4952,6 @@ "version": "2.15.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", - "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -4971,7 +4967,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4981,7 +4976,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4991,7 +4985,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -5004,7 +4997,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -5014,14 +5006,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -5037,7 +5027,6 @@ "version": "1.21.6", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", - "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -5111,7 +5100,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -5124,7 +5112,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -5634,7 +5621,6 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/lucide-react": { @@ -5659,7 +5645,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -5669,7 +5654,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -5696,7 +5680,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -5713,7 +5696,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0", @@ -5725,7 +5707,6 @@ "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, "funding": [ { "type": "github", @@ -5768,7 +5749,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5797,7 +5777,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5857,7 +5836,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { @@ -5887,7 +5865,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5897,14 +5874,12 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, "license": "MIT" }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -5921,14 +5896,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -5941,7 +5914,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5951,7 +5923,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5961,7 +5932,6 @@ "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5990,7 +5960,6 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, "license": "MIT", "dependencies": { "postcss-value-parser": "^4.0.0", @@ -6008,7 +5977,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, "license": "MIT", "dependencies": { "camelcase-css": "^2.0.1" @@ -6028,7 +5996,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -6064,7 +6031,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -6090,7 +6056,6 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -6104,7 +6069,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -6148,7 +6112,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -6373,7 +6336,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, "license": "MIT", "dependencies": { "pify": "^2.3.0" @@ -6383,7 +6345,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -6430,11 +6391,20 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "license": "MIT" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.13.0", @@ -6462,7 +6432,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -6509,7 +6478,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -6529,6 +6497,16 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -6555,7 +6533,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -6568,17 +6545,28 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", + "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -6601,7 +6589,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -6611,7 +6598,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -6630,7 +6616,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6645,7 +6630,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6655,14 +6639,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6675,7 +6657,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -6692,7 +6673,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -6705,7 +6685,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6728,7 +6707,6 @@ "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", @@ -6764,7 +6742,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6787,7 +6764,6 @@ "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", - "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -6841,7 +6817,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, "license": "MIT", "dependencies": { "any-promise": "^1.0.0" @@ -6851,7 +6826,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, "license": "MIT", "dependencies": { "thenify": ">= 3.1.0 < 4" @@ -6870,7 +6844,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -6879,11 +6852,15 @@ "node": ">=8.0" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } }, "node_modules/ts-api-utils": { "version": "1.3.0", @@ -6902,7 +6879,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/tslib": { @@ -6966,6 +6942,7 @@ "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -7056,7 +7033,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/vaul": { @@ -7154,27 +7130,10 @@ } } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -7200,7 +7159,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -7219,7 +7177,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -7237,7 +7194,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7247,14 +7203,12 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -7269,7 +7223,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7282,7 +7235,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -7291,32 +7243,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", - "license": "MIT", + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "node": ">=10" } }, "node_modules/yaml": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -7325,6 +7265,80 @@ "node": ">= 14" } }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 387c1fd..7256962 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,13 @@ "type": "module", "scripts": { "dev": "vite", + "dev:all": "concurrently -k -n DB,API,WEB -c blue,magenta,green \"npm run db:up && docker logs -f crm_omt_db\" \"npm run server:dev\" \"vite\"", + "db:up": "docker compose up -d db", + "db:down": "docker compose down", + "db:reset": "docker compose down -v && docker compose up -d db", + "server:install": "npm --prefix server install", + "server:dev": "npm --prefix server run dev", + "server:start": "npm --prefix server start", "build": "vite build", "build:dev": "vite build --mode development", "lint": "eslint .", @@ -39,7 +46,6 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", - "@supabase/supabase-js": "^2.49.8", "@tanstack/react-query": "^5.56.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -70,6 +76,7 @@ "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^3.5.0", "autoprefixer": "^10.4.20", + "concurrently": "^9.1.0", "eslint": "^9.9.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-refresh": "^0.4.9", diff --git a/server/.env b/server/.env new file mode 100644 index 0000000..e436ef2 --- /dev/null +++ b/server/.env @@ -0,0 +1,6 @@ +# Server config (copy to server/.env or set in your shell) +PORT=4000 +DATABASE_URL=postgres://postgres:postgres@localhost:5432/crm_omt +JWT_SECRET=change-me-to-a-long-random-string +JWT_EXPIRES_IN=12h +CORS_ORIGIN=http://localhost:5173,http://localhost:8080 diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..bb57fd9 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,9 @@ +# Server config (copy to server/.env or set in your shell) +PORT=4000 +DATABASE_URL=postgres://postgres:postgres@localhost:5432/crm_omt +JWT_SECRET=change-me-to-a-long-random-string +JWT_EXPIRES_IN=12h +CORS_ORIGIN=http://localhost:5173,http://localhost:8080 + +# Frontend clients on the same LAN can use the host machine's IP automatically. +# Add fixed origins here if you want to restrict access more tightly. diff --git a/server/db/init/00_auth_shim.sql b/server/db/init/00_auth_shim.sql new file mode 100644 index 0000000..82450bd --- /dev/null +++ b/server/db/init/00_auth_shim.sql @@ -0,0 +1,63 @@ +-- ===================================================================== +-- 0000 Auth shim +-- Provides `auth.users`, `auth.uid()`, `auth.role()`, `auth.jwt()` so +-- the application migrations (which were written for Supabase) run +-- unmodified. The backend sets `request.jwt.claim.sub` (and friends) +-- per request from the verified JWT, then `set local role authenticated`. +-- ===================================================================== + +create extension if not exists "pgcrypto"; +create extension if not exists "citext"; + +-- Supabase ships these roles; create them if missing (e.g. plain Postgres). +do $$ begin + if not exists (select 1 from pg_roles where rolname = 'anon') then + create role anon nologin noinherit; + end if; + if not exists (select 1 from pg_roles where rolname = 'authenticated') then + create role authenticated nologin noinherit; + end if; + if not exists (select 1 from pg_roles where rolname = 'service_role') then + create role service_role nologin noinherit bypassrls; + end if; +end $$; + +create schema if not exists auth; + +-- Minimal `auth.users` compatible with FKs in app migrations. +create table if not exists auth.users ( + id uuid primary key default gen_random_uuid(), + email citext unique, + password_hash text not null, + full_name text, + is_active boolean not null default true, + created_at timestamptz not null default now(), + last_login_at timestamptz +); + +create or replace function auth.uid() +returns uuid +language sql +stable +as $$ + select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid +$$; + +create or replace function auth.role() +returns text +language sql +stable +as $$ + select coalesce(nullif(current_setting('request.jwt.claim.role', true), ''), 'anon') +$$; + +create or replace function auth.jwt() +returns jsonb +language sql +stable +as $$ + select coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb, '{}'::jsonb) +$$; + +grant usage on schema auth to authenticated, anon, service_role; +grant select on auth.users to authenticated, service_role; diff --git a/server/db/init/01_run_migrations.sh b/server/db/init/01_run_migrations.sh new file mode 100755 index 0000000..13c92fa --- /dev/null +++ b/server/db/init/01_run_migrations.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Run all SQL migrations from /sql/migrations/ in lexical order. +# On plain Postgres (no pg_cron extension), wrap any bare +# `create extension if not exists pg_cron;` line so it doesn't abort. +set -euo pipefail + +TMPDIR_M=/tmp/migrations +mkdir -p "$TMPDIR_M" +echo ">> applying app migrations from /sql/migrations" +for f in /sql/migrations/*.sql; do + base="$(basename "$f")" + # Replace the bare pg_cron extension creation with a soft variant. + sed -E "s|^create extension if not exists pg_cron;|do \$\$ begin create extension if not exists pg_cron; exception when others then raise notice 'pg_cron unavailable, skipping schedules'; end \$\$;|i" "$f" > "$TMPDIR_M/$base" + echo ">> $base" + psql -v ON_ERROR_STOP=1 \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" \ + -f "$TMPDIR_M/$base" +done +echo ">> migrations complete" diff --git a/server/db/init/50_employee_payments.sql b/server/db/init/50_employee_payments.sql new file mode 100644 index 0000000..d7ce3a0 --- /dev/null +++ b/server/db/init/50_employee_payments.sql @@ -0,0 +1,32 @@ +-- ===================================================================== +-- Local extension migration: simple employee payment ledger used by the +-- Employee Payment Report UI. Backed by the API; not a Supabase migration. +-- ===================================================================== + +create table if not exists app.employees ( + id uuid primary key default gen_random_uuid(), + emp_id text not null unique, + name text not null, + email text, + department text, + location text, + created_at timestamptz not null default now() +); + +create table if not exists app.employee_transactions ( + id uuid primary key default gen_random_uuid(), + employee_id uuid not null references app.employees(id) on delete cascade, + transaction_date date not null, + collection_amount numeric(18,2) not null default 0, + deposit_amount numeric(18,2) not null default 0, + currency text not null check (currency in ('USD','LBP')), + created_at timestamptz not null default now() +); + +create index if not exists idx_emp_tx_emp on app.employee_transactions(employee_id, transaction_date desc); + +-- These tables are owned by the API; RLS off, gated at the HTTP layer. +alter table app.employees disable row level security; +alter table app.employee_transactions disable row level security; +grant select, insert, update, delete on app.employees to authenticated; +grant select, insert, update, delete on app.employee_transactions to authenticated; diff --git a/server/db/init/99_seed_admin.sh b/server/db/init/99_seed_admin.sh new file mode 100755 index 0000000..b098b47 --- /dev/null +++ b/server/db/init/99_seed_admin.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Seed (or reset) the default admin user, default shop, owner role, Till 1. +set -euo pipefail + +ADMIN_EMAIL="${ADMIN_EMAIL:-admin@local.test}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-ChangeMe123!}" +ADMIN_NAME="${ADMIN_NAME:-Local Admin}" + +echo ">> seeding admin user: ${ADMIN_EMAIL}" + +# Use psql -v to safely substitute values inside the DO block via :'name' -- +# but :'name' only works at top level. So we generate plain SQL with the +# values inlined as quoted literals (escaping single quotes). +escape() { printf "%s" "$1" | sed "s/'/''/g"; } +EM=$(escape "$ADMIN_EMAIL") +PW=$(escape "$ADMIN_PASSWORD") +NM=$(escape "$ADMIN_NAME") + +psql -v ON_ERROR_STOP=1 \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" <> admin user ensured: ${ADMIN_EMAIL}" diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..1292bfd --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1752 @@ +{ + "name": "crm-omt-server", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crm-omt-server", + "version": "0.1.0", + "dependencies": { + "bcrypt": "^5.1.1", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.1", + "jsonwebtoken": "^9.0.2", + "pg": "^8.13.1" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..5df557e --- /dev/null +++ b/server/package.json @@ -0,0 +1,19 @@ +{ + "name": "crm-omt-server", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "bcrypt": "^5.1.1", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.1", + "jsonwebtoken": "^9.0.2", + "pg": "^8.13.1" + } +} diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..fa442f8 --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,410 @@ +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import pkg from 'pg'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; + +const { Pool } = pkg; + +const PORT = Number(process.env.PORT || 4000); +const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/crm_omt'; +const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me'; +const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '12h'; +const LOCAL_DEV_TOOLS_ENABLED = process.env.ENABLE_LOCAL_TEST_ROUTES === '1' + || (process.env.NODE_ENV !== 'production' && /localhost|127\.0\.0\.1/i.test(DATABASE_URL)); +const CORS_ORIGINS = (process.env.CORS_ORIGIN || 'http://localhost:5173,http://localhost:8080') + .split(',').map(s => s.trim()).filter(Boolean); + +function isPrivateIpv4(hostname) { + return /^10\./.test(hostname) + || /^127\./.test(hostname) + || /^192\.168\./.test(hostname) + || /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname); +} + +function isAllowedOrigin(origin) { + if (!origin) return true; + if (CORS_ORIGINS.includes(origin)) return true; + + try { + const url = new URL(origin); + return ['localhost', '127.0.0.1'].includes(url.hostname) || isPrivateIpv4(url.hostname); + } catch { + return false; + } +} + +const pool = new Pool({ connectionString: DATABASE_URL, max: 10 }); + +const app = express(); +app.use(cors({ + origin(origin, callback) { + callback(isAllowedOrigin(origin) ? null : new Error('Not allowed by CORS'), isAllowedOrigin(origin)); + }, + credentials: true, +})); +app.use(express.json({ limit: '1mb' })); + +// ---- helpers --------------------------------------------------------- + +function signToken(user) { + return jwt.sign( + { sub: user.id, email: user.email, role: 'authenticated' }, + JWT_SECRET, + { expiresIn: JWT_EXPIRES_IN, audience: 'authenticated' }, + ); +} + +function authRequired(req, res, next) { + const hdr = req.get('authorization') || ''; + const m = hdr.match(/^Bearer\s+(.+)$/i); + if (!m) return res.status(401).json({ error: 'missing token' }); + try { + const claims = jwt.verify(m[1], JWT_SECRET); + req.user = { id: claims.sub, email: claims.email, role: claims.role || 'authenticated', claims }; + next(); + } catch (e) { + return res.status(401).json({ error: 'invalid token' }); + } +} + +/** + * Run `fn(client)` on a checked-out connection that has its session + * configured to impersonate the authenticated user, so RLS works: + * SET LOCAL request.jwt.claim.sub = + * SET LOCAL request.jwt.claims = + * SET LOCAL ROLE authenticated + */ +async function withUserClient(req, fn) { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query("SELECT set_config('request.jwt.claim.sub', $1, true)", [req.user.id]); + await client.query("SELECT set_config('request.jwt.claim.role', $1, true)", [req.user.role]); + await client.query("SELECT set_config('request.jwt.claims', $1, true)", [JSON.stringify(req.user.claims)]); + await client.query("SET LOCAL ROLE authenticated"); + const out = await fn(client); + await client.query('COMMIT'); + return out; + } catch (e) { + await client.query('ROLLBACK').catch(() => {}); + throw e; + } finally { + client.release(); + } +} + +function dbError(res, e) { + console.error('[db]', e.code || '', e.message); + res.status(400).json({ error: e.message, code: e.code, detail: e.detail }); +} + +// ---- auth ------------------------------------------------------------ + +app.post('/auth/login', async (req, res) => { + const { email, password } = req.body || {}; + if (!email || !password) return res.status(400).json({ error: 'email and password required' }); + try { + const { rows } = await pool.query( + 'SELECT id, email, password_hash, full_name, is_active FROM auth.users WHERE email = $1 LIMIT 1', + [String(email).trim().toLowerCase()], + ); + const u = rows[0]; + if (!u || !u.is_active) return res.status(401).json({ error: 'invalid credentials' }); + const ok = await bcrypt.compare(password, u.password_hash); + if (!ok) return res.status(401).json({ error: 'invalid credentials' }); + await pool.query('UPDATE auth.users SET last_login_at = now() WHERE id = $1', [u.id]); + const token = signToken(u); + res.json({ token, user: { id: u.id, email: u.email, full_name: u.full_name } }); + } catch (e) { dbError(res, e); } +}); + +app.post('/auth/logout', authRequired, (_req, res) => { + // Stateless JWT — client just drops the token. (Add a denylist if needed.) + res.json({ ok: true }); +}); + +// ---- generic RPC ----------------------------------------------------- + +// POST /rpc/:fn body = { ...named args matching app.(...) signature } +app.post('/rpc/:fn', authRequired, async (req, res) => { + const fn = req.params.fn; + if (!/^[a-z_][a-z0-9_]{0,62}$/i.test(fn)) { + return res.status(400).json({ error: 'invalid function name' }); + } + const args = req.body && typeof req.body === 'object' ? req.body : {}; + const names = Object.keys(args); + // Always call as `SELECT * FROM app.(...)` so SETOF/TABLE/composite + // functions expand to rows/columns. Scalar functions yield one row with a + // single column named after the function. + const argList = names.map((n, i) => `${n} => $${i + 1}`).join(', '); + const sql = `SELECT * FROM app.${fn}(${argList})`; + const params = names.map((n) => args[n]); + try { + const data = await withUserClient(req, async (client) => { + const r = await client.query(sql, params); + // Scalar: 1 row, 1 column => unwrap. + if (r.rows.length === 1 && r.fields.length === 1) { + return r.rows[0][r.fields[0].name]; + } + // Single-row composite (e.g. RETURNS record / OUT params): return as object. + if (r.rows.length === 1) return r.rows[0]; + return r.rows; + }); + res.json({ data }); + } catch (e) { dbError(res, e); } +}); + +// ---- table/view reads ------------------------------------------------ + +// GET /from/:view?col=val&col2=val2 -> SELECT * FROM app. WHERE ... +const ALLOWED_VIEWS = new Set([ + 'v_my_tills', 'v_my_shops', 'v_services_active', 'v_my_recent_transactions', + 'v_manage_tills', + // Owner / manager dashboards (RLS still restricts rows to allowed shops): + 'v_owner_dashboard', 'v_z_report', 'v_employee_scorecard_30d', 'alerts', 'v_end_of_day_reports', +]); +app.get('/from/:view', authRequired, async (req, res) => { + const view = req.params.view; + if (!ALLOWED_VIEWS.has(view)) return res.status(404).json({ error: 'unknown view' }); + const filters = Object.entries(req.query).filter(([k]) => /^[a-z_][a-z0-9_]*$/i.test(k)); + const where = filters.length + ? 'WHERE ' + filters.map(([k], i) => `${k} = $${i + 1}`).join(' AND ') + : ''; + const params = filters.map(([, v]) => v); + try { + const data = await withUserClient(req, async (client) => { + const r = await client.query(`SELECT * FROM app.${view} ${where}`, params); + return r.rows; + }); + res.json({ data }); + } catch (e) { dbError(res, e); } +}); + +// ---- employees + employee transactions (Employee Payment Report) ----- + +app.get('/employees', authRequired, async (_req, res) => { + try { + const { rows } = await pool.query( + 'SELECT id, emp_id, name, email, department, location FROM app.employees ORDER BY name', + ); + res.json({ data: rows }); + } catch (e) { dbError(res, e); } +}); + +app.post('/employees', authRequired, async (req, res) => { + const { emp_id, name, email, department, location } = req.body || {}; + if (!emp_id || !name) return res.status(400).json({ error: 'emp_id and name required' }); + try { + const { rows } = await pool.query( + `INSERT INTO app.employees(emp_id, name, email, department, location) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (emp_id) DO UPDATE + SET name = EXCLUDED.name, email = EXCLUDED.email, + department = EXCLUDED.department, location = EXCLUDED.location + RETURNING id, emp_id, name, email, department, location`, + [emp_id, name, email || null, department || null, location || null], + ); + res.json({ data: rows[0] }); + } catch (e) { dbError(res, e); } +}); + +app.get('/employee_transactions', authRequired, async (req, res) => { + const { employee_id } = req.query; + try { + const { rows } = await pool.query( + employee_id + ? `SELECT id, employee_id, transaction_date, collection_amount, deposit_amount, currency + FROM app.employee_transactions WHERE employee_id = $1 ORDER BY transaction_date` + : `SELECT id, employee_id, transaction_date, collection_amount, deposit_amount, currency + FROM app.employee_transactions ORDER BY transaction_date`, + employee_id ? [employee_id] : [], + ); + res.json({ data: rows }); + } catch (e) { dbError(res, e); } +}); + +app.post('/employee_transactions', authRequired, async (req, res) => { + const { employee_id, transaction_date, collection_amount, deposit_amount, currency } = req.body || {}; + if (!employee_id || !transaction_date) { + return res.status(400).json({ error: 'employee_id and transaction_date required' }); + } + try { + const { rows } = await pool.query( + `INSERT INTO app.employee_transactions + (employee_id, transaction_date, collection_amount, deposit_amount, currency) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, employee_id, transaction_date, collection_amount, deposit_amount, currency`, + [ + employee_id, + transaction_date, + Number(collection_amount) || 0, + Number(deposit_amount) || 0, + currency || 'USD', + ], + ); + res.json({ data: rows[0] }); + } catch (e) { dbError(res, e); } +}); + +// ---- admin: user management ----------------------------------------- + +async function ensureAdmin(req, res) { + // Owner-anywhere == admin in the UI. Compute via app.is_owner_anywhere(). + try { + const ok = await withUserClient(req, async (client) => { + const r = await client.query('SELECT app.is_owner_anywhere() AS ok'); + return !!r.rows[0]?.ok; + }); + if (!ok) { res.status(403).json({ error: 'admin only' }); return false; } + return true; + } catch (e) { dbError(res, e); return false; } +} + +app.get('/admin/users', authRequired, async (req, res) => { + if (!(await ensureAdmin(req, res))) return; + try { + const { rows } = await pool.query(` + SELECT u.id, u.email, u.is_active, u.full_name, + p.full_name AS profile_name, + coalesce( + (SELECT a.role::text FROM app.user_shop_assignments a + WHERE a.user_id = u.id ORDER BY (a.role = 'owner') DESC LIMIT 1), + 'cashier' + ) AS shop_role, + EXISTS (SELECT 1 FROM app.user_shop_assignments a + WHERE a.user_id = u.id AND a.role = 'owner') AS is_admin, + (SELECT e.emp_id FROM app.employees e WHERE e.email = u.email LIMIT 1) AS emp_id, + (SELECT e.department FROM app.employees e WHERE e.email = u.email LIMIT 1) AS department + FROM auth.users u + LEFT JOIN app.user_profiles p ON p.user_id = u.id + ORDER BY u.created_at + `); + res.json({ data: rows }); + } catch (e) { dbError(res, e); } +}); + +app.post('/admin/users', authRequired, async (req, res) => { + if (!(await ensureAdmin(req, res))) return; + const { email, password, name, role, department, empId } = req.body || {}; + if (!email || !password || !name) { + return res.status(400).json({ error: 'email, password, name required' }); + } + if (String(password).length < 6) { + return res.status(400).json({ error: 'password must be at least 6 chars' }); + } + const isAdmin = role === 'admin'; + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const hash = await bcrypt.hash(password, 10); + const u = await client.query( + `INSERT INTO auth.users(email, password_hash, full_name, is_active) + VALUES ($1, $2, $3, true) RETURNING id, email, full_name`, + [String(email).trim().toLowerCase(), hash, name], + ); + const userId = u.rows[0].id; + await client.query( + `INSERT INTO app.user_profiles(user_id, full_name, is_active) + VALUES ($1, $2, true) + ON CONFLICT (user_id) DO UPDATE SET full_name = EXCLUDED.full_name`, + [userId, name], + ); + // Assign to the first available shop (Default Shop typically) so they have a role. + const shop = await client.query('SELECT id FROM app.shops ORDER BY created_at LIMIT 1'); + if (shop.rows[0]) { + await client.query( + `INSERT INTO app.user_shop_assignments(user_id, shop_id, role, assigned_by) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, shop_id) DO UPDATE SET role = EXCLUDED.role`, + [userId, shop.rows[0].id, isAdmin ? 'owner' : 'cashier', req.user.id], + ); + } + if (!isAdmin && empId) { + await client.query( + `INSERT INTO app.employees(emp_id, name, email, department) + VALUES ($1, $2, $3, $4) + ON CONFLICT (emp_id) DO UPDATE + SET name = EXCLUDED.name, email = EXCLUDED.email, + department = EXCLUDED.department`, + [empId, name, email, department || null], + ); + } + await client.query('COMMIT'); + res.json({ data: { id: userId, email: u.rows[0].email, full_name: u.rows[0].full_name } }); + } catch (e) { + await client.query('ROLLBACK').catch(() => {}); + dbError(res, e); + } finally { + client.release(); + } +}); + +app.delete('/admin/users/:id', authRequired, async (req, res) => { + if (!(await ensureAdmin(req, res))) return; + if (req.params.id === req.user.id) { + return res.status(400).json({ error: 'cannot delete yourself' }); + } + try { + await pool.query('DELETE FROM auth.users WHERE id = $1', [req.params.id]); + res.json({ ok: true }); + } catch (e) { dbError(res, e); } +}); + +app.post('/admin/dev/end_of_day/reopen_latest', authRequired, async (req, res) => { + if (!LOCAL_DEV_TOOLS_ENABLED) { + return res.status(404).json({ error: 'not found' }); + } + if (!(await ensureAdmin(req, res))) return; + + const { shop_id: shopId } = req.body || {}; + if (!shopId) { + return res.status(400).json({ error: 'shop_id required' }); + } + + try { + const r = await pool.query( + `with shop_bounds as ( + select + min(business_date) as oldest_business_date, + min(submitted_at) as oldest_submitted_at + from app.end_of_day_reports + where shop_id = $1 + ), + latest as ( + select id + from app.end_of_day_reports + where shop_id = $1 + order by submitted_at desc + limit 1 + ) + update app.end_of_day_reports e + set business_date = shop_bounds.oldest_business_date - interval '1 day', + submitted_at = shop_bounds.oldest_submitted_at - interval '1 day' + from latest, shop_bounds + where e.id = latest.id + returning e.id, e.shop_id, e.business_date, e.submitted_at`, + [shopId], + ); + const data = r.rows[0] ?? null; + + if (!data) { + return res.status(404).json({ error: 'no end-of-day report found for this shop' }); + } + + res.json({ data }); + } catch (e) { dbError(res, e); } +}); + +// ---- health ---------------------------------------------------------- + +app.get('/health', async (_req, res) => { + try { await pool.query('SELECT 1'); res.json({ ok: true }); } + catch (e) { res.status(500).json({ ok: false, error: e.message }); } +}); + +app.listen(PORT, () => { + console.log(`[server] listening on http://localhost:${PORT}`); +}); diff --git a/src/App.css b/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/src/components/AdminDataEntryModal.tsx b/src/components/AdminDataEntryModal.tsx index 516e69d..bb31c2d 100644 --- a/src/components/AdminDataEntryModal.tsx +++ b/src/components/AdminDataEntryModal.tsx @@ -1,5 +1,4 @@ - -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -12,6 +11,8 @@ import { format } from "date-fns"; import { cn } from "@/lib/utils"; import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData"; import { useToast } from "@/hooks/use-toast"; +import { Currency } from "@/lib/currency"; +import { useAuth } from "@/hooks/useAuth"; interface AdminDataEntryModalProps { isOpen: boolean; @@ -21,14 +22,27 @@ interface AdminDataEntryModalProps { export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminDataEntryModalProps) => { const { employees, addTransaction } = useSupabaseEmployeeData(); + const { user } = useAuth(); const { toast } = useToast(); - + + const isEmployee = user?.role === 'employee'; + const lockedEmployeeId = isEmployee + ? employees.find(e => e.emp_id === user?.empId || e.email === user?.email)?.id + : undefined; + const [selectedEmployeeId, setSelectedEmployeeId] = useState(''); const [collectionAmount, setCollectionAmount] = useState(''); const [depositAmount, setDepositAmount] = useState(''); + const [currency, setCurrency] = useState('USD'); const [selectedDate, setSelectedDate] = useState(new Date()); const [loading, setLoading] = useState(false); + useEffect(() => { + if (isEmployee && lockedEmployeeId) { + setSelectedEmployeeId(lockedEmployeeId); + } + }, [isEmployee, lockedEmployeeId, isOpen]); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -59,7 +73,8 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData await addTransaction(selectedEmployeeId, { transaction_date: format(selectedDate, 'yyyy-MM-dd'), collection_amount: collection, - deposit_amount: deposit + deposit_amount: deposit, + currency, }); toast({ @@ -71,6 +86,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData setSelectedEmployeeId(''); setCollectionAmount(''); setDepositAmount(''); + setCurrency('USD'); setSelectedDate(new Date()); onDataUpdate(); @@ -89,6 +105,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData setSelectedEmployeeId(''); setCollectionAmount(''); setDepositAmount(''); + setCurrency('USD'); setSelectedDate(new Date()); onClose(); }; @@ -97,26 +114,40 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData - Insert Employee Data + + {isEmployee ? "Submit Transaction" : "Insert Employee Data"} +
- - + - {employees.map(employee => ( + {(isEmployee && lockedEmployeeId + ? employees.filter(e => e.id === lockedEmployeeId) + : employees + ).map(employee => ( {employee.name} (ID: {employee.emp_id}) ))} + {isEmployee && !lockedEmployeeId && ( +

+ Your account is not linked to an employee record. Ask an admin to set your Employee ID. +

+ )}
@@ -148,10 +179,23 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
+
+ + +
+
setCollectionAmount(e.target.value)} className="text-right" - step="0.01" + step={currency === 'USD' ? '0.01' : '1'} min="0" disabled={loading} /> @@ -168,7 +212,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
setDepositAmount(e.target.value)} className="text-right" - step="0.01" + step={currency === 'USD' ? '0.01' : '1'} min="0" disabled={loading} /> diff --git a/src/components/CashierTools.tsx b/src/components/CashierTools.tsx new file mode 100644 index 0000000..928397f --- /dev/null +++ b/src/components/CashierTools.tsx @@ -0,0 +1,213 @@ +import React, { useEffect, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { useToast } from "@/hooks/use-toast"; +import { supabase } from "@/integrations/supabase/client"; + +/** + * FxSwapDialog — cashier swaps cash between USD and LBP at the + * currently posted rate. Server-side validates the rate against the + * active fx_rates band, requires the two legs to balance within 1 LBP, + * and posts both cash_movements rows + a FX_SWAP transaction atomically. + */ +export const FxSwapDialog: React.FC<{ + open: boolean; + onClose: () => void; + shopId: string; + tillId: string; + defaultRate?: string; + onDone?: () => void; +}> = ({ open, onClose, shopId, tillId, defaultRate, onDone }) => { + const { toast } = useToast(); + const [direction, setDirection] = useState<"sell_usd" | "buy_usd">("sell_usd"); + const [usdAmount, setUsdAmount] = useState(""); + const [lbpAmount, setLbpAmount] = useState(""); + const [rate, setRate] = useState(defaultRate ?? ""); + const [notes, setNotes] = useState(""); + const [busy, setBusy] = useState(false); + + // Fetch current FX rate when opened. + useEffect(() => { + if (!open || !shopId) return; + (async () => { + const { data } = await supabase + .from("fx_rates") + .select("usd_to_lbp_rate, effective_to") + .eq("shop_id", shopId); + const active = (data ?? []).find((r: { effective_to: string | null }) => !r.effective_to); + if (active) setRate(String((active as { usd_to_lbp_rate: number }).usd_to_lbp_rate)); + })(); + }, [open, shopId]); + + // Auto-compute the opposite leg whenever rate or one amount changes. + const computeFromUsd = (usd: string) => { + setUsdAmount(usd); + const r = Number(rate); + const u = Number(usd); + if (r > 0 && u > 0) setLbpAmount(String(Math.round(u * r))); + }; + const computeFromLbp = (lbp: string) => { + setLbpAmount(lbp); + const r = Number(rate); + const l = Number(lbp); + if (r > 0 && l > 0) setUsdAmount((l / r).toFixed(2)); + }; + + const submit = async () => { + const u = Number(usdAmount); + const l = Number(lbpAmount); + const r = Number(rate); + if (!u || !l || !r || !shopId || !tillId) { + toast({ title: "Fill all fields", variant: "destructive" }); + return; + } + setBusy(true); + try { + // direction === sell_usd: USD leaves till (positive usd_out), LBP enters (positive lbp_in) + // direction === buy_usd: USD enters till (negative usd_out), LBP leaves (negative lbp_in) + const usdOut = direction === "sell_usd" ? u : -u; + const lbpIn = direction === "sell_usd" ? l : -l; + const { error } = await supabase.rpc("record_fx_swap", { + p_shop: shopId, + p_till: tillId, + p_usd_out: usdOut, + p_lbp_in: lbpIn, + p_fx_rate: r, + p_notes: notes.trim() || null, + }); + if (error) throw error; + toast({ title: "FX swap recorded" }); + setUsdAmount(""); setLbpAmount(""); setNotes(""); + onDone?.(); + onClose(); + } catch (e) { + toast({ title: "Swap failed", description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + return ( + !o && onClose()}> + + + Cash FX Swap + + Convert till cash between USD and LBP at the posted rate. + + +
+
+ + +
+
+
+ + computeFromUsd(e.target.value)} /> +
+
+ + computeFromLbp(e.target.value)} /> +
+
+
+ + setRate(e.target.value)} /> +

+ Must match the currently posted rate within tolerance. +

+
+
+ + setNotes(e.target.value)} /> +
+
+ + + + +
+
+ ); +}; + +/** + * SelfDealOverrideDialog — manager enters their PIN to authorize the + * NEXT money-transfer transaction even if the cashier matches the + * sender/beneficiary KYC. The override is stored in a session GUC and + * automatically cleared by the trigger after one use. + */ +export const SelfDealOverrideDialog: React.FC<{ + open: boolean; + onClose: () => void; + shopId: string; + onGranted?: () => void; +}> = ({ open, onClose, shopId, onGranted }) => { + const { toast } = useToast(); + const [pin, setPin] = useState(""); + const [busy, setBusy] = useState(false); + + const submit = async () => { + if (!pin || !shopId) return; + setBusy(true); + try { + const { error } = await supabase.rpc("manager_allow_next_self_deal", { + p_manager_pin: pin, + p_shop: shopId, + }); + if (error) throw error; + toast({ + title: "Override granted", + description: "The next self-deal transfer in this session will be allowed.", + }); + setPin(""); + onGranted?.(); + onClose(); + } catch (e) { + toast({ title: "Override denied", description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + return ( + !o && onClose()}> + + + Manager Self-Deal Override + + Enter the manager / owner PIN to allow exactly one transfer + where the cashier is sender or beneficiary. The override is + single-use and audit-logged. + + +
+
+ + setPin(e.target.value)} autoFocus /> +
+
+ + + + +
+
+ ); +}; diff --git a/src/components/DetailedEmployeePaymentReport.tsx b/src/components/DetailedEmployeePaymentReport.tsx index 262af43..ba959cc 100644 --- a/src/components/DetailedEmployeePaymentReport.tsx +++ b/src/components/DetailedEmployeePaymentReport.tsx @@ -1,9 +1,9 @@ - import React, { useState } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Card, CardContent } from "@/components/ui/card"; import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData"; +import { Currency, convert, formatCurrency, getUsdToLbpRate } from "@/lib/currency"; interface DetailedTransaction { location: string; @@ -17,18 +17,12 @@ interface DetailedTransaction { } export const DetailedEmployeePaymentReport = () => { - const { employees, transactions } = useSupabaseEmployeeData(); + const [displayCurrency, setDisplayCurrency] = useState("USD"); + const { employees, transactions } = useSupabaseEmployeeData(displayCurrency); const [selectedEmployeeId, setSelectedEmployeeId] = useState('all'); - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('en-IN', { - style: 'currency', - currency: 'INR' - }).format(amount); - }; - const formatDate = (date: string) => { - return new Date(date).toLocaleDateString('en-IN'); + return new Date(date).toLocaleDateString('en-US'); }; // Process transactions with the specific business logic @@ -38,6 +32,11 @@ export const DetailedEmployeePaymentReport = () => { const employeeTransactions = transactions .filter(t => t.employee_id === employeeId) + .map(t => ({ + ...t, + collection_amount: convert(t.collection_amount, t.currency, displayCurrency), + deposit_amount: convert(t.deposit_amount, t.currency, displayCurrency), + })) .sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); const detailedTransactions: DetailedTransaction[] = []; @@ -62,7 +61,7 @@ export const DetailedEmployeePaymentReport = () => { if (collections > 0) { pendingCollections.push({ amount: collections, date }); detailedTransactions.push({ - location: 'BGRoad, Karnataka', + location: employee.location || 'BGRoad, Karnataka', empId: employee.emp_id.replace('EMP', ''), empName: employee.name, collectionAmount: collections, @@ -106,7 +105,7 @@ export const DetailedEmployeePaymentReport = () => { // If there's remaining deposit after clearing collections, add separate deposit entries while (remainingDeposit > 0) { detailedTransactions.push({ - location: 'BGRoad, Karnataka', + location: employee.location || 'BGRoad, Karnataka', empId: employee.emp_id.replace('EMP', ''), empName: employee.name, collectionAmount: 0, @@ -133,30 +132,54 @@ export const DetailedEmployeePaymentReport = () => { const detailedTransactions = getTransactionsToShow(); - // Calculate totals + // Calculate totals (combined, in display currency) const totalCollection = detailedTransactions.reduce((sum, t) => sum + t.collectionAmount, 0); const totalDeposit = detailedTransactions.reduce((sum, t) => sum + t.depositAmount, 0); const totalDifference = totalDeposit - totalCollection; + // Per-currency totals from raw transactions, filtered to current selection + const filteredRawTx = selectedEmployeeId === 'all' + ? transactions + : transactions.filter(t => t.employee_id === selectedEmployeeId); + const totalCollectionUSD = filteredRawTx.filter(t => t.currency === 'USD').reduce((s, t) => s + t.collection_amount, 0); + const totalCollectionLBP = filteredRawTx.filter(t => t.currency === 'LBP').reduce((s, t) => s + t.collection_amount, 0); + const totalDepositUSD = filteredRawTx.filter(t => t.currency === 'USD').reduce((s, t) => s + t.deposit_amount, 0); + const totalDepositLBP = filteredRawTx.filter(t => t.currency === 'LBP').reduce((s, t) => s + t.deposit_amount, 0); + const totalDifferenceUSD = totalDepositUSD - totalCollectionUSD; + const totalDifferenceLBP = totalDepositLBP - totalCollectionLBP; + return (
{/* Header */}

Employee Payment Report (Detailed)

+
+ Rate: 1 USD = {getUsdToLbpRate().toLocaleString()} LBP + +
{/* Summary Cards */}
-
-
+
+
-
-

Total Collection

-

(MM) Amount

-

{formatCurrency(totalCollection)}

+
+

Total Collection (MM)

+

USD: {formatCurrency(totalCollectionUSD, "USD")}

+

LBP: {formatCurrency(totalCollectionLBP, "LBP")}

+

≈ {formatCurrency(totalCollection, displayCurrency)}

@@ -164,16 +187,17 @@ export const DetailedEmployeePaymentReport = () => { -
-
+
+
-
-

Total Deposit

-

Amount

-

{formatCurrency(totalDeposit)}

+
+

Total Deposit Amount

+

USD: {formatCurrency(totalDepositUSD, "USD")}

+

LBP: {formatCurrency(totalDepositLBP, "LBP")}

+

≈ {formatCurrency(totalDeposit, displayCurrency)}

@@ -181,17 +205,18 @@ export const DetailedEmployeePaymentReport = () => { -
-
+
+
=
-
+

Net Difference

-

Amount

-

= 0 ? 'text-green-600' : 'text-red-600'}`}> - {formatCurrency(totalDifference)} +

USD: = 0 ? 'text-green-600' : 'text-red-600'}`}>{formatCurrency(totalDifferenceUSD, "USD")}

+

LBP: = 0 ? 'text-green-600' : 'text-red-600'}`}>{formatCurrency(totalDifferenceLBP, "LBP")}

+

= 0 ? 'text-green-600' : 'text-red-600'}`}> + ≈ {formatCurrency(totalDifference, displayCurrency)}

@@ -239,13 +264,13 @@ export const DetailedEmployeePaymentReport = () => { {transaction.empId} {transaction.empName} - {transaction.collectionAmount > 0 ? transaction.collectionAmount.toLocaleString() : '-'} + {transaction.collectionAmount > 0 ? formatCurrency(transaction.collectionAmount, displayCurrency) : '-'} {transaction.collectionDate ? formatDate(transaction.collectionDate) : '-'} - {transaction.depositAmount > 0 ? transaction.depositAmount.toLocaleString() : '-'} + {transaction.depositAmount > 0 ? formatCurrency(transaction.depositAmount, displayCurrency) : '-'} {transaction.depositDate ? formatDate(transaction.depositDate) : '-'} @@ -255,7 +280,7 @@ export const DetailedEmployeePaymentReport = () => { transaction.difference === 0 ? 'text-gray-600' : transaction.difference > 0 ? 'text-green-600' : 'text-red-600' }`}> - {transaction.difference === 0 ? '-' : transaction.difference.toLocaleString()} + {transaction.difference === 0 ? '-' : formatCurrency(transaction.difference, displayCurrency)} diff --git a/src/components/LoginPage.tsx b/src/components/LoginPage.tsx index 3b0562f..243d985 100644 --- a/src/components/LoginPage.tsx +++ b/src/components/LoginPage.tsx @@ -10,8 +10,8 @@ interface LoginPageProps { } export const LoginPage = ({ onLogin }: LoginPageProps) => { - const [email, setEmail] = useState('admin@astra.in'); - const [password, setPassword] = useState('123456'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const { signIn } = useAuth(); @@ -21,13 +21,10 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => { e.preventDefault(); setLoading(true); - console.log('Attempting login with:', { email, password }); - try { const { error } = await signIn(email, password); if (error) { - console.error('Login error:', error); toast({ title: "Login Error", description: error.message, @@ -43,7 +40,6 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => { onLogin(); } catch (err) { - console.error('Unexpected error:', err); toast({ title: "Error", description: "An unexpected error occurred", @@ -83,7 +79,7 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
setEmail(e.target.value)} className="w-full h-12 px-4 border border-gray-300 rounded-lg focus:border-purple-500 focus:ring-purple-500" diff --git a/src/components/ManagerConsole.tsx b/src/components/ManagerConsole.tsx new file mode 100644 index 0000000..119b4f1 --- /dev/null +++ b/src/components/ManagerConsole.tsx @@ -0,0 +1,768 @@ +import React, { useEffect, useMemo, useState, useCallback } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/hooks/useAuth"; +import { supabase } from "@/integrations/supabase/client"; + +type Currency = "USD" | "LBP"; +type IdDocType = "lebanese_id" | "passport" | "residence_permit" | "driver_license" | "other"; + +const SERVICE_CODES = [ + "OMT_SEND", "OMT_RECEIVE", "WU_SEND", "WU_RECEIVE", + "WHISH_SEND", "OMT_BILL", "EDL_BILL", + "ALFA_RECHARGE", "TOUCH_RECHARGE", "GOODS_SALE", "REPAIR", +]; + +interface ShopUser { + user_id: string; + full_name: string; + role: string; +} + +interface FeeRow { + id: string; + service_code: string; + currency: Currency; + min_amount: number; + max_amount: number; + fee_fixed: number; + fee_pct: number; + commission_fixed: number; + commission_pct: number; + effective_from: string; + effective_to: string | null; +} + +interface FxRow { + id: string; + usd_to_lbp_rate: number; + tolerance_pct: number; + effective_from: string; + effective_to: string | null; +} + +interface SafeBalanceRow { + shop_id: string; + shop_name: string; + safe_id: string; + safe_name: string; + currency: Currency | null; + balance: number; + updated_at: string | null; +} + +interface BankDepositRow { + id: string; + amount: number; + currency: Currency; + bank_ref: string | null; + notes: string | null; + created_at: string; +} + +/** + * ManagerConsole — single screen for managers/owners to: + * - Seed / view fee_schedule rows (per service per currency). + * - Seed / view fx_rates rows. + * - Set cashier KYC (id_type, id_number, phone) used by self-deal blocking. + * - Record a bank deposit out of the safe and view safe balances. + * + * All writes go through SECURITY DEFINER RPCs from migrations 0021/0023/0024/0025; + * direct table writes are blocked by RLS. + */ +export const ManagerConsole: React.FC = () => { + const { user } = useAuth(); + const { toast } = useToast(); + + const managerShops = useMemo( + () => (user?.shops ?? []).filter(s => s.role === "manager" || s.role === "owner"), + [user] + ); + const [shopId, setShopId] = useState(""); + useEffect(() => { + if (managerShops.length && !shopId) setShopId(managerShops[0].shop_id); + }, [managerShops, shopId]); + + if (!managerShops.length) { + return ( + + + You don't have manager or owner role in any shop. + + + ); + } + + return ( +
+ {managerShops.length > 1 && ( +
+ + +
+ )} + + + + Fee Schedule + FX Rates + Tills + Cashier KYC + Safe / Bank + + + + + + + + + + + + + + + + + + +
+ ); +}; + +// --------------------------------------------------------------------------- +// Fee schedule tab +// --------------------------------------------------------------------------- +const FeeScheduleTab: React.FC<{ shopId: string }> = ({ shopId }) => { + const { toast } = useToast(); + const [rows, setRows] = useState([]); + const [busy, setBusy] = useState(false); + + const [serviceCode, setServiceCode] = useState("OMT_SEND"); + const [currency, setCurrency] = useState("USD"); + const [minAmount, setMinAmount] = useState("0"); + const [maxAmount, setMaxAmount] = useState("999999999"); + const [feeFixed, setFeeFixed] = useState("0"); + const [feePct, setFeePct] = useState("0"); + const [commFixed, setCommFixed] = useState("0"); + const [commPct, setCommPct] = useState("0"); + + const refresh = useCallback(async () => { + if (!shopId) return; + const { data, error } = await supabase + .from("fee_schedule") + .select("id, service_code, currency, min_amount, max_amount, fee_fixed, fee_pct, commission_fixed, commission_pct, effective_from, effective_to") + .eq("shop_id", shopId) + .order("service_code") + .order("effective_from", { ascending: false }); + if (error) { + toast({ title: "Could not load fee schedule", description: error.message, variant: "destructive" }); + return; + } + setRows((data ?? []) as FeeRow[]); + }, [shopId, toast]); + + useEffect(() => { refresh(); }, [refresh]); + + const submit = async () => { + if (!shopId) return; + setBusy(true); + try { + const { error } = await supabase.rpc("set_fee_bracket", { + p_shop: shopId, + p_service_code: serviceCode, + p_currency: currency, + p_min_amount: Number(minAmount) || 0, + p_max_amount: Number(maxAmount) || 0, + p_fee_fixed: Number(feeFixed) || 0, + p_fee_pct: Number(feePct) || 0, + p_commission_fixed: Number(commFixed) || 0, + p_commission_pct: Number(commPct) || 0, + }); + if (error) throw error; + toast({ title: "Fee bracket saved" }); + await refresh(); + } catch (e) { + toast({ title: "Save failed", description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + return ( + + Fee Schedule + +
+
+ + +
+
+ + +
+
setMinAmount(e.target.value)} />
+
setMaxAmount(e.target.value)} />
+
setFeeFixed(e.target.value)} />
+
setFeePct(e.target.value)} />
+
setCommFixed(e.target.value)} />
+
setCommPct(e.target.value)} />
+
+ + + + + + ServiceCurr + MinMax + Fee fixFee % + Comm fixComm % + FromTo + + + + {rows.map(r => ( + + {r.service_code} + {r.currency} + {r.min_amount} + {r.max_amount} + {r.fee_fixed} + {r.fee_pct} + {r.commission_fixed} + {r.commission_pct} + {new Date(r.effective_from).toLocaleDateString()} + {r.effective_to ? new Date(r.effective_to).toLocaleDateString() : "—"} + + ))} + {!rows.length && No brackets yet.} + +
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// FX rates tab +// --------------------------------------------------------------------------- +const FxRatesTab: React.FC<{ shopId: string }> = ({ shopId }) => { + const { toast } = useToast(); + const [rows, setRows] = useState([]); + const [rate, setRate] = useState("89500"); + const [tol, setTol] = useState("1.0"); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + if (!shopId) return; + const { data, error } = await supabase + .from("fx_rates") + .select("id, usd_to_lbp_rate, tolerance_pct, effective_from, effective_to") + .eq("shop_id", shopId) + .order("effective_from", { ascending: false }); + if (error) { + toast({ title: "Could not load FX rates", description: error.message, variant: "destructive" }); + return; + } + setRows((data ?? []) as FxRow[]); + }, [shopId, toast]); + + useEffect(() => { refresh(); }, [refresh]); + + const submit = async () => { + if (!shopId) return; + setBusy(true); + try { + const { error } = await supabase.rpc("set_fx_rate", { + p_shop: shopId, + p_usd_to_lbp: Number(rate), + p_tolerance_pct: Number(tol), + }); + if (error) throw error; + toast({ title: "FX rate saved" }); + await refresh(); + } catch (e) { + toast({ title: "Save failed", description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + return ( + + FX Rates (USD → LBP) + +
+
setRate(e.target.value)} />
+
setTol(e.target.value)} />
+
+ +
+
+ + + + RateTolerance + FromTo + + + + {rows.map(r => ( + + {r.usd_to_lbp_rate.toLocaleString()} + {r.tolerance_pct}% + {new Date(r.effective_from).toLocaleString()} + {r.effective_to ? new Date(r.effective_to).toLocaleString() : "active"} + + ))} + {!rows.length && No rates yet.} + +
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Cashier KYC tab +// --------------------------------------------------------------------------- +const KycTab: React.FC<{ shopId: string }> = ({ shopId }) => { + const { toast } = useToast(); + const [users, setUsers] = useState([]); + const [userId, setUserId] = useState(""); + const [idType, setIdType] = useState("lebanese_id"); + const [idNumber, setIdNumber] = useState(""); + const [phone, setPhone] = useState(""); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!shopId) { setUsers([]); return; } + let cancelled = false; + (async () => { + const { data, error } = await supabase.rpc("get_shop_users", { p_shop_id: shopId }); + if (cancelled) return; + if (error) { + toast({ title: "Could not load users", description: error.message, variant: "destructive" }); + return; + } + const formatted = ((data ?? []) as Array<{user_id: string; full_name: string | null; role: string}>).map(r => ({ + user_id: r.user_id, + full_name: r.full_name ?? "Unknown", + role: r.role, + })); + setUsers(formatted); + })(); + return () => { cancelled = true; }; + }, [shopId, toast]); + + const submit = async () => { + if (!shopId || !userId || !idNumber.trim()) { + toast({ title: "User and ID number required", variant: "destructive" }); + return; + } + setBusy(true); + try { + const { error } = await supabase.rpc("set_user_kyc", { + p_user_id: userId, + p_shop: shopId, + p_id_type: idType, + p_id_number: idNumber.trim(), + p_phone_kyc: phone.trim() || null, + }); + if (error) throw error; + toast({ title: "KYC saved" }); + setIdNumber(""); setPhone(""); + } catch (e) { + toast({ title: "Save failed", description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + return ( + + Cashier KYC (used for self-deal blocking) + +

+ Recording a cashier's ID and phone here lets the database refuse any + OMT/WU/Whish transfer where the cashier is the sender or beneficiary. +

+
+
+ + +
+
+ + +
+
setIdNumber(e.target.value)} />
+
setPhone(e.target.value)} placeholder="+961…" />
+
+ +
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Safe + bank deposit tab +// --------------------------------------------------------------------------- +const SafeAndBankTab: React.FC<{ shopId: string }> = ({ shopId }) => { + const { toast } = useToast(); + const [balances, setBalances] = useState([]); + const [deposits, setDeposits] = useState([]); + + const [currency, setCurrency] = useState("USD"); + const [amount, setAmount] = useState(""); + const [bankRef, setBankRef] = useState(""); + const [slipUrl, setSlipUrl] = useState(""); + const [notes, setNotes] = useState(""); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + if (!shopId) return; + const [bal, dep] = await Promise.all([ + supabase.from("v_safe_balance").select("*").eq("shop_id", shopId), + supabase + .from("bank_deposits") + .select("id, amount, currency, bank_ref, notes, created_at") + .eq("shop_id", shopId) + .order("created_at", { ascending: false }) + .limit(20), + ]); + if (bal.error) toast({ title: "Could not load safe balance", description: bal.error.message, variant: "destructive" }); + else setBalances((bal.data ?? []) as SafeBalanceRow[]); + if (dep.error) toast({ title: "Could not load deposits", description: dep.error.message, variant: "destructive" }); + else setDeposits((dep.data ?? []) as BankDepositRow[]); + }, [shopId, toast]); + + useEffect(() => { refresh(); }, [refresh]); + + const submit = async () => { + if (!shopId || !amount) return; + setBusy(true); + try { + const { error } = await supabase.rpc("record_bank_deposit", { + p_shop: shopId, + p_currency: currency, + p_amount: Number(amount), + p_bank_ref: bankRef.trim() || null, + p_deposit_slip_url: slipUrl.trim() || null, + p_notes: notes.trim() || null, + }); + if (error) throw error; + toast({ title: "Bank deposit recorded" }); + setAmount(""); setBankRef(""); setSlipUrl(""); setNotes(""); + await refresh(); + } catch (e) { + toast({ title: "Deposit failed", description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + return ( +
+ + Safe balance + + + + SafeCurrencyBalanceUpdated + + + {balances.map((b, i) => ( + + {b.safe_name} + {b.currency ?? "—"} + {Number(b.balance).toLocaleString()} + {b.updated_at ? new Date(b.updated_at).toLocaleString() : "—"} + + ))} + {!balances.length && No safe activity yet.} + +
+
+
+ + + Record bank deposit + +
+
+ + +
+
setAmount(e.target.value)} />
+
setBankRef(e.target.value)} />
+
setSlipUrl(e.target.value)} />
+
setNotes(e.target.value)} />
+
+ +
+
+
+
+ + + Recent deposits + + + + WhenCurrAmountBank refNotes + + + {deposits.map(d => ( + + {new Date(d.created_at).toLocaleString()} + {d.currency} + {Number(d.amount).toLocaleString()} + {d.bank_ref ?? "—"} + {d.notes ?? "—"} + + ))} + {!deposits.length && No deposits yet.} + +
+
+
+
+ ); +}; + +// --------------------------------------------------------------------------- +// Tills tab — owner-only create / rename / activate-deactivate +// --------------------------------------------------------------------------- +interface TillRow { + till_id: string; + shop_id: string; + name: string; + is_active: boolean; + created_at: string; +} + +const TillsTab: React.FC<{ shopId: string }> = ({ shopId }) => { + const { user } = useAuth(); + const { toast } = useToast(); + const [rows, setRows] = useState([]); + const [busy, setBusy] = useState(false); + const [newName, setNewName] = useState(""); + const [editingId, setEditingId] = useState(""); + const [editingName, setEditingName] = useState(""); + + const isOwner = useMemo( + () => (user?.shops ?? []).some(s => s.shop_id === shopId && s.role === "owner"), + [user, shopId] + ); + + const load = useCallback(async () => { + if (!shopId) return; + const { data, error } = await supabase + .from("v_manage_tills") + .select("till_id, shop_id, name, is_active, created_at") + .eq("shop_id", shopId); + if (error) { + toast({ title: "Could not load tills", description: error.message, variant: "destructive" }); + return; + } + setRows(((data ?? []) as TillRow[]).slice().sort((a, b) => a.name.localeCompare(b.name))); + }, [shopId, toast]); + + useEffect(() => { load(); }, [load]); + + const create = async () => { + const name = newName.trim(); + if (!name) return; + setBusy(true); + const { error } = await supabase.rpc("create_till", { p_shop: shopId, p_name: name }); + setBusy(false); + if (error) { + toast({ title: "Could not create till", description: error.message, variant: "destructive" }); + return; + } + toast({ title: "Till created" }); + setNewName(""); + load(); + }; + + const rename = async (till_id: string) => { + const name = editingName.trim(); + if (!name) return; + setBusy(true); + const { error } = await supabase.rpc("rename_till", { p_till: till_id, p_name: name }); + setBusy(false); + if (error) { + toast({ title: "Could not rename till", description: error.message, variant: "destructive" }); + return; + } + toast({ title: "Till renamed" }); + setEditingId(""); setEditingName(""); + load(); + }; + + const setActive = async (till_id: string, active: boolean) => { + setBusy(true); + const { error } = await supabase.rpc("set_till_active", { p_till: till_id, p_active: active }); + setBusy(false); + if (error) { + toast({ title: active ? "Could not activate" : "Could not deactivate", + description: error.message, variant: "destructive" }); + return; + } + toast({ title: active ? "Till activated" : "Till deactivated" }); + load(); + }; + + return ( + + + Tills + + + {!isOwner && ( +
+ Only the shop owner can create, rename, or deactivate tills. You can view the list below. +
+ )} + + {isOwner && ( +
+
+ + setNewName(e.target.value)} /> +
+ +
+ )} + + + + + Name + Status + Created + Actions + + + + {rows.map((t) => ( + + + {editingId === t.till_id ? ( + setEditingName(e.target.value)} + className="h-9" /> + ) : t.name} + + + + {t.is_active ? "Active" : "Inactive"} + + + + {new Date(t.created_at).toLocaleDateString()} + + + {isOwner && (editingId === t.till_id ? ( + <> + + + + ) : ( + <> + + {t.is_active ? ( + + ) : ( + + )} + + ))} + + + ))} + {!rows.length && ( + + No tills yet — add one above. + + )} + +
+
+
+ ); +}; diff --git a/src/components/OutstandingReportDashboard.tsx b/src/components/OutstandingReportDashboard.tsx index 2700b41..a612e67 100644 --- a/src/components/OutstandingReportDashboard.tsx +++ b/src/components/OutstandingReportDashboard.tsx @@ -1,11 +1,13 @@ - -import React from 'react'; +import React, { useState } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Card, CardContent } from "@/components/ui/card"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData"; +import { Currency, formatCurrency, getUsdToLbpRate } from "@/lib/currency"; export const OutstandingReportDashboard = () => { - const { employees, getEmployeeSummary } = useSupabaseEmployeeData(); + const [displayCurrency, setDisplayCurrency] = useState("USD"); + const { employees, getEmployeeSummary } = useSupabaseEmployeeData(displayCurrency); const employeeSummaries = employees.map(employee => { const summary = getEmployeeSummary(employee.id); @@ -15,40 +17,56 @@ export const OutstandingReportDashboard = () => { }; }); - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('en-IN', { - style: 'currency', - currency: 'INR' - }).format(amount); - }; - const formatDate = (date: string) => { - return new Date(date).toLocaleDateString('en-IN'); + return new Date(date).toLocaleDateString('en-US'); }; - // Calculate totals + // Calculate totals (combined, in display currency) const totalCollection = employeeSummaries.reduce((sum, emp) => sum + emp.totalCollection, 0); const totalDeposit = employeeSummaries.reduce((sum, emp) => sum + emp.totalDeposit, 0); const totalDifference = totalCollection - totalDeposit; + // Per-currency totals (raw, untouched amounts for clarity) + const totalCollectionUSD = employeeSummaries.reduce((s, e) => s + e.totalCollectionUSD, 0); + const totalCollectionLBP = employeeSummaries.reduce((s, e) => s + e.totalCollectionLBP, 0); + const totalDepositUSD = employeeSummaries.reduce((s, e) => s + e.totalDepositUSD, 0); + const totalDepositLBP = employeeSummaries.reduce((s, e) => s + e.totalDepositLBP, 0); + const totalDifferenceUSD = totalCollectionUSD - totalDepositUSD; + const totalDifferenceLBP = totalCollectionLBP - totalDepositLBP; + return (
-
- Outstanding Report (All Locations) +
+
+ Outstanding Report (All Locations) +
+
+ Rate: 1 USD = {getUsdToLbpRate().toLocaleString()} LBP + +
{/* Summary Cards */}
-
-
+
+
-
+

Total Collection (MM)

-

(All Locations)

-

{formatCurrency(totalCollection)}

+

USD: {formatCurrency(totalCollectionUSD, "USD")}

+

LBP: {formatCurrency(totalCollectionLBP, "LBP")}

+

≈ {formatCurrency(totalCollection, displayCurrency)}

@@ -56,16 +74,17 @@ export const OutstandingReportDashboard = () => { -
-
+
+
-
+

Total Deposit Amount

-

(All Locations)

-

{formatCurrency(totalDeposit)}

+

USD: {formatCurrency(totalDepositUSD, "USD")}

+

LBP: {formatCurrency(totalDepositLBP, "LBP")}

+

≈ {formatCurrency(totalDeposit, displayCurrency)}

@@ -73,16 +92,17 @@ export const OutstandingReportDashboard = () => { -
-
+
+
=
-
+

Difference Amount

-

(All Locations)

-

{formatCurrency(totalDifference)}

+

USD: 0 ? 'text-red-600' : 'text-green-600'}`}>{formatCurrency(totalDifferenceUSD, "USD")}

+

LBP: 0 ? 'text-red-600' : 'text-green-600'}`}>{formatCurrency(totalDifferenceLBP, "LBP")}

+

0 ? 'text-red-600' : 'text-green-600'}`}>≈ {formatCurrency(totalDifference, displayCurrency)}

@@ -105,18 +125,20 @@ export const OutstandingReportDashboard = () => { {employeeSummaries.map((employee) => ( - BGRoad, Karnataka + {employee.location || 'BGRoad, Karnataka'} {employee.emp_id.replace('EMP', '')} {employee.name} - {employee.totalCollection.toLocaleString()} + {formatCurrency(employee.totalCollection, displayCurrency)} - {employee.lastTransactionDate ? formatDate(employee.lastTransactionDate) : '26 Mar 2025'} + {employee.lastTransactionDate ? formatDate(employee.lastTransactionDate) : '-'} 0 ? 'text-red-600' : 'text-green-600'}`}> - {employee.outstandingAmount > 0 ? employee.outstandingAmount.toLocaleString() : `(${Math.abs(employee.outstandingAmount).toLocaleString()})`} + {employee.outstandingAmount > 0 + ? formatCurrency(employee.outstandingAmount, displayCurrency) + : `(${formatCurrency(Math.abs(employee.outstandingAmount), displayCurrency)})`} diff --git a/src/components/OwnerOverview.tsx b/src/components/OwnerOverview.tsx new file mode 100644 index 0000000..e68a075 --- /dev/null +++ b/src/components/OwnerOverview.tsx @@ -0,0 +1,529 @@ +import React, { useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/hooks/useAuth"; +import { api } from "@/lib/api"; + +/** + * Owner / Manager overview. + * + * Surfaces what the shop owner actually wants to see when they walk in: + * - per-shop snapshot: open shifts, open alerts, today's gross + * - last closed shifts with their variance USD/LBP + * - 30-day cashier scorecard (variance + voids) + * - open alerts they can acknowledge + * + * All data comes from the existing app.* views/tables wired through the + * Express `/from/:view` route (see ALLOWED_VIEWS in server/src/index.js). + * RLS continues to scope rows to the user's shops. + */ + +interface ShopRow { + shop_id: string; + shop_name: string; + open_shifts: number; + open_alerts: number; + critical_alerts: number; + open_recon_exceptions: number; + period_started_at: string; + last_end_of_day_at: string | null; + today_gross_usd: string | number; + today_gross_lbp: string | number; +} + +interface EndOfDayReportRow { + id: string; + shop_id: string; + business_date: string; + submitted_at: string; + submitted_by_name: string; + note: string | null; + period_started_at: string; + period_ended_at: string; + completed_txn_count: number; + voided_txn_count: number; + gross_usd: string | number; + gross_lbp: string | number; + safe_drop_usd: string | number; + safe_drop_lbp: string | number; + closed_shift_count: number; + total_variance_usd: string | number; + total_variance_lbp: string | number; + activity_count: number; + activity_log: Array<{ + occurred_at: string; + event_type: string; + metadata: Record; + }>; +} + +interface ZRow { + shift_id: string; + shop_id: string; + cashier_id: string; + opened_at: string; + closed_at: string | null; + status: string; + expected_close_usd: string | number; + expected_close_lbp: string | number; + declared_close_usd: string | number | null; + declared_close_lbp: string | number | null; + variance_usd: string | number | null; + variance_lbp: string | number | null; +} + +interface ScoreRow { + cashier_id: string; + shop_id: string; + shifts_30d: number; + total_var_usd: string | number; + total_var_lbp: string | number; + voids_30d: number; +} + +interface AlertRow { + id: string; + shop_id: string; + kind: string; + severity: "info" | "warn" | "critical"; + subject_id: string | null; + payload: Record; + created_at: string; + acknowledged_at: string | null; +} + +const fmtNum = (v: string | number | null | undefined, decimals = 2) => { + const n = Number(v ?? 0); + if (!Number.isFinite(n)) return "0"; + return n.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); +}; + +const varianceTone = (v: string | number | null | undefined) => { + const n = Number(v ?? 0); + if (n === 0) return "text-emerald-700"; + if (n < 0) return "text-rose-700 font-semibold"; + return "text-amber-700 font-semibold"; +}; + +const fmtBusinessDate = (value: string) => { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleDateString(); +}; + +const isLocalDev = import.meta.env.DEV; + +export const OwnerOverview: React.FC = () => { + const { user } = useAuth(); + const { toast } = useToast(); + + const [shops, setShops] = useState([]); + const [zRows, setZRows] = useState([]); + const [scores, setScores] = useState([]); + const [alerts, setAlerts] = useState([]); + const [reports, setReports] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshKey, setRefreshKey] = useState(0); + const [submittingShopId, setSubmittingShopId] = useState(null); + const [selectedReportId, setSelectedReportId] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + const [shopRes, zRes, scoreRes, alertRes, reportRes] = await Promise.all([ + api.fromView("v_owner_dashboard"), + api.fromView("v_z_report"), + api.fromView("v_employee_scorecard_30d"), + api.fromView("alerts"), + api.fromView("v_end_of_day_reports"), + ]); + if (cancelled) return; + setShops(shopRes.data ?? []); + // Most recent first; cap to 25 closed shifts for the table. + const closed = (zRes.data ?? []) + .filter(r => r.status === "closed" && r.closed_at) + .sort((a, b) => (b.closed_at ?? "").localeCompare(a.closed_at ?? "")) + .slice(0, 25); + setZRows(closed); + setScores(scoreRes.data ?? []); + const open = (alertRes.data ?? []) + .filter(a => !a.acknowledged_at) + .sort((a, b) => b.created_at.localeCompare(a.created_at)); + setAlerts(open); + const recentReports = (reportRes.data ?? []) + .slice() + .sort((a, b) => b.submitted_at.localeCompare(a.submitted_at)); + setReports(recentReports); + setSelectedReportId(current => ( + current && recentReports.some(r => r.id === current) + ? current + : recentReports[0]?.id ?? null + )); + setLoading(false); + })(); + return () => { cancelled = true; }; + }, [refreshKey]); + + const ackAlert = async (id: string) => { + const { error } = await api.rpc("ack_alert", { + p_alert: id, + p_note: "Acknowledged from owner overview", + }); + if (error) { + toast({ title: "Could not acknowledge", description: error.message, variant: "destructive" }); + return; + } + setRefreshKey(k => k + 1); + }; + + const shopName = (id: string) => + shops.find(s => s.shop_id === id)?.shop_name + ?? user?.shops.find(s => s.shop_id === id)?.shop_name + ?? id.slice(0, 8); + + const latestReportForShop = (shopId: string) => + reports.find(r => r.shop_id === shopId) ?? null; + + const selectedReport = reports.find(r => r.id === selectedReportId) ?? null; + + const submitEndOfDay = async (shopId: string) => { + setSubmittingShopId(shopId); + const { error } = await api.rpc("submit_end_of_day", { + p_shop: shopId, + p_note: "Submitted from owner overview", + }); + if (error) { + toast({ title: "Could not submit end of day", description: error.message, variant: "destructive" }); + setSubmittingShopId(null); + return; + } + toast({ title: "End of day submitted", description: `${shopName(shopId)} counters reset for the next day.` }); + setSubmittingShopId(null); + setRefreshKey(k => k + 1); + }; + + const reopenLatestEndOfDayForTesting = async (shopId: string) => { + setSubmittingShopId(shopId); + const { error } = await api.post("/admin/dev/end_of_day/reopen_latest", { + shop_id: shopId, + }); + if (error) { + toast({ title: "Could not reset local EOD", description: error.message, variant: "destructive" }); + setSubmittingShopId(null); + return; + } + toast({ + title: "Local EOD reset", + description: `${shopName(shopId)} latest end-of-day report was moved back one day for another test pass.`, + }); + setSubmittingShopId(null); + setRefreshKey(k => k + 1); + }; + + return ( +
+ + + End of day + + +
+ Owner-only submit. This writes an append-only end-of-day report, captures the activity log for the current period, and resets dashboard counters from the submission time forward. +
+
+ {shops.map(s => { + const latest = latestReportForShop(s.shop_id); + return ( +
+
+
{s.shop_name}
+
+ {latest + ? `Last end of day: ${new Date(latest.submitted_at).toLocaleString()}` + : "No end-of-day report submitted yet."} +
+
+
+
Open shifts: {s.open_shifts}
+
Current gross USD: {fmtNum(s.today_gross_usd, 2)}
+
Current gross LBP: {fmtNum(s.today_gross_lbp, 0)}
+
+
+ + {isLocalDev && ( + + )} +
+
+ ); + })} +
+
+
+ + {/* Per-shop KPIs */} + + + Current day across your shops + + + +
+ Gross counters reset from the most recent end-of-day submission for each shop. If no report exists yet, they fall back to the current Beirut business day. +
+ {shops.length === 0 ? ( +
No shops visible.
+ ) : ( +
+ {shops.map(s => ( +
+
{s.shop_name}
+
+
Open shifts{s.open_shifts}
+
+ Open alerts + 0 ? "text-rose-700" : Number(s.open_alerts) > 0 ? "text-amber-700" : ""}`}> + {s.open_alerts}{Number(s.critical_alerts) > 0 ? ` (${s.critical_alerts}!)` : ""} + +
+
Recon exceptions{s.open_recon_exceptions}
+
Today gross USD{fmtNum(s.today_gross_usd, 2)}
+
Today gross LBP{fmtNum(s.today_gross_lbp, 0)}
+
Current period started{new Date(s.period_started_at).toLocaleString()}
+
+
+ ))} +
+ )} +
+
+ + {/* Open alerts */} + + + Open alerts ({alerts.length}) + + + {alerts.length === 0 ? ( +
No open alerts. Drawers are clean.
+ ) : ( + + + + When + Shop + Kind + Severity + Detail + Action + + + + {alerts.map(a => ( + + {new Date(a.created_at).toLocaleString()} + {shopName(a.shop_id)} + {a.kind} + + {a.severity} + + + {JSON.stringify(a.payload)} + + + + + + ))} + +
+ )} +
+
+ + {/* Recently closed shifts with variance */} + + + Recently closed shifts — variance + + + {zRows.length === 0 ? ( +
No closed shifts yet.
+ ) : ( + + + + Closed + Shop + Expected USD + Counted USD + Δ USD + Expected LBP + Counted LBP + Δ LBP + + + + {zRows.map(r => ( + + {r.closed_at ? new Date(r.closed_at).toLocaleString() : "—"} + {shopName(r.shop_id)} + {fmtNum(r.expected_close_usd, 2)} + {fmtNum(r.declared_close_usd, 2)} + {fmtNum(r.variance_usd, 2)} + {fmtNum(r.expected_close_lbp, 0)} + {fmtNum(r.declared_close_lbp, 0)} + {fmtNum(r.variance_lbp, 0)} + + ))} + +
+ )} +
+
+ + {/* 30-day cashier scorecard */} + + + Cashier scorecard (last 30 days) + + + {scores.length === 0 ? ( +
No closed shifts in the last 30 days.
+ ) : ( + + + + Cashier + Shop + Shifts + Σ Δ USD + Σ Δ LBP + Voids + + + + {scores + .slice() + .sort((a, b) => Math.abs(Number(b.total_var_usd ?? 0)) - Math.abs(Number(a.total_var_usd ?? 0))) + .map(s => ( + + {s.cashier_id.slice(0, 8)}… + {shopName(s.shop_id)} + {s.shifts_30d} + {fmtNum(s.total_var_usd, 2)} + {fmtNum(s.total_var_lbp, 0)} + {s.voids_30d} + + ))} + +
+ )} +
+
+ + + + Recent end-of-day reports + + + {reports.length === 0 ? ( +
No end-of-day reports yet.
+ ) : ( + + + + Business date + Shop + Submitted + By + Txns + Gross USD + Gross LBP + Activities + + + + {reports.map(r => ( + setSelectedReportId(r.id)} + > + {fmtBusinessDate(r.business_date)} + {shopName(r.shop_id)} + {new Date(r.submitted_at).toLocaleString()} + {r.submitted_by_name} + {r.completed_txn_count} + {fmtNum(r.gross_usd, 2)} + {fmtNum(r.gross_lbp, 0)} + {r.activity_count} + + ))} + +
+ )} +
+
+ + + + Activity log for selected report + + + {!selectedReport ? ( +
Select an end-of-day report to inspect the activity log.
+ ) : selectedReport.activity_log.length === 0 ? ( +
No activity events were captured in that reporting period.
+ ) : ( + + + + When + Event + Metadata + + + + {selectedReport.activity_log.map((entry, index) => ( + + {new Date(entry.occurred_at).toLocaleString()} + {entry.event_type} + + {JSON.stringify(entry.metadata)} + + + ))} + +
+ )} +
+
+
+ ); +}; + +export default OwnerOverview; diff --git a/src/components/ShiftControl.tsx b/src/components/ShiftControl.tsx new file mode 100644 index 0000000..f563863 --- /dev/null +++ b/src/components/ShiftControl.tsx @@ -0,0 +1,590 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/hooks/useAuth"; +import { supabase } from "@/integrations/supabase/client"; +import { FxSwapDialog, SelfDealOverrideDialog } from "@/components/CashierTools"; + +interface MyTill { + till_id: string; + shop_id: string; + name: string; + is_active: boolean; +} + +interface OpenShift { + shift_id: string; + till_id: string; + opened_at: string; + status: string; + opening_usd: number; + opening_lbp: number; + declared_at?: string | null; + declared_close_usd?: number | null; + declared_close_lbp?: number | null; +} + +interface LiveDrawerSummary { + shift_id: string; + expected_usd: number; + expected_lbp: number; + customer_in_usd: number; + customer_in_lbp: number; + payout_out_usd: number; + payout_out_lbp: number; + dropped_to_safe_usd: number; + dropped_to_safe_lbp: number; + fx_net_usd: number; + fx_net_lbp: number; + txn_count: number; + last_txn_at: string | null; +} + +function formatAmount(value: number, decimals: number) { + return value.toLocaleString(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +} + +/** + * ShiftControl + * + * Open / declare-close / finalize-close. The cashier MUST have an open + * shift before TransactionEntry can record anything (the RPC checks). + * For the owner, this also serves as a one-stop view of every till. + */ +export const ShiftControl: React.FC = () => { + const { user } = useAuth(); + const { toast } = useToast(); + + const [shopId, setShopId] = useState(""); + const [tills, setTills] = useState([]); + const [tillId, setTillId] = useState(""); + const [shopUsers, setShopUsers] = useState<{user_id: string, full_name: string, role: string}[]>([]); + const [assignedUserId, setAssignedUserId] = useState(""); + const [openShift, setOpenShift] = useState(null); + + const [openingUsd, setOpeningUsd] = useState(""); + const [openingLbp, setOpeningLbp] = useState(""); + + const [declaredUsd, setDeclaredUsd] = useState(""); + const [declaredLbp, setDeclaredLbp] = useState(""); + + const [dropUsd, setDropUsd] = useState(""); + const [dropLbp, setDropLbp] = useState(""); + + const [fxOpen, setFxOpen] = useState(false); + const [overrideOpen, setOverrideOpen] = useState(false); + + const [busy, setBusy] = useState(false); + const [drawerSummary, setDrawerSummary] = useState(null); + + // Result of the most recent finalize_close, used to display + // Expected / Declared / Variance to the cashier and manager. + const [closeResult, setCloseResult] = useState<{ + expected_usd: number; + expected_lbp: number; + declared_usd: number; + declared_lbp: number; + variance_usd: number; + variance_lbp: number; + } | null>(null); + + const shops = user?.shops ?? []; + const selectedShopRole = shops.find((shop) => shop.shop_id === shopId)?.role; + const canAssignShift = selectedShopRole === "owner" || selectedShopRole === "manager"; + useEffect(() => { + if (shops.length === 1) setShopId(shops[0].shop_id); + }, [shops]); + + // Load tills for the chosen shop + useEffect(() => { + if (!shopId) { setShopUsers([]); setAssignedUserId(""); return; } + let cancelled = false; + (async () => { + const { data, error } = await supabase + .rpc('get_shop_users', { p_shop_id: shopId }); + + if (cancelled) return; + if (error) { + console.error("Could not load shop users:", error); + return; + } + + const formattedUsers = (data || []).map((row: any) => ({ + user_id: row.user_id, + full_name: row.full_name || 'Unknown', + role: row.role + })); + setShopUsers(formattedUsers); + + // Auto-assign to current user if they are in the list + if (user && formattedUsers.some(u => u.user_id === user.id)) { + setAssignedUserId(user.id); + } else if (formattedUsers.length > 0) { + setAssignedUserId(formattedUsers[0].user_id); + } + })(); + return () => { cancelled = true; }; + }, [shopId, user]); + + useEffect(() => { + if (!shopId) { setTills([]); setTillId(""); return; } + let cancelled = false; + (async () => { + const { data, error } = await supabase + .from("v_my_tills") + .select("till_id, shop_id, name, is_active") + .eq("shop_id", shopId); + if (cancelled) return; + if (error) { + toast({ title: "Could not load tills", description: error.message, + variant: "destructive" }); + return; + } + setTills((data ?? []) as MyTill[]); + if (data && data.length === 1) setTillId(data[0].till_id); + })(); + return () => { cancelled = true; }; + }, [shopId, toast]); + + // Refresh open-shift status for caller + const refreshShift = useCallback(async () => { + if (!shopId) { setOpenShift(null); return; } + const { data } = await supabase.rpc("my_active_shift", { p_shop: shopId }); + const row = Array.isArray(data) + ? (data[0] as OpenShift | undefined) ?? null + : ((data as OpenShift | null) ?? null); + setOpenShift(row); + if (row) setTillId(row.till_id); + }, [shopId]); + + const refreshDrawer = useCallback(async (shiftId?: string) => { + if (!shiftId) { + setDrawerSummary(null); + return; + } + const { data, error } = await supabase.rpc("live_drawer", { p_shift_id: shiftId }); + if (error) { + toast({ + title: "Could not load live drawer", + description: error.message, + variant: "destructive", + }); + return; + } + const row = Array.isArray(data) + ? (data[0] as LiveDrawerSummary | undefined) ?? null + : ((data as LiveDrawerSummary | null) ?? null); + setDrawerSummary(row); + }, [toast]); + + useEffect(() => { refreshShift(); }, [refreshShift]); + + useEffect(() => { + if (!openShift) { + setDrawerSummary(null); + return; + } + refreshDrawer(openShift.shift_id); + if (openShift.status !== "open") return; + const timer = window.setInterval(() => { + refreshDrawer(openShift.shift_id); + }, 15000); + return () => window.clearInterval(timer); + }, [openShift, refreshDrawer]); + + const handleOpen = async () => { + if (!tillId) { + toast({ title: "Pick a till", variant: "destructive" }); + return; + } + setBusy(true); + try { + const assignedUserParam = canAssignShift && assignedUserId && assignedUserId !== user?.id + ? assignedUserId + : undefined; + const { error } = await supabase.rpc("open_shift", { + p_till_id: tillId, + p_opening_usd: Number.parseFloat(openingUsd) || 0, + p_opening_lbp: Number.parseFloat(openingLbp) || 0, + p_assigned_user_id: assignedUserParam, + }); + if (error) throw error; + toast({ title: "Shift opened" }); + setOpeningUsd(""); setOpeningLbp(""); + await refreshShift(); + } catch (e) { + toast({ title: "Could not open shift", + description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + const handleDeclare = async () => { + if (!openShift) return; + if (!declaredUsd.trim() && !declaredLbp.trim()) { + toast({ + title: "Count required", + description: "Enter the counted USD and/or LBP before declaring close.", + variant: "destructive", + }); + return; + } + setBusy(true); + try { + const { error } = await supabase.rpc("declare_close", { + p_shift_id: openShift.shift_id, + p_declared_close_usd: Number.parseFloat(declaredUsd) || 0, + p_declared_close_lbp: Number.parseFloat(declaredLbp) || 0, + }); + if (error) throw error; + toast({ title: "Declared. Finalize when ready." }); + await refreshDrawer(openShift.shift_id); + await refreshShift(); + } catch (e) { + toast({ title: "Could not declare close", + description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + + const handleDrop = async () => { + if (!openShift) return; + if (!dropUsd && !dropLbp) return; + setBusy(true); + try { + const { error } = await supabase.rpc("record_cash_drop", { + p_shift_id: openShift.shift_id, + p_drop_usd: Number.parseFloat(dropUsd) || 0, + p_drop_lbp: Number.parseFloat(dropLbp) || 0, + p_notes: "Mid-day safe drop", + }); + if (error) throw error; + toast({ title: "Safe drop recorded successfully" }); + setDropUsd(""); setDropLbp(""); + await refreshDrawer(openShift.shift_id); + await refreshShift(); + } catch (e) { + toast({ title: "Could not record drop", + description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + const handleFinalize = async () => { + if (!openShift) return; + setBusy(true); + const declared_usd = Number(declaredUsd) || 0; + const declared_lbp = Number(declaredLbp) || 0; + try { + const { data, error } = await supabase.rpc("finalize_close", { + p_shift_id: openShift.shift_id, + }); + if (error) throw error; + // finalize_close returns a single row { expected_usd, expected_lbp, variance_usd, variance_lbp } + const row = (Array.isArray(data) ? data[0] : data) as + | { expected_usd?: number; expected_lbp?: number; variance_usd?: number; variance_lbp?: number } + | null; + if (row) { + setCloseResult({ + expected_usd: Number(row.expected_usd ?? 0), + expected_lbp: Number(row.expected_lbp ?? 0), + declared_usd, + declared_lbp, + variance_usd: Number(row.variance_usd ?? 0), + variance_lbp: Number(row.variance_lbp ?? 0), + }); + } + toast({ title: "Shift closed" }); + setDeclaredUsd(""); setDeclaredLbp(""); + setDrawerSummary(null); + await refreshShift(); + } catch (e) { + toast({ title: "Could not finalize", + description: (e as Error).message, variant: "destructive" }); + } finally { setBusy(false); } + }; + + return ( + + + Shift control + + +
+
+ + +
+
+ + +
+ {!openShift && canAssignShift && ( +
+ + +
+ )} +
+ + {openShift ? ( +
+
+ Open shift: till{" "} + {openShift.till_id.slice(0, 8)}… opened{" "} + {new Date(openShift.opened_at).toLocaleString()} · status{" "} + {openShift.status} +
+ Opening float: USD {openShift.opening_usd} / LBP{" "} + {openShift.opening_lbp} +
+ + {drawerSummary && ( +
+
+
+

Live drawer now

+ + {drawerSummary.txn_count} txns + +
+
+
+
Expected USD
+
+ {formatAmount(drawerSummary.expected_usd, 2)} +
+
+
+
Expected LBP
+
+ {formatAmount(drawerSummary.expected_lbp, 0)} +
+
+
+
+ Last transaction: {drawerSummary.last_txn_at ? new Date(drawerSummary.last_txn_at).toLocaleString() : "No completed transactions yet"} +
+
+ +
+

What moved this drawer

+
+
+ Customer in USD + {formatAmount(drawerSummary.customer_in_usd, 2)} +
+
+ Customer in LBP + {formatAmount(drawerSummary.customer_in_lbp, 0)} +
+
+ Payout out USD + {formatAmount(drawerSummary.payout_out_usd, 2)} +
+
+ Payout out LBP + {formatAmount(drawerSummary.payout_out_lbp, 0)} +
+
+ Dropped to safe USD + {formatAmount(drawerSummary.dropped_to_safe_usd, 2)} +
+
+ Dropped to safe LBP + {formatAmount(drawerSummary.dropped_to_safe_lbp, 0)} +
+
+ FX net USD + {formatAmount(drawerSummary.fx_net_usd, 2)} +
+
+ FX net LBP + {formatAmount(drawerSummary.fx_net_lbp, 0)} +
+
+
+
+ )} + + {openShift.status === "open" ? ( + <> +
+

Mid-Day Safe Drop

+

+ Transfer large sums of money out of the drawer and into the shop safe to secure it. This removes it from your end-of-shift expected count. +

+
+
+ + setDropUsd(e.target.value)} /> +
+
+ + setDropLbp(e.target.value)} /> +
+ +
+
+ +
+ + +
+ +
+
+ + setDeclaredUsd(e.target.value)} /> +
+
+ + setDeclaredLbp(e.target.value)} /> +
+ +
+ + ) : ( +
+
+
Close declared
+
+
Counted USD: {formatAmount(Number(openShift.declared_close_usd ?? 0), 2)}
+
Counted LBP: {formatAmount(Number(openShift.declared_close_lbp ?? 0), 0)}
+
+

+ The drawer count is locked. Finalize to reveal the expected cash and variance. +

+
+ +
+ )} +
+ ) : closeResult ? ( +
+
+

Last shift close — variance summary

+ +
+
+ {([ + { label: "USD", expected: closeResult.expected_usd, declared: closeResult.declared_usd, variance: closeResult.variance_usd, decimals: 2 }, + { label: "LBP", expected: closeResult.expected_lbp, declared: closeResult.declared_lbp, variance: closeResult.variance_lbp, decimals: 0 }, + ] as const).map((r) => { + const fmt = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: r.decimals, maximumFractionDigits: r.decimals }); + const tone = + r.variance === 0 ? "text-emerald-700 bg-emerald-50 border-emerald-200" + : r.variance < 0 ? "text-rose-700 bg-rose-50 border-rose-200" + : "text-amber-700 bg-amber-50 border-amber-200"; + const label = + r.variance === 0 ? "Match" + : r.variance < 0 ? "SHORT" + : "OVER"; + return ( +
+
{r.label}
+
Expected{fmt(r.expected)}
+
Counted{fmt(r.declared)}
+
+ Δ {label}{fmt(r.variance)} +
+
+ ); + })} +
+

+ Negative Δ = drawer is short of what the system expected. + Positive Δ = drawer has more than expected. Anything non-zero + should be investigated before the cashier leaves. +

+
+ ) : ( +
+
+
+ + setOpeningUsd(e.target.value)} /> +
+
+ + setOpeningLbp(e.target.value)} /> +
+ +
+

+ Count the drawer cash before opening. The amounts + you declare here become the audit baseline for this shift. +

+
+ )} +
+ setFxOpen(false)} + shopId={shopId} + tillId={openShift?.till_id ?? tillId} + onDone={refreshShift} + /> + setOverrideOpen(false)} + shopId={shopId} + /> +
+ ); +}; diff --git a/src/components/TransactionEntry.tsx b/src/components/TransactionEntry.tsx new file mode 100644 index 0000000..8f53253 --- /dev/null +++ b/src/components/TransactionEntry.tsx @@ -0,0 +1,1223 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, SelectContent, SelectGroup, SelectItem, SelectLabel, + SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/hooks/use-toast"; +import { useAuth } from "@/hooks/useAuth"; +import { supabase } from "@/integrations/supabase/client"; +import { + SERVICES_BY_CATEGORY, CATEGORY_LABEL, findService, + type ServiceCategory, type ServiceDef, +} from "@/lib/services"; + +interface TransactionEntryProps { + isOpen: boolean; + onClose: () => void; + onCreated?: (txnId: string) => void; +} + +interface MyTill { + till_id: string; + shop_id: string; + name: string; + is_active: boolean; +} + +interface ProductCatalogItem { + id: string; + service_code: string; + product_code: string; + name: string; + unit_cost_usd: number; + unit_face_usd: number; + unit_cost_lbp?: number; + unit_face_lbp?: number; +} + +interface OpenShift { + shift_id: string; + till_id: string; + opened_at: string; +} + +interface RecentTransactionRow { + id: string; + reference_no: number; + service_code: string; + service_name: string; + payment_method: PaymentMethod; + gross_usd: number; + gross_lbp: number; + external_ref: string | null; + occurred_at: string; +} + +interface ReceiptPrintRow { + receipt_id: string; + qr_token: string; + pdf_url: string | null; +} + +interface TransactionConfirmation { + txnId: string; + referenceNo: number | null; + serviceName: string; + paymentMethod: PaymentMethod; + grossUsd: number; + grossLbp: number; + externalRef: string | null; + occurredAt: string | null; + receiptId: string | null; + receiptToken: string | null; + receiptPdfUrl: string | null; +} + +// Real `app.payment_method` enum values from migration 0003. +const PAYMENT_METHODS = [ + "cash_usd", "cash_lbp", "whish", "omt_wallet", "card", "bank_transfer", +] as const; +type PaymentMethod = typeof PAYMENT_METHODS[number]; + +const PAYMENT_METHOD_LABELS: Record = { + cash_usd: "Cash USD", + cash_lbp: "Cash LBP", + whish: "Whish", + omt_wallet: "OMT Wallet", + card: "Card", + bank_transfer: "Bank transfer", +}; + +const ID_DOC_TYPES = [ + "lebanese_id", "passport", "residence_permit", "driver_license", "other", +] as const; + +const QUICK_SERVICES: Array<{ code: string; icon: string; accent: string; label: string; note: string }> = [ + { code: "OMT_SEND", icon: "💸", accent: "bg-blue-50 hover:bg-blue-100 border-blue-200 text-blue-700", label: "OMT Send", note: "Take cash, then capture sender and beneficiary." }, + { code: "OMT_RECEIVE", icon: "🏦", accent: "bg-emerald-50 hover:bg-emerald-100 border-emerald-200 text-emerald-700", label: "OMT Receive", note: "Confirm payout code and beneficiary ID first." }, + { code: "WHISH_SEND", icon: "📱", accent: "bg-purple-50 hover:bg-purple-100 border-purple-200 text-purple-700", label: "Whish Transfer", note: "Fast wallet send with sender checks." }, + { code: "ALFA_RECHARGE", icon: "📡", accent: "bg-indigo-50 hover:bg-indigo-100 border-indigo-200 text-indigo-700", label: "Alfa Recharge", note: "Use the catalog product for faster entry." }, + { code: "TOUCH_RECHARGE", icon: "📞", accent: "bg-orange-50 hover:bg-orange-100 border-orange-200 text-orange-700", label: "Touch Recharge", note: "Voucher or e-recharge reference required." }, + { code: "EDL_BILL", icon: "⚡", accent: "bg-slate-50 hover:bg-slate-100 border-slate-200 text-slate-700", label: "EDL Bill", note: "Reference, biller, and account number required." }, +]; + +const SERVICE_GUIDANCE: Record = { + OMT_SEND: { + description: "Collect the cash first, then capture sender identity and beneficiary details before saving.", + required: ["Amount", "Sender ID", "Sender phone", "Beneficiary", "Reference"], + }, + OMT_RECEIVE: { + description: "No cash should leave the drawer until the payout code and beneficiary ID are confirmed.", + required: ["Payout code", "Beneficiary", "Beneficiary ID", "Amount"], + }, + WHISH_SEND: { + description: "Treat this like a money transfer: sender checks, beneficiary details, and provider reference are all mandatory.", + required: ["Amount", "Sender ID", "Sender phone", "Beneficiary", "Reference"], + }, + ALFA_RECHARGE: { + description: "Choose the product from the catalog when possible so the face value drops straight into the amount field.", + required: ["Operator", "Subscriber number", "Product", "Voucher or e-ref"], + }, + TOUCH_RECHARGE: { + description: "Choose the product from the catalog when possible so the face value drops straight into the amount field.", + required: ["Operator", "Subscriber number", "Product", "Voucher or e-ref"], + }, + EDL_BILL: { + description: "Check the account number, biller code, and customer reference before collecting the payment.", + required: ["Amount", "Account number", "Reference", "Biller"], + }, +}; + +const ACTION_LABELS: Record = { + OMT_SEND: "Record OMT send", + OMT_RECEIVE: "Record OMT receive", + WHISH_SEND: "Record Whish send", + ALFA_RECHARGE: "Record Alfa recharge", + TOUCH_RECHARGE: "Record Touch recharge", + EDL_BILL: "Record EDL bill", + OMT_BILL: "Record bill payment", + GOODS_SALE: "Record goods sale", + REPAIR: "Record repair", +}; + +function num(s: string): number { + const v = Number.parseFloat(s); + return Number.isFinite(v) ? v : 0; +} + +/** + * Single entry point used by both shop owner and cashier. Picks the + * service category, then renders only the fields that category requires + * and dispatches to the matching record_* RPC. + */ +export const TransactionEntry: React.FC = ({ + isOpen, onClose, onCreated, +}) => { + const { user } = useAuth(); + const { toast } = useToast(); + + const shops = user?.shops ?? []; + const [shopId, setShopId] = useState(""); + const [tills, setTills] = useState([]); + const [tillId, setTillId] = useState(""); + const [shift, setShift] = useState(null); + const [catalogItems, setCatalogItems] = useState([]); + + const [serviceCode, setServiceCode] = useState(""); + const service: ServiceDef | undefined = useMemo( + () => findService(serviceCode), [serviceCode]); + + // Money + const [paymentMethod, setPaymentMethod] = useState("cash_usd"); + const [grossUsd, setGrossUsd] = useState(""); + const [grossLbp, setGrossLbp] = useState(""); + const [feeUsd, setFeeUsd] = useState(""); + const [feeLbp, setFeeLbp] = useState(""); + const [commissionUsd, setCommissionUsd] = useState(""); + const [commissionLbp, setCommissionLbp] = useState(""); + const [fxRate, setFxRate] = useState("89500"); + + // OMT / WU send + const [direction, setDirection] = useState<"domestic"|"international">("domestic"); + const [senderName, setSenderName] = useState(""); + const [senderIdType, setSenderIdType] = useState("lebanese_id"); + const [senderIdNumber, setSenderIdNumber] = useState(""); + const [senderPhone, setSenderPhone] = useState(""); + const [destinationCountry, setDestinationCountry] = useState(""); + const [purposeCode, setPurposeCode] = useState("family_support"); + const [beneficiaryName, setBeneficiaryName] = useState(""); + const [beneficiaryPhone, setBeneficiaryPhone] = useState(""); + const [externalRef, setExternalRef] = useState(""); + + // OMT receive + const [payoutCode, setPayoutCode] = useState(""); + const [recvIdType, setRecvIdType] = useState("lebanese_id"); + const [recvIdNumber, setRecvIdNumber] = useState(""); + const [originCountry, setOriginCountry] = useState(""); + + // Bills + const [billerCode, setBillerCode] = useState(""); + const [accountNumber, setAccountNumber] = useState(""); + const [billPeriod, setBillPeriod] = useState(""); + + // Recharge + const [operator, setOperator] = useState(""); + const [msisdn, setMsisdn] = useState(""); + const [productCode, setProductCode] = useState(""); + const [voucherSerial, setVoucherSerial] = useState(""); + const [erechargeRef, setErechargeRef] = useState(""); + const [unitFaceUsd, setUnitFaceUsd] = useState(""); + const [unitCostUsd, setUnitCostUsd] = useState(""); + + // Goods sale + const [sku, setSku] = useState(""); + const [qty, setQty] = useState("1"); + const [unitPriceUsd, setUnitPriceUsd] = useState(""); + const [goodsUnitCostUsd, setGoodsUnitCostUsd] = useState(""); + const [serialNumber, setSerialNumber] = useState(""); + + // Repair + const [deviceType, setDeviceType] = useState(""); + const [deviceImei, setDeviceImei] = useState(""); + const [issueSummary, setIssueSummary] = useState(""); + const [warrantyDays, setWarrantyDays] = useState("0"); + + const [notes, setNotes] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [lastRecorded, setLastRecorded] = useState(null); + const [isClosing, setIsClosing] = useState(false); + + // Auto-select if the user has a single shop. + useEffect(() => { if (shops.length === 1) setShopId(shops[0].shop_id); }, + [shops]); + + + // Fetch active products for the shop + useEffect(() => { + if (!shopId) { + setCatalogItems([]); + return; + } + let cancelled = false; + (async () => { + const { data, error } = await supabase + .from("products") + .select("*") + .eq("shop_id", shopId) + .eq("is_active", true); + if (cancelled) return; + if (!error && data) { + setCatalogItems(data as ProductCatalogItem[]); + } + })(); + return () => { cancelled = true; }; + }, [shopId]); + + + // Default operator from selected service + useEffect(() => { + if (!service) return; + if (service.code === "ALFA_RECHARGE") setOperator("ALFA"); + else if (service.code === "TOUCH_RECHARGE") setOperator("TOUCH"); + else if (service.code === "OGERO_RECHARGE") setOperator("OGERO"); + else if (service.code === "INTERNET_RECHARGE") setOperator(""); + if (service.code === "EDL_BILL") setBillerCode("EDL"); + if (service.code === "OMT_BILL") setBillerCode(""); + }, [service]); + + // Load tills for the chosen shop + useEffect(() => { + if (!isOpen || !shopId) { setTills([]); setTillId(""); return; } + let cancelled = false; + (async () => { + const { data, error } = await supabase + .from("v_my_tills") + .select("till_id, shop_id, name, is_active") + .eq("shop_id", shopId); + if (cancelled) return; + if (error) { toast({ title: "Could not load tills", + description: error.message, variant: "destructive" }); + return; } + setTills((data ?? []) as MyTill[]); + if (data && data.length === 1) setTillId(data[0].till_id); + })(); + return () => { cancelled = true; }; + }, [isOpen, shopId, toast]); + + // Open-shift status + useEffect(() => { + if (!isOpen || !shopId) { setShift(null); return; } + let cancelled = false; + (async () => { + const { data } = await supabase.rpc("my_active_shift", { p_shop: shopId }); + if (cancelled) return; + const row = Array.isArray(data) + ? (data[0] as OpenShift | undefined) ?? null + : ((data as OpenShift | null) ?? null); + setShift(row); + if (row) setTillId(row.till_id); + })(); + return () => { cancelled = true; }; + }, [isOpen, shopId]); + + const reset = () => { + setLastRecorded(null); + setServiceCode(""); + setPaymentMethod("cash_usd"); + setGrossUsd(""); setGrossLbp(""); + setFeeUsd(""); setFeeLbp(""); + setCommissionUsd(""); setCommissionLbp(""); + setFxRate(""); + setOperator(""); + setSenderName(""); setSenderIdNumber(""); setSenderPhone(""); + setDestinationCountry(""); setBeneficiaryName(""); setBeneficiaryPhone(""); + setExternalRef(""); + setPayoutCode(""); setRecvIdNumber(""); setOriginCountry(""); + setBillerCode(""); setAccountNumber(""); setBillPeriod(""); + setMsisdn(""); setProductCode(""); setVoucherSerial(""); setErechargeRef(""); + setUnitFaceUsd(""); setUnitCostUsd(""); + setSku(""); setQty("1"); setUnitPriceUsd(""); setGoodsUnitCostUsd(""); + setSerialNumber(""); + setDeviceType(""); setDeviceImei(""); setIssueSummary(""); setWarrantyDays("0"); + setNotes(""); + }; + + useEffect(() => { + if (isOpen) { + setIsClosing(false); + return; + } + + reset(); + }, [isOpen]); + + const handleClose = () => { + setIsClosing(true); + onClose(); + }; + + const recordAnother = () => { + reset(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!shopId || !service || !shift || !tillId) { + toast({ + title: "Missing context", + description: !shift + ? "You have no open shift in this shop. Open a shift first." + : "Pick a shop, till and service.", + variant: "destructive", + }); + return; + } + if (num(grossUsd) === 0 && num(grossLbp) === 0) { + toast({ title: "Amount required", + description: "Enter a gross USD or LBP amount.", + variant: "destructive" }); + return; + } + + setSubmitting(true); + try { + let rpc: { fn: string; args: Record }; + + switch (service.code) { + case "ALFA_RECHARGE": + case "TOUCH_RECHARGE": + case "OGERO_RECHARGE": + case "INTERNET_RECHARGE": + if (!voucherSerial.trim() && !erechargeRef.trim()) { + throw new Error("Either voucher serial or e-recharge reference is required."); + } + if (!msisdn.trim() || !productCode.trim() || !operator.trim()) { + throw new Error("Operator, MSISDN and product code are required."); + } + rpc = { + fn: "record_recharge", + args: { + p_shop: shopId, p_till: tillId, p_service_code: service.code, + p_payment_method: paymentMethod, + p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), + p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), + p_fx_rate: num(fxRate) || null, + p_operator: operator, p_msisdn: msisdn, + p_product_code: productCode, + p_voucher_serial: voucherSerial || null, + p_e_recharge_ref: erechargeRef || null, + p_unit_face_usd: num(unitFaceUsd) || null, + p_unit_cost_usd: num(unitCostUsd) || null, + p_notes: notes || null, + }, + }; + break; + + case "OMT_SEND": + case "WU_SEND": + if (!senderName.trim() || !senderIdNumber.trim() + || !senderPhone.trim() || !beneficiaryName.trim() + || !externalRef.trim() || !purposeCode.trim()) { + throw new Error("Sender ID, phone, beneficiary, ref and purpose are required."); + } + rpc = { + fn: "record_omt_send", + args: { + p_shop: shopId, p_till: tillId, + p_payment_method: paymentMethod, + p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), + p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), + p_commission_usd: num(commissionUsd), + p_commission_lbp: num(commissionLbp), + p_fx_rate: num(fxRate) || null, + p_external_ref: externalRef, + p_direction: direction, + p_sender_full_name: senderName, + p_sender_id_type: senderIdType, + p_sender_id_number: senderIdNumber, + p_sender_phone: senderPhone, + p_sender_dob: null, + p_sender_nationality: null, + p_beneficiary_full_name: beneficiaryName, + p_beneficiary_phone: beneficiaryPhone || null, + p_destination_country: + direction === "international" ? destinationCountry : null, + p_purpose_code: purposeCode, + p_purpose_note: null, + p_kyc_doc_url: null, + p_customer_id: null, + p_notes: notes || null, + p_service_code: service.code, + }, + }; + break; + + case "WHISH_SEND": + if (!senderName.trim() || !senderIdNumber.trim() + || !senderPhone.trim() || !beneficiaryName.trim() + || !externalRef.trim() || !purposeCode.trim()) { + throw new Error("Sender ID, phone, beneficiary, ref and purpose are required."); + } + rpc = { + fn: "record_whish_send", + args: { + p_shop: shopId, p_till: tillId, + p_payment_method: paymentMethod, + p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), + p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), + p_commission_usd: num(commissionUsd), + p_commission_lbp: num(commissionLbp), + p_fx_rate: num(fxRate) || null, + p_external_ref: externalRef, + p_direction: direction, + p_sender_full_name: senderName, + p_sender_id_type: senderIdType, + p_sender_id_number: senderIdNumber, + p_sender_phone: senderPhone, + p_beneficiary_full_name: beneficiaryName, + p_beneficiary_phone: beneficiaryPhone || null, + p_purpose_code: purposeCode, + p_purpose_note: null, + p_kyc_doc_url: null, + p_customer_id: null, + p_notes: notes || null, + }, + }; + break; + + case "OMT_RECEIVE": + case "WU_RECEIVE": + if (!payoutCode.trim() || !beneficiaryName.trim() + || !recvIdNumber.trim()) { + throw new Error("Payout code, beneficiary name and ID number are required."); + } + rpc = { + fn: "record_omt_receive", + args: { + p_shop: shopId, p_till: tillId, + p_payment_method: paymentMethod, + p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), + p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), + p_commission_usd: num(commissionUsd), + p_commission_lbp: num(commissionLbp), + p_fx_rate: num(fxRate) || null, + p_payout_code: payoutCode, + p_beneficiary_full_name: beneficiaryName, + p_beneficiary_id_type: recvIdType, + p_beneficiary_id_number: recvIdNumber, + p_beneficiary_phone: beneficiaryPhone || null, + p_origin_country: originCountry || null, + p_kyc_doc_url: null, + p_customer_id: null, + p_notes: notes || null, + p_service_code: service.code, + }, + }; + break; + + case "OMT_BILL": + case "EDL_BILL": + if (!billerCode.trim() || !accountNumber.trim() + || !externalRef.trim()) { + throw new Error("Biller, account number and reference are required."); + } + rpc = { + fn: "record_bill", + args: { + p_shop: shopId, p_till: tillId, p_service_code: service.code, + p_payment_method: paymentMethod, + p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), + p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), + p_fx_rate: num(fxRate) || null, + p_external_ref: externalRef, + p_biller_code: billerCode, + p_account_number: accountNumber, + p_period: billPeriod || null, + p_customer_name: beneficiaryName || null, + p_customer_id: null, + p_notes: notes || null, + }, + }; + break; + + case "GOODS_SALE": + if (!sku.trim() || num(unitPriceUsd) <= 0) { + throw new Error("SKU and unit price are required."); + } + rpc = { + fn: "record_goods_sale", + args: { + p_shop: shopId, p_till: tillId, + p_payment_method: paymentMethod, + p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), + p_fx_rate: num(fxRate) || null, + p_sku: sku, p_qty: Math.max(1, parseInt(qty, 10) || 1), + p_unit_cost_usd: num(goodsUnitCostUsd), + p_unit_price_usd: num(unitPriceUsd), + p_serial_number: serialNumber || null, + p_customer_id: null, + p_notes: notes || null, + }, + }; + break; + + case "REPAIR": + if (!deviceType.trim() || !issueSummary.trim()) { + throw new Error("Device type and issue summary are required."); + } + rpc = { + fn: "record_repair", + args: { + p_shop: shopId, p_till: tillId, + p_payment_method: paymentMethod, + p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), + p_fx_rate: num(fxRate) || null, + p_device_type: deviceType, + p_device_imei: deviceImei || null, + p_issue_summary: issueSummary, + p_warranty_days: parseInt(warrantyDays, 10) || 0, + p_customer_id: null, + p_notes: notes || null, + }, + }; + break; + + case "REFUND": + throw new Error( + "Refunds must be issued from the original transaction, not entered manually."); + + default: + throw new Error(`Unsupported service: ${service.code}`); + } + + const { data, error } = await supabase.rpc(rpc.fn, rpc.args); + if (error) throw error; + + const txnId = data as string; + const recentRes = await supabase + .from("v_my_recent_transactions") + .select("*") + .eq("id", txnId); + const recentRow = recentRes.data?.[0]; + const receiptRes = await supabase.rpc( + "record_receipt_print", + { p_txn_id: txnId, p_kind: "original", p_device: "cashier-ui" }, + ); + const receiptRow = Array.isArray(receiptRes.data) + ? receiptRes.data[0] + : receiptRes.data; + + setLastRecorded({ + txnId, + referenceNo: recentRow?.reference_no ?? null, + serviceName: recentRow?.service_name ?? service?.label ?? service.code, + paymentMethod: recentRow?.payment_method ?? paymentMethod, + grossUsd: Number(recentRow?.gross_usd ?? num(grossUsd)), + grossLbp: Number(recentRow?.gross_lbp ?? num(grossLbp)), + externalRef: (recentRow?.external_ref ?? externalRef) || null, + occurredAt: recentRow?.occurred_at ?? null, + receiptId: receiptRow?.receipt_id ?? null, + receiptToken: receiptRow?.qr_token ?? null, + receiptPdfUrl: receiptRow?.pdf_url ?? null, + }); + + toast({ + title: "Transaction recorded", + description: receiptRes.error + ? "Transaction saved, but the signed receipt log still needs attention." + : recentRow?.reference_no + ? `Receipt #${recentRow.reference_no} is ready.` + : "Transaction saved successfully.", + }); + onCreated?.(txnId); + } catch (err) { + toast({ + title: "Could not record transaction", + description: (err as Error).message, + variant: "destructive", + }); + } finally { + setSubmitting(false); + } + }; + + const cat: ServiceCategory | undefined = service?.category; + const canStartTransaction = Boolean(shopId && tillId && shift); + const serviceGuidance = service ? SERVICE_GUIDANCE[service.code] : null; + const submitLabel = service ? ACTION_LABELS[service.code] ?? "Record transaction" : "Record transaction"; + + if (!isOpen && isClosing) return null; + + return ( + { if (!open) handleClose(); }}> + + + + New transaction + + + Pick the service category, then fill the fields it requires. + The server assigns the receipt number and chains the row into + the per-shop ledger. + + + + {lastRecorded ? ( +
+
+
+
+

Transaction saved

+

+ The server accepted the transaction and chained it into the ledger. +

+
+
+
Receipt
+
+ {lastRecorded.referenceNo ?? "Pending"} +
+
+
+ +
+
+
Service
+
{lastRecorded.serviceName}
+
+
+
Payment method
+
{PAYMENT_METHOD_LABELS[lastRecorded.paymentMethod] ?? lastRecorded.paymentMethod}
+
+
+
Amount
+
+ USD {lastRecorded.grossUsd.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + {lastRecorded.grossLbp > 0 ? ` · LBP ${lastRecorded.grossLbp.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : ""} +
+
+
+
External reference
+
{lastRecorded.externalRef || "Not captured"}
+
+
+
Signed receipt log
+
+ {lastRecorded.receiptId ? "Logged and ready for verification" : "Not logged yet"} +
+
+
Receipt log ID: {lastRecorded.receiptId ?? "Unavailable"}
+
QR token: {lastRecorded.receiptToken ?? "Unavailable"}
+
PDF: {lastRecorded.receiptPdfUrl ?? "Not generated yet"}
+
+
+
+ +
+ Transaction ID: {lastRecorded.txnId} + {lastRecorded.occurredAt ? ` · ${new Date(lastRecorded.occurredAt).toLocaleString()}` : ""} +
+
+ +
+ + +
+
+ ) : ( + + + {/* Shop / Till / Open shift */} +
+
+ + +
+
+ + +
+
+ +
+ {shift + ? `opened ${new Date(shift.opened_at).toLocaleTimeString()}` + : shopId ? "open one in Shift Control" : "—"} +
+
+
+ +
+ {canStartTransaction + ? "Ready for cashier entry. Pick a service and record the customer-facing transaction." + : "Pick the shop and till, then open your shift in Shift Control before starting customer transactions."} +
+ + {/* Quick Action POS UI */} + {!serviceCode ? ( +
+
+ {QUICK_SERVICES.map((quickService) => ( + + ))} +
+
+ +
+ +
+
+
+ ) : ( +
+
+
+ {cat} + {service?.label} +
+ +
+ {serviceGuidance && ( +
+
+

Before you save

+

{serviceGuidance.description}

+
+
+ {serviceGuidance.required.map((item) => ( + + {item} + + ))} +
+
+ )} + {/* Money */} +
+
+ + setGrossUsd(e.target.value)} /> +
+
+ + setGrossLbp(e.target.value)} /> +
+ {(cat === "money_transfer" || cat === "telecom_recharge" + || cat === "bills") && ( + <> +
+ + setFeeUsd(e.target.value)} /> +
+
+ + setFeeLbp(e.target.value)} /> +
+ + )} + {cat === "money_transfer" && ( + <> +
+ + setCommissionUsd(e.target.value)} /> +
+
+ + setCommissionLbp(e.target.value)} /> +
+ + )} +
+ +
+
+ + +
+ {/* FX rate removed to prevent manipulation */} +
+ + {/* Money-transfer SEND fields */} + {(service?.code === "OMT_SEND" || service?.code === "WU_SEND" + || service?.code === "WHISH_SEND") && ( +
+
Sender / Beneficiary
+
+
+ + +
+
+ + setExternalRef(e.target.value)} /> +
+
+ + setSenderName(e.target.value)} /> +
+
+ + setSenderPhone(e.target.value)} /> +
+
+ + +
+
+ + setSenderIdNumber(e.target.value)} /> +
+
+ + setBeneficiaryName(e.target.value)} /> +
+
+ + setBeneficiaryPhone(e.target.value)} /> +
+ {direction === "international" && ( +
+ + setDestinationCountry(e.target.value.toUpperCase())} /> +
+ )} +
+ + +
+
+
+ )} + + {/* Money-transfer RECEIVE fields */} + {(service?.code === "OMT_RECEIVE" || service?.code === "WU_RECEIVE") && ( +
+
Beneficiary payout
+
+
+ + setPayoutCode(e.target.value)} /> +
+
+ + setOriginCountry(e.target.value.toUpperCase())} /> +
+
+ + setBeneficiaryName(e.target.value)} /> +
+
+ + setBeneficiaryPhone(e.target.value)} /> +
+
+ + +
+
+ + setRecvIdNumber(e.target.value)} /> +
+
+
+ )} + + {/* Bill fields */} + {(service?.code === "OMT_BILL" || service?.code === "EDL_BILL") && ( +
+
Bill
+
+
+ + setBillerCode(e.target.value.toUpperCase())} /> +
+
+ + setAccountNumber(e.target.value)} /> +
+
+ + setBillPeriod(e.target.value)} /> +
+
+ + setExternalRef(e.target.value)} /> +
+
+ + setBeneficiaryName(e.target.value)} /> +
+
+
+ )} + + {/* Recharge fields */} + {cat === "telecom_recharge" && ( +
+
Recharge
+
+
+ + setOperator(e.target.value.toUpperCase())} /> +
+
+ + setMsisdn(e.target.value)} /> +
+
+ + +
+
+ + setVoucherSerial(e.target.value)} /> +
+
+ + setErechargeRef(e.target.value)} /> +
+ {/* Face value USD and Cost USD inputs removed for security against skimming. These should be fetched by Product Code automatically down the line. */} +
+

+ Either a voucher serial or an e-recharge reference is required. +

+
+ )} + + {/* Goods sale */} + {service?.code === "GOODS_SALE" && ( +
+
Goods sale
+
+
+ + setSku(e.target.value)} /> +
+
+ + setQty(e.target.value)} /> +
+
+ + setUnitPriceUsd(e.target.value)} /> +
+
+ + setGoodsUnitCostUsd(e.target.value)} /> +
+
+ + setSerialNumber(e.target.value)} /> +
+
+
+ )} + + {/* Repair */} + {service?.code === "REPAIR" && ( +
+
Repair
+
+
+ + setDeviceType(e.target.value)} /> +
+
+ + setDeviceImei(e.target.value)} /> +
+
+ + setIssueSummary(e.target.value)} /> +
+
+ + setWarrantyDays(e.target.value)} /> +
+
+
+ )} + +
+ +