Compare commits
10
Commits
20b578e49c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
654b524f1b | ||
|
|
1f23102050 | ||
|
|
1896cbdd11 | ||
|
|
1a3de58de6 | ||
|
|
66955137ce | ||
|
|
c7e29fac6d | ||
|
|
341a0de9ed | ||
|
|
dcde85e666 | ||
|
|
a8eec0e383 | ||
|
|
49cd117a2f |
@@ -0,0 +1,13 @@
|
|||||||
|
# Copy this file to .env before running docker compose.
|
||||||
|
# Do NOT commit your real .env file.
|
||||||
|
|
||||||
|
# Postgres container password (use a strong random string)
|
||||||
|
POSTGRES_PASSWORD=replace-with-strong-password
|
||||||
|
|
||||||
|
# Database name used by the app
|
||||||
|
POSTGRES_DB=crm_omt
|
||||||
|
|
||||||
|
# Seed admin user created on first DB initialization
|
||||||
|
ADMIN_EMAIL=admin@local.test
|
||||||
|
ADMIN_PASSWORD=ChangeMe123!
|
||||||
|
ADMIN_NAME=Owner
|
||||||
@@ -22,3 +22,9 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# production secrets
|
||||||
|
.env
|
||||||
|
server/.env
|
||||||
|
server/.env.production
|
||||||
|
backups/
|
||||||
|
|||||||
@@ -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.<fn>(...)` 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.
|
||||||
@@ -1,73 +1,124 @@
|
|||||||
# Welcome to your Lovable project
|
# CRM OMT — Cash Collection Management System
|
||||||
|
|
||||||
## Project info
|
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.
|
||||||
|
|
||||||
**URL**: https://lovable.dev/projects/a9d33dc6-ac68-4ccf-a697-7b7f5a0390bf
|
## The problem
|
||||||
|
|
||||||
## How can I edit this code?
|
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.
|
||||||
|
|
||||||
There are several ways of editing your application.
|
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.
|
||||||
|
|
||||||
**Use Lovable**
|
## What this app gives the owner
|
||||||
|
|
||||||
Simply visit the [Lovable Project](https://lovable.dev/projects/a9d33dc6-ac68-4ccf-a697-7b7f5a0390bf) and start prompting.
|
- **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.
|
||||||
|
|
||||||
Changes made via Lovable will be committed automatically to this repo.
|
## Architecture
|
||||||
|
|
||||||
**Use your preferred IDE**
|
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:
|
||||||
|
|
||||||
If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
|
- **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.<fn>(...)`
|
||||||
|
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.
|
||||||
|
|
||||||
The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
|
See [CLAUDE.md](CLAUDE.md) for the full design notes and conventions.
|
||||||
|
|
||||||
Follow these steps:
|
## Run it
|
||||||
|
|
||||||
```sh
|
```bash
|
||||||
# Step 1: Clone the repository using the project's Git URL.
|
# one-time
|
||||||
git clone <YOUR_GIT_URL>
|
cp server/.env.example server/.env # set JWT_SECRET, etc.
|
||||||
|
npm install
|
||||||
|
npm --prefix server install
|
||||||
|
|
||||||
# Step 2: Navigate to the project directory.
|
# day-to-day (DB + API + Web all together)
|
||||||
cd <YOUR_PROJECT_NAME>
|
npm run dev:all
|
||||||
|
# DB → docker container crm_omt_db on :5432
|
||||||
|
# API → node server on :4000
|
||||||
|
# WEB → vite on :5173
|
||||||
|
|
||||||
# Step 3: Install the necessary dependencies.
|
# build the frontend
|
||||||
npm i
|
npm run build
|
||||||
|
|
||||||
# Step 4: Start the development server with auto-reloading and an instant preview.
|
# wipe & rebuild the DB (reruns migrations + reseeds the admin)
|
||||||
npm run dev
|
npm run db:reset
|
||||||
```
|
```
|
||||||
|
|
||||||
**Edit a file directly in GitHub**
|
Default seed admin (override via env in [docker-compose.yml](docker-compose.yml)):
|
||||||
|
|
||||||
- Navigate to the desired file(s).
|
- email: `admin@local.test`
|
||||||
- Click the "Edit" button (pencil icon) at the top right of the file view.
|
- password: `ChangeMe123!`
|
||||||
- Make your changes and commit the changes.
|
|
||||||
|
|
||||||
**Use GitHub Codespaces**
|
## Shop deployment quickstart
|
||||||
|
|
||||||
- Navigate to the main page of your repository.
|
For a machine in the shop, use the full guide at [docs/SHOP_SETUP.md](docs/SHOP_SETUP.md).
|
||||||
- Click on the "Code" button (green button) near the top right.
|
|
||||||
- Select the "Codespaces" tab.
|
|
||||||
- Click on "New codespace" to launch a new Codespace environment.
|
|
||||||
- Edit files directly within the Codespace and commit and push your changes once you're done.
|
|
||||||
|
|
||||||
## What technologies are used for this project?
|
Fast path:
|
||||||
|
|
||||||
This project is built with:
|
```bash
|
||||||
|
git clone https://github.com/Krikorios/OMT-SM.git
|
||||||
|
cd OMT-SM
|
||||||
|
|
||||||
- Vite
|
cp .env.example .env
|
||||||
- TypeScript
|
cp server/.env.example server/.env
|
||||||
- React
|
|
||||||
- shadcn-ui
|
|
||||||
- Tailwind CSS
|
|
||||||
|
|
||||||
## How can I deploy this project?
|
npm install
|
||||||
|
npm --prefix server install
|
||||||
|
|
||||||
Simply open [Lovable](https://lovable.dev/projects/a9d33dc6-ac68-4ccf-a697-7b7f5a0390bf) and click on Share -> Publish.
|
./scripts/start_prod.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Can I connect a custom domain to my Lovable project?
|
Important:
|
||||||
|
|
||||||
Yes, you can!
|
- Set strong secrets in `.env` and `server/.env` before first production use.
|
||||||
|
- Match `POSTGRES_PASSWORD` in `.env` with the password inside `server/.env` `DATABASE_URL`.
|
||||||
|
|
||||||
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
|
## 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.
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
|
||||||
|
Fetch and display employee-wise transaction data
|
||||||
|
|
||||||
|
Calculate running outstanding balances per employee
|
||||||
|
|
||||||
|
Apply deposit payments to past shortfalls based on business rules
|
||||||
|
|
||||||
|
Built using React, TypeScript, and Supabase (PostgreSQL backend)
|
||||||
|
|
||||||
Read more here: [Setting up a custom domain](https://docs.lovable.dev/tips-tricks/custom-domain#step-by-step-guide)
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: crm_omt_db
|
||||||
|
restart: unless-stopped
|
||||||
|
# bind to loopback only — DB is reachable from the API on this box, not from the LAN
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
environment:
|
||||||
|
# all of these MUST come from the .env file at repo root — no defaults
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-crm_omt}
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL:?ADMIN_EMAIL is required}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}
|
||||||
|
ADMIN_NAME: ${ADMIN_NAME:-Owner}
|
||||||
|
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:
|
||||||
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Shop Setup Guide (Production)
|
||||||
|
|
||||||
|
This guide is for a fresh machine at the shop.
|
||||||
|
|
||||||
|
## 1. Prerequisites
|
||||||
|
|
||||||
|
- Docker Desktop installed and running
|
||||||
|
- Node.js 20+ and npm
|
||||||
|
- Git
|
||||||
|
|
||||||
|
## 2. Pull and prepare
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Krikorios/OMT-SM.git
|
||||||
|
cd OMT-SM
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npm --prefix server install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Configure environment
|
||||||
|
|
||||||
|
Create root env for Docker DB + seeded owner:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` and set strong values for `POSTGRES_PASSWORD` and `ADMIN_PASSWORD`.
|
||||||
|
|
||||||
|
Create API env:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp server/.env.example server/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `server/.env`:
|
||||||
|
- Set `DATABASE_URL` password to match root `.env` `POSTGRES_PASSWORD`
|
||||||
|
- Set a strong `JWT_SECRET` (32+ chars)
|
||||||
|
- Keep `NODE_ENV=production`
|
||||||
|
|
||||||
|
## 4. Start in production mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/start_prod.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The app is then reachable on:
|
||||||
|
- http://localhost:4000
|
||||||
|
- http://127.0.0.1:4000
|
||||||
|
|
||||||
|
## 5. Optional operations
|
||||||
|
|
||||||
|
Reset DB and rerun all migrations + seed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:reset
|
||||||
|
```
|
||||||
|
|
||||||
|
Run nightly backups (cron example is inside the script header):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. First login
|
||||||
|
|
||||||
|
Use the admin email/password from root `.env`:
|
||||||
|
- `ADMIN_EMAIL`
|
||||||
|
- `ADMIN_PASSWORD`
|
||||||
|
|
||||||
|
Then create cashier/manager users from the UI.
|
||||||
@@ -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.
|
||||||
Generated
+258
-244
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -5,6 +5,13 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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": "vite build",
|
||||||
"build:dev": "vite build --mode development",
|
"build:dev": "vite build --mode development",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@@ -39,7 +46,6 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.0",
|
"@radix-ui/react-toggle": "^1.1.0",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||||
"@radix-ui/react-tooltip": "^1.1.4",
|
"@radix-ui/react-tooltip": "^1.1.4",
|
||||||
"@supabase/supabase-js": "^2.49.8",
|
|
||||||
"@tanstack/react-query": "^5.56.2",
|
"@tanstack/react-query": "^5.56.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -70,6 +76,7 @@
|
|||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"concurrently": "^9.1.0",
|
||||||
"eslint": "^9.9.0",
|
"eslint": "^9.9.0",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.9",
|
"eslint-plugin-react-refresh": "^0.4.9",
|
||||||
|
|||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Nightly backup of the production DB.
|
||||||
|
# Add to crontab: 5 2 * * * /path/to/cash-collection-management-system/scripts/backup.sh >> /var/log/crm_omt_backup.log 2>&1
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-$REPO_DIR/backups}"
|
||||||
|
KEEP_DAYS="${KEEP_DAYS:-30}"
|
||||||
|
TS="$(date +%Y%m%d_%H%M%S)"
|
||||||
|
OUT="$BACKUP_DIR/crm_omt_${TS}.sql.gz"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
docker exec crm_omt_db pg_dump -U postgres -d crm_omt --clean --if-exists \
|
||||||
|
| gzip -9 > "$OUT"
|
||||||
|
|
||||||
|
# Prune anything older than KEEP_DAYS days
|
||||||
|
find "$BACKUP_DIR" -name 'crm_omt_*.sql.gz' -type f -mtime "+$KEEP_DAYS" -delete
|
||||||
|
|
||||||
|
echo "[backup] wrote $OUT ($(du -h "$OUT" | cut -f1))"
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- DB invariants: run against a closed E2E shift to verify SQL-side rules.
|
||||||
|
-- Pass shift id via: psql ... -v shift_id="'<uuid>'"
|
||||||
|
-- =====================================================================
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
\timing off
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo === shift under test ===
|
||||||
|
select id, status, opening_usd, expected_close_usd, declared_close_usd,
|
||||||
|
variance_usd, opening_lbp, expected_close_lbp, variance_lbp
|
||||||
|
from app.shifts where id = :shift_id;
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo === INV1: every completed REPAIR txn has >=1 cash_movements row ===
|
||||||
|
select t.id, t.service_code, t.status
|
||||||
|
from app.transactions t
|
||||||
|
where t.shift_id = :shift_id
|
||||||
|
and t.status = 'completed'
|
||||||
|
and t.service_code = 'REPAIR'
|
||||||
|
and not exists (select 1 from app.cash_movements cm where cm.ref_txn_id = t.id);
|
||||||
|
\echo (expect 0 rows)
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo === INV2: voided txns have net-zero cash (per currency) ===
|
||||||
|
with v as (
|
||||||
|
select id from app.transactions
|
||||||
|
where shift_id = :shift_id and status = 'voided'
|
||||||
|
)
|
||||||
|
select cm.ref_txn_id, cm.currency, sum(cm.amount) as net
|
||||||
|
from app.cash_movements cm
|
||||||
|
join v on v.id = cm.ref_txn_id
|
||||||
|
group by cm.ref_txn_id, cm.currency
|
||||||
|
having sum(cm.amount) <> 0;
|
||||||
|
\echo (expect 0 rows)
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo === INV3: opening + Σ cash_movements(post-open) = expected_close ===
|
||||||
|
with s as (select * from app.shifts where id = :shift_id),
|
||||||
|
mv_usd as (
|
||||||
|
select coalesce(sum(amount),0) as total
|
||||||
|
from app.cash_movements
|
||||||
|
where shift_id = :shift_id and currency = 'USD' and type <> 'opening_float'
|
||||||
|
),
|
||||||
|
mv_lbp as (
|
||||||
|
select coalesce(sum(amount),0) as total
|
||||||
|
from app.cash_movements
|
||||||
|
where shift_id = :shift_id and currency = 'LBP' and type <> 'opening_float'
|
||||||
|
)
|
||||||
|
select s.opening_usd, mv_usd.total as movements_usd,
|
||||||
|
(s.opening_usd + mv_usd.total) as computed_usd,
|
||||||
|
s.expected_close_usd,
|
||||||
|
(s.opening_usd + mv_usd.total = s.expected_close_usd) as usd_ok,
|
||||||
|
s.opening_lbp, mv_lbp.total as movements_lbp,
|
||||||
|
(s.opening_lbp + mv_lbp.total) as computed_lbp,
|
||||||
|
s.expected_close_lbp,
|
||||||
|
(s.opening_lbp + mv_lbp.total = s.expected_close_lbp) as lbp_ok
|
||||||
|
from s, mv_usd, mv_lbp;
|
||||||
|
\echo (expect usd_ok = t AND lbp_ok = t)
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo === INV4: variance = declared - expected ===
|
||||||
|
select id,
|
||||||
|
(declared_close_usd - expected_close_usd) as computed_var_usd,
|
||||||
|
variance_usd,
|
||||||
|
(declared_close_usd - expected_close_usd) = variance_usd as usd_ok,
|
||||||
|
(declared_close_lbp - expected_close_lbp) as computed_var_lbp,
|
||||||
|
variance_lbp,
|
||||||
|
(declared_close_lbp - expected_close_lbp) = variance_lbp as lbp_ok
|
||||||
|
from app.shifts where id = :shift_id;
|
||||||
|
\echo (expect usd_ok = t AND lbp_ok = t)
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo === INV5: cash_movements sign rule never violated (whole DB) ===
|
||||||
|
select id, shift_id, type, currency, amount
|
||||||
|
from app.cash_movements
|
||||||
|
where (type in ('sale_in','fx_swap_in','opening_float') and amount <= 0)
|
||||||
|
or (type in ('payout_out','drop_to_safe','bank_deposit','expense','fx_swap_out') and amount >= 0);
|
||||||
|
\echo (expect 0 rows)
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo === INV6: cash_movements.ref_txn_id always resolves ===
|
||||||
|
select cm.id, cm.ref_txn_id
|
||||||
|
from app.cash_movements cm
|
||||||
|
where cm.ref_txn_id is not null
|
||||||
|
and not exists (select 1 from app.transactions t where t.id = cm.ref_txn_id);
|
||||||
|
\echo (expect 0 rows)
|
||||||
|
|
||||||
|
\echo
|
||||||
|
\echo === DONE ===
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// End-to-end production-readiness validation against the running stack.
|
||||||
|
// Usage: node scripts/e2e_validate.mjs
|
||||||
|
// ENV: API=http://localhost:4000 EMAIL=admin@local.test PASSWORD=ChangeMe123!
|
||||||
|
|
||||||
|
const API = process.env.API || 'http://localhost:4000';
|
||||||
|
const EMAIL = process.env.EMAIL || 'admin@local.test';
|
||||||
|
const PASSWORD = process.env.PASSWORD || 'ChangeMe123!';
|
||||||
|
|
||||||
|
let TOKEN = null;
|
||||||
|
let failures = 0;
|
||||||
|
let checks = 0;
|
||||||
|
|
||||||
|
const fmt = (n) => Number(n).toFixed(2);
|
||||||
|
const eq = (a, b, tol = 0.0001) => Math.abs(Number(a) - Number(b)) <= tol;
|
||||||
|
|
||||||
|
function pass(msg) { checks++; console.log(` PASS ${msg}`); }
|
||||||
|
function fail(msg, extra='') { checks++; failures++; console.log(` FAIL ${msg}${extra ? ' ('+extra+')' : ''}`); }
|
||||||
|
function step(msg) { console.log(`\n== ${msg}`); }
|
||||||
|
|
||||||
|
async function req(method, path, body) {
|
||||||
|
const r = await fetch(`${API}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await r.text();
|
||||||
|
let json; try { json = JSON.parse(text); } catch { json = { raw: text }; }
|
||||||
|
if (!r.ok) {
|
||||||
|
const err = new Error(`${method} ${path} -> ${r.status} ${JSON.stringify(json)}`);
|
||||||
|
err.body = json;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rpc = (fn, args) => req('POST', `/rpc/${fn}`, args).then(r => r.data);
|
||||||
|
const view = (v) => req('GET', `/from/${v}`).then(r => r.data);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
step('login');
|
||||||
|
const login = await req('POST', '/auth/login', { email: EMAIL, password: PASSWORD });
|
||||||
|
TOKEN = login.token;
|
||||||
|
const ADMIN_ID = login.user.id;
|
||||||
|
pass(`login as ${login.user.email}`);
|
||||||
|
|
||||||
|
step('discover shop + till');
|
||||||
|
const shops = await view('v_my_shops');
|
||||||
|
if (!shops.length) { fail('no shops visible'); process.exit(1); }
|
||||||
|
const shopId = shops[0].shop_id;
|
||||||
|
pass(`shop ${shops[0].name} (${shopId})`);
|
||||||
|
|
||||||
|
const tillName = `E2E-Till-${Date.now()}`;
|
||||||
|
const newTill = await rpc('create_till', { p_shop: shopId, p_name: tillName });
|
||||||
|
const tillId = typeof newTill === 'string' ? newTill : newTill?.id ?? newTill;
|
||||||
|
pass(`created till ${tillName} (${tillId})`);
|
||||||
|
|
||||||
|
step('open shift with opening float USD 100 / LBP 0 (owner assigns to self)');
|
||||||
|
const openingUsd = 100, openingLbp = 0;
|
||||||
|
await rpc('open_shift', {
|
||||||
|
p_till_id: tillId,
|
||||||
|
p_opening_usd: openingUsd,
|
||||||
|
p_opening_lbp: openingLbp,
|
||||||
|
p_assigned_user_id: ADMIN_ID,
|
||||||
|
});
|
||||||
|
const active = await rpc('my_active_shift', { p_shop: shopId });
|
||||||
|
const shift = Array.isArray(active) ? active[0] : active;
|
||||||
|
if (!shift?.shift_id) { fail('shift did not open'); process.exit(1); }
|
||||||
|
const shiftId = shift.shift_id;
|
||||||
|
pass(`shift opened ${shiftId} status=${shift.status}`);
|
||||||
|
|
||||||
|
step('record REPAIR USD 30 (cash-only path)');
|
||||||
|
const repair1 = await rpc('record_repair', {
|
||||||
|
p_shop: shopId, p_till: tillId,
|
||||||
|
p_payment_method: 'cash_usd',
|
||||||
|
p_gross_usd: 30, p_gross_lbp: 0,
|
||||||
|
p_fx_rate: null,
|
||||||
|
p_device_type: 'iPhone 12',
|
||||||
|
p_device_imei: null,
|
||||||
|
p_issue_summary: 'screen replacement',
|
||||||
|
p_warranty_days: 7,
|
||||||
|
p_customer_id: null,
|
||||||
|
p_notes: 'e2e r1',
|
||||||
|
});
|
||||||
|
pass(`REPAIR #1 txn ${repair1}`);
|
||||||
|
|
||||||
|
step('record REPAIR USD 20');
|
||||||
|
const repair2 = await rpc('record_repair', {
|
||||||
|
p_shop: shopId, p_till: tillId,
|
||||||
|
p_payment_method: 'cash_usd',
|
||||||
|
p_gross_usd: 20, p_gross_lbp: 0,
|
||||||
|
p_fx_rate: null,
|
||||||
|
p_device_type: 'Samsung A50',
|
||||||
|
p_device_imei: null,
|
||||||
|
p_issue_summary: 'battery replacement',
|
||||||
|
p_warranty_days: 30,
|
||||||
|
p_customer_id: null,
|
||||||
|
p_notes: 'e2e r2',
|
||||||
|
});
|
||||||
|
pass(`REPAIR #2 txn ${repair2}`);
|
||||||
|
|
||||||
|
step('midday safe drop USD 25');
|
||||||
|
await rpc('record_cash_drop', {
|
||||||
|
p_shift_id: shiftId,
|
||||||
|
p_drop_usd: 25,
|
||||||
|
p_drop_lbp: 0,
|
||||||
|
p_notes: 'e2e midday',
|
||||||
|
});
|
||||||
|
pass('drop recorded');
|
||||||
|
|
||||||
|
step('ENFORCEMENT: void without voided_paper_photo evidence must be REJECTED');
|
||||||
|
let voidRejected = false;
|
||||||
|
try {
|
||||||
|
await rpc('void_transaction', {
|
||||||
|
p_txn_id: repair1,
|
||||||
|
p_reason: 'e2e void without evidence',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
voidRejected = true;
|
||||||
|
const msg = e.body?.error || e.message;
|
||||||
|
if (/voided_paper_photo/i.test(msg)) {
|
||||||
|
pass(`void correctly rejected: ${msg}`);
|
||||||
|
} else {
|
||||||
|
fail(`void rejected but for unexpected reason: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!voidRejected) fail('SECURITY: void with no evidence was accepted!');
|
||||||
|
|
||||||
|
step('ENFORCEMENT: cash_movements sign guard (drop with negative amount should fail)');
|
||||||
|
let dropGuardOk = false;
|
||||||
|
try {
|
||||||
|
await rpc('record_cash_drop', {
|
||||||
|
p_shift_id: shiftId,
|
||||||
|
p_drop_usd: -10,
|
||||||
|
p_drop_lbp: 0,
|
||||||
|
p_notes: 'e2e negative drop',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
dropGuardOk = true;
|
||||||
|
pass(`negative drop correctly rejected: ${e.body?.error || e.message}`);
|
||||||
|
}
|
||||||
|
if (!dropGuardOk) fail('SECURITY: negative drop amount was accepted!');
|
||||||
|
|
||||||
|
// Expected drawer math (USD, no void applied):
|
||||||
|
// opening 100
|
||||||
|
// + REPAIR1 +30
|
||||||
|
// + REPAIR2 +20
|
||||||
|
// - drop -25
|
||||||
|
// = 125
|
||||||
|
const expectedUsd = 125;
|
||||||
|
const expectedLbp = 0;
|
||||||
|
const counted = 124; // intentional $1 short
|
||||||
|
|
||||||
|
step('declare close USD 124 (intentional $1 short)');
|
||||||
|
await rpc('declare_close', {
|
||||||
|
p_shift_id: shiftId,
|
||||||
|
p_declared_close_usd: counted,
|
||||||
|
p_declared_close_lbp: 0,
|
||||||
|
});
|
||||||
|
pass('declared');
|
||||||
|
|
||||||
|
step('finalize close + assert expected/variance');
|
||||||
|
const fin = await rpc('finalize_close', { p_shift_id: shiftId });
|
||||||
|
const row = Array.isArray(fin) ? fin[0] : fin;
|
||||||
|
console.log(' finalize_close ->', JSON.stringify(row));
|
||||||
|
eq(row.expected_usd, expectedUsd)
|
||||||
|
? pass(`expected_usd = ${fmt(row.expected_usd)} (== ${expectedUsd})`)
|
||||||
|
: fail(`expected_usd = ${fmt(row.expected_usd)} (!= ${expectedUsd})`);
|
||||||
|
eq(row.expected_lbp, expectedLbp)
|
||||||
|
? pass(`expected_lbp = ${fmt(row.expected_lbp)} (== ${expectedLbp})`)
|
||||||
|
: fail(`expected_lbp = ${fmt(row.expected_lbp)} (!= ${expectedLbp})`);
|
||||||
|
eq(row.variance_usd, counted - expectedUsd)
|
||||||
|
? pass(`variance_usd = ${fmt(row.variance_usd)} (== ${counted - expectedUsd})`)
|
||||||
|
: fail(`variance_usd = ${fmt(row.variance_usd)} (expected ${counted - expectedUsd})`);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// DB-level invariant checks via /rpc/<admin diagnostic>... we don't
|
||||||
|
// have such an RPC, so just print a SQL block for the runner to exec.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
step('SUMMARY');
|
||||||
|
console.log(` checks: ${checks} failures: ${failures}`);
|
||||||
|
if (failures) process.exit(2);
|
||||||
|
|
||||||
|
console.log(`\nShift under test: ${shiftId}`);
|
||||||
|
})().catch(e => {
|
||||||
|
console.error('FATAL', e.message);
|
||||||
|
if (e.body) console.error(JSON.stringify(e.body, null, 2));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start the full production stack on this box.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# 1. DB up (idempotent)
|
||||||
|
docker compose up -d db
|
||||||
|
|
||||||
|
# 2. Wait for DB to be healthy
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if docker exec crm_omt_db pg_isready -U postgres -d crm_omt >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3. Build frontend (no-op if dist/ already current; safe to re-run)
|
||||||
|
if [ ! -d dist ] || [ -n "$(find src -newer dist -type f -print -quit 2>/dev/null)" ]; then
|
||||||
|
echo "[prod] building frontend..."
|
||||||
|
npm run build
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Start API in production mode (foreground; use a process manager for restart)
|
||||||
|
cd server
|
||||||
|
node src/index.js
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Production API config (copy to server/.env)
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=4000
|
||||||
|
BIND_HOST=127.0.0.1
|
||||||
|
DATABASE_URL=postgres://postgres:replace-with-db-password@localhost:5432/crm_omt
|
||||||
|
JWT_SECRET=replace-with-a-long-random-string-at-least-32-chars
|
||||||
|
JWT_EXPIRES_IN=12h
|
||||||
|
|
||||||
|
# Same-origin when SPA is served by this API.
|
||||||
|
# Keep localhost/127.0.0.1 unless you intentionally expose the service behind a reverse proxy.
|
||||||
|
CORS_ORIGIN=http://localhost:4000,http://127.0.0.1:4000
|
||||||
|
ENABLE_LOCAL_TEST_ROUTES=0
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 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_system_admin boolean not null default false,
|
||||||
|
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;
|
||||||
Executable
+20
@@ -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"
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 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;
|
||||||
|
|
||||||
|
-- Report-ready bridge from the modern POS/shift ledger into the legacy
|
||||||
|
-- employee payment report shape. A negative closed-shift variance means the
|
||||||
|
-- cashier is short, so it increases outstanding collection. A positive
|
||||||
|
-- variance means the drawer is over, so it is treated as a deposit/credit.
|
||||||
|
create or replace view app.v_employee_outstanding_balances as
|
||||||
|
with manual as (
|
||||||
|
select
|
||||||
|
e.id as employee_id,
|
||||||
|
e.emp_id,
|
||||||
|
e.name,
|
||||||
|
e.email,
|
||||||
|
e.department,
|
||||||
|
e.location,
|
||||||
|
et.currency,
|
||||||
|
sum(et.collection_amount) as manual_collection,
|
||||||
|
sum(et.deposit_amount) as manual_deposit,
|
||||||
|
0::numeric as shift_shortage,
|
||||||
|
0::numeric as shift_overage,
|
||||||
|
max(et.transaction_date)::timestamptz as last_activity_at
|
||||||
|
from app.employees e
|
||||||
|
join app.employee_transactions et on et.employee_id = e.id
|
||||||
|
group by e.id, e.emp_id, e.name, e.email, e.department, e.location, et.currency
|
||||||
|
), shift_variance as (
|
||||||
|
select
|
||||||
|
e.id as employee_id,
|
||||||
|
coalesce(e.emp_id, 'AUTH-' || left(u.id::text, 8)) as emp_id,
|
||||||
|
coalesce(e.name, p.full_name, u.full_name, u.email) as name,
|
||||||
|
u.email,
|
||||||
|
e.department,
|
||||||
|
e.location,
|
||||||
|
currency_rows.currency,
|
||||||
|
0::numeric as manual_collection,
|
||||||
|
0::numeric as manual_deposit,
|
||||||
|
sum(greatest(-currency_rows.variance_amount, 0)) as shift_shortage,
|
||||||
|
sum(greatest(currency_rows.variance_amount, 0)) as shift_overage,
|
||||||
|
max(sh.closed_at) as last_activity_at
|
||||||
|
from app.shifts sh
|
||||||
|
join auth.users u on u.id = sh.user_id
|
||||||
|
left join app.user_profiles p on p.user_id = u.id
|
||||||
|
left join app.employees e on lower(e.email) = lower(u.email)
|
||||||
|
cross join lateral (values
|
||||||
|
('USD'::text, coalesce(sh.variance_usd, 0)::numeric),
|
||||||
|
('LBP'::text, coalesce(sh.variance_lbp, 0)::numeric)
|
||||||
|
) as currency_rows(currency, variance_amount)
|
||||||
|
where sh.status = 'closed'
|
||||||
|
and currency_rows.variance_amount <> 0
|
||||||
|
group by e.id, coalesce(e.emp_id, 'AUTH-' || left(u.id::text, 8)),
|
||||||
|
coalesce(e.name, p.full_name, u.full_name, u.email), u.email,
|
||||||
|
e.department, e.location, currency_rows.currency
|
||||||
|
), combined as (
|
||||||
|
select * from manual
|
||||||
|
union all
|
||||||
|
select * from shift_variance
|
||||||
|
)
|
||||||
|
select
|
||||||
|
coalesce(
|
||||||
|
employee_id,
|
||||||
|
(
|
||||||
|
substr(md5(coalesce(email, emp_id)), 1, 8) || '-' ||
|
||||||
|
substr(md5(coalesce(email, emp_id)), 9, 4) || '-' ||
|
||||||
|
substr(md5(coalesce(email, emp_id)), 13, 4) || '-' ||
|
||||||
|
substr(md5(coalesce(email, emp_id)), 17, 4) || '-' ||
|
||||||
|
substr(md5(coalesce(email, emp_id)), 21, 12)
|
||||||
|
)::uuid
|
||||||
|
) as employee_id,
|
||||||
|
emp_id,
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
department,
|
||||||
|
location,
|
||||||
|
currency,
|
||||||
|
sum(manual_collection) as manual_collection,
|
||||||
|
sum(manual_deposit) as manual_deposit,
|
||||||
|
sum(shift_shortage) as shift_shortage,
|
||||||
|
sum(shift_overage) as shift_overage,
|
||||||
|
sum(manual_collection + shift_shortage) as total_collection,
|
||||||
|
sum(manual_deposit + shift_overage) as total_deposit,
|
||||||
|
sum(manual_collection + shift_shortage - manual_deposit - shift_overage) as outstanding_amount,
|
||||||
|
max(last_activity_at) as last_activity_at
|
||||||
|
from combined
|
||||||
|
group by employee_id, emp_id, name, email, department, location, currency;
|
||||||
|
|
||||||
|
grant select on app.v_employee_outstanding_balances to authenticated;
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/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" <<SQL
|
||||||
|
do \$\$
|
||||||
|
declare
|
||||||
|
v_user_id uuid;
|
||||||
|
v_shop_id uuid;
|
||||||
|
begin
|
||||||
|
insert into auth.users(email, password_hash, full_name, is_system_admin, is_active)
|
||||||
|
values ('${EM}', crypt('${PW}', gen_salt('bf', 10)), '${NM}', true, true)
|
||||||
|
on conflict (email) do update
|
||||||
|
set password_hash = excluded.password_hash,
|
||||||
|
full_name = excluded.full_name,
|
||||||
|
is_system_admin = true,
|
||||||
|
is_active = true
|
||||||
|
returning id into v_user_id;
|
||||||
|
|
||||||
|
insert into app.user_profiles(user_id, full_name, is_active)
|
||||||
|
values (v_user_id, '${NM}', true)
|
||||||
|
on conflict (user_id) do update
|
||||||
|
set full_name = excluded.full_name,
|
||||||
|
is_active = true;
|
||||||
|
|
||||||
|
insert into app.shops(name, created_by)
|
||||||
|
values ('Default Shop', v_user_id)
|
||||||
|
on conflict do nothing;
|
||||||
|
|
||||||
|
select id into v_shop_id from app.shops where name = 'Default Shop' limit 1;
|
||||||
|
|
||||||
|
insert into app.user_shop_assignments(user_id, shop_id, role, assigned_by)
|
||||||
|
values (v_user_id, v_shop_id, 'owner', v_user_id)
|
||||||
|
on conflict (user_id, shop_id) do update set role = 'owner';
|
||||||
|
|
||||||
|
insert into app.tills(shop_id, name)
|
||||||
|
values (v_shop_id, 'Till 1')
|
||||||
|
on conflict (shop_id, name) do nothing;
|
||||||
|
end \$\$;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo ">> admin user ensured: ${ADMIN_EMAIL}"
|
||||||
Generated
+1752
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import pkg from 'pg';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const { Pool } = pkg;
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
||||||
|
const BIND_HOST = process.env.BIND_HOST || (IS_PROD ? '127.0.0.1' : '0.0.0.0');
|
||||||
|
const PORT = Number(process.env.PORT || 4000);
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/crm_omt';
|
||||||
|
if (IS_PROD) {
|
||||||
|
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
|
||||||
|
console.error('[server] refusing to start: JWT_SECRET missing or too short in production');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (/:(postgres|password|change[-_]?me)@/i.test(DATABASE_URL)) {
|
||||||
|
console.error('[server] refusing to start: DATABASE_URL still uses a default password');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 = !IS_PROD
|
||||||
|
&& (process.env.ENABLE_LOCAL_TEST_ROUTES === '1'
|
||||||
|
|| /localhost|127\.0\.0\.1/i.test(DATABASE_URL));
|
||||||
|
const CORS_ORIGINS = (process.env.CORS_ORIGIN || (IS_PROD ? '' : 'http://localhost:5173,http://localhost:8080'))
|
||||||
|
.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
// Always allow same-origin requests when we're serving the SPA from this server.
|
||||||
|
const SAME_ORIGIN_HOSTS = ['localhost', '127.0.0.1'];
|
||||||
|
const STATIC_DIR = process.env.STATIC_DIR
|
||||||
|
|| path.resolve(__dirname, '..', '..', 'dist');
|
||||||
|
const SERVE_STATIC = IS_PROD && fs.existsSync(STATIC_DIR);
|
||||||
|
|
||||||
|
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;
|
||||||
|
// Same-origin: when the SPA is served from this server, the browser sends
|
||||||
|
// Origin: http://<host>:<PORT>. Allow that pair regardless of NODE_ENV.
|
||||||
|
try {
|
||||||
|
const url = new URL(origin);
|
||||||
|
if (SAME_ORIGIN_HOSTS.includes(url.hostname) && Number(url.port || (url.protocol === 'https:' ? 443 : 80)) === PORT) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (IS_PROD) return false; // in prod, only explicit list + same-origin
|
||||||
|
return SAME_ORIGIN_HOSTS.includes(url.hostname) || isPrivateIpv4(url.hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: DATABASE_URL, max: 10 });
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Same-origin requests (where the browser's Origin host:port matches the
|
||||||
|
// request's own Host header) are always allowed. This makes the API + SPA
|
||||||
|
// combo work whether the cashier opens http://localhost:4000,
|
||||||
|
// http://127.0.0.1:4000, or http://thispc.local:4000.
|
||||||
|
function isSameOrigin(req) {
|
||||||
|
const origin = req.get('origin');
|
||||||
|
if (!origin) return true;
|
||||||
|
try {
|
||||||
|
const o = new URL(origin);
|
||||||
|
const host = req.get('host') || '';
|
||||||
|
return `${o.hostname}:${o.port || (o.protocol === 'https:' ? '443' : '80')}` === host
|
||||||
|
|| o.host === host;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(cors({
|
||||||
|
origin(origin, callback) {
|
||||||
|
// Pass-through; per-request same-origin check happens below.
|
||||||
|
if (!origin) return callback(null, true);
|
||||||
|
if (isAllowedOrigin(origin)) return callback(null, true);
|
||||||
|
return callback(null, false);
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
// Belt + suspenders: if the cors() middleware rejected based on a stale
|
||||||
|
// allowlist but the request is actually same-origin, let it through.
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (isSameOrigin(req)) {
|
||||||
|
const origin = req.get('origin');
|
||||||
|
if (origin && !res.get('Access-Control-Allow-Origin')) {
|
||||||
|
res.set('Access-Control-Allow-Origin', origin);
|
||||||
|
res.set('Vary', 'Origin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
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 = <user_id>
|
||||||
|
* SET LOCAL request.jwt.claims = <full claims as json>
|
||||||
|
* 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,
|
||||||
|
coalesce((to_jsonb(u)->>'is_system_admin')::boolean, false) AS is_system_admin
|
||||||
|
FROM auth.users u 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, is_system_admin: u.is_system_admin } });
|
||||||
|
} 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.<fn>(...) 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.<fn>(...)` 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.<view> WHERE ...
|
||||||
|
const ALLOWED_VIEWS = new Set([
|
||||||
|
'v_my_tills', 'v_my_shops', 'v_services_active', 'v_my_recent_transactions',
|
||||||
|
'v_manage_tills', 'v_service_ui_settings',
|
||||||
|
'v_employee_outstanding_balances',
|
||||||
|
// 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) {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT coalesce((to_jsonb(u)->>'is_system_admin')::boolean, false) AS ok
|
||||||
|
FROM auth.users u
|
||||||
|
WHERE u.id = $1
|
||||||
|
`, [req.user.id]);
|
||||||
|
const ok = !!rows[0]?.ok;
|
||||||
|
if (!ok) {
|
||||||
|
res.status(403).json({ error: 'admin only' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) { dbError(res, e); return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUserRole(role) {
|
||||||
|
if (role === 'admin' || role === 'owner' || role === 'employee') return role;
|
||||||
|
return 'employee';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function firstShopId(client) {
|
||||||
|
const shop = await client.query('SELECT id FROM app.shops ORDER BY created_at LIMIT 1');
|
||||||
|
return shop.rows[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignShopRole(client, userId, role, assignedBy, shopIdParam) {
|
||||||
|
const shopId = shopIdParam || await firstShopId(client);
|
||||||
|
if (!shopId) return;
|
||||||
|
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, shopId, role, assignedBy],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertEmployee(client, { empId, name, email, department }) {
|
||||||
|
if (!empId) return;
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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((to_jsonb(u)->>'is_system_admin')::boolean, false) AS is_system_admin,
|
||||||
|
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,
|
||||||
|
(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, shopId } = 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 userRole = normalizeUserRole(role);
|
||||||
|
const isAdmin = userRole === 'admin';
|
||||||
|
const shopRole = userRole === 'employee' ? 'cashier' : 'owner';
|
||||||
|
|
||||||
|
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_system_admin, is_active)
|
||||||
|
VALUES ($1, $2, $3, $4, true) RETURNING id, email, full_name`,
|
||||||
|
[String(email).trim().toLowerCase(), hash, name, isAdmin],
|
||||||
|
);
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
if (!isAdmin) {
|
||||||
|
await assignShopRole(client, userId, shopRole, req.user.id, shopId);
|
||||||
|
await upsertEmployee(client, { empId, name, email, department });
|
||||||
|
}
|
||||||
|
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 }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- static SPA ------------------------------------------------------
|
||||||
|
// In production we serve the React build from this same process so there
|
||||||
|
// is only one port to manage. CORS is not crossed when the SPA and API
|
||||||
|
// share an origin, which is the whole point on the shop's local box.
|
||||||
|
if (SERVE_STATIC) {
|
||||||
|
console.log(`[server] serving SPA from ${STATIC_DIR}`);
|
||||||
|
app.use(express.static(STATIC_DIR, { index: false, maxAge: '1h' }));
|
||||||
|
app.get(/^\/(?!auth|rpc|from|employees|employee_transactions|admin|health).*/,
|
||||||
|
(_req, res) => res.sendFile(path.join(STATIC_DIR, 'index.html')));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(PORT, BIND_HOST, () => {
|
||||||
|
console.log(`[server] listening on http://${BIND_HOST}:${PORT} (NODE_ENV=${process.env.NODE_ENV || 'development'})`);
|
||||||
|
});
|
||||||
-42
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
import React, { useState } from 'react';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -12,6 +11,8 @@ import { format } from "date-fns";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
|
import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { Currency } from "@/lib/currency";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
|
||||||
interface AdminDataEntryModalProps {
|
interface AdminDataEntryModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -21,14 +22,27 @@ interface AdminDataEntryModalProps {
|
|||||||
|
|
||||||
export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminDataEntryModalProps) => {
|
export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminDataEntryModalProps) => {
|
||||||
const { employees, addTransaction } = useSupabaseEmployeeData();
|
const { employees, addTransaction } = useSupabaseEmployeeData();
|
||||||
|
const { user } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const isSelfEntryRole = user?.role === 'employee' || user?.role === 'owner';
|
||||||
|
const lockedEmployeeId = isSelfEntryRole
|
||||||
|
? employees.find(e => e.emp_id === user?.empId || e.email === user?.email)?.id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>('');
|
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>('');
|
||||||
const [collectionAmount, setCollectionAmount] = useState<string>('');
|
const [collectionAmount, setCollectionAmount] = useState<string>('');
|
||||||
const [depositAmount, setDepositAmount] = useState<string>('');
|
const [depositAmount, setDepositAmount] = useState<string>('');
|
||||||
|
const [currency, setCurrency] = useState<Currency>('USD');
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSelfEntryRole && lockedEmployeeId) {
|
||||||
|
setSelectedEmployeeId(lockedEmployeeId);
|
||||||
|
}
|
||||||
|
}, [isSelfEntryRole, lockedEmployeeId, isOpen]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -59,7 +73,8 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
|
|||||||
await addTransaction(selectedEmployeeId, {
|
await addTransaction(selectedEmployeeId, {
|
||||||
transaction_date: format(selectedDate, 'yyyy-MM-dd'),
|
transaction_date: format(selectedDate, 'yyyy-MM-dd'),
|
||||||
collection_amount: collection,
|
collection_amount: collection,
|
||||||
deposit_amount: deposit
|
deposit_amount: deposit,
|
||||||
|
currency,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
@@ -71,6 +86,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
|
|||||||
setSelectedEmployeeId('');
|
setSelectedEmployeeId('');
|
||||||
setCollectionAmount('');
|
setCollectionAmount('');
|
||||||
setDepositAmount('');
|
setDepositAmount('');
|
||||||
|
setCurrency('USD');
|
||||||
setSelectedDate(new Date());
|
setSelectedDate(new Date());
|
||||||
|
|
||||||
onDataUpdate();
|
onDataUpdate();
|
||||||
@@ -89,34 +105,52 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
|
|||||||
setSelectedEmployeeId('');
|
setSelectedEmployeeId('');
|
||||||
setCollectionAmount('');
|
setCollectionAmount('');
|
||||||
setDepositAmount('');
|
setDepositAmount('');
|
||||||
|
setCurrency('USD');
|
||||||
setSelectedDate(new Date());
|
setSelectedDate(new Date());
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-[500px] bg-white">
|
<DialogContent className="w-[min(96vw,500px)] sm:max-w-[500px] bg-white p-4 sm:p-6">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-2xl font-bold text-slate-800">Insert Employee Data</DialogTitle>
|
<DialogTitle className="text-2xl font-bold text-slate-800">
|
||||||
|
{isSelfEntryRole ? "Submit Transaction" : "Insert Employee Data"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Record collection and deposit amounts with the proper date and currency.
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6 mt-6">
|
<form onSubmit={handleSubmit} className="space-y-6 mt-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="employee" className="text-sm font-medium text-slate-700">
|
<Label htmlFor="employee" className="text-sm font-medium text-slate-700">
|
||||||
Select Employee
|
{isSelfEntryRole ? "Employee" : "Select Employee"}
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={selectedEmployeeId} onValueChange={setSelectedEmployeeId}>
|
<Select
|
||||||
|
value={selectedEmployeeId}
|
||||||
|
onValueChange={setSelectedEmployeeId}
|
||||||
|
disabled={isSelfEntryRole}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Choose an employee" />
|
<SelectValue placeholder={isSelfEntryRole ? "Your account" : "Choose an employee"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{employees.map(employee => (
|
{(isSelfEntryRole && lockedEmployeeId
|
||||||
|
? employees.filter(e => e.id === lockedEmployeeId)
|
||||||
|
: employees
|
||||||
|
).map(employee => (
|
||||||
<SelectItem key={employee.id} value={employee.id}>
|
<SelectItem key={employee.id} value={employee.id}>
|
||||||
{employee.name} (ID: {employee.emp_id})
|
{employee.name} (ID: {employee.emp_id})
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{isSelfEntryRole && !lockedEmployeeId && (
|
||||||
|
<p className="text-xs text-red-600">
|
||||||
|
Your account is not linked to an employee record. Ask an admin to set your Employee ID.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -148,10 +182,23 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
|
|||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium text-slate-700">Currency</Label>
|
||||||
|
<Select value={currency} onValueChange={(v) => setCurrency(v as Currency)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select currency" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="USD">US Dollar (USD)</SelectItem>
|
||||||
|
<SelectItem value="LBP">Lebanese Pound (LBP)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="collection" className="text-sm font-medium text-slate-700">
|
<Label htmlFor="collection" className="text-sm font-medium text-slate-700">
|
||||||
MM Collection Amount (₹)
|
MM Collection Amount ({currency})
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="collection"
|
id="collection"
|
||||||
@@ -160,7 +207,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
|
|||||||
value={collectionAmount}
|
value={collectionAmount}
|
||||||
onChange={(e) => setCollectionAmount(e.target.value)}
|
onChange={(e) => setCollectionAmount(e.target.value)}
|
||||||
className="text-right"
|
className="text-right"
|
||||||
step="0.01"
|
step={currency === 'USD' ? '0.01' : '1'}
|
||||||
min="0"
|
min="0"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
@@ -168,7 +215,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="deposit" className="text-sm font-medium text-slate-700">
|
<Label htmlFor="deposit" className="text-sm font-medium text-slate-700">
|
||||||
Deposit Amount (₹)
|
Deposit Amount ({currency})
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="deposit"
|
id="deposit"
|
||||||
@@ -177,14 +224,14 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
|
|||||||
value={depositAmount}
|
value={depositAmount}
|
||||||
onChange={(e) => setDepositAmount(e.target.value)}
|
onChange={(e) => setDepositAmount(e.target.value)}
|
||||||
className="text-right"
|
className="text-right"
|
||||||
step="0.01"
|
step={currency === 'USD' ? '0.01' : '1'}
|
||||||
min="0"
|
min="0"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-4">
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="w-[min(96vw,480px)] sm:max-w-[480px] bg-white p-4 sm:p-6">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-semibold text-slate-800">Cash FX Swap</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Convert till cash between USD and LBP at the posted rate.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<div>
|
||||||
|
<Label>Direction</Label>
|
||||||
|
<Select value={direction} onValueChange={(v) => setDirection(v as "sell_usd" | "buy_usd")}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sell_usd">Sell USD → take LBP</SelectItem>
|
||||||
|
<SelectItem value="buy_usd">Buy USD ← give LBP</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>USD amount</Label>
|
||||||
|
<Input type="number" step="0.01" value={usdAmount}
|
||||||
|
onChange={(e) => computeFromUsd(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>LBP amount</Label>
|
||||||
|
<Input type="number" step="1" value={lbpAmount}
|
||||||
|
onChange={(e) => computeFromLbp(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Rate (USD → LBP)</Label>
|
||||||
|
<Input type="number" value={rate} onChange={(e) => setRate(e.target.value)} />
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Must match the currently posted rate within tolerance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<Input value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button onClick={submit} disabled={busy || !usdAmount || !lbpAmount}>
|
||||||
|
{busy ? "Posting…" : "Post swap"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="w-[min(96vw,420px)] sm:max-w-[420px] bg-white p-4 sm:p-6">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-semibold text-slate-800">Manager Self-Deal Override</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
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.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<div>
|
||||||
|
<Label>Manager PIN</Label>
|
||||||
|
<Input type="password" value={pin} onChange={(e) => setPin(e.target.value)} autoFocus />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button onClick={submit} disabled={busy || !pin || !shopId}>
|
||||||
|
{busy ? "Verifying…" : "Authorize next"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
|
import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
|
||||||
|
import { Currency, convert, formatCurrency, getUsdToLbpRate } from "@/lib/currency";
|
||||||
|
|
||||||
interface DetailedTransaction {
|
interface DetailedTransaction {
|
||||||
location: string;
|
location: string;
|
||||||
@@ -17,18 +17,12 @@ interface DetailedTransaction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DetailedEmployeePaymentReport = () => {
|
export const DetailedEmployeePaymentReport = () => {
|
||||||
const { employees, transactions } = useSupabaseEmployeeData();
|
const [displayCurrency, setDisplayCurrency] = useState<Currency>("USD");
|
||||||
|
const { employees, transactions } = useSupabaseEmployeeData(displayCurrency);
|
||||||
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>('all');
|
const [selectedEmployeeId, setSelectedEmployeeId] = useState<string>('all');
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
return new Intl.NumberFormat('en-IN', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'INR'
|
|
||||||
}).format(amount);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
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
|
// Process transactions with the specific business logic
|
||||||
@@ -38,6 +32,11 @@ export const DetailedEmployeePaymentReport = () => {
|
|||||||
|
|
||||||
const employeeTransactions = transactions
|
const employeeTransactions = transactions
|
||||||
.filter(t => t.employee_id === employeeId)
|
.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());
|
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||||||
|
|
||||||
const detailedTransactions: DetailedTransaction[] = [];
|
const detailedTransactions: DetailedTransaction[] = [];
|
||||||
@@ -62,7 +61,7 @@ export const DetailedEmployeePaymentReport = () => {
|
|||||||
if (collections > 0) {
|
if (collections > 0) {
|
||||||
pendingCollections.push({ amount: collections, date });
|
pendingCollections.push({ amount: collections, date });
|
||||||
detailedTransactions.push({
|
detailedTransactions.push({
|
||||||
location: 'BGRoad, Karnataka',
|
location: employee.location || 'BGRoad, Karnataka',
|
||||||
empId: employee.emp_id.replace('EMP', ''),
|
empId: employee.emp_id.replace('EMP', ''),
|
||||||
empName: employee.name,
|
empName: employee.name,
|
||||||
collectionAmount: collections,
|
collectionAmount: collections,
|
||||||
@@ -106,7 +105,7 @@ export const DetailedEmployeePaymentReport = () => {
|
|||||||
// If there's remaining deposit after clearing collections, add separate deposit entries
|
// If there's remaining deposit after clearing collections, add separate deposit entries
|
||||||
while (remainingDeposit > 0) {
|
while (remainingDeposit > 0) {
|
||||||
detailedTransactions.push({
|
detailedTransactions.push({
|
||||||
location: 'BGRoad, Karnataka',
|
location: employee.location || 'BGRoad, Karnataka',
|
||||||
empId: employee.emp_id.replace('EMP', ''),
|
empId: employee.emp_id.replace('EMP', ''),
|
||||||
empName: employee.name,
|
empName: employee.name,
|
||||||
collectionAmount: 0,
|
collectionAmount: 0,
|
||||||
@@ -133,30 +132,54 @@ export const DetailedEmployeePaymentReport = () => {
|
|||||||
|
|
||||||
const detailedTransactions = getTransactionsToShow();
|
const detailedTransactions = getTransactionsToShow();
|
||||||
|
|
||||||
// Calculate totals
|
// Calculate totals (combined, in display currency)
|
||||||
const totalCollection = detailedTransactions.reduce((sum, t) => sum + t.collectionAmount, 0);
|
const totalCollection = detailedTransactions.reduce((sum, t) => sum + t.collectionAmount, 0);
|
||||||
const totalDeposit = detailedTransactions.reduce((sum, t) => sum + t.depositAmount, 0);
|
const totalDeposit = detailedTransactions.reduce((sum, t) => sum + t.depositAmount, 0);
|
||||||
const totalDifference = totalDeposit - totalCollection;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold text-purple-600">Employee Payment Report (Detailed)</h2>
|
<h2 className="text-xl font-semibold text-purple-600">Employee Payment Report (Detailed)</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-gray-500">Rate: 1 USD = {getUsdToLbpRate().toLocaleString()} LBP</span>
|
||||||
|
<Select value={displayCurrency} onValueChange={(v) => setDisplayCurrency(v as Currency)}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="USD">View in USD</SelectItem>
|
||||||
|
<SelectItem value="LBP">View in LBP</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-3 gap-6 mb-6">
|
<div className="grid grid-cols-3 gap-6 mb-6">
|
||||||
<Card className="bg-white shadow-sm">
|
<Card className="bg-white shadow-sm">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center shrink-0">
|
||||||
<div className="w-6 h-6 bg-gray-600 rounded-full"></div>
|
<div className="w-6 h-6 bg-gray-600 rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-gray-500 mb-1">Total Collection</p>
|
<p className="text-sm text-gray-500 mb-1">Total Collection (MM)</p>
|
||||||
<p className="text-sm text-gray-500">(MM) Amount</p>
|
<p className="text-xs text-gray-500">USD: <span className="font-medium text-gray-700">{formatCurrency(totalCollectionUSD, "USD")}</span></p>
|
||||||
<p className="text-2xl font-bold text-gray-800">{formatCurrency(totalCollection)}</p>
|
<p className="text-xs text-gray-500">LBP: <span className="font-medium text-gray-700">{formatCurrency(totalCollectionLBP, "LBP")}</span></p>
|
||||||
|
<p className="text-lg font-bold text-gray-800 mt-1">≈ {formatCurrency(totalCollection, displayCurrency)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -164,16 +187,17 @@ export const DetailedEmployeePaymentReport = () => {
|
|||||||
|
|
||||||
<Card className="bg-white shadow-sm">
|
<Card className="bg-white shadow-sm">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center shrink-0">
|
||||||
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center">
|
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center">
|
||||||
<span className="text-white text-xs">✓</span>
|
<span className="text-white text-xs">✓</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-gray-500 mb-1">Total Deposit</p>
|
<p className="text-sm text-gray-500 mb-1">Total Deposit Amount</p>
|
||||||
<p className="text-sm text-gray-500">Amount</p>
|
<p className="text-xs text-gray-500">USD: <span className="font-medium text-gray-700">{formatCurrency(totalDepositUSD, "USD")}</span></p>
|
||||||
<p className="text-2xl font-bold text-gray-800">{formatCurrency(totalDeposit)}</p>
|
<p className="text-xs text-gray-500">LBP: <span className="font-medium text-gray-700">{formatCurrency(totalDepositLBP, "LBP")}</span></p>
|
||||||
|
<p className="text-lg font-bold text-gray-800 mt-1">≈ {formatCurrency(totalDeposit, displayCurrency)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -181,17 +205,18 @@ export const DetailedEmployeePaymentReport = () => {
|
|||||||
|
|
||||||
<Card className="bg-white shadow-sm">
|
<Card className="bg-white shadow-sm">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center shrink-0">
|
||||||
<div className="w-6 h-6 bg-red-500 rounded-full flex items-center justify-center">
|
<div className="w-6 h-6 bg-red-500 rounded-full flex items-center justify-center">
|
||||||
<span className="text-white text-xs">=</span>
|
<span className="text-white text-xs">=</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-gray-500 mb-1">Net Difference</p>
|
<p className="text-sm text-gray-500 mb-1">Net Difference</p>
|
||||||
<p className="text-sm text-gray-500">Amount</p>
|
<p className="text-xs text-gray-500">USD: <span className={`font-medium ${totalDifferenceUSD >= 0 ? 'text-green-600' : 'text-red-600'}`}>{formatCurrency(totalDifferenceUSD, "USD")}</span></p>
|
||||||
<p className={`text-2xl font-bold ${totalDifference >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
<p className="text-xs text-gray-500">LBP: <span className={`font-medium ${totalDifferenceLBP >= 0 ? 'text-green-600' : 'text-red-600'}`}>{formatCurrency(totalDifferenceLBP, "LBP")}</span></p>
|
||||||
{formatCurrency(totalDifference)}
|
<p className={`text-lg font-bold mt-1 ${totalDifference >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
≈ {formatCurrency(totalDifference, displayCurrency)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -239,13 +264,13 @@ export const DetailedEmployeePaymentReport = () => {
|
|||||||
<TableCell className="font-medium py-4">{transaction.empId}</TableCell>
|
<TableCell className="font-medium py-4">{transaction.empId}</TableCell>
|
||||||
<TableCell className="py-4 font-medium text-gray-800">{transaction.empName}</TableCell>
|
<TableCell className="py-4 font-medium text-gray-800">{transaction.empName}</TableCell>
|
||||||
<TableCell className="py-4 font-medium">
|
<TableCell className="py-4 font-medium">
|
||||||
{transaction.collectionAmount > 0 ? transaction.collectionAmount.toLocaleString() : '-'}
|
{transaction.collectionAmount > 0 ? formatCurrency(transaction.collectionAmount, displayCurrency) : '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-4 text-gray-600">
|
<TableCell className="py-4 text-gray-600">
|
||||||
{transaction.collectionDate ? formatDate(transaction.collectionDate) : '-'}
|
{transaction.collectionDate ? formatDate(transaction.collectionDate) : '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-4 font-medium">
|
<TableCell className="py-4 font-medium">
|
||||||
{transaction.depositAmount > 0 ? transaction.depositAmount.toLocaleString() : '-'}
|
{transaction.depositAmount > 0 ? formatCurrency(transaction.depositAmount, displayCurrency) : '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-4 text-gray-600">
|
<TableCell className="py-4 text-gray-600">
|
||||||
{transaction.depositDate ? formatDate(transaction.depositDate) : '-'}
|
{transaction.depositDate ? formatDate(transaction.depositDate) : '-'}
|
||||||
@@ -255,7 +280,7 @@ export const DetailedEmployeePaymentReport = () => {
|
|||||||
transaction.difference === 0 ? 'text-gray-600' :
|
transaction.difference === 0 ? 'text-gray-600' :
|
||||||
transaction.difference > 0 ? 'text-green-600' : 'text-red-600'
|
transaction.difference > 0 ? 'text-green-600' : 'text-red-600'
|
||||||
}`}>
|
}`}>
|
||||||
{transaction.difference === 0 ? '-' : transaction.difference.toLocaleString()}
|
{transaction.difference === 0 ? '-' : formatCurrency(transaction.difference, displayCurrency)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ interface LoginPageProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LoginPage = ({ onLogin }: LoginPageProps) => {
|
export const LoginPage = ({ onLogin }: LoginPageProps) => {
|
||||||
const [email, setEmail] = useState('admin@astra.in');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('123456');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const { signIn } = useAuth();
|
const { signIn } = useAuth();
|
||||||
@@ -21,13 +21,10 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
console.log('Attempting login with:', { email, password });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { error } = await signIn(email, password);
|
const { error } = await signIn(email, password);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Login error:', error);
|
|
||||||
toast({
|
toast({
|
||||||
title: "Login Error",
|
title: "Login Error",
|
||||||
description: error.message,
|
description: error.message,
|
||||||
@@ -43,7 +40,6 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
|
|||||||
|
|
||||||
onLogin();
|
onLogin();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Unexpected error:', err);
|
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Error",
|
||||||
description: "An unexpected error occurred",
|
description: "An unexpected error occurred",
|
||||||
@@ -57,13 +53,23 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex">
|
<div className="min-h-screen flex">
|
||||||
{/* Left side with purple gradient */}
|
{/* Left side with purple gradient */}
|
||||||
<div className="flex-1 bg-gradient-to-br from-purple-600 via-purple-700 to-purple-800 flex items-center justify-center text-white p-12">
|
<div
|
||||||
|
className="bg-gradient-to-br flex flex-1 items-center justify-center p-12 text-white"
|
||||||
|
style={{
|
||||||
|
'--tw-gradient-from': '#4C4895',
|
||||||
|
'--tw-gradient-to': '#37337b',
|
||||||
|
'--tw-gradient-stops': 'var(--tw-gradient-from), var(--tw-gradient-to)',
|
||||||
|
} as React.CSSProperties}
|
||||||
|
>
|
||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
<h1 className="text-4xl font-bold mb-4">Cash Management Dashboard</h1>
|
<h1 className="text-4xl font-bold mb-4">Cash Management Dashboard</h1>
|
||||||
<p className="text-lg text-purple-100">Real-time Cash Reconciliation – Ensuring Accuracy & Transparency</p>
|
<p className="text-lg text-purple-100">
|
||||||
|
Real-time Cash Reconciliation – Ensuring Accuracy & Transparency
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Right side with login form */}
|
{/* Right side with login form */}
|
||||||
<div className="flex-1 bg-white flex items-center justify-center p-12">
|
<div className="flex-1 bg-white flex items-center justify-center p-12">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
@@ -73,7 +79,7 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
|
|||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="admin@astra.in"
|
placeholder="Email address"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => 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"
|
className="w-full h-12 px-4 border border-gray-300 rounded-lg focus:border-purple-500 focus:ring-purple-500"
|
||||||
@@ -100,12 +106,6 @@ export const LoginPage = ({ onLogin }: LoginPageProps) => {
|
|||||||
{loading ? "Signing in..." : "Login to dashboard"}
|
{loading ? "Signing in..." : "Login to dashboard"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
|
||||||
<p className="text-sm text-gray-600 mb-2">Default Login Credentials:</p>
|
|
||||||
<p className="text-sm font-mono">Email: admin@astra.in</p>
|
|
||||||
<p className="text-sm font-mono">Password: 123456</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,909 @@
|
|||||||
|
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", "WHISH_RECEIVE", "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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceUiRow {
|
||||||
|
shop_id: string;
|
||||||
|
service_code: string;
|
||||||
|
default_name: string;
|
||||||
|
category: string;
|
||||||
|
is_active: boolean;
|
||||||
|
display_name: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string>("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (managerShops.length && !shopId) setShopId(managerShops[0].shop_id);
|
||||||
|
}, [managerShops, shopId]);
|
||||||
|
|
||||||
|
if (!managerShops.length) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-slate-500">
|
||||||
|
You don't have manager or owner role in any shop.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{managerShops.length > 1 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label>Shop:</Label>
|
||||||
|
<Select value={shopId} onValueChange={setShopId}>
|
||||||
|
<SelectTrigger className="w-72"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{managerShops.map(s => (
|
||||||
|
<SelectItem key={s.shop_id} value={s.shop_id}>
|
||||||
|
{s.shop_name} · {s.role}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs defaultValue="fees" className="space-y-4">
|
||||||
|
<TabsList className="grid w-full grid-cols-6">
|
||||||
|
<TabsTrigger value="fees">Fee Schedule</TabsTrigger>
|
||||||
|
<TabsTrigger value="fx">FX Rates</TabsTrigger>
|
||||||
|
<TabsTrigger value="services">Service Titles</TabsTrigger>
|
||||||
|
<TabsTrigger value="tills">Tills</TabsTrigger>
|
||||||
|
<TabsTrigger value="kyc">Cashier KYC</TabsTrigger>
|
||||||
|
<TabsTrigger value="safe">Safe / Bank</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="fees">
|
||||||
|
<FeeScheduleTab shopId={shopId} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="fx">
|
||||||
|
<FxRatesTab shopId={shopId} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="services">
|
||||||
|
<ServiceTitlesTab shopId={shopId} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="tills">
|
||||||
|
<TillsTab shopId={shopId} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="kyc">
|
||||||
|
<KycTab shopId={shopId} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="safe">
|
||||||
|
<SafeAndBankTab shopId={shopId} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Service title/icon tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const ServiceTitlesTab: React.FC<{ shopId: string }> = ({ shopId }) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [rows, setRows] = useState<ServiceUiRow[]>([]);
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, { display_name: string; icon: string }>>({});
|
||||||
|
const [busyCode, setBusyCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
if (!shopId) return;
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("v_service_ui_settings")
|
||||||
|
.select("shop_id, service_code, default_name, category, is_active, display_name, icon, updated_at")
|
||||||
|
.eq("shop_id", shopId);
|
||||||
|
if (error) {
|
||||||
|
toast({ title: "Could not load service titles", description: error.message, variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextRows = ((data ?? []) as ServiceUiRow[])
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => `${a.category}:${a.default_name}`.localeCompare(`${b.category}:${b.default_name}`));
|
||||||
|
setRows(nextRows);
|
||||||
|
setDrafts(Object.fromEntries(nextRows.map((row) => [row.service_code, {
|
||||||
|
display_name: row.display_name ?? "",
|
||||||
|
icon: row.icon ?? "",
|
||||||
|
}])));
|
||||||
|
}, [shopId, toast]);
|
||||||
|
|
||||||
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
const updateDraft = (serviceCode: string, patch: Partial<{ display_name: string; icon: string }>) => {
|
||||||
|
setDrafts((current) => ({
|
||||||
|
...current,
|
||||||
|
[serviceCode]: {
|
||||||
|
display_name: current[serviceCode]?.display_name ?? "",
|
||||||
|
icon: current[serviceCode]?.icon ?? "",
|
||||||
|
...patch,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async (row: ServiceUiRow) => {
|
||||||
|
if (!shopId) return;
|
||||||
|
const draft = drafts[row.service_code] ?? { display_name: "", icon: "" };
|
||||||
|
setBusyCode(row.service_code);
|
||||||
|
const { error } = await supabase.rpc("set_service_ui_setting", {
|
||||||
|
p_shop: shopId,
|
||||||
|
p_service_code: row.service_code,
|
||||||
|
p_display_name: draft.display_name,
|
||||||
|
p_icon: draft.icon,
|
||||||
|
});
|
||||||
|
setBusyCode(null);
|
||||||
|
if (error) {
|
||||||
|
toast({ title: "Could not save service title", description: error.message, variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast({ title: "Service display saved", description: row.service_code });
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Service Titles & Icons</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="text-sm text-slate-500">
|
||||||
|
Customize how transaction buttons and receipt lists appear for this shop. Service codes stay unchanged for accounting.
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Service</TableHead>
|
||||||
|
<TableHead>Default title</TableHead>
|
||||||
|
<TableHead>Display title</TableHead>
|
||||||
|
<TableHead>Icon</TableHead>
|
||||||
|
<TableHead className="text-right">Action</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const draft = drafts[row.service_code] ?? { display_name: "", icon: "" };
|
||||||
|
return (
|
||||||
|
<TableRow key={row.service_code}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{row.service_code}</div>
|
||||||
|
<div className="text-xs text-slate-500">{row.category}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.default_name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={draft.display_name}
|
||||||
|
placeholder={row.default_name}
|
||||||
|
maxLength={80}
|
||||||
|
onChange={(event) => updateDraft(row.service_code, { display_name: event.target.value })}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={draft.icon}
|
||||||
|
placeholder="Emoji or short symbol"
|
||||||
|
maxLength={16}
|
||||||
|
onChange={(event) => updateDraft(row.service_code, { icon: event.target.value })}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button onClick={() => save(row)} disabled={busyCode === row.service_code || !shopId}>
|
||||||
|
{busyCode === row.service_code ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!rows.length && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center text-slate-500 py-6">
|
||||||
|
No services available.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fee schedule tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const FeeScheduleTab: React.FC<{ shopId: string }> = ({ shopId }) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [rows, setRows] = useState<FeeRow[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const [serviceCode, setServiceCode] = useState("OMT_SEND");
|
||||||
|
const [currency, setCurrency] = useState<Currency>("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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Fee Schedule</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Service</Label>
|
||||||
|
<Select value={serviceCode} onValueChange={setServiceCode}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SERVICE_CODES.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Currency</Label>
|
||||||
|
<Select value={currency} onValueChange={v => setCurrency(v as Currency)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="USD">USD</SelectItem>
|
||||||
|
<SelectItem value="LBP">LBP</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div><Label>Min amount</Label><Input value={minAmount} onChange={e => setMinAmount(e.target.value)} /></div>
|
||||||
|
<div><Label>Max amount</Label><Input value={maxAmount} onChange={e => setMaxAmount(e.target.value)} /></div>
|
||||||
|
<div><Label>Fee fixed</Label><Input value={feeFixed} onChange={e => setFeeFixed(e.target.value)} /></div>
|
||||||
|
<div><Label>Fee %</Label><Input value={feePct} onChange={e => setFeePct(e.target.value)} /></div>
|
||||||
|
<div><Label>Commission fixed</Label><Input value={commFixed} onChange={e => setCommFixed(e.target.value)} /></div>
|
||||||
|
<div><Label>Commission %</Label><Input value={commPct} onChange={e => setCommPct(e.target.value)} /></div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={submit} disabled={busy || !shopId}>
|
||||||
|
{busy ? "Saving…" : "Save bracket"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Service</TableHead><TableHead>Curr</TableHead>
|
||||||
|
<TableHead>Min</TableHead><TableHead>Max</TableHead>
|
||||||
|
<TableHead>Fee fix</TableHead><TableHead>Fee %</TableHead>
|
||||||
|
<TableHead>Comm fix</TableHead><TableHead>Comm %</TableHead>
|
||||||
|
<TableHead>From</TableHead><TableHead>To</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map(r => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell>{r.service_code}</TableCell>
|
||||||
|
<TableCell>{r.currency}</TableCell>
|
||||||
|
<TableCell>{r.min_amount}</TableCell>
|
||||||
|
<TableCell>{r.max_amount}</TableCell>
|
||||||
|
<TableCell>{r.fee_fixed}</TableCell>
|
||||||
|
<TableCell>{r.fee_pct}</TableCell>
|
||||||
|
<TableCell>{r.commission_fixed}</TableCell>
|
||||||
|
<TableCell>{r.commission_pct}</TableCell>
|
||||||
|
<TableCell>{new Date(r.effective_from).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell>{r.effective_to ? new Date(r.effective_to).toLocaleDateString() : "—"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!rows.length && <TableRow><TableCell colSpan={10} className="text-center text-slate-500 py-4">No brackets yet.</TableCell></TableRow>}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FX rates tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const FxRatesTab: React.FC<{ shopId: string }> = ({ shopId }) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [rows, setRows] = useState<FxRow[]>([]);
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>FX Rates (USD → LBP)</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div><Label>USD → LBP rate</Label><Input value={rate} onChange={e => setRate(e.target.value)} /></div>
|
||||||
|
<div><Label>Tolerance %</Label><Input value={tol} onChange={e => setTol(e.target.value)} /></div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button onClick={submit} disabled={busy || !shopId}>{busy ? "Saving…" : "Post new rate"}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Rate</TableHead><TableHead>Tolerance</TableHead>
|
||||||
|
<TableHead>From</TableHead><TableHead>To</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map(r => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell>{r.usd_to_lbp_rate.toLocaleString()}</TableCell>
|
||||||
|
<TableCell>{r.tolerance_pct}%</TableCell>
|
||||||
|
<TableCell>{new Date(r.effective_from).toLocaleString()}</TableCell>
|
||||||
|
<TableCell>{r.effective_to ? new Date(r.effective_to).toLocaleString() : "active"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!rows.length && <TableRow><TableCell colSpan={4} className="text-center text-slate-500 py-4">No rates yet.</TableCell></TableRow>}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cashier KYC tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const KycTab: React.FC<{ shopId: string }> = ({ shopId }) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [users, setUsers] = useState<ShopUser[]>([]);
|
||||||
|
const [userId, setUserId] = useState("");
|
||||||
|
const [idType, setIdType] = useState<IdDocType>("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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Cashier KYC (used for self-deal blocking)</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label>User</Label>
|
||||||
|
<Select value={userId} onValueChange={setUserId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Pick a user" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{users.map(u => (
|
||||||
|
<SelectItem key={u.user_id} value={u.user_id}>
|
||||||
|
{u.full_name} · {u.role}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>ID type</Label>
|
||||||
|
<Select value={idType} onValueChange={v => setIdType(v as IdDocType)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="lebanese_id">Lebanese ID</SelectItem>
|
||||||
|
<SelectItem value="passport">Passport</SelectItem>
|
||||||
|
<SelectItem value="residence_permit">Residence permit</SelectItem>
|
||||||
|
<SelectItem value="driver_license">Driver license</SelectItem>
|
||||||
|
<SelectItem value="other">Other</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div><Label>ID number</Label><Input value={idNumber} onChange={e => setIdNumber(e.target.value)} /></div>
|
||||||
|
<div className="md:col-span-2"><Label>Phone (KYC)</Label><Input value={phone} onChange={e => setPhone(e.target.value)} placeholder="+961…" /></div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button onClick={submit} disabled={busy || !shopId || !userId}>
|
||||||
|
{busy ? "Saving…" : "Save KYC"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Safe + bank deposit tab
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const SafeAndBankTab: React.FC<{ shopId: string }> = ({ shopId }) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [balances, setBalances] = useState<SafeBalanceRow[]>([]);
|
||||||
|
const [deposits, setDeposits] = useState<BankDepositRow[]>([]);
|
||||||
|
|
||||||
|
const [currency, setCurrency] = useState<Currency>("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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Safe balance</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow><TableHead>Safe</TableHead><TableHead>Currency</TableHead><TableHead className="text-right">Balance</TableHead><TableHead>Updated</TableHead></TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{balances.map((b, i) => (
|
||||||
|
<TableRow key={`${b.safe_id}-${b.currency ?? i}`}>
|
||||||
|
<TableCell>{b.safe_name}</TableCell>
|
||||||
|
<TableCell>{b.currency ?? "—"}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{Number(b.balance).toLocaleString()}</TableCell>
|
||||||
|
<TableCell>{b.updated_at ? new Date(b.updated_at).toLocaleString() : "—"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!balances.length && <TableRow><TableCell colSpan={4} className="text-center text-slate-500 py-4">No safe activity yet.</TableCell></TableRow>}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Record bank deposit</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Currency</Label>
|
||||||
|
<Select value={currency} onValueChange={v => setCurrency(v as Currency)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="USD">USD</SelectItem>
|
||||||
|
<SelectItem value="LBP">LBP</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div><Label>Amount</Label><Input value={amount} onChange={e => setAmount(e.target.value)} /></div>
|
||||||
|
<div><Label>Bank ref</Label><Input value={bankRef} onChange={e => setBankRef(e.target.value)} /></div>
|
||||||
|
<div><Label>Slip URL</Label><Input value={slipUrl} onChange={e => setSlipUrl(e.target.value)} /></div>
|
||||||
|
<div className="md:col-span-3"><Label>Notes</Label><Input value={notes} onChange={e => setNotes(e.target.value)} /></div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button onClick={submit} disabled={busy || !shopId || !amount}>
|
||||||
|
{busy ? "Posting…" : "Post deposit"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader><CardTitle>Recent deposits</CardTitle></CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow><TableHead>When</TableHead><TableHead>Curr</TableHead><TableHead className="text-right">Amount</TableHead><TableHead>Bank ref</TableHead><TableHead>Notes</TableHead></TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{deposits.map(d => (
|
||||||
|
<TableRow key={d.id}>
|
||||||
|
<TableCell>{new Date(d.created_at).toLocaleString()}</TableCell>
|
||||||
|
<TableCell>{d.currency}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{Number(d.amount).toLocaleString()}</TableCell>
|
||||||
|
<TableCell>{d.bank_ref ?? "—"}</TableCell>
|
||||||
|
<TableCell>{d.notes ?? "—"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!deposits.length && <TableRow><TableCell colSpan={5} className="text-center text-slate-500 py-4">No deposits yet.</TableCell></TableRow>}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<TillRow[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [editingId, setEditingId] = useState<string>("");
|
||||||
|
const [editingName, setEditingName] = useState<string>("");
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Tills</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{!isOwner && (
|
||||||
|
<div className="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded p-3">
|
||||||
|
Only the shop <strong>owner</strong> can create, rename, or deactivate tills. You can view the list below.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3 items-end">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm font-medium text-slate-700">New till name</Label>
|
||||||
|
<Input value={newName} placeholder="e.g. Till 2" onChange={(e) => setNewName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Button onClick={create} disabled={busy || !newName.trim()}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white h-10">
|
||||||
|
Add Till
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((t) => (
|
||||||
|
<TableRow key={t.till_id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{editingId === t.till_id ? (
|
||||||
|
<Input value={editingName} onChange={(e) => setEditingName(e.target.value)}
|
||||||
|
className="h-9" />
|
||||||
|
) : t.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={t.is_active
|
||||||
|
? "inline-block px-2 py-0.5 text-xs rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||||
|
: "inline-block px-2 py-0.5 text-xs rounded-full bg-slate-100 text-slate-600 border border-slate-200"}>
|
||||||
|
{t.is_active ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-500 text-sm">
|
||||||
|
{new Date(t.created_at).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right space-x-2">
|
||||||
|
{isOwner && (editingId === t.till_id ? (
|
||||||
|
<>
|
||||||
|
<Button size="sm" onClick={() => rename(t.till_id)}
|
||||||
|
disabled={busy || !editingName.trim()}>Save</Button>
|
||||||
|
<Button size="sm" variant="outline"
|
||||||
|
onClick={() => { setEditingId(""); setEditingName(""); }}>Cancel</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline"
|
||||||
|
onClick={() => { setEditingId(t.till_id); setEditingName(t.name); }}>
|
||||||
|
Rename
|
||||||
|
</Button>
|
||||||
|
{t.is_active ? (
|
||||||
|
<Button size="sm" variant="outline"
|
||||||
|
className="border-rose-300 text-rose-700 hover:bg-rose-50"
|
||||||
|
onClick={() => setActive(t.till_id, false)} disabled={busy}>
|
||||||
|
Deactivate
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline"
|
||||||
|
className="border-emerald-300 text-emerald-700 hover:bg-emerald-50"
|
||||||
|
onClick={() => setActive(t.till_id, true)} disabled={busy}>
|
||||||
|
Activate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!rows.length && (
|
||||||
|
<TableRow><TableCell colSpan={4} className="text-center text-slate-500 py-4">
|
||||||
|
No tills yet — add one above.
|
||||||
|
</TableCell></TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
import React from 'react';
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
|
import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
|
||||||
|
import { Currency, convert, formatCurrency, getUsdToLbpRate } from "@/lib/currency";
|
||||||
|
|
||||||
export const OutstandingReportDashboard = () => {
|
export const OutstandingReportDashboard = () => {
|
||||||
const { employees, getEmployeeSummary } = useSupabaseEmployeeData();
|
const [displayCurrency, setDisplayCurrency] = useState<Currency>("USD");
|
||||||
|
const { employees, getEmployeeSummary, outstandingBalances } = useSupabaseEmployeeData(displayCurrency);
|
||||||
|
|
||||||
const employeeSummaries = employees.map(employee => {
|
const legacyEmployeeSummaries = employees.map(employee => {
|
||||||
const summary = getEmployeeSummary(employee.id);
|
const summary = getEmployeeSummary(employee.id);
|
||||||
return {
|
return {
|
||||||
...employee,
|
...employee,
|
||||||
@@ -15,40 +17,117 @@ export const OutstandingReportDashboard = () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const balancedSummaries = Array.from(
|
||||||
return new Intl.NumberFormat('en-IN', {
|
outstandingBalances.reduce((map, row) => {
|
||||||
style: 'currency',
|
const current = map.get(row.employee_id) ?? {
|
||||||
currency: 'INR'
|
id: row.employee_id,
|
||||||
}).format(amount);
|
emp_id: row.emp_id,
|
||||||
|
name: row.name,
|
||||||
|
location: row.location || "",
|
||||||
|
totalCollectionUSD: 0,
|
||||||
|
totalCollectionLBP: 0,
|
||||||
|
totalDepositUSD: 0,
|
||||||
|
totalDepositLBP: 0,
|
||||||
|
shiftShortageUSD: 0,
|
||||||
|
shiftShortageLBP: 0,
|
||||||
|
shiftOverageUSD: 0,
|
||||||
|
shiftOverageLBP: 0,
|
||||||
|
totalCollection: 0,
|
||||||
|
totalDeposit: 0,
|
||||||
|
outstandingAmount: 0,
|
||||||
|
lastTransactionDate: null as string | null,
|
||||||
};
|
};
|
||||||
|
if (row.currency === "USD") {
|
||||||
|
current.totalCollectionUSD += row.total_collection;
|
||||||
|
current.totalDepositUSD += row.total_deposit;
|
||||||
|
current.shiftShortageUSD += row.shift_shortage;
|
||||||
|
current.shiftOverageUSD += row.shift_overage;
|
||||||
|
} else {
|
||||||
|
current.totalCollectionLBP += row.total_collection;
|
||||||
|
current.totalDepositLBP += row.total_deposit;
|
||||||
|
current.shiftShortageLBP += row.shift_shortage;
|
||||||
|
current.shiftOverageLBP += row.shift_overage;
|
||||||
|
}
|
||||||
|
current.totalCollection += convert(row.total_collection, row.currency, displayCurrency);
|
||||||
|
current.totalDeposit += convert(row.total_deposit, row.currency, displayCurrency);
|
||||||
|
current.outstandingAmount += convert(row.outstanding_amount, row.currency, displayCurrency);
|
||||||
|
if (row.last_activity_at && (!current.lastTransactionDate || row.last_activity_at > current.lastTransactionDate)) {
|
||||||
|
current.lastTransactionDate = row.last_activity_at;
|
||||||
|
}
|
||||||
|
map.set(row.employee_id, current);
|
||||||
|
return map;
|
||||||
|
}, new Map<string, {
|
||||||
|
id: string;
|
||||||
|
emp_id: string;
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
totalCollectionUSD: number;
|
||||||
|
totalCollectionLBP: number;
|
||||||
|
totalDepositUSD: number;
|
||||||
|
totalDepositLBP: number;
|
||||||
|
shiftShortageUSD: number;
|
||||||
|
shiftShortageLBP: number;
|
||||||
|
shiftOverageUSD: number;
|
||||||
|
shiftOverageLBP: number;
|
||||||
|
totalCollection: number;
|
||||||
|
totalDeposit: number;
|
||||||
|
outstandingAmount: number;
|
||||||
|
lastTransactionDate: string | null;
|
||||||
|
}>()).values()
|
||||||
|
);
|
||||||
|
|
||||||
|
const employeeSummaries = outstandingBalances.length ? balancedSummaries : legacyEmployeeSummaries;
|
||||||
|
|
||||||
const formatDate = (date: string) => {
|
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 totalCollection = employeeSummaries.reduce((sum, emp) => sum + emp.totalCollection, 0);
|
||||||
const totalDeposit = employeeSummaries.reduce((sum, emp) => sum + emp.totalDeposit, 0);
|
const totalDeposit = employeeSummaries.reduce((sum, emp) => sum + emp.totalDeposit, 0);
|
||||||
const totalDifference = totalCollection - totalDeposit;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-xl font-semibold text-purple-600 mb-4">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-xl font-semibold text-purple-600">
|
||||||
Outstanding Report (All Locations)
|
Outstanding Report (All Locations)
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-gray-500">Rate: 1 USD = {getUsdToLbpRate().toLocaleString()} LBP</span>
|
||||||
|
<Select value={displayCurrency} onValueChange={(v) => setDisplayCurrency(v as Currency)}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="USD">View in USD</SelectItem>
|
||||||
|
<SelectItem value="LBP">View in LBP</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-3 gap-6 mb-6">
|
<div className="grid grid-cols-3 gap-6 mb-6">
|
||||||
<Card className="bg-white shadow-sm">
|
<Card className="bg-white shadow-sm">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center shrink-0">
|
||||||
<div className="w-6 h-6 bg-gray-600 rounded-full"></div>
|
<div className="w-6 h-6 bg-gray-600 rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-gray-500 mb-1">Total Collection (MM)</p>
|
<p className="text-sm text-gray-500 mb-1">Total Collection / Shortage</p>
|
||||||
<p className="text-sm text-gray-500">(All Locations)</p>
|
<p className="text-xs text-gray-500">USD: <span className="font-medium text-gray-700">{formatCurrency(totalCollectionUSD, "USD")}</span></p>
|
||||||
<p className="text-2xl font-bold text-gray-800">{formatCurrency(totalCollection)}</p>
|
<p className="text-xs text-gray-500">LBP: <span className="font-medium text-gray-700">{formatCurrency(totalCollectionLBP, "LBP")}</span></p>
|
||||||
|
<p className="text-lg font-bold text-gray-800 mt-1">≈ {formatCurrency(totalCollection, displayCurrency)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -56,16 +135,17 @@ export const OutstandingReportDashboard = () => {
|
|||||||
|
|
||||||
<Card className="bg-white shadow-sm">
|
<Card className="bg-white shadow-sm">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center shrink-0">
|
||||||
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center">
|
<div className="w-6 h-6 bg-green-500 rounded-full flex items-center justify-center">
|
||||||
<span className="text-white text-xs">✓</span>
|
<span className="text-white text-xs">✓</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-gray-500 mb-1">Total Deposit Amount</p>
|
<p className="text-sm text-gray-500 mb-1">Total Deposit / Overage</p>
|
||||||
<p className="text-sm text-gray-500">(All Locations)</p>
|
<p className="text-xs text-gray-500">USD: <span className="font-medium text-gray-700">{formatCurrency(totalDepositUSD, "USD")}</span></p>
|
||||||
<p className="text-2xl font-bold text-gray-800">{formatCurrency(totalDeposit)}</p>
|
<p className="text-xs text-gray-500">LBP: <span className="font-medium text-gray-700">{formatCurrency(totalDepositLBP, "LBP")}</span></p>
|
||||||
|
<p className="text-lg font-bold text-gray-800 mt-1">≈ {formatCurrency(totalDeposit, displayCurrency)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -73,16 +153,17 @@ export const OutstandingReportDashboard = () => {
|
|||||||
|
|
||||||
<Card className="bg-white shadow-sm">
|
<Card className="bg-white shadow-sm">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center shrink-0">
|
||||||
<div className="w-6 h-6 bg-red-500 rounded-full flex items-center justify-center">
|
<div className="w-6 h-6 bg-red-500 rounded-full flex items-center justify-center">
|
||||||
<span className="text-white text-xs">=</span>
|
<span className="text-white text-xs">=</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-gray-500 mb-1">Difference Amount</p>
|
<p className="text-sm text-gray-500 mb-1">Difference Amount</p>
|
||||||
<p className="text-sm text-gray-500">(All Locations)</p>
|
<p className="text-xs text-gray-500">USD: <span className={`font-medium ${totalDifferenceUSD > 0 ? 'text-red-600' : 'text-green-600'}`}>{formatCurrency(totalDifferenceUSD, "USD")}</span></p>
|
||||||
<p className="text-2xl font-bold text-red-600">{formatCurrency(totalDifference)}</p>
|
<p className="text-xs text-gray-500">LBP: <span className={`font-medium ${totalDifferenceLBP > 0 ? 'text-red-600' : 'text-green-600'}`}>{formatCurrency(totalDifferenceLBP, "LBP")}</span></p>
|
||||||
|
<p className={`text-lg font-bold mt-1 ${totalDifference > 0 ? 'text-red-600' : 'text-green-600'}`}>≈ {formatCurrency(totalDifference, displayCurrency)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -97,7 +178,8 @@ export const OutstandingReportDashboard = () => {
|
|||||||
<TableHead className="font-medium text-gray-600 py-4">Location</TableHead>
|
<TableHead className="font-medium text-gray-600 py-4">Location</TableHead>
|
||||||
<TableHead className="font-medium text-gray-600 py-4">Emp. ID</TableHead>
|
<TableHead className="font-medium text-gray-600 py-4">Emp. ID</TableHead>
|
||||||
<TableHead className="font-medium text-gray-600 py-4">Emp. Name</TableHead>
|
<TableHead className="font-medium text-gray-600 py-4">Emp. Name</TableHead>
|
||||||
<TableHead className="font-medium text-gray-600 py-4">Collections (MM)</TableHead>
|
<TableHead className="font-medium text-gray-600 py-4">Collections / Shortage</TableHead>
|
||||||
|
<TableHead className="font-medium text-gray-600 py-4">Shift Variance</TableHead>
|
||||||
<TableHead className="font-medium text-gray-600 py-4">Date</TableHead>
|
<TableHead className="font-medium text-gray-600 py-4">Date</TableHead>
|
||||||
<TableHead className="font-medium text-gray-600 py-4">Difference</TableHead>
|
<TableHead className="font-medium text-gray-600 py-4">Difference</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -105,49 +187,45 @@ export const OutstandingReportDashboard = () => {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{employeeSummaries.map((employee) => (
|
{employeeSummaries.map((employee) => (
|
||||||
<TableRow key={employee.id} className="hover:bg-gray-50 transition-colors duration-150">
|
<TableRow key={employee.id} className="hover:bg-gray-50 transition-colors duration-150">
|
||||||
<TableCell className="py-4 text-gray-600">BGRoad, Karnataka</TableCell>
|
<TableCell className="py-4 text-gray-600">{employee.location || 'BGRoad, Karnataka'}</TableCell>
|
||||||
<TableCell className="font-medium py-4">{employee.emp_id.replace('EMP', '')}</TableCell>
|
<TableCell className="font-medium py-4">{employee.emp_id.replace('EMP', '')}</TableCell>
|
||||||
<TableCell className="py-4 font-medium text-gray-800">{employee.name}</TableCell>
|
<TableCell className="py-4 font-medium text-gray-800">{employee.name}</TableCell>
|
||||||
<TableCell className="py-4 font-medium">
|
<TableCell className="py-4 font-medium">
|
||||||
{employee.totalCollection.toLocaleString()}
|
{formatCurrency(employee.totalCollection, displayCurrency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="py-4 text-xs text-gray-600">
|
||||||
|
{'shiftShortageUSD' in employee ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div>Short USD: <span className="font-medium text-red-600">{formatCurrency(employee.shiftShortageUSD, "USD")}</span></div>
|
||||||
|
<div>Short LBP: <span className="font-medium text-red-600">{formatCurrency(employee.shiftShortageLBP, "LBP")}</span></div>
|
||||||
|
<div>Over USD: <span className="font-medium text-green-600">{formatCurrency(employee.shiftOverageUSD, "USD")}</span></div>
|
||||||
|
<div>Over LBP: <span className="font-medium text-green-600">{formatCurrency(employee.shiftOverageLBP, "LBP")}</span></div>
|
||||||
|
</div>
|
||||||
|
) : "Manual only"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-4 text-gray-600">
|
<TableCell className="py-4 text-gray-600">
|
||||||
{employee.lastTransactionDate ? formatDate(employee.lastTransactionDate) : '26 Mar 2025'}
|
{employee.lastTransactionDate ? formatDate(employee.lastTransactionDate) : '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-4">
|
<TableCell className="py-4">
|
||||||
<span className={`font-medium ${employee.outstandingAmount > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
<span className={`font-medium ${employee.outstandingAmount > 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)})`}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
{employeeSummaries.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center text-gray-500 py-6">
|
||||||
|
No outstanding balances yet.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="flex items-center justify-between pt-4">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
|
||||||
<span>Show</span>
|
|
||||||
<select className="border border-gray-300 rounded px-3 py-1">
|
|
||||||
<option>10</option>
|
|
||||||
<option>25</option>
|
|
||||||
<option>50</option>
|
|
||||||
</select>
|
|
||||||
<span>Rows</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded">1</button>
|
|
||||||
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded">2</button>
|
|
||||||
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded">3</button>
|
|
||||||
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded">4</button>
|
|
||||||
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded">5</button>
|
|
||||||
<span className="px-2 text-gray-400">...</span>
|
|
||||||
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded">10</button>
|
|
||||||
<button className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded">→</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{employeeSummaries.length === 0 && (
|
{employeeSummaries.length === 0 && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="text-gray-400 text-lg mb-2">No employee data available</div>
|
<div className="text-gray-400 text-lg mb-2">No employee data available</div>
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
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<ShopRow[]>([]);
|
||||||
|
const [zRows, setZRows] = useState<ZRow[]>([]);
|
||||||
|
const [scores, setScores] = useState<ScoreRow[]>([]);
|
||||||
|
const [alerts, setAlerts] = useState<AlertRow[]>([]);
|
||||||
|
const [reports, setReports] = useState<EndOfDayReportRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [submittingShopId, setSubmittingShopId] = useState<string | null>(null);
|
||||||
|
const [selectedReportId, setSelectedReportId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const [shopRes, zRes, scoreRes, alertRes, reportRes] = await Promise.all([
|
||||||
|
api.fromView<ShopRow>("v_owner_dashboard"),
|
||||||
|
api.fromView<ZRow>("v_z_report"),
|
||||||
|
api.fromView<ScoreRow>("v_employee_scorecard_30d"),
|
||||||
|
api.fromView<AlertRow>("alerts"),
|
||||||
|
api.fromView<EndOfDayReportRow>("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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>End of day</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="text-sm text-slate-500">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{shops.map(s => {
|
||||||
|
const latest = latestReportForShop(s.shop_id);
|
||||||
|
return (
|
||||||
|
<div key={s.shop_id} className="border rounded-lg p-4 bg-white space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-slate-800">{s.shop_name}</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
{latest
|
||||||
|
? `Last end of day: ${new Date(latest.submitted_at).toLocaleString()}`
|
||||||
|
: "No end-of-day report submitted yet."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-600 space-y-1">
|
||||||
|
<div>Open shifts: <span className="font-mono">{s.open_shifts}</span></div>
|
||||||
|
<div>Current gross USD: <span className="font-mono">{fmtNum(s.today_gross_usd, 2)}</span></div>
|
||||||
|
<div>Current gross LBP: <span className="font-mono">{fmtNum(s.today_gross_lbp, 0)}</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => submitEndOfDay(s.shop_id)}
|
||||||
|
disabled={loading || submittingShopId === s.shop_id}
|
||||||
|
className="bg-slate-900 hover:bg-slate-800 text-white"
|
||||||
|
>
|
||||||
|
{submittingShopId === s.shop_id ? "Submitting…" : "Submit end of day"}
|
||||||
|
</Button>
|
||||||
|
{isLocalDev && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => reopenLatestEndOfDayForTesting(s.shop_id)}
|
||||||
|
disabled={loading || submittingShopId === s.shop_id}
|
||||||
|
>
|
||||||
|
Reset local EOD test
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Per-shop KPIs */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Current day across your shops</CardTitle>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setRefreshKey(k => k + 1)} disabled={loading}>
|
||||||
|
{loading ? "Loading…" : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-xs text-slate-500 mb-4">
|
||||||
|
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.
|
||||||
|
</div>
|
||||||
|
{shops.length === 0 ? (
|
||||||
|
<div className="text-sm text-slate-500">No shops visible.</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{shops.map(s => (
|
||||||
|
<div key={s.shop_id} className="border rounded-lg p-4 bg-white">
|
||||||
|
<div className="text-sm font-semibold text-slate-800 mb-2 truncate">{s.shop_name}</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex flex-col"><span className="text-slate-500">Open shifts</span><span className="text-base font-mono">{s.open_shifts}</span></div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-slate-500">Open alerts</span>
|
||||||
|
<span className={`text-base font-mono ${Number(s.critical_alerts) > 0 ? "text-rose-700" : Number(s.open_alerts) > 0 ? "text-amber-700" : ""}`}>
|
||||||
|
{s.open_alerts}{Number(s.critical_alerts) > 0 ? ` (${s.critical_alerts}!)` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col"><span className="text-slate-500">Recon exceptions</span><span className="text-base font-mono">{s.open_recon_exceptions}</span></div>
|
||||||
|
<div className="flex flex-col"><span className="text-slate-500">Today gross USD</span><span className="text-base font-mono">{fmtNum(s.today_gross_usd, 2)}</span></div>
|
||||||
|
<div className="flex flex-col col-span-2"><span className="text-slate-500">Today gross LBP</span><span className="text-base font-mono">{fmtNum(s.today_gross_lbp, 0)}</span></div>
|
||||||
|
<div className="flex flex-col col-span-2"><span className="text-slate-500">Current period started</span><span className="text-xs font-mono">{new Date(s.period_started_at).toLocaleString()}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Open alerts */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Open alerts ({alerts.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{alerts.length === 0 ? (
|
||||||
|
<div className="text-sm text-emerald-700">No open alerts. Drawers are clean.</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
<TableHead>Shop</TableHead>
|
||||||
|
<TableHead>Kind</TableHead>
|
||||||
|
<TableHead>Severity</TableHead>
|
||||||
|
<TableHead>Detail</TableHead>
|
||||||
|
<TableHead className="text-right">Action</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{alerts.map(a => (
|
||||||
|
<TableRow key={a.id}>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs">{new Date(a.created_at).toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="text-sm">{shopName(a.shop_id)}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{a.kind}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={
|
||||||
|
a.severity === "critical" ? "text-rose-700 font-semibold"
|
||||||
|
: a.severity === "warn" ? "text-amber-700"
|
||||||
|
: "text-slate-600"
|
||||||
|
}>{a.severity}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs font-mono max-w-md truncate" title={JSON.stringify(a.payload)}>
|
||||||
|
{JSON.stringify(a.payload)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => ackAlert(a.id)}>Acknowledge</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recently closed shifts with variance */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recently closed shifts — variance</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{zRows.length === 0 ? (
|
||||||
|
<div className="text-sm text-slate-500">No closed shifts yet.</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Closed</TableHead>
|
||||||
|
<TableHead>Shop</TableHead>
|
||||||
|
<TableHead className="text-right">Expected USD</TableHead>
|
||||||
|
<TableHead className="text-right">Counted USD</TableHead>
|
||||||
|
<TableHead className="text-right">Δ USD</TableHead>
|
||||||
|
<TableHead className="text-right">Expected LBP</TableHead>
|
||||||
|
<TableHead className="text-right">Counted LBP</TableHead>
|
||||||
|
<TableHead className="text-right">Δ LBP</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{zRows.map(r => (
|
||||||
|
<TableRow key={r.shift_id}>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs">{r.closed_at ? new Date(r.closed_at).toLocaleString() : "—"}</TableCell>
|
||||||
|
<TableCell className="text-sm">{shopName(r.shop_id)}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{fmtNum(r.expected_close_usd, 2)}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{fmtNum(r.declared_close_usd, 2)}</TableCell>
|
||||||
|
<TableCell className={`text-right font-mono ${varianceTone(r.variance_usd)}`}>{fmtNum(r.variance_usd, 2)}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{fmtNum(r.expected_close_lbp, 0)}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{fmtNum(r.declared_close_lbp, 0)}</TableCell>
|
||||||
|
<TableCell className={`text-right font-mono ${varianceTone(r.variance_lbp)}`}>{fmtNum(r.variance_lbp, 0)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 30-day cashier scorecard */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Cashier scorecard (last 30 days)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{scores.length === 0 ? (
|
||||||
|
<div className="text-sm text-slate-500">No closed shifts in the last 30 days.</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Cashier</TableHead>
|
||||||
|
<TableHead>Shop</TableHead>
|
||||||
|
<TableHead className="text-right">Shifts</TableHead>
|
||||||
|
<TableHead className="text-right">Σ Δ USD</TableHead>
|
||||||
|
<TableHead className="text-right">Σ Δ LBP</TableHead>
|
||||||
|
<TableHead className="text-right">Voids</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{scores
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => Math.abs(Number(b.total_var_usd ?? 0)) - Math.abs(Number(a.total_var_usd ?? 0)))
|
||||||
|
.map(s => (
|
||||||
|
<TableRow key={`${s.cashier_id}:${s.shop_id}`}>
|
||||||
|
<TableCell className="font-mono text-xs">{s.cashier_id.slice(0, 8)}…</TableCell>
|
||||||
|
<TableCell className="text-sm">{shopName(s.shop_id)}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{s.shifts_30d}</TableCell>
|
||||||
|
<TableCell className={`text-right font-mono ${varianceTone(s.total_var_usd)}`}>{fmtNum(s.total_var_usd, 2)}</TableCell>
|
||||||
|
<TableCell className={`text-right font-mono ${varianceTone(s.total_var_lbp)}`}>{fmtNum(s.total_var_lbp, 0)}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{s.voids_30d}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent end-of-day reports</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{reports.length === 0 ? (
|
||||||
|
<div className="text-sm text-slate-500">No end-of-day reports yet.</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Business date</TableHead>
|
||||||
|
<TableHead>Shop</TableHead>
|
||||||
|
<TableHead>Submitted</TableHead>
|
||||||
|
<TableHead>By</TableHead>
|
||||||
|
<TableHead className="text-right">Txns</TableHead>
|
||||||
|
<TableHead className="text-right">Gross USD</TableHead>
|
||||||
|
<TableHead className="text-right">Gross LBP</TableHead>
|
||||||
|
<TableHead className="text-right">Activities</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{reports.map(r => (
|
||||||
|
<TableRow
|
||||||
|
key={r.id}
|
||||||
|
className={selectedReportId === r.id ? "bg-slate-50" : "cursor-pointer"}
|
||||||
|
onClick={() => setSelectedReportId(r.id)}
|
||||||
|
>
|
||||||
|
<TableCell>{fmtBusinessDate(r.business_date)}</TableCell>
|
||||||
|
<TableCell>{shopName(r.shop_id)}</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs">{new Date(r.submitted_at).toLocaleString()}</TableCell>
|
||||||
|
<TableCell>{r.submitted_by_name}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{r.completed_txn_count}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{fmtNum(r.gross_usd, 2)}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{fmtNum(r.gross_lbp, 0)}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{r.activity_count}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Activity log for selected report</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!selectedReport ? (
|
||||||
|
<div className="text-sm text-slate-500">Select an end-of-day report to inspect the activity log.</div>
|
||||||
|
) : selectedReport.activity_log.length === 0 ? (
|
||||||
|
<div className="text-sm text-slate-500">No activity events were captured in that reporting period.</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
<TableHead>Event</TableHead>
|
||||||
|
<TableHead>Metadata</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{selectedReport.activity_log.map((entry, index) => (
|
||||||
|
<TableRow key={`${selectedReport.id}:${index}`}>
|
||||||
|
<TableCell className="whitespace-nowrap text-xs">{new Date(entry.occurred_at).toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{entry.event_type}</TableCell>
|
||||||
|
<TableCell className="text-xs font-mono max-w-md truncate" title={JSON.stringify(entry.metadata)}>
|
||||||
|
{JSON.stringify(entry.metadata)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OwnerOverview;
|
||||||
@@ -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<string>("");
|
||||||
|
const [tills, setTills] = useState<MyTill[]>([]);
|
||||||
|
const [tillId, setTillId] = useState<string>("");
|
||||||
|
const [shopUsers, setShopUsers] = useState<{user_id: string, full_name: string, role: string}[]>([]);
|
||||||
|
const [assignedUserId, setAssignedUserId] = useState<string>("");
|
||||||
|
const [openShift, setOpenShift] = useState<OpenShift | null>(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<LiveDrawerSummary | null>(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 (
|
||||||
|
<Card className="shadow-sm border-slate-200">
|
||||||
|
<CardHeader className="border-b bg-slate-50/50 py-4 px-6">
|
||||||
|
<CardTitle className="text-lg font-semibold text-slate-800">Shift control</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5 pt-5">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm font-medium text-slate-700">Shop</Label>
|
||||||
|
<Select value={shopId} onValueChange={setShopId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select shop" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{shops.map((s) => (
|
||||||
|
<SelectItem key={s.shop_id} value={s.shop_id}>
|
||||||
|
{s.shop_name} · {s.role}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm font-medium text-slate-700">Till</Label>
|
||||||
|
<Select value={tillId} onValueChange={setTillId}
|
||||||
|
disabled={!!openShift}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select till" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tills.map((t) => (
|
||||||
|
<SelectItem key={t.till_id} value={t.till_id}>{t.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{!openShift && canAssignShift && (
|
||||||
|
<div className="space-y-1.5 md:col-span-2">
|
||||||
|
<Label className="text-sm font-medium text-slate-700">Assign shift to employee</Label>
|
||||||
|
<Select value={assignedUserId} onValueChange={setAssignedUserId}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select user" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{shopUsers.map((u) => (
|
||||||
|
<SelectItem key={u.user_id} value={u.user_id}>{u.full_name} ({u.role})</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openShift ? (
|
||||||
|
<div className="border rounded p-3 space-y-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium">Open shift:</span> till{" "}
|
||||||
|
{openShift.till_id.slice(0, 8)}… opened{" "}
|
||||||
|
{new Date(openShift.opened_at).toLocaleString()} · status{" "}
|
||||||
|
<span className="font-mono">{openShift.status}</span>
|
||||||
|
<br />
|
||||||
|
Opening float: USD {openShift.opening_usd} / LBP{" "}
|
||||||
|
{openShift.opening_lbp}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{drawerSummary && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 pt-4 border-t mt-4">
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold text-slate-800">Live drawer now</h4>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{drawerSummary.txn_count} txns
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div className="rounded border bg-white p-3">
|
||||||
|
<div className="text-xs uppercase tracking-wider text-slate-500 mb-1">Expected USD</div>
|
||||||
|
<div className="font-mono text-lg text-slate-900">
|
||||||
|
{formatAmount(drawerSummary.expected_usd, 2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded border bg-white p-3">
|
||||||
|
<div className="text-xs uppercase tracking-wider text-slate-500 mb-1">Expected LBP</div>
|
||||||
|
<div className="font-mono text-lg text-slate-900">
|
||||||
|
{formatAmount(drawerSummary.expected_lbp, 0)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
Last transaction: {drawerSummary.last_txn_at ? new Date(drawerSummary.last_txn_at).toLocaleString() : "No completed transactions yet"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-4 space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-slate-800">What moved this drawer</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span className="text-slate-500">Customer in USD</span>
|
||||||
|
<span className="font-mono text-emerald-700">{formatAmount(drawerSummary.customer_in_usd, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span className="text-slate-500">Customer in LBP</span>
|
||||||
|
<span className="font-mono text-emerald-700">{formatAmount(drawerSummary.customer_in_lbp, 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span className="text-slate-500">Payout out USD</span>
|
||||||
|
<span className="font-mono text-rose-700">{formatAmount(drawerSummary.payout_out_usd, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span className="text-slate-500">Payout out LBP</span>
|
||||||
|
<span className="font-mono text-rose-700">{formatAmount(drawerSummary.payout_out_lbp, 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span className="text-slate-500">Dropped to safe USD</span>
|
||||||
|
<span className="font-mono text-amber-700">{formatAmount(drawerSummary.dropped_to_safe_usd, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span className="text-slate-500">Dropped to safe LBP</span>
|
||||||
|
<span className="font-mono text-amber-700">{formatAmount(drawerSummary.dropped_to_safe_lbp, 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span className="text-slate-500">FX net USD</span>
|
||||||
|
<span className="font-mono text-slate-700">{formatAmount(drawerSummary.fx_net_usd, 2)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-3">
|
||||||
|
<span className="text-slate-500">FX net LBP</span>
|
||||||
|
<span className="font-mono text-slate-700">{formatAmount(drawerSummary.fx_net_lbp, 0)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{openShift.status === "open" ? (
|
||||||
|
<>
|
||||||
|
<div className="pt-4 border-t mt-4">
|
||||||
|
<h4 className="text-sm font-semibold mb-2">Mid-Day Safe Drop</h4>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3 items-end">
|
||||||
|
<div>
|
||||||
|
<Label>Drop USD</Label>
|
||||||
|
<Input type="number" step="0.01" value={dropUsd} placeholder="0.00"
|
||||||
|
onChange={(e) => setDropUsd(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Drop LBP</Label>
|
||||||
|
<Input type="number" step="1" value={dropLbp} placeholder="0"
|
||||||
|
onChange={(e) => setDropLbp(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleDrop} disabled={busy || (!dropUsd && !dropLbp)} variant="outline" className="border-amber-600 text-amber-700 hover:bg-amber-50">
|
||||||
|
Record Safe Drop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setFxOpen(true)}
|
||||||
|
className="border-blue-600 text-blue-700 hover:bg-blue-50">
|
||||||
|
Cash FX Swap
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setOverrideOpen(true)}
|
||||||
|
className="border-rose-600 text-rose-700 hover:bg-rose-50">
|
||||||
|
Manager Override (self-deal)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3 items-end mt-4">
|
||||||
|
<div>
|
||||||
|
<Label>Declared USD</Label>
|
||||||
|
<Input type="number" step="0.01" value={declaredUsd}
|
||||||
|
onChange={(e) => setDeclaredUsd(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Declared LBP</Label>
|
||||||
|
<Input type="number" step="1" value={declaredLbp}
|
||||||
|
onChange={(e) => setDeclaredLbp(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleDeclare} disabled={busy}>
|
||||||
|
Declare close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 pt-4 border-t mt-4">
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm">
|
||||||
|
<div className="font-semibold text-amber-900 mb-2">Close declared</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 font-mono text-amber-900">
|
||||||
|
<div>Counted USD: {formatAmount(Number(openShift.declared_close_usd ?? 0), 2)}</div>
|
||||||
|
<div>Counted LBP: {formatAmount(Number(openShift.declared_close_lbp ?? 0), 0)}</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-amber-800">
|
||||||
|
The drawer count is locked. Finalize to reveal the expected cash and variance.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleFinalize}
|
||||||
|
disabled={busy}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white">
|
||||||
|
Finalize close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : closeResult ? (
|
||||||
|
<div className="border rounded p-4 space-y-3 bg-slate-50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-base font-semibold">Last shift close — variance summary</h4>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setCloseResult(null)}>Dismiss</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
{([
|
||||||
|
{ 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 (
|
||||||
|
<div key={r.label} className={`border rounded p-3 ${tone}`}>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wider mb-2">{r.label}</div>
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">Expected</span><span className="font-mono">{fmt(r.expected)}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-slate-600">Counted</span><span className="font-mono">{fmt(r.declared)}</span></div>
|
||||||
|
<div className="flex justify-between font-semibold pt-1 border-t mt-1">
|
||||||
|
<span>Δ {label}</span><span className="font-mono">{fmt(r.variance)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border border-slate-200 bg-slate-50/40 rounded-lg p-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 items-end">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm font-medium text-slate-700">Opening USD</Label>
|
||||||
|
<Input type="number" step="0.01" value={openingUsd} placeholder="0.00"
|
||||||
|
onChange={(e) => setOpeningUsd(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm font-medium text-slate-700">Opening LBP</Label>
|
||||||
|
<Input type="number" step="1" value={openingLbp} placeholder="0"
|
||||||
|
onChange={(e) => setOpeningLbp(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleOpen}
|
||||||
|
disabled={busy || !tillId}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white h-10">
|
||||||
|
Open shift
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Count the drawer cash <em>before</em> opening. The amounts
|
||||||
|
you declare here become the audit baseline for this shift.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<FxSwapDialog
|
||||||
|
open={fxOpen}
|
||||||
|
onClose={() => setFxOpen(false)}
|
||||||
|
shopId={shopId}
|
||||||
|
tillId={openShift?.till_id ?? tillId}
|
||||||
|
onDone={refreshShift}
|
||||||
|
/>
|
||||||
|
<SelfDealOverrideDialog
|
||||||
|
open={overrideOpen}
|
||||||
|
onClose={() => setOverrideOpen(false)}
|
||||||
|
shopId={shopId}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { BadgeDollarSign, Eye, Printer, RotateCcw, Search, XCircle } from "lucide-react";
|
||||||
|
|
||||||
|
type TxnStatus = "completed" | "voided";
|
||||||
|
|
||||||
|
interface RecentTransactionRow {
|
||||||
|
id: string;
|
||||||
|
reference_no: number;
|
||||||
|
shop_id: string;
|
||||||
|
till_id: string;
|
||||||
|
shift_id: string;
|
||||||
|
service_code: string;
|
||||||
|
service_name: string;
|
||||||
|
category: string;
|
||||||
|
payment_method: string;
|
||||||
|
gross_usd: string | number;
|
||||||
|
gross_lbp: string | number;
|
||||||
|
revenue_usd: string | number;
|
||||||
|
revenue_lbp: string | number;
|
||||||
|
external_ref: string | null;
|
||||||
|
external_ref_provider: string | null;
|
||||||
|
beneficiary_name: string | null;
|
||||||
|
beneficiary_phone: string | null;
|
||||||
|
status: TxnStatus;
|
||||||
|
occurred_at: string;
|
||||||
|
user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReceiptPrintRow {
|
||||||
|
receipt_id: string;
|
||||||
|
qr_token: string;
|
||||||
|
pdf_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtUsd = (value: string | number | null | undefined) =>
|
||||||
|
Number(value ?? 0).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
const fmtLbp = (value: string | number | null | undefined) =>
|
||||||
|
Number(value ?? 0).toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
export const TransactionCenter: React.FC = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [rows, setRows] = useState<RecentTransactionRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"all" | TxnStatus>("all");
|
||||||
|
const [selected, setSelected] = useState<RecentTransactionRow | null>(null);
|
||||||
|
const [receipt, setReceipt] = useState<ReceiptPrintRow | null>(null);
|
||||||
|
const [voidTarget, setVoidTarget] = useState<RecentTransactionRow | null>(null);
|
||||||
|
const [voidReason, setVoidReason] = useState("");
|
||||||
|
const [managerPin, setManagerPin] = useState("");
|
||||||
|
const [refundTarget, setRefundTarget] = useState<RecentTransactionRow | null>(null);
|
||||||
|
const [refundUsd, setRefundUsd] = useState("");
|
||||||
|
const [refundLbp, setRefundLbp] = useState("");
|
||||||
|
const [refundReason, setRefundReason] = useState("");
|
||||||
|
const [refundManagerPin, setRefundManagerPin] = useState("");
|
||||||
|
const [busyTxnId, setBusyTxnId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const { data, error } = await api.fromView<RecentTransactionRow>("v_my_recent_transactions");
|
||||||
|
if (error) {
|
||||||
|
toast({ title: "Could not load transactions", description: error.message, variant: "destructive" });
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRows((data ?? []).sort((a, b) => b.occurred_at.localeCompare(a.occurred_at)).slice(0, 100));
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { refresh(); }, []);
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
return rows.filter((row) => {
|
||||||
|
if (statusFilter !== "all" && row.status !== statusFilter) return false;
|
||||||
|
if (!needle) return true;
|
||||||
|
return [
|
||||||
|
row.reference_no,
|
||||||
|
row.service_code,
|
||||||
|
row.service_name,
|
||||||
|
row.payment_method,
|
||||||
|
row.external_ref,
|
||||||
|
row.beneficiary_name,
|
||||||
|
row.beneficiary_phone,
|
||||||
|
row.id,
|
||||||
|
].some((value) => String(value ?? "").toLowerCase().includes(needle));
|
||||||
|
});
|
||||||
|
}, [query, rows, statusFilter]);
|
||||||
|
|
||||||
|
const reprintReceipt = async (row: RecentTransactionRow) => {
|
||||||
|
setBusyTxnId(row.id);
|
||||||
|
const { data, error } = await api.rpc<ReceiptPrintRow[] | ReceiptPrintRow>("record_receipt_print", {
|
||||||
|
p_txn_id: row.id,
|
||||||
|
p_kind: "reprint",
|
||||||
|
p_device: "transaction-center",
|
||||||
|
});
|
||||||
|
setBusyTxnId(null);
|
||||||
|
if (error) {
|
||||||
|
toast({ title: "Could not log reprint", description: error.message, variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const receiptRow = Array.isArray(data) ? data[0] : data;
|
||||||
|
setSelected(row);
|
||||||
|
setReceipt(receiptRow ?? null);
|
||||||
|
toast({ title: "Receipt reprint logged", description: `Receipt #${row.reference_no} has a fresh verification token.` });
|
||||||
|
};
|
||||||
|
|
||||||
|
const voidTransaction = async () => {
|
||||||
|
if (!voidTarget) return;
|
||||||
|
setBusyTxnId(voidTarget.id);
|
||||||
|
const { error } = await api.rpc("void_transaction", {
|
||||||
|
p_txn_id: voidTarget.id,
|
||||||
|
p_reason: voidReason,
|
||||||
|
p_approver_pin: managerPin.trim() || null,
|
||||||
|
});
|
||||||
|
setBusyTxnId(null);
|
||||||
|
if (error) {
|
||||||
|
toast({ title: "Could not void transaction", description: error.message, variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast({ title: "Transaction voided", description: `Receipt #${voidTarget.reference_no} was reversed.` });
|
||||||
|
setVoidTarget(null);
|
||||||
|
setVoidReason("");
|
||||||
|
setManagerPin("");
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRefundDialog = (row: RecentTransactionRow) => {
|
||||||
|
setRefundTarget(row);
|
||||||
|
setRefundUsd(Number(row.gross_usd) > 0 ? String(Number(row.gross_usd)) : "");
|
||||||
|
setRefundLbp(Number(row.gross_lbp) > 0 ? String(Number(row.gross_lbp)) : "");
|
||||||
|
setRefundReason("");
|
||||||
|
setRefundManagerPin("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const issueRefund = async () => {
|
||||||
|
if (!refundTarget) return;
|
||||||
|
setBusyTxnId(refundTarget.id);
|
||||||
|
const { data, error } = await api.rpc<string>("issue_refund", {
|
||||||
|
p_original_txn: refundTarget.id,
|
||||||
|
p_amount_usd: Number(refundUsd) || 0,
|
||||||
|
p_amount_lbp: Number(refundLbp) || 0,
|
||||||
|
p_reason: refundReason,
|
||||||
|
p_manager_pin: refundManagerPin,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
setBusyTxnId(null);
|
||||||
|
toast({ title: "Could not issue refund", description: error.message, variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
await api.rpc<ReceiptPrintRow[] | ReceiptPrintRow>("record_receipt_print", {
|
||||||
|
p_txn_id: data,
|
||||||
|
p_kind: "original",
|
||||||
|
p_device: "transaction-center-refund",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusyTxnId(null);
|
||||||
|
toast({ title: "Refund issued", description: `Refund recorded against receipt #${refundTarget.reference_no}.` });
|
||||||
|
setRefundTarget(null);
|
||||||
|
setRefundUsd("");
|
||||||
|
setRefundLbp("");
|
||||||
|
setRefundReason("");
|
||||||
|
setRefundManagerPin("");
|
||||||
|
await refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg font-semibold text-slate-800">Transactions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-[1fr_180px_auto] gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="Search receipt, service, reference, beneficiary"
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select value={statusFilter} onValueChange={(value) => setStatusFilter(value as "all" | TxnStatus)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
<SelectItem value="completed">Completed</SelectItem>
|
||||||
|
<SelectItem value="voided">Voided</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" onClick={refresh} disabled={loading}>Refresh</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border bg-white overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-slate-50">
|
||||||
|
<TableHead>Receipt</TableHead>
|
||||||
|
<TableHead>Service</TableHead>
|
||||||
|
<TableHead>Amount</TableHead>
|
||||||
|
<TableHead>External Ref</TableHead>
|
||||||
|
<TableHead>Customer</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredRows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
<TableCell className="font-mono">#{row.reference_no}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-slate-800">{row.service_name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{row.payment_method}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
<div>USD {fmtUsd(row.gross_usd)}</div>
|
||||||
|
<div>LBP {fmtLbp(row.gross_lbp)}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[180px] truncate">{row.external_ref || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div>{row.beneficiary_name || "-"}</div>
|
||||||
|
<div className="text-xs text-slate-500">{row.beneficiary_phone || ""}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className={row.status === "completed" ? "text-emerald-700" : "text-rose-700 font-medium"}>
|
||||||
|
{row.status}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{new Date(row.occurred_at).toLocaleString()}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => { setSelected(row); setReceipt(null); }}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => reprintReceipt(row)} disabled={busyTxnId === row.id}>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setVoidTarget(row)}
|
||||||
|
disabled={row.status !== "completed" || busyTxnId === row.id}
|
||||||
|
className="text-rose-700 border-rose-200 hover:text-rose-800"
|
||||||
|
>
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => openRefundDialog(row)}
|
||||||
|
disabled={row.status !== "completed" || row.service_code === "REFUND" || busyTxnId === row.id}
|
||||||
|
className="text-amber-700 border-amber-200 hover:text-amber-800"
|
||||||
|
>
|
||||||
|
<BadgeDollarSign className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!filteredRows.length && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="py-8 text-center text-slate-500">
|
||||||
|
{loading ? "Loading transactions..." : "No matching transactions."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
|
||||||
|
<DialogContent className="w-[min(96vw,620px)] sm:max-w-[620px] bg-white">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Receipt #{selected?.reference_no}</DialogTitle>
|
||||||
|
<DialogDescription>{selected?.service_name} transaction details and verification data.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{selected && (
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><div className="text-slate-500">Transaction ID</div><div className="font-mono break-all">{selected.id}</div></div>
|
||||||
|
<div><div className="text-slate-500">Status</div><div>{selected.status}</div></div>
|
||||||
|
<div><div className="text-slate-500">External reference</div><div>{selected.external_ref || "Not captured"}</div></div>
|
||||||
|
<div><div className="text-slate-500">Occurred</div><div>{new Date(selected.occurred_at).toLocaleString()}</div></div>
|
||||||
|
<div><div className="text-slate-500">USD</div><div className="font-mono">{fmtUsd(selected.gross_usd)}</div></div>
|
||||||
|
<div><div className="text-slate-500">LBP</div><div className="font-mono">{fmtLbp(selected.gross_lbp)}</div></div>
|
||||||
|
</div>
|
||||||
|
{receipt && (
|
||||||
|
<div className="rounded-lg border bg-slate-50 p-3 space-y-1">
|
||||||
|
<div className="font-medium text-slate-800">Reprint logged</div>
|
||||||
|
<div>Receipt log ID: <span className="font-mono">{receipt.receipt_id}</span></div>
|
||||||
|
<div className="break-all">QR token: <span className="font-mono">{receipt.qr_token}</span></div>
|
||||||
|
<div>PDF: <span className="font-mono">{receipt.pdf_url ?? "Not generated yet"}</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
{selected && <Button variant="outline" onClick={() => reprintReceipt(selected)}><Printer className="h-4 w-4 mr-2" />Log reprint</Button>}
|
||||||
|
<Button onClick={() => setSelected(null)}>Close</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={!!voidTarget} onOpenChange={(open) => !open && setVoidTarget(null)}>
|
||||||
|
<DialogContent className="w-[min(96vw,520px)] sm:max-w-[520px] bg-white">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Void receipt #{voidTarget?.reference_no}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Voiding reverses cash, float, stock, and voucher movements. Manager PIN is required after the self-void window or for another cashier's transaction.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>Reason</Label>
|
||||||
|
<Textarea value={voidReason} onChange={(event) => setVoidReason(event.target.value)} placeholder="At least 5 characters" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Manager PIN when required</Label>
|
||||||
|
<Input type="password" value={managerPin} onChange={(event) => setManagerPin(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setVoidTarget(null)}>Cancel</Button>
|
||||||
|
<Button onClick={voidTransaction} disabled={!voidReason.trim() || busyTxnId === voidTarget?.id} className="bg-rose-700 hover:bg-rose-800 text-white">
|
||||||
|
<RotateCcw className="h-4 w-4 mr-2" />Void and reverse
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={!!refundTarget} onOpenChange={(open) => !open && setRefundTarget(null)}>
|
||||||
|
<DialogContent className="w-[min(96vw,540px)] sm:max-w-[540px] bg-white">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Refund receipt #{refundTarget?.reference_no}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Refunds are linked to the original transaction and require manager role, manager PIN, and an open manager shift in the same shop.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label>Refund USD</Label>
|
||||||
|
<Input type="number" step="0.01" min="0" value={refundUsd} onChange={(event) => setRefundUsd(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Refund LBP</Label>
|
||||||
|
<Input type="number" step="1" min="0" value={refundLbp} onChange={(event) => setRefundLbp(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
Original amount: USD {fmtUsd(refundTarget?.gross_usd)} / LBP {fmtLbp(refundTarget?.gross_lbp)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Reason</Label>
|
||||||
|
<Textarea value={refundReason} onChange={(event) => setRefundReason(event.target.value)} placeholder="At least 5 characters" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Manager PIN</Label>
|
||||||
|
<Input type="password" value={refundManagerPin} onChange={(event) => setRefundManagerPin(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setRefundTarget(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={issueRefund}
|
||||||
|
disabled={
|
||||||
|
!refundReason.trim()
|
||||||
|
|| !refundManagerPin
|
||||||
|
|| ((Number(refundUsd) || 0) <= 0 && (Number(refundLbp) || 0) <= 0)
|
||||||
|
|| busyTxnId === refundTarget?.id
|
||||||
|
}
|
||||||
|
className="bg-amber-700 hover:bg-amber-800 text-white"
|
||||||
|
>
|
||||||
|
<BadgeDollarSign className="h-4 w-4 mr-2" />Issue refund
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,262 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
type UserRole = "admin" | "owner" | "employee";
|
||||||
|
|
||||||
|
interface AdminUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
is_active: boolean;
|
||||||
|
full_name: string | null;
|
||||||
|
is_system_admin: boolean;
|
||||||
|
shop_role: "owner" | "manager" | "cashier" | "auditor";
|
||||||
|
emp_id: string | null;
|
||||||
|
department: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Shop {
|
||||||
|
shop_id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayRole(user: AdminUser): UserRole {
|
||||||
|
// ensure we handle cases where it comes in as string "true" somehow, though API should send boolean
|
||||||
|
if (user.is_system_admin === true || String(user.is_system_admin) === "true") return "admin";
|
||||||
|
if (user.shop_role === "owner") return "owner";
|
||||||
|
return "employee";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserManagement = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { employees, refreshData } = useSupabaseEmployeeData();
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
|
const [shops, setShops] = useState<Shop[]>([]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [empId, setEmpId] = useState("");
|
||||||
|
const [department, setDepartment] = useState("Collections");
|
||||||
|
const [role, setRole] = useState<UserRole>("employee");
|
||||||
|
const [shopId, setShopId] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const { data: userData, error: userError } = await api.get<AdminUser[]>("/admin/users");
|
||||||
|
if (userError) {
|
||||||
|
toast({ title: "Could not load users", description: userError.message, variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUsers(userData ?? []);
|
||||||
|
|
||||||
|
const { data: shopsData } = await api.fromView<Shop>("v_my_shops");
|
||||||
|
setShops(shopsData ?? []);
|
||||||
|
if (shopsData && shopsData.length > 0 && !shopId) {
|
||||||
|
setShopId(shopsData[0].shop_id);
|
||||||
|
}
|
||||||
|
}, [toast, shopId]);
|
||||||
|
|
||||||
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const finalEmpId =
|
||||||
|
role === "employee"
|
||||||
|
? empId.trim() || `EMP${String(employees.length + 1).padStart(3, "0")}`
|
||||||
|
: undefined;
|
||||||
|
const finalDep = role === "employee" ? department : undefined;
|
||||||
|
const finalShopId = role !== "admin" ? shopId : undefined;
|
||||||
|
|
||||||
|
const { error } = await api.post<AdminUser>("/admin/users", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
department: finalDep,
|
||||||
|
empId: finalEmpId,
|
||||||
|
shopId: finalShopId
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
toast({ title: "Could not create user", description: error.message, variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast({ title: "User created", description: `${name} (${email})` });
|
||||||
|
setName("");
|
||||||
|
setEmail("");
|
||||||
|
setPassword("");
|
||||||
|
setEmpId("");
|
||||||
|
setDepartment("Collections");
|
||||||
|
setRole("employee");
|
||||||
|
await Promise.all([refresh(), refreshData()]);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
const { error } = await api.delete(`/admin/users/${id}`);
|
||||||
|
if (error) {
|
||||||
|
toast({
|
||||||
|
title: "Could not delete user",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast({ title: "User deleted" });
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">Create User</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="Full name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password (min 6)"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value as UserRole)}
|
||||||
|
className="h-10 px-3 border border-gray-300 rounded-md bg-white"
|
||||||
|
>
|
||||||
|
<option value="employee">Employee</option>
|
||||||
|
<option value="owner">Owner</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{role !== "admin" && shops.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={shopId}
|
||||||
|
onChange={(e) => setShopId(e.target.value)}
|
||||||
|
className="h-10 px-3 border border-gray-300 rounded-md bg-white"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>Select Shop</option>
|
||||||
|
{shops.map(s => (
|
||||||
|
<option key={s.shop_id} value={s.shop_id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{role === "employee" && (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
placeholder="Department"
|
||||||
|
value={department}
|
||||||
|
onChange={(e) => setDepartment(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Employee ID (optional)"
|
||||||
|
value={empId}
|
||||||
|
onChange={(e) => setEmpId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className="bg-orange-500 hover:bg-orange-600 text-white"
|
||||||
|
>
|
||||||
|
{busy ? "Adding..." : "Add User"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Users ({users.length})
|
||||||
|
</h2>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Department</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.map((u) => {
|
||||||
|
const r = displayRole(u);
|
||||||
|
return (
|
||||||
|
<TableRow key={u.id}>
|
||||||
|
<TableCell className="font-medium">{u.full_name ?? "-"}</TableCell>
|
||||||
|
<TableCell>{u.email}</TableCell>
|
||||||
|
<TableCell>{u.department ?? "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
r === "admin"
|
||||||
|
? "px-2 py-1 text-xs rounded bg-purple-100 text-purple-700"
|
||||||
|
: r === "owner"
|
||||||
|
? "px-2 py-1 text-xs rounded bg-emerald-100 text-emerald-700"
|
||||||
|
: "px-2 py-1 text-xs rounded bg-gray-100 text-gray-700"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(u.id)}
|
||||||
|
className="text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{users.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center text-gray-500">
|
||||||
|
No users yet
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
|
||||||
import { ChevronDown } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Accordion = AccordionPrimitive.Root
|
|
||||||
|
|
||||||
const AccordionItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AccordionPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn("border-b", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AccordionItem.displayName = "AccordionItem"
|
|
||||||
|
|
||||||
const AccordionTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<AccordionPrimitive.Header className="flex">
|
|
||||||
<AccordionPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
||||||
</AccordionPrimitive.Trigger>
|
|
||||||
</AccordionPrimitive.Header>
|
|
||||||
))
|
|
||||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const AccordionContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<AccordionPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
|
||||||
</AccordionPrimitive.Content>
|
|
||||||
))
|
|
||||||
|
|
||||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
|
||||||
|
|
||||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { buttonVariants } from "@/components/ui/button"
|
|
||||||
|
|
||||||
const AlertDialog = AlertDialogPrimitive.Root
|
|
||||||
|
|
||||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
|
||||||
|
|
||||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
|
||||||
|
|
||||||
const AlertDialogOverlay = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AlertDialogPrimitive.Overlay
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
|
||||||
|
|
||||||
const AlertDialogContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AlertDialogPortal>
|
|
||||||
<AlertDialogOverlay />
|
|
||||||
<AlertDialogPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</AlertDialogPortal>
|
|
||||||
))
|
|
||||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const AlertDialogHeader = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col space-y-2 text-center sm:text-left",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
|
||||||
|
|
||||||
const AlertDialogFooter = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
|
||||||
|
|
||||||
const AlertDialogTitle = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AlertDialogPrimitive.Title
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-lg font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
|
||||||
|
|
||||||
const AlertDialogDescription = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AlertDialogPrimitive.Description
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AlertDialogDescription.displayName =
|
|
||||||
AlertDialogPrimitive.Description.displayName
|
|
||||||
|
|
||||||
const AlertDialogAction = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AlertDialogPrimitive.Action
|
|
||||||
ref={ref}
|
|
||||||
className={cn(buttonVariants(), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
|
||||||
|
|
||||||
const AlertDialogCancel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AlertDialogPrimitive.Cancel
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({ variant: "outline" }),
|
|
||||||
"mt-2 sm:mt-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogPortal,
|
|
||||||
AlertDialogOverlay,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const alertVariants = cva(
|
|
||||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-background text-foreground",
|
|
||||||
destructive:
|
|
||||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const Alert = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
|
||||||
>(({ className, variant, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
role="alert"
|
|
||||||
className={cn(alertVariants({ variant }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Alert.displayName = "Alert"
|
|
||||||
|
|
||||||
const AlertTitle = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<h5
|
|
||||||
ref={ref}
|
|
||||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AlertTitle.displayName = "AlertTitle"
|
|
||||||
|
|
||||||
const AlertDescription = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AlertDescription.displayName = "AlertDescription"
|
|
||||||
|
|
||||||
export { Alert, AlertTitle, AlertDescription }
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
|
||||||
|
|
||||||
const AspectRatio = AspectRatioPrimitive.Root
|
|
||||||
|
|
||||||
export { AspectRatio }
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Avatar = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AvatarPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
|
||||||
|
|
||||||
const AvatarImage = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AvatarPrimitive.Image
|
|
||||||
ref={ref}
|
|
||||||
className={cn("aspect-square h-full w-full", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
|
||||||
|
|
||||||
const AvatarFallback = React.forwardRef<
|
|
||||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<AvatarPrimitive.Fallback
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
|
||||||
|
|
||||||
export { Avatar, AvatarImage, AvatarFallback }
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const badgeVariants = cva(
|
|
||||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default:
|
|
||||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
|
||||||
secondary:
|
|
||||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
||||||
destructive:
|
|
||||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
|
||||||
outline: "text-foreground",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface BadgeProps
|
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
|
||||||
VariantProps<typeof badgeVariants> {}
|
|
||||||
|
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
|
||||||
return (
|
|
||||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Badge, badgeVariants }
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Breadcrumb = React.forwardRef<
|
|
||||||
HTMLElement,
|
|
||||||
React.ComponentPropsWithoutRef<"nav"> & {
|
|
||||||
separator?: React.ReactNode
|
|
||||||
}
|
|
||||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
|
||||||
Breadcrumb.displayName = "Breadcrumb"
|
|
||||||
|
|
||||||
const BreadcrumbList = React.forwardRef<
|
|
||||||
HTMLOListElement,
|
|
||||||
React.ComponentPropsWithoutRef<"ol">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ol
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
BreadcrumbList.displayName = "BreadcrumbList"
|
|
||||||
|
|
||||||
const BreadcrumbItem = React.forwardRef<
|
|
||||||
HTMLLIElement,
|
|
||||||
React.ComponentPropsWithoutRef<"li">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<li
|
|
||||||
ref={ref}
|
|
||||||
className={cn("inline-flex items-center gap-1.5", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
|
||||||
|
|
||||||
const BreadcrumbLink = React.forwardRef<
|
|
||||||
HTMLAnchorElement,
|
|
||||||
React.ComponentPropsWithoutRef<"a"> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}
|
|
||||||
>(({ asChild, className, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "a"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
ref={ref}
|
|
||||||
className={cn("transition-colors hover:text-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
BreadcrumbLink.displayName = "BreadcrumbLink"
|
|
||||||
|
|
||||||
const BreadcrumbPage = React.forwardRef<
|
|
||||||
HTMLSpanElement,
|
|
||||||
React.ComponentPropsWithoutRef<"span">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<span
|
|
||||||
ref={ref}
|
|
||||||
role="link"
|
|
||||||
aria-disabled="true"
|
|
||||||
aria-current="page"
|
|
||||||
className={cn("font-normal text-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
BreadcrumbPage.displayName = "BreadcrumbPage"
|
|
||||||
|
|
||||||
const BreadcrumbSeparator = ({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"li">) => (
|
|
||||||
<li
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("[&>svg]:size-3.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children ?? <ChevronRight />}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
|
||||||
|
|
||||||
const BreadcrumbEllipsis = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) => (
|
|
||||||
<span
|
|
||||||
role="presentation"
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
<span className="sr-only">More</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
|
||||||
|
|
||||||
export {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
BreadcrumbEllipsis,
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import useEmblaCarousel, {
|
|
||||||
type UseEmblaCarouselType,
|
|
||||||
} from "embla-carousel-react"
|
|
||||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
type CarouselApi = UseEmblaCarouselType[1]
|
|
||||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
|
||||||
type CarouselOptions = UseCarouselParameters[0]
|
|
||||||
type CarouselPlugin = UseCarouselParameters[1]
|
|
||||||
|
|
||||||
type CarouselProps = {
|
|
||||||
opts?: CarouselOptions
|
|
||||||
plugins?: CarouselPlugin
|
|
||||||
orientation?: "horizontal" | "vertical"
|
|
||||||
setApi?: (api: CarouselApi) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type CarouselContextProps = {
|
|
||||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
|
||||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
|
||||||
scrollPrev: () => void
|
|
||||||
scrollNext: () => void
|
|
||||||
canScrollPrev: boolean
|
|
||||||
canScrollNext: boolean
|
|
||||||
} & CarouselProps
|
|
||||||
|
|
||||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
|
||||||
|
|
||||||
function useCarousel() {
|
|
||||||
const context = React.useContext(CarouselContext)
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useCarousel must be used within a <Carousel />")
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
const Carousel = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
orientation = "horizontal",
|
|
||||||
opts,
|
|
||||||
setApi,
|
|
||||||
plugins,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const [carouselRef, api] = useEmblaCarousel(
|
|
||||||
{
|
|
||||||
...opts,
|
|
||||||
axis: orientation === "horizontal" ? "x" : "y",
|
|
||||||
},
|
|
||||||
plugins
|
|
||||||
)
|
|
||||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
|
||||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
|
||||||
|
|
||||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
|
||||||
if (!api) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setCanScrollPrev(api.canScrollPrev())
|
|
||||||
setCanScrollNext(api.canScrollNext())
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const scrollPrev = React.useCallback(() => {
|
|
||||||
api?.scrollPrev()
|
|
||||||
}, [api])
|
|
||||||
|
|
||||||
const scrollNext = React.useCallback(() => {
|
|
||||||
api?.scrollNext()
|
|
||||||
}, [api])
|
|
||||||
|
|
||||||
const handleKeyDown = React.useCallback(
|
|
||||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
if (event.key === "ArrowLeft") {
|
|
||||||
event.preventDefault()
|
|
||||||
scrollPrev()
|
|
||||||
} else if (event.key === "ArrowRight") {
|
|
||||||
event.preventDefault()
|
|
||||||
scrollNext()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[scrollPrev, scrollNext]
|
|
||||||
)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!api || !setApi) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setApi(api)
|
|
||||||
}, [api, setApi])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!api) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onSelect(api)
|
|
||||||
api.on("reInit", onSelect)
|
|
||||||
api.on("select", onSelect)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
api?.off("select", onSelect)
|
|
||||||
}
|
|
||||||
}, [api, onSelect])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CarouselContext.Provider
|
|
||||||
value={{
|
|
||||||
carouselRef,
|
|
||||||
api: api,
|
|
||||||
opts,
|
|
||||||
orientation:
|
|
||||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
|
||||||
scrollPrev,
|
|
||||||
scrollNext,
|
|
||||||
canScrollPrev,
|
|
||||||
canScrollNext,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
onKeyDownCapture={handleKeyDown}
|
|
||||||
className={cn("relative", className)}
|
|
||||||
role="region"
|
|
||||||
aria-roledescription="carousel"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</CarouselContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Carousel.displayName = "Carousel"
|
|
||||||
|
|
||||||
const CarouselContent = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const { carouselRef, orientation } = useCarousel()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={carouselRef} className="overflow-hidden">
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex",
|
|
||||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
CarouselContent.displayName = "CarouselContent"
|
|
||||||
|
|
||||||
const CarouselItem = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const { orientation } = useCarousel()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
role="group"
|
|
||||||
aria-roledescription="slide"
|
|
||||||
className={cn(
|
|
||||||
"min-w-0 shrink-0 grow-0 basis-full",
|
|
||||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
CarouselItem.displayName = "CarouselItem"
|
|
||||||
|
|
||||||
const CarouselPrevious = React.forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
React.ComponentProps<typeof Button>
|
|
||||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
|
||||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
ref={ref}
|
|
||||||
variant={variant}
|
|
||||||
size={size}
|
|
||||||
className={cn(
|
|
||||||
"absolute h-8 w-8 rounded-full",
|
|
||||||
orientation === "horizontal"
|
|
||||||
? "-left-12 top-1/2 -translate-y-1/2"
|
|
||||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
disabled={!canScrollPrev}
|
|
||||||
onClick={scrollPrev}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Previous slide</span>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
CarouselPrevious.displayName = "CarouselPrevious"
|
|
||||||
|
|
||||||
const CarouselNext = React.forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
React.ComponentProps<typeof Button>
|
|
||||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
|
||||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
ref={ref}
|
|
||||||
variant={variant}
|
|
||||||
size={size}
|
|
||||||
className={cn(
|
|
||||||
"absolute h-8 w-8 rounded-full",
|
|
||||||
orientation === "horizontal"
|
|
||||||
? "-right-12 top-1/2 -translate-y-1/2"
|
|
||||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
disabled={!canScrollNext}
|
|
||||||
onClick={scrollNext}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ArrowRight className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Next slide</span>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
CarouselNext.displayName = "CarouselNext"
|
|
||||||
|
|
||||||
export {
|
|
||||||
type CarouselApi,
|
|
||||||
Carousel,
|
|
||||||
CarouselContent,
|
|
||||||
CarouselItem,
|
|
||||||
CarouselPrevious,
|
|
||||||
CarouselNext,
|
|
||||||
}
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as RechartsPrimitive from "recharts"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
|
||||||
const THEMES = { light: "", dark: ".dark" } as const
|
|
||||||
|
|
||||||
export type ChartConfig = {
|
|
||||||
[k in string]: {
|
|
||||||
label?: React.ReactNode
|
|
||||||
icon?: React.ComponentType
|
|
||||||
} & (
|
|
||||||
| { color?: string; theme?: never }
|
|
||||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChartContextProps = {
|
|
||||||
config: ChartConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
|
||||||
|
|
||||||
function useChart() {
|
|
||||||
const context = React.useContext(ChartContext)
|
|
||||||
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useChart must be used within a <ChartContainer />")
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChartContainer = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div"> & {
|
|
||||||
config: ChartConfig
|
|
||||||
children: React.ComponentProps<
|
|
||||||
typeof RechartsPrimitive.ResponsiveContainer
|
|
||||||
>["children"]
|
|
||||||
}
|
|
||||||
>(({ id, className, children, config, ...props }, ref) => {
|
|
||||||
const uniqueId = React.useId()
|
|
||||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChartContext.Provider value={{ config }}>
|
|
||||||
<div
|
|
||||||
data-chart={chartId}
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChartStyle id={chartId} config={config} />
|
|
||||||
<RechartsPrimitive.ResponsiveContainer>
|
|
||||||
{children}
|
|
||||||
</RechartsPrimitive.ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</ChartContext.Provider>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
ChartContainer.displayName = "Chart"
|
|
||||||
|
|
||||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
|
||||||
const colorConfig = Object.entries(config).filter(
|
|
||||||
([_, config]) => config.theme || config.color
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!colorConfig.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<style
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: Object.entries(THEMES)
|
|
||||||
.map(
|
|
||||||
([theme, prefix]) => `
|
|
||||||
${prefix} [data-chart=${id}] {
|
|
||||||
${colorConfig
|
|
||||||
.map(([key, itemConfig]) => {
|
|
||||||
const color =
|
|
||||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
|
||||||
itemConfig.color
|
|
||||||
return color ? ` --color-${key}: ${color};` : null
|
|
||||||
})
|
|
||||||
.join("\n")}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.join("\n"),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
|
||||||
|
|
||||||
const ChartTooltipContent = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
|
||||||
React.ComponentProps<"div"> & {
|
|
||||||
hideLabel?: boolean
|
|
||||||
hideIndicator?: boolean
|
|
||||||
indicator?: "line" | "dot" | "dashed"
|
|
||||||
nameKey?: string
|
|
||||||
labelKey?: string
|
|
||||||
}
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
active,
|
|
||||||
payload,
|
|
||||||
className,
|
|
||||||
indicator = "dot",
|
|
||||||
hideLabel = false,
|
|
||||||
hideIndicator = false,
|
|
||||||
label,
|
|
||||||
labelFormatter,
|
|
||||||
labelClassName,
|
|
||||||
formatter,
|
|
||||||
color,
|
|
||||||
nameKey,
|
|
||||||
labelKey,
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const { config } = useChart()
|
|
||||||
|
|
||||||
const tooltipLabel = React.useMemo(() => {
|
|
||||||
if (hideLabel || !payload?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const [item] = payload
|
|
||||||
const key = `${labelKey || item.dataKey || item.name || "value"}`
|
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
|
||||||
const value =
|
|
||||||
!labelKey && typeof label === "string"
|
|
||||||
? config[label as keyof typeof config]?.label || label
|
|
||||||
: itemConfig?.label
|
|
||||||
|
|
||||||
if (labelFormatter) {
|
|
||||||
return (
|
|
||||||
<div className={cn("font-medium", labelClassName)}>
|
|
||||||
{labelFormatter(value, payload)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
|
||||||
}, [
|
|
||||||
label,
|
|
||||||
labelFormatter,
|
|
||||||
payload,
|
|
||||||
hideLabel,
|
|
||||||
labelClassName,
|
|
||||||
config,
|
|
||||||
labelKey,
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!active || !payload?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{!nestLabel ? tooltipLabel : null}
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
{payload.map((item, index) => {
|
|
||||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
|
||||||
const indicatorColor = color || item.payload.fill || item.color
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.dataKey}
|
|
||||||
className={cn(
|
|
||||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
|
||||||
indicator === "dot" && "items-center"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatter && item?.value !== undefined && item.name ? (
|
|
||||||
formatter(item.value, item.name, item, index, item.payload)
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{itemConfig?.icon ? (
|
|
||||||
<itemConfig.icon />
|
|
||||||
) : (
|
|
||||||
!hideIndicator && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
|
||||||
{
|
|
||||||
"h-2.5 w-2.5": indicator === "dot",
|
|
||||||
"w-1": indicator === "line",
|
|
||||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
|
||||||
indicator === "dashed",
|
|
||||||
"my-0.5": nestLabel && indicator === "dashed",
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--color-bg": indicatorColor,
|
|
||||||
"--color-border": indicatorColor,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-1 justify-between leading-none",
|
|
||||||
nestLabel ? "items-end" : "items-center"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
{nestLabel ? tooltipLabel : null}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{itemConfig?.label || item.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{item.value && (
|
|
||||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
|
||||||
{item.value.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
ChartTooltipContent.displayName = "ChartTooltip"
|
|
||||||
|
|
||||||
const ChartLegend = RechartsPrimitive.Legend
|
|
||||||
|
|
||||||
const ChartLegendContent = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div"> &
|
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
|
||||||
hideIcon?: boolean
|
|
||||||
nameKey?: string
|
|
||||||
}
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const { config } = useChart()
|
|
||||||
|
|
||||||
if (!payload?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-center gap-4",
|
|
||||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{payload.map((item) => {
|
|
||||||
const key = `${nameKey || item.dataKey || "value"}`
|
|
||||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.value}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{itemConfig?.icon && !hideIcon ? (
|
|
||||||
<itemConfig.icon />
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
|
||||||
style={{
|
|
||||||
backgroundColor: item.color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{itemConfig?.label}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
ChartLegendContent.displayName = "ChartLegend"
|
|
||||||
|
|
||||||
// Helper to extract item config from a payload.
|
|
||||||
function getPayloadConfigFromPayload(
|
|
||||||
config: ChartConfig,
|
|
||||||
payload: unknown,
|
|
||||||
key: string
|
|
||||||
) {
|
|
||||||
if (typeof payload !== "object" || payload === null) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const payloadPayload =
|
|
||||||
"payload" in payload &&
|
|
||||||
typeof payload.payload === "object" &&
|
|
||||||
payload.payload !== null
|
|
||||||
? payload.payload
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
let configLabelKey: string = key
|
|
||||||
|
|
||||||
if (
|
|
||||||
key in payload &&
|
|
||||||
typeof payload[key as keyof typeof payload] === "string"
|
|
||||||
) {
|
|
||||||
configLabelKey = payload[key as keyof typeof payload] as string
|
|
||||||
} else if (
|
|
||||||
payloadPayload &&
|
|
||||||
key in payloadPayload &&
|
|
||||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
|
||||||
) {
|
|
||||||
configLabelKey = payloadPayload[
|
|
||||||
key as keyof typeof payloadPayload
|
|
||||||
] as string
|
|
||||||
}
|
|
||||||
|
|
||||||
return configLabelKey in config
|
|
||||||
? config[configLabelKey]
|
|
||||||
: config[key as keyof typeof config]
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
ChartContainer,
|
|
||||||
ChartTooltip,
|
|
||||||
ChartTooltipContent,
|
|
||||||
ChartLegend,
|
|
||||||
ChartLegendContent,
|
|
||||||
ChartStyle,
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
|
||||||
import { Check } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Checkbox = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CheckboxPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<CheckboxPrimitive.Indicator
|
|
||||||
className={cn("flex items-center justify-center text-current")}
|
|
||||||
>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</CheckboxPrimitive.Indicator>
|
|
||||||
</CheckboxPrimitive.Root>
|
|
||||||
))
|
|
||||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Checkbox }
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
|
||||||
|
|
||||||
const Collapsible = CollapsiblePrimitive.Root
|
|
||||||
|
|
||||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
|
||||||
|
|
||||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
|
||||||
|
|
||||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
|
||||||
import { Command as CommandPrimitive } from "cmdk"
|
|
||||||
import { Search } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
|
||||||
|
|
||||||
const Command = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Command.displayName = CommandPrimitive.displayName
|
|
||||||
|
|
||||||
interface CommandDialogProps extends DialogProps {}
|
|
||||||
|
|
||||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
|
||||||
return (
|
|
||||||
<Dialog {...props}>
|
|
||||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
|
||||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
|
||||||
{children}
|
|
||||||
</Command>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const CommandInput = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
<CommandPrimitive.Input
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
|
||||||
|
|
||||||
const CommandList = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.List>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.List
|
|
||||||
ref={ref}
|
|
||||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandList.displayName = CommandPrimitive.List.displayName
|
|
||||||
|
|
||||||
const CommandEmpty = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
|
||||||
>((props, ref) => (
|
|
||||||
<CommandPrimitive.Empty
|
|
||||||
ref={ref}
|
|
||||||
className="py-6 text-center text-sm"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
|
||||||
|
|
||||||
const CommandGroup = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.Group
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
|
||||||
|
|
||||||
const CommandSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 h-px bg-border", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
const CommandItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<CommandPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const CommandShortcut = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
CommandShortcut.displayName = "CommandShortcut"
|
|
||||||
|
|
||||||
export {
|
|
||||||
Command,
|
|
||||||
CommandDialog,
|
|
||||||
CommandInput,
|
|
||||||
CommandList,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandItem,
|
|
||||||
CommandShortcut,
|
|
||||||
CommandSeparator,
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const ContextMenu = ContextMenuPrimitive.Root
|
|
||||||
|
|
||||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
|
|
||||||
|
|
||||||
const ContextMenuGroup = ContextMenuPrimitive.Group
|
|
||||||
|
|
||||||
const ContextMenuPortal = ContextMenuPrimitive.Portal
|
|
||||||
|
|
||||||
const ContextMenuSub = ContextMenuPrimitive.Sub
|
|
||||||
|
|
||||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
|
|
||||||
|
|
||||||
const ContextMenuSubTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.SubTrigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
|
||||||
</ContextMenuPrimitive.SubTrigger>
|
|
||||||
))
|
|
||||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
|
|
||||||
|
|
||||||
const ContextMenuSubContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.SubContent
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
|
|
||||||
|
|
||||||
const ContextMenuContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.Portal>
|
|
||||||
<ContextMenuPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</ContextMenuPrimitive.Portal>
|
|
||||||
))
|
|
||||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const ContextMenuItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const ContextMenuCheckboxItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.CheckboxItem
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
checked={checked}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<ContextMenuPrimitive.ItemIndicator>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</ContextMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</ContextMenuPrimitive.CheckboxItem>
|
|
||||||
))
|
|
||||||
ContextMenuCheckboxItem.displayName =
|
|
||||||
ContextMenuPrimitive.CheckboxItem.displayName
|
|
||||||
|
|
||||||
const ContextMenuRadioItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.RadioItem
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<ContextMenuPrimitive.ItemIndicator>
|
|
||||||
<Circle className="h-2 w-2 fill-current" />
|
|
||||||
</ContextMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</ContextMenuPrimitive.RadioItem>
|
|
||||||
))
|
|
||||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
|
|
||||||
|
|
||||||
const ContextMenuLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.Label
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1.5 text-sm font-semibold text-foreground",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
|
|
||||||
|
|
||||||
const ContextMenuSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ContextMenuPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
const ContextMenuShortcut = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
|
||||||
|
|
||||||
export {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuCheckboxItem,
|
|
||||||
ContextMenuRadioItem,
|
|
||||||
ContextMenuLabel,
|
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuShortcut,
|
|
||||||
ContextMenuGroup,
|
|
||||||
ContextMenuPortal,
|
|
||||||
ContextMenuSub,
|
|
||||||
ContextMenuSubContent,
|
|
||||||
ContextMenuSubTrigger,
|
|
||||||
ContextMenuRadioGroup,
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Drawer as DrawerPrimitive } from "vaul"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Drawer = ({
|
|
||||||
shouldScaleBackground = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
|
||||||
<DrawerPrimitive.Root
|
|
||||||
shouldScaleBackground={shouldScaleBackground}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
Drawer.displayName = "Drawer"
|
|
||||||
|
|
||||||
const DrawerTrigger = DrawerPrimitive.Trigger
|
|
||||||
|
|
||||||
const DrawerPortal = DrawerPrimitive.Portal
|
|
||||||
|
|
||||||
const DrawerClose = DrawerPrimitive.Close
|
|
||||||
|
|
||||||
const DrawerOverlay = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DrawerPrimitive.Overlay
|
|
||||||
ref={ref}
|
|
||||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
|
||||||
|
|
||||||
const DrawerContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<DrawerPortal>
|
|
||||||
<DrawerOverlay />
|
|
||||||
<DrawerPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
|
||||||
{children}
|
|
||||||
</DrawerPrimitive.Content>
|
|
||||||
</DrawerPortal>
|
|
||||||
))
|
|
||||||
DrawerContent.displayName = "DrawerContent"
|
|
||||||
|
|
||||||
const DrawerHeader = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
DrawerHeader.displayName = "DrawerHeader"
|
|
||||||
|
|
||||||
const DrawerFooter = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
DrawerFooter.displayName = "DrawerFooter"
|
|
||||||
|
|
||||||
const DrawerTitle = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DrawerPrimitive.Title
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
|
||||||
|
|
||||||
const DrawerDescription = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DrawerPrimitive.Description
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
Drawer,
|
|
||||||
DrawerPortal,
|
|
||||||
DrawerOverlay,
|
|
||||||
DrawerTrigger,
|
|
||||||
DrawerClose,
|
|
||||||
DrawerContent,
|
|
||||||
DrawerHeader,
|
|
||||||
DrawerFooter,
|
|
||||||
DrawerTitle,
|
|
||||||
DrawerDescription,
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
|
||||||
|
|
||||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
|
||||||
|
|
||||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
|
||||||
|
|
||||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
|
||||||
|
|
||||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
|
||||||
|
|
||||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
|
||||||
|
|
||||||
const DropdownMenuSubTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
|
||||||
))
|
|
||||||
DropdownMenuSubTrigger.displayName =
|
|
||||||
DropdownMenuPrimitive.SubTrigger.displayName
|
|
||||||
|
|
||||||
const DropdownMenuSubContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.SubContent
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DropdownMenuSubContent.displayName =
|
|
||||||
DropdownMenuPrimitive.SubContent.displayName
|
|
||||||
|
|
||||||
const DropdownMenuContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Portal>
|
|
||||||
<DropdownMenuPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</DropdownMenuPrimitive.Portal>
|
|
||||||
))
|
|
||||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const DropdownMenuItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
checked={checked}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
))
|
|
||||||
DropdownMenuCheckboxItem.displayName =
|
|
||||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
|
||||||
|
|
||||||
const DropdownMenuRadioItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.RadioItem
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<Circle className="h-2 w-2 fill-current" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
|
||||||
))
|
|
||||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
|
||||||
|
|
||||||
const DropdownMenuLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Label
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1.5 text-sm font-semibold",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
|
||||||
|
|
||||||
const DropdownMenuSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<DropdownMenuPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
const DropdownMenuShortcut = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
|
||||||
|
|
||||||
export {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import {
|
|
||||||
Controller,
|
|
||||||
ControllerProps,
|
|
||||||
FieldPath,
|
|
||||||
FieldValues,
|
|
||||||
FormProvider,
|
|
||||||
useFormContext,
|
|
||||||
} from "react-hook-form"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
|
|
||||||
const Form = FormProvider
|
|
||||||
|
|
||||||
type FormFieldContextValue<
|
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
||||||
> = {
|
|
||||||
name: TName
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
|
||||||
{} as FormFieldContextValue
|
|
||||||
)
|
|
||||||
|
|
||||||
const FormField = <
|
|
||||||
TFieldValues extends FieldValues = FieldValues,
|
|
||||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
|
||||||
>({
|
|
||||||
...props
|
|
||||||
}: ControllerProps<TFieldValues, TName>) => {
|
|
||||||
return (
|
|
||||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
|
||||||
<Controller {...props} />
|
|
||||||
</FormFieldContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const useFormField = () => {
|
|
||||||
const fieldContext = React.useContext(FormFieldContext)
|
|
||||||
const itemContext = React.useContext(FormItemContext)
|
|
||||||
const { getFieldState, formState } = useFormContext()
|
|
||||||
|
|
||||||
const fieldState = getFieldState(fieldContext.name, formState)
|
|
||||||
|
|
||||||
if (!fieldContext) {
|
|
||||||
throw new Error("useFormField should be used within <FormField>")
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id } = itemContext
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: fieldContext.name,
|
|
||||||
formItemId: `${id}-form-item`,
|
|
||||||
formDescriptionId: `${id}-form-item-description`,
|
|
||||||
formMessageId: `${id}-form-item-message`,
|
|
||||||
...fieldState,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormItemContextValue = {
|
|
||||||
id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
|
||||||
{} as FormItemContextValue
|
|
||||||
)
|
|
||||||
|
|
||||||
const FormItem = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const id = React.useId()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormItemContext.Provider value={{ id }}>
|
|
||||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
|
||||||
</FormItemContext.Provider>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
FormItem.displayName = "FormItem"
|
|
||||||
|
|
||||||
const FormLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const { error, formItemId } = useFormField()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Label
|
|
||||||
ref={ref}
|
|
||||||
className={cn(error && "text-destructive", className)}
|
|
||||||
htmlFor={formItemId}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
FormLabel.displayName = "FormLabel"
|
|
||||||
|
|
||||||
const FormControl = React.forwardRef<
|
|
||||||
React.ElementRef<typeof Slot>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof Slot>
|
|
||||||
>(({ ...props }, ref) => {
|
|
||||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Slot
|
|
||||||
ref={ref}
|
|
||||||
id={formItemId}
|
|
||||||
aria-describedby={
|
|
||||||
!error
|
|
||||||
? `${formDescriptionId}`
|
|
||||||
: `${formDescriptionId} ${formMessageId}`
|
|
||||||
}
|
|
||||||
aria-invalid={!!error}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
FormControl.displayName = "FormControl"
|
|
||||||
|
|
||||||
const FormDescription = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const { formDescriptionId } = useFormField()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
ref={ref}
|
|
||||||
id={formDescriptionId}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
FormDescription.displayName = "FormDescription"
|
|
||||||
|
|
||||||
const FormMessage = React.forwardRef<
|
|
||||||
HTMLParagraphElement,
|
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
|
||||||
>(({ className, children, ...props }, ref) => {
|
|
||||||
const { error, formMessageId } = useFormField()
|
|
||||||
const body = error ? String(error?.message) : children
|
|
||||||
|
|
||||||
if (!body) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
ref={ref}
|
|
||||||
id={formMessageId}
|
|
||||||
className={cn("text-sm font-medium text-destructive", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{body}
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
FormMessage.displayName = "FormMessage"
|
|
||||||
|
|
||||||
export {
|
|
||||||
useFormField,
|
|
||||||
Form,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormMessage,
|
|
||||||
FormField,
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const HoverCard = HoverCardPrimitive.Root
|
|
||||||
|
|
||||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
|
||||||
|
|
||||||
const HoverCardContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
|
||||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
|
||||||
<HoverCardPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
align={align}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
|
||||||
|
|
||||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { OTPInput, OTPInputContext } from "input-otp"
|
|
||||||
import { Dot } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const InputOTP = React.forwardRef<
|
|
||||||
React.ElementRef<typeof OTPInput>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
|
||||||
>(({ className, containerClassName, ...props }, ref) => (
|
|
||||||
<OTPInput
|
|
||||||
ref={ref}
|
|
||||||
containerClassName={cn(
|
|
||||||
"flex items-center gap-2 has-[:disabled]:opacity-50",
|
|
||||||
containerClassName
|
|
||||||
)}
|
|
||||||
className={cn("disabled:cursor-not-allowed", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
InputOTP.displayName = "InputOTP"
|
|
||||||
|
|
||||||
const InputOTPGroup = React.forwardRef<
|
|
||||||
React.ElementRef<"div">,
|
|
||||||
React.ComponentPropsWithoutRef<"div">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div ref={ref} className={cn("flex items-center", className)} {...props} />
|
|
||||||
))
|
|
||||||
InputOTPGroup.displayName = "InputOTPGroup"
|
|
||||||
|
|
||||||
const InputOTPSlot = React.forwardRef<
|
|
||||||
React.ElementRef<"div">,
|
|
||||||
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
|
||||||
>(({ index, className, ...props }, ref) => {
|
|
||||||
const inputOTPContext = React.useContext(OTPInputContext)
|
|
||||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
|
||||||
isActive && "z-10 ring-2 ring-ring ring-offset-background",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{char}
|
|
||||||
{hasFakeCaret && (
|
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
InputOTPSlot.displayName = "InputOTPSlot"
|
|
||||||
|
|
||||||
const InputOTPSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<"div">,
|
|
||||||
React.ComponentPropsWithoutRef<"div">
|
|
||||||
>(({ ...props }, ref) => (
|
|
||||||
<div ref={ref} role="separator" {...props}>
|
|
||||||
<Dot />
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
InputOTPSeparator.displayName = "InputOTPSeparator"
|
|
||||||
|
|
||||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
|
||||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const MenubarMenu = MenubarPrimitive.Menu
|
|
||||||
|
|
||||||
const MenubarGroup = MenubarPrimitive.Group
|
|
||||||
|
|
||||||
const MenubarPortal = MenubarPrimitive.Portal
|
|
||||||
|
|
||||||
const MenubarSub = MenubarPrimitive.Sub
|
|
||||||
|
|
||||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
|
|
||||||
|
|
||||||
const Menubar = React.forwardRef<
|
|
||||||
React.ElementRef<typeof MenubarPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<MenubarPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
Menubar.displayName = MenubarPrimitive.Root.displayName
|
|
||||||
|
|
||||||
const MenubarTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof MenubarPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<MenubarPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const MenubarSubTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, children, ...props }, ref) => (
|
|
||||||
<MenubarPrimitive.SubTrigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
|
||||||
</MenubarPrimitive.SubTrigger>
|
|
||||||
))
|
|
||||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
|
|
||||||
|
|
||||||
const MenubarSubContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof MenubarPrimitive.SubContent>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<MenubarPrimitive.SubContent
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
|
|
||||||
|
|
||||||
const MenubarContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof MenubarPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
|
|
||||||
ref
|
|
||||||
) => (
|
|
||||||
<MenubarPrimitive.Portal>
|
|
||||||
<MenubarPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
align={align}
|
|
||||||
alignOffset={alignOffset}
|
|
||||||
sideOffset={sideOffset}
|
|
||||||
className={cn(
|
|
||||||
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</MenubarPrimitive.Portal>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const MenubarItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof MenubarPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, ...props }, ref) => (
|
|
||||||
<MenubarPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const MenubarCheckboxItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
|
|
||||||
>(({ className, children, checked, ...props }, ref) => (
|
|
||||||
<MenubarPrimitive.CheckboxItem
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
checked={checked}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<MenubarPrimitive.ItemIndicator>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</MenubarPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</MenubarPrimitive.CheckboxItem>
|
|
||||||
))
|
|
||||||
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
|
|
||||||
|
|
||||||
const MenubarRadioItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<MenubarPrimitive.RadioItem
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
||||||
<MenubarPrimitive.ItemIndicator>
|
|
||||||
<Circle className="h-2 w-2 fill-current" />
|
|
||||||
</MenubarPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</MenubarPrimitive.RadioItem>
|
|
||||||
))
|
|
||||||
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
|
|
||||||
|
|
||||||
const MenubarLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof MenubarPrimitive.Label>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
|
|
||||||
inset?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, inset, ...props }, ref) => (
|
|
||||||
<MenubarPrimitive.Label
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1.5 text-sm font-semibold",
|
|
||||||
inset && "pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
|
|
||||||
|
|
||||||
const MenubarSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof MenubarPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<MenubarPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
const MenubarShortcut = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
MenubarShortcut.displayname = "MenubarShortcut"
|
|
||||||
|
|
||||||
export {
|
|
||||||
Menubar,
|
|
||||||
MenubarMenu,
|
|
||||||
MenubarTrigger,
|
|
||||||
MenubarContent,
|
|
||||||
MenubarItem,
|
|
||||||
MenubarSeparator,
|
|
||||||
MenubarLabel,
|
|
||||||
MenubarCheckboxItem,
|
|
||||||
MenubarRadioGroup,
|
|
||||||
MenubarRadioItem,
|
|
||||||
MenubarPortal,
|
|
||||||
MenubarSubContent,
|
|
||||||
MenubarSubTrigger,
|
|
||||||
MenubarGroup,
|
|
||||||
MenubarSub,
|
|
||||||
MenubarShortcut,
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
|
||||||
import { cva } from "class-variance-authority"
|
|
||||||
import { ChevronDown } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const NavigationMenu = React.forwardRef<
|
|
||||||
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<NavigationMenuPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<NavigationMenuViewport />
|
|
||||||
</NavigationMenuPrimitive.Root>
|
|
||||||
))
|
|
||||||
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
|
|
||||||
|
|
||||||
const NavigationMenuList = React.forwardRef<
|
|
||||||
React.ElementRef<typeof NavigationMenuPrimitive.List>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<NavigationMenuPrimitive.List
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"group flex flex-1 list-none items-center justify-center space-x-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
|
|
||||||
|
|
||||||
const NavigationMenuItem = NavigationMenuPrimitive.Item
|
|
||||||
|
|
||||||
const navigationMenuTriggerStyle = cva(
|
|
||||||
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
|
|
||||||
)
|
|
||||||
|
|
||||||
const NavigationMenuTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<NavigationMenuPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}{" "}
|
|
||||||
<ChevronDown
|
|
||||||
className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</NavigationMenuPrimitive.Trigger>
|
|
||||||
))
|
|
||||||
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const NavigationMenuContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<NavigationMenuPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const NavigationMenuLink = NavigationMenuPrimitive.Link
|
|
||||||
|
|
||||||
const NavigationMenuViewport = React.forwardRef<
|
|
||||||
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div className={cn("absolute left-0 top-full flex justify-center")}>
|
|
||||||
<NavigationMenuPrimitive.Viewport
|
|
||||||
className={cn(
|
|
||||||
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
NavigationMenuViewport.displayName =
|
|
||||||
NavigationMenuPrimitive.Viewport.displayName
|
|
||||||
|
|
||||||
const NavigationMenuIndicator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<NavigationMenuPrimitive.Indicator
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
|
||||||
</NavigationMenuPrimitive.Indicator>
|
|
||||||
))
|
|
||||||
NavigationMenuIndicator.displayName =
|
|
||||||
NavigationMenuPrimitive.Indicator.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
navigationMenuTriggerStyle,
|
|
||||||
NavigationMenu,
|
|
||||||
NavigationMenuList,
|
|
||||||
NavigationMenuItem,
|
|
||||||
NavigationMenuContent,
|
|
||||||
NavigationMenuTrigger,
|
|
||||||
NavigationMenuLink,
|
|
||||||
NavigationMenuIndicator,
|
|
||||||
NavigationMenuViewport,
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { ButtonProps, buttonVariants } from "@/components/ui/button"
|
|
||||||
|
|
||||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
|
||||||
<nav
|
|
||||||
role="navigation"
|
|
||||||
aria-label="pagination"
|
|
||||||
className={cn("mx-auto flex w-full justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
Pagination.displayName = "Pagination"
|
|
||||||
|
|
||||||
const PaginationContent = React.forwardRef<
|
|
||||||
HTMLUListElement,
|
|
||||||
React.ComponentProps<"ul">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ul
|
|
||||||
ref={ref}
|
|
||||||
className={cn("flex flex-row items-center gap-1", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
PaginationContent.displayName = "PaginationContent"
|
|
||||||
|
|
||||||
const PaginationItem = React.forwardRef<
|
|
||||||
HTMLLIElement,
|
|
||||||
React.ComponentProps<"li">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<li ref={ref} className={cn("", className)} {...props} />
|
|
||||||
))
|
|
||||||
PaginationItem.displayName = "PaginationItem"
|
|
||||||
|
|
||||||
type PaginationLinkProps = {
|
|
||||||
isActive?: boolean
|
|
||||||
} & Pick<ButtonProps, "size"> &
|
|
||||||
React.ComponentProps<"a">
|
|
||||||
|
|
||||||
const PaginationLink = ({
|
|
||||||
className,
|
|
||||||
isActive,
|
|
||||||
size = "icon",
|
|
||||||
...props
|
|
||||||
}: PaginationLinkProps) => (
|
|
||||||
<a
|
|
||||||
aria-current={isActive ? "page" : undefined}
|
|
||||||
className={cn(
|
|
||||||
buttonVariants({
|
|
||||||
variant: isActive ? "outline" : "ghost",
|
|
||||||
size,
|
|
||||||
}),
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
PaginationLink.displayName = "PaginationLink"
|
|
||||||
|
|
||||||
const PaginationPrevious = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
|
||||||
<PaginationLink
|
|
||||||
aria-label="Go to previous page"
|
|
||||||
size="default"
|
|
||||||
className={cn("gap-1 pl-2.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
<span>Previous</span>
|
|
||||||
</PaginationLink>
|
|
||||||
)
|
|
||||||
PaginationPrevious.displayName = "PaginationPrevious"
|
|
||||||
|
|
||||||
const PaginationNext = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
|
||||||
<PaginationLink
|
|
||||||
aria-label="Go to next page"
|
|
||||||
size="default"
|
|
||||||
className={cn("gap-1 pr-2.5", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span>Next</span>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</PaginationLink>
|
|
||||||
)
|
|
||||||
PaginationNext.displayName = "PaginationNext"
|
|
||||||
|
|
||||||
const PaginationEllipsis = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) => (
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
<span className="sr-only">More pages</span>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
|
||||||
|
|
||||||
export {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationEllipsis,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Progress = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
|
||||||
>(({ className, value, ...props }, ref) => (
|
|
||||||
<ProgressPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ProgressPrimitive.Indicator
|
|
||||||
className="h-full w-full flex-1 bg-primary transition-all"
|
|
||||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
|
||||||
/>
|
|
||||||
</ProgressPrimitive.Root>
|
|
||||||
))
|
|
||||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Progress }
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
|
||||||
import { Circle } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const RadioGroup = React.forwardRef<
|
|
||||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<RadioGroupPrimitive.Root
|
|
||||||
className={cn("grid gap-2", className)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
|
||||||
|
|
||||||
const RadioGroupItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<RadioGroupPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
|
||||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
|
||||||
</RadioGroupPrimitive.Indicator>
|
|
||||||
</RadioGroupPrimitive.Item>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
|
||||||
|
|
||||||
export { RadioGroup, RadioGroupItem }
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { GripVertical } from "lucide-react"
|
|
||||||
import * as ResizablePrimitive from "react-resizable-panels"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const ResizablePanelGroup = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
|
||||||
<ResizablePrimitive.PanelGroup
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
const ResizablePanel = ResizablePrimitive.Panel
|
|
||||||
|
|
||||||
const ResizableHandle = ({
|
|
||||||
withHandle,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
|
||||||
withHandle?: boolean
|
|
||||||
}) => (
|
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{withHandle && (
|
|
||||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
|
||||||
<GripVertical className="h-2.5 w-2.5" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
|
||||||
)
|
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const ScrollArea = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<ScrollAreaPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn("relative overflow-hidden", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
|
||||||
{children}
|
|
||||||
</ScrollAreaPrimitive.Viewport>
|
|
||||||
<ScrollBar />
|
|
||||||
<ScrollAreaPrimitive.Corner />
|
|
||||||
</ScrollAreaPrimitive.Root>
|
|
||||||
))
|
|
||||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
|
||||||
|
|
||||||
const ScrollBar = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
||||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
|
||||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
|
||||||
ref={ref}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"flex touch-none select-none transition-colors",
|
|
||||||
orientation === "vertical" &&
|
|
||||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
|
||||||
orientation === "horizontal" &&
|
|
||||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
|
||||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
||||||
))
|
|
||||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
|
||||||
|
|
||||||
export { ScrollArea, ScrollBar }
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Separator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
|
||||||
ref
|
|
||||||
) => (
|
|
||||||
<SeparatorPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
decorative={decorative}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 bg-border",
|
|
||||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Separator }
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
import { X } from "lucide-react"
|
|
||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Sheet = SheetPrimitive.Root
|
|
||||||
|
|
||||||
const SheetTrigger = SheetPrimitive.Trigger
|
|
||||||
|
|
||||||
const SheetClose = SheetPrimitive.Close
|
|
||||||
|
|
||||||
const SheetPortal = SheetPrimitive.Portal
|
|
||||||
|
|
||||||
const SheetOverlay = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SheetPrimitive.Overlay
|
|
||||||
className={cn(
|
|
||||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
|
||||||
|
|
||||||
const sheetVariants = cva(
|
|
||||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
side: {
|
|
||||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
|
||||||
bottom:
|
|
||||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
|
||||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
|
||||||
right:
|
|
||||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
side: "right",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
interface SheetContentProps
|
|
||||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
|
||||||
VariantProps<typeof sheetVariants> { }
|
|
||||||
|
|
||||||
const SheetContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
|
||||||
SheetContentProps
|
|
||||||
>(({ side = "right", className, children, ...props }, ref) => (
|
|
||||||
<SheetPortal>
|
|
||||||
<SheetOverlay />
|
|
||||||
<SheetPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(sheetVariants({ side }), className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</SheetPrimitive.Close>
|
|
||||||
</SheetPrimitive.Content>
|
|
||||||
</SheetPortal>
|
|
||||||
))
|
|
||||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const SheetHeader = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col space-y-2 text-center sm:text-left",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
SheetHeader.displayName = "SheetHeader"
|
|
||||||
|
|
||||||
const SheetFooter = ({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
SheetFooter.displayName = "SheetFooter"
|
|
||||||
|
|
||||||
const SheetTitle = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SheetPrimitive.Title
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-lg font-semibold text-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
|
||||||
|
|
||||||
const SheetDescription = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SheetPrimitive.Description
|
|
||||||
ref={ref}
|
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
Sheet, SheetClose,
|
|
||||||
SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetOverlay, SheetPortal, SheetTitle, SheetTrigger
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,761 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { VariantProps, cva } from "class-variance-authority"
|
|
||||||
import { PanelLeft } from "lucide-react"
|
|
||||||
|
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
|
|
||||||
const SIDEBAR_COOKIE_NAME = "sidebar:state"
|
|
||||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
|
||||||
const SIDEBAR_WIDTH = "16rem"
|
|
||||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
|
||||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
|
||||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
|
||||||
|
|
||||||
type SidebarContext = {
|
|
||||||
state: "expanded" | "collapsed"
|
|
||||||
open: boolean
|
|
||||||
setOpen: (open: boolean) => void
|
|
||||||
openMobile: boolean
|
|
||||||
setOpenMobile: (open: boolean) => void
|
|
||||||
isMobile: boolean
|
|
||||||
toggleSidebar: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const SidebarContext = React.createContext<SidebarContext | null>(null)
|
|
||||||
|
|
||||||
function useSidebar() {
|
|
||||||
const context = React.useContext(SidebarContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
const SidebarProvider = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div"> & {
|
|
||||||
defaultOpen?: boolean
|
|
||||||
open?: boolean
|
|
||||||
onOpenChange?: (open: boolean) => void
|
|
||||||
}
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
defaultOpen = true,
|
|
||||||
open: openProp,
|
|
||||||
onOpenChange: setOpenProp,
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const isMobile = useIsMobile()
|
|
||||||
const [openMobile, setOpenMobile] = React.useState(false)
|
|
||||||
|
|
||||||
// This is the internal state of the sidebar.
|
|
||||||
// We use openProp and setOpenProp for control from outside the component.
|
|
||||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
|
||||||
const open = openProp ?? _open
|
|
||||||
const setOpen = React.useCallback(
|
|
||||||
(value: boolean | ((value: boolean) => boolean)) => {
|
|
||||||
const openState = typeof value === "function" ? value(open) : value
|
|
||||||
if (setOpenProp) {
|
|
||||||
setOpenProp(openState)
|
|
||||||
} else {
|
|
||||||
_setOpen(openState)
|
|
||||||
}
|
|
||||||
|
|
||||||
// This sets the cookie to keep the sidebar state.
|
|
||||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
|
||||||
},
|
|
||||||
[setOpenProp, open]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Helper to toggle the sidebar.
|
|
||||||
const toggleSidebar = React.useCallback(() => {
|
|
||||||
return isMobile
|
|
||||||
? setOpenMobile((open) => !open)
|
|
||||||
: setOpen((open) => !open)
|
|
||||||
}, [isMobile, setOpen, setOpenMobile])
|
|
||||||
|
|
||||||
// Adds a keyboard shortcut to toggle the sidebar.
|
|
||||||
React.useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (
|
|
||||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
|
||||||
(event.metaKey || event.ctrlKey)
|
|
||||||
) {
|
|
||||||
event.preventDefault()
|
|
||||||
toggleSidebar()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown)
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
|
||||||
}, [toggleSidebar])
|
|
||||||
|
|
||||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
|
||||||
// This makes it easier to style the sidebar with Tailwind classes.
|
|
||||||
const state = open ? "expanded" : "collapsed"
|
|
||||||
|
|
||||||
const contextValue = React.useMemo<SidebarContext>(
|
|
||||||
() => ({
|
|
||||||
state,
|
|
||||||
open,
|
|
||||||
setOpen,
|
|
||||||
isMobile,
|
|
||||||
openMobile,
|
|
||||||
setOpenMobile,
|
|
||||||
toggleSidebar,
|
|
||||||
}),
|
|
||||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarContext.Provider value={contextValue}>
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<div
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--sidebar-width": SIDEBAR_WIDTH,
|
|
||||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
|
||||||
...style,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
</SidebarContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
SidebarProvider.displayName = "SidebarProvider"
|
|
||||||
|
|
||||||
const Sidebar = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div"> & {
|
|
||||||
side?: "left" | "right"
|
|
||||||
variant?: "sidebar" | "floating" | "inset"
|
|
||||||
collapsible?: "offcanvas" | "icon" | "none"
|
|
||||||
}
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
side = "left",
|
|
||||||
variant = "sidebar",
|
|
||||||
collapsible = "offcanvas",
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
|
||||||
|
|
||||||
if (collapsible === "none") {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
return (
|
|
||||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
|
||||||
<SheetContent
|
|
||||||
data-sidebar="sidebar"
|
|
||||||
data-mobile="true"
|
|
||||||
className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
side={side}
|
|
||||||
>
|
|
||||||
<div className="flex h-full w-full flex-col">{children}</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
className="group peer hidden md:block text-sidebar-foreground"
|
|
||||||
data-state={state}
|
|
||||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
|
||||||
data-variant={variant}
|
|
||||||
data-side={side}
|
|
||||||
>
|
|
||||||
{/* This is what handles the sidebar gap on desktop */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear",
|
|
||||||
"group-data-[collapsible=offcanvas]:w-0",
|
|
||||||
"group-data-[side=right]:rotate-180",
|
|
||||||
variant === "floating" || variant === "inset"
|
|
||||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
|
||||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex",
|
|
||||||
side === "left"
|
|
||||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
|
||||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
|
||||||
// Adjust the padding for floating and inset variants.
|
|
||||||
variant === "floating" || variant === "inset"
|
|
||||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
|
||||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-sidebar="sidebar"
|
|
||||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Sidebar.displayName = "Sidebar"
|
|
||||||
|
|
||||||
const SidebarTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof Button>,
|
|
||||||
React.ComponentProps<typeof Button>
|
|
||||||
>(({ className, onClick, ...props }, ref) => {
|
|
||||||
const { toggleSidebar } = useSidebar()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="trigger"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={cn("h-7 w-7", className)}
|
|
||||||
onClick={(event) => {
|
|
||||||
onClick?.(event)
|
|
||||||
toggleSidebar()
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<PanelLeft />
|
|
||||||
<span className="sr-only">Toggle Sidebar</span>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarTrigger.displayName = "SidebarTrigger"
|
|
||||||
|
|
||||||
const SidebarRail = React.forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
React.ComponentProps<"button">
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
const { toggleSidebar } = useSidebar()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="rail"
|
|
||||||
aria-label="Toggle Sidebar"
|
|
||||||
tabIndex={-1}
|
|
||||||
onClick={toggleSidebar}
|
|
||||||
title="Toggle Sidebar"
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
|
|
||||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
|
||||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
|
||||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
|
||||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
|
||||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarRail.displayName = "SidebarRail"
|
|
||||||
|
|
||||||
const SidebarInset = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"main">
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex min-h-svh flex-1 flex-col bg-background",
|
|
||||||
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarInset.displayName = "SidebarInset"
|
|
||||||
|
|
||||||
const SidebarInput = React.forwardRef<
|
|
||||||
React.ElementRef<typeof Input>,
|
|
||||||
React.ComponentProps<typeof Input>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="input"
|
|
||||||
className={cn(
|
|
||||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarInput.displayName = "SidebarInput"
|
|
||||||
|
|
||||||
const SidebarHeader = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div">
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="header"
|
|
||||||
className={cn("flex flex-col gap-2 p-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarHeader.displayName = "SidebarHeader"
|
|
||||||
|
|
||||||
const SidebarFooter = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div">
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="footer"
|
|
||||||
className={cn("flex flex-col gap-2 p-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarFooter.displayName = "SidebarFooter"
|
|
||||||
|
|
||||||
const SidebarSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof Separator>,
|
|
||||||
React.ComponentProps<typeof Separator>
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<Separator
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="separator"
|
|
||||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarSeparator.displayName = "SidebarSeparator"
|
|
||||||
|
|
||||||
const SidebarContent = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div">
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="content"
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarContent.displayName = "SidebarContent"
|
|
||||||
|
|
||||||
const SidebarGroup = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div">
|
|
||||||
>(({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="group"
|
|
||||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarGroup.displayName = "SidebarGroup"
|
|
||||||
|
|
||||||
const SidebarGroupLabel = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
|
||||||
>(({ className, asChild = false, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "div"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="group-label"
|
|
||||||
className={cn(
|
|
||||||
"duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
|
||||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarGroupLabel.displayName = "SidebarGroupLabel"
|
|
||||||
|
|
||||||
const SidebarGroupAction = React.forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
|
||||||
>(({ className, asChild = false, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="group-action"
|
|
||||||
className={cn(
|
|
||||||
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
|
||||||
// Increases the hit area of the button on mobile.
|
|
||||||
"after:absolute after:-inset-2 after:md:hidden",
|
|
||||||
"group-data-[collapsible=icon]:hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarGroupAction.displayName = "SidebarGroupAction"
|
|
||||||
|
|
||||||
const SidebarGroupContent = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="group-content"
|
|
||||||
className={cn("w-full text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SidebarGroupContent.displayName = "SidebarGroupContent"
|
|
||||||
|
|
||||||
const SidebarMenu = React.forwardRef<
|
|
||||||
HTMLUListElement,
|
|
||||||
React.ComponentProps<"ul">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ul
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu"
|
|
||||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SidebarMenu.displayName = "SidebarMenu"
|
|
||||||
|
|
||||||
const SidebarMenuItem = React.forwardRef<
|
|
||||||
HTMLLIElement,
|
|
||||||
React.ComponentProps<"li">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<li
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu-item"
|
|
||||||
className={cn("group/menu-item relative", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SidebarMenuItem.displayName = "SidebarMenuItem"
|
|
||||||
|
|
||||||
const sidebarMenuButtonVariants = cva(
|
|
||||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
||||||
outline:
|
|
||||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-8 text-sm",
|
|
||||||
sm: "h-7 text-xs",
|
|
||||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const SidebarMenuButton = React.forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
React.ComponentProps<"button"> & {
|
|
||||||
asChild?: boolean
|
|
||||||
isActive?: boolean
|
|
||||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
|
||||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
|
||||||
>(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
asChild = false,
|
|
||||||
isActive = false,
|
|
||||||
variant = "default",
|
|
||||||
size = "default",
|
|
||||||
tooltip,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
},
|
|
||||||
ref
|
|
||||||
) => {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
const { isMobile, state } = useSidebar()
|
|
||||||
|
|
||||||
const button = (
|
|
||||||
<Comp
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu-button"
|
|
||||||
data-size={size}
|
|
||||||
data-active={isActive}
|
|
||||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!tooltip) {
|
|
||||||
return button
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof tooltip === "string") {
|
|
||||||
tooltip = {
|
|
||||||
children: tooltip,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="right"
|
|
||||||
align="center"
|
|
||||||
hidden={state !== "collapsed" || isMobile}
|
|
||||||
{...tooltip}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
SidebarMenuButton.displayName = "SidebarMenuButton"
|
|
||||||
|
|
||||||
const SidebarMenuAction = React.forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
React.ComponentProps<"button"> & {
|
|
||||||
asChild?: boolean
|
|
||||||
showOnHover?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu-action"
|
|
||||||
className={cn(
|
|
||||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
|
|
||||||
// Increases the hit area of the button on mobile.
|
|
||||||
"after:absolute after:-inset-2 after:md:hidden",
|
|
||||||
"peer-data-[size=sm]/menu-button:top-1",
|
|
||||||
"peer-data-[size=default]/menu-button:top-1.5",
|
|
||||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
|
||||||
"group-data-[collapsible=icon]:hidden",
|
|
||||||
showOnHover &&
|
|
||||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarMenuAction.displayName = "SidebarMenuAction"
|
|
||||||
|
|
||||||
const SidebarMenuBadge = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu-badge"
|
|
||||||
className={cn(
|
|
||||||
"absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none",
|
|
||||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
|
||||||
"peer-data-[size=sm]/menu-button:top-1",
|
|
||||||
"peer-data-[size=default]/menu-button:top-1.5",
|
|
||||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
|
||||||
"group-data-[collapsible=icon]:hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SidebarMenuBadge.displayName = "SidebarMenuBadge"
|
|
||||||
|
|
||||||
const SidebarMenuSkeleton = React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
React.ComponentProps<"div"> & {
|
|
||||||
showIcon?: boolean
|
|
||||||
}
|
|
||||||
>(({ className, showIcon = false, ...props }, ref) => {
|
|
||||||
// Random width between 50 to 90%.
|
|
||||||
const width = React.useMemo(() => {
|
|
||||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu-skeleton"
|
|
||||||
className={cn("rounded-md h-8 flex gap-2 px-2 items-center", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{showIcon && (
|
|
||||||
<Skeleton
|
|
||||||
className="size-4 rounded-md"
|
|
||||||
data-sidebar="menu-skeleton-icon"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Skeleton
|
|
||||||
className="h-4 flex-1 max-w-[--skeleton-width]"
|
|
||||||
data-sidebar="menu-skeleton-text"
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--skeleton-width": width,
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton"
|
|
||||||
|
|
||||||
const SidebarMenuSub = React.forwardRef<
|
|
||||||
HTMLUListElement,
|
|
||||||
React.ComponentProps<"ul">
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<ul
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu-sub"
|
|
||||||
className={cn(
|
|
||||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
|
||||||
"group-data-[collapsible=icon]:hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SidebarMenuSub.displayName = "SidebarMenuSub"
|
|
||||||
|
|
||||||
const SidebarMenuSubItem = React.forwardRef<
|
|
||||||
HTMLLIElement,
|
|
||||||
React.ComponentProps<"li">
|
|
||||||
>(({ ...props }, ref) => <li ref={ref} {...props} />)
|
|
||||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem"
|
|
||||||
|
|
||||||
const SidebarMenuSubButton = React.forwardRef<
|
|
||||||
HTMLAnchorElement,
|
|
||||||
React.ComponentProps<"a"> & {
|
|
||||||
asChild?: boolean
|
|
||||||
size?: "sm" | "md"
|
|
||||||
isActive?: boolean
|
|
||||||
}
|
|
||||||
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "a"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
ref={ref}
|
|
||||||
data-sidebar="menu-sub-button"
|
|
||||||
data-size={size}
|
|
||||||
data-active={isActive}
|
|
||||||
className={cn(
|
|
||||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
|
||||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
|
||||||
size === "sm" && "text-xs",
|
|
||||||
size === "md" && "text-sm",
|
|
||||||
"group-data-[collapsible=icon]:hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton"
|
|
||||||
|
|
||||||
export {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarFooter,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupAction,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarInput,
|
|
||||||
SidebarInset,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuAction,
|
|
||||||
SidebarMenuBadge,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarMenuSkeleton,
|
|
||||||
SidebarMenuSub,
|
|
||||||
SidebarMenuSubButton,
|
|
||||||
SidebarMenuSubItem,
|
|
||||||
SidebarProvider,
|
|
||||||
SidebarRail,
|
|
||||||
SidebarSeparator,
|
|
||||||
SidebarTrigger,
|
|
||||||
useSidebar,
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Skeleton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Skeleton }
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Slider = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SliderPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-full touch-none select-none items-center",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
|
|
||||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
|
||||||
</SliderPrimitive.Track>
|
|
||||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
|
||||||
</SliderPrimitive.Root>
|
|
||||||
))
|
|
||||||
Slider.displayName = SliderPrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Slider }
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SwitchPrimitives.Root
|
|
||||||
className={cn(
|
|
||||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<SwitchPrimitives.Thumb
|
|
||||||
className={cn(
|
|
||||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SwitchPrimitives.Root>
|
|
||||||
))
|
|
||||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
|
||||||
|
|
||||||
export { Switch }
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
|
||||||
import { type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { toggleVariants } from "@/components/ui/toggle"
|
|
||||||
|
|
||||||
const ToggleGroupContext = React.createContext<
|
|
||||||
VariantProps<typeof toggleVariants>
|
|
||||||
>({
|
|
||||||
size: "default",
|
|
||||||
variant: "default",
|
|
||||||
})
|
|
||||||
|
|
||||||
const ToggleGroup = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
|
|
||||||
VariantProps<typeof toggleVariants>
|
|
||||||
>(({ className, variant, size, children, ...props }, ref) => (
|
|
||||||
<ToggleGroupPrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn("flex items-center justify-center gap-1", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
|
||||||
{children}
|
|
||||||
</ToggleGroupContext.Provider>
|
|
||||||
</ToggleGroupPrimitive.Root>
|
|
||||||
))
|
|
||||||
|
|
||||||
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
|
|
||||||
|
|
||||||
const ToggleGroupItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
|
||||||
VariantProps<typeof toggleVariants>
|
|
||||||
>(({ className, children, variant, size, ...props }, ref) => {
|
|
||||||
const context = React.useContext(ToggleGroupContext)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToggleGroupPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
toggleVariants({
|
|
||||||
variant: context.variant || variant,
|
|
||||||
size: context.size || size,
|
|
||||||
}),
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ToggleGroupPrimitive.Item>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
|
|
||||||
|
|
||||||
export { ToggleGroup, ToggleGroupItem }
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const toggleVariants = cva(
|
|
||||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-transparent",
|
|
||||||
outline:
|
|
||||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-10 px-3",
|
|
||||||
sm: "h-9 px-2.5",
|
|
||||||
lg: "h-11 px-5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const Toggle = React.forwardRef<
|
|
||||||
React.ElementRef<typeof TogglePrimitive.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
|
|
||||||
VariantProps<typeof toggleVariants>
|
|
||||||
>(({ className, variant, size, ...props }, ref) => (
|
|
||||||
<TogglePrimitive.Root
|
|
||||||
ref={ref}
|
|
||||||
className={cn(toggleVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
|
|
||||||
Toggle.displayName = TogglePrimitive.Root.displayName
|
|
||||||
|
|
||||||
export { Toggle, toggleVariants }
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768
|
|
||||||
|
|
||||||
export function useIsMobile() {
|
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
||||||
const onChange = () => {
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
||||||
}
|
|
||||||
mql.addEventListener("change", onChange)
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
||||||
return () => mql.removeEventListener("change", onChange)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return !!isMobile
|
|
||||||
}
|
|
||||||
+86
-30
@@ -1,51 +1,107 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api, type ApiSession } from "@/lib/api";
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
export type UiRole = "admin" | "owner" | "employee";
|
||||||
import { User, Session } from '@supabase/supabase-js';
|
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
export interface UiShopRole {
|
||||||
|
shop_id: string;
|
||||||
|
shop_name: string;
|
||||||
|
role: "owner" | "manager" | "cashier" | "auditor";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UiUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: UiRole;
|
||||||
|
empId?: string | null;
|
||||||
|
shops: UiShopRole[];
|
||||||
|
isSystemAdmin: boolean;
|
||||||
|
isOwnerAnywhere: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MeRow {
|
||||||
|
user_id: string;
|
||||||
|
full_name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_system_admin?: boolean;
|
||||||
|
is_owner_anywhere: boolean;
|
||||||
|
emp_id?: string | null;
|
||||||
|
shops: UiShopRole[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMe(session: ApiSession): Promise<UiUser> {
|
||||||
|
const { data, error } = await api.rpc<MeRow[] | MeRow>("me");
|
||||||
|
if (error) {
|
||||||
|
return {
|
||||||
|
id: session.user.id,
|
||||||
|
email: session.user.email,
|
||||||
|
name: session.user.full_name ?? session.user.email,
|
||||||
|
role: "employee",
|
||||||
|
empId: null,
|
||||||
|
shops: [],
|
||||||
|
isSystemAdmin: false,
|
||||||
|
isOwnerAnywhere: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const row = (Array.isArray(data) ? data[0] : data) as MeRow | undefined;
|
||||||
|
const shops = (row?.shops ?? []) as UiShopRole[];
|
||||||
|
const isSystemAdmin = !!row?.is_system_admin;
|
||||||
|
const isOwner = !!row?.is_owner_anywhere;
|
||||||
|
return {
|
||||||
|
id: session.user.id,
|
||||||
|
email: session.user.email,
|
||||||
|
name: row?.full_name?.trim() || session.user.full_name || session.user.email,
|
||||||
|
role: isSystemAdmin ? "admin" : isOwner ? "owner" : "employee",
|
||||||
|
empId: row?.emp_id ?? null,
|
||||||
|
shops,
|
||||||
|
isSystemAdmin,
|
||||||
|
isOwnerAnywhere: isOwner,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<UiUser | null>(null);
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<ApiSession | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Set up auth state listener
|
let cancelled = false;
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
|
||||||
(event, session) => {
|
|
||||||
setSession(session);
|
|
||||||
setUser(session?.user ?? null);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check for existing session
|
const apply = async (s: ApiSession | null) => {
|
||||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
if (cancelled) return;
|
||||||
setSession(session);
|
setSession(s);
|
||||||
setUser(session?.user ?? null);
|
if (!s) { setUser(null); return; }
|
||||||
setLoading(false);
|
setUser(await loadMe(s));
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: { session: existing } } = api.auth.getSession();
|
||||||
|
apply(existing).finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
|
||||||
|
const { data: sub } = api.auth.onAuthStateChange((_event, s) => {
|
||||||
|
apply(s);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => subscription.unsubscribe();
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
sub.subscription.unsubscribe();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const signIn = async (email: string, password: string) => {
|
const signIn = async (email: string, password: string) => {
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({
|
const { data, error } = await api.auth.signInWithPassword({
|
||||||
email,
|
email: email.trim(),
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
return { data, error };
|
return { data: { session: data.session }, error };
|
||||||
};
|
};
|
||||||
|
|
||||||
const signOut = async () => {
|
const signOut = async () => {
|
||||||
const { error } = await supabase.auth.signOut();
|
const { error } = await api.auth.signOut();
|
||||||
|
setSession(null);
|
||||||
|
setUser(null);
|
||||||
return { error };
|
return { error };
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return { user, session, loading, signIn, signOut };
|
||||||
user,
|
|
||||||
session,
|
|
||||||
loading,
|
|
||||||
signIn,
|
|
||||||
signOut,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
+41
-131
@@ -1,5 +1,10 @@
|
|||||||
|
/**
|
||||||
import { useState, useEffect } from 'react';
|
* Legacy adapter over `useSupabaseEmployeeData`. Exposes the older shape
|
||||||
|
* (`{ date, collection, deposit }`) consumed by EmployeePaymentReport so
|
||||||
|
* we don't have to change that component. Backed by the API; no mocks.
|
||||||
|
*/
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useSupabaseEmployeeData } from "./useSupabaseEmployeeData";
|
||||||
|
|
||||||
export interface Employee {
|
export interface Employee {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,150 +31,55 @@ export interface EmployeeSummary {
|
|||||||
lastTransactionDate: string | null;
|
lastTransactionDate: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'employeeCollectionData';
|
|
||||||
|
|
||||||
// Sample employee data
|
|
||||||
const initialEmployees: Employee[] = [
|
|
||||||
{
|
|
||||||
id: 'EMP001',
|
|
||||||
name: 'Mayank Sharma',
|
|
||||||
email: 'mayank.sharma@company.com',
|
|
||||||
department: 'Collections'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'EMP002',
|
|
||||||
name: 'Priya Patel',
|
|
||||||
email: 'priya.patel@company.com',
|
|
||||||
department: 'Collections'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'EMP003',
|
|
||||||
name: 'Rajesh Kumar',
|
|
||||||
email: 'rajesh.kumar@company.com',
|
|
||||||
department: 'Collections'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'EMP004',
|
|
||||||
name: 'Anjali Singh',
|
|
||||||
email: 'anjali.singh@company.com',
|
|
||||||
department: 'Collections'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'EMP005',
|
|
||||||
name: 'Vikram Gupta',
|
|
||||||
email: 'vikram.gupta@company.com',
|
|
||||||
department: 'Collections'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Sample transaction data for demonstration
|
|
||||||
const initialTransactions: Record<string, Transaction[]> = {
|
|
||||||
'EMP001': [
|
|
||||||
{ date: '2025-03-26', collection: 10000, deposit: 0 },
|
|
||||||
{ date: '2025-03-27', collection: 20000, deposit: 0 },
|
|
||||||
{ date: '2025-03-28', collection: 0, deposit: 5000 },
|
|
||||||
{ date: '2025-03-29', collection: 0, deposit: 7000 },
|
|
||||||
{ date: '2025-03-30', collection: 0, deposit: 8000 },
|
|
||||||
{ date: '2025-03-31', collection: 0, deposit: 15000 }
|
|
||||||
],
|
|
||||||
'EMP002': [
|
|
||||||
{ date: '2025-03-25', collection: 15000, deposit: 0 },
|
|
||||||
{ date: '2025-03-26', collection: 12000, deposit: 15000 },
|
|
||||||
{ date: '2025-03-27', collection: 0, deposit: 12000 }
|
|
||||||
],
|
|
||||||
'EMP003': [
|
|
||||||
{ date: '2025-03-24', collection: 8000, deposit: 0 },
|
|
||||||
{ date: '2025-03-25', collection: 0, deposit: 8000 }
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useEmployeeData = () => {
|
export const useEmployeeData = () => {
|
||||||
const [employees] = useState<Employee[]>(initialEmployees);
|
const src = useSupabaseEmployeeData("USD");
|
||||||
const [transactions, setTransactions] = useState<Record<string, Transaction[]>>({});
|
|
||||||
|
|
||||||
// Load data from localStorage on mount
|
const employees = useMemo<Employee[]>(
|
||||||
useEffect(() => {
|
() => src.employees.map((e) => ({
|
||||||
const savedData = localStorage.getItem(STORAGE_KEY);
|
id: e.id,
|
||||||
if (savedData) {
|
name: e.name,
|
||||||
try {
|
email: e.email,
|
||||||
const parsed = JSON.parse(savedData);
|
department: e.department,
|
||||||
setTransactions(parsed);
|
})),
|
||||||
} catch (error) {
|
[src.employees],
|
||||||
console.error('Error loading saved data:', error);
|
);
|
||||||
setTransactions(initialTransactions);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setTransactions(initialTransactions);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Save data to localStorage whenever transactions change
|
const addTransaction = async (
|
||||||
useEffect(() => {
|
employeeId: string,
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(transactions));
|
transaction: Transaction,
|
||||||
}, [transactions]);
|
) => {
|
||||||
|
await src.addTransaction(employeeId, {
|
||||||
const addTransaction = (employeeId: string, transaction: Omit<Transaction, 'id'>) => {
|
transaction_date: transaction.date,
|
||||||
setTransactions(prev => ({
|
collection_amount: transaction.collection,
|
||||||
...prev,
|
deposit_amount: transaction.deposit,
|
||||||
[employeeId]: [...(prev[employeeId] || []), transaction].sort((a, b) =>
|
currency: "USD",
|
||||||
new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const processTransactions = (employeeTransactions: Transaction[]): ProcessedTransaction[] => {
|
|
||||||
let runningBalance = 0;
|
|
||||||
|
|
||||||
return employeeTransactions.map(transaction => {
|
|
||||||
const difference = transaction.deposit - transaction.collection;
|
|
||||||
runningBalance += difference;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...transaction,
|
|
||||||
difference,
|
|
||||||
runningBalance
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEmployeeTransactions = (employeeId: string): ProcessedTransaction[] => {
|
const getEmployeeTransactions = (employeeId: string): ProcessedTransaction[] =>
|
||||||
const employeeTransactions = transactions[employeeId] || [];
|
src.getEmployeeTransactions(employeeId).map((t) => ({
|
||||||
return processTransactions(employeeTransactions);
|
date: t.transaction_date,
|
||||||
};
|
collection: t.collection_amount,
|
||||||
|
deposit: t.deposit_amount,
|
||||||
|
difference: t.difference,
|
||||||
|
runningBalance: t.runningBalance,
|
||||||
|
}));
|
||||||
|
|
||||||
const getEmployeeSummary = (employeeId: string): EmployeeSummary => {
|
const getEmployeeSummary = (employeeId: string): EmployeeSummary => {
|
||||||
const employeeTransactions = transactions[employeeId] || [];
|
const s = src.getEmployeeSummary(employeeId);
|
||||||
|
|
||||||
if (employeeTransactions.length === 0) {
|
|
||||||
return {
|
return {
|
||||||
totalCollection: 0,
|
totalCollection: s.totalCollection,
|
||||||
totalDeposit: 0,
|
totalDeposit: s.totalDeposit,
|
||||||
outstandingAmount: 0,
|
outstandingAmount: s.outstandingAmount,
|
||||||
lastTransactionDate: null
|
lastTransactionDate: s.lastTransactionDate,
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCollection = employeeTransactions.reduce((sum, t) => sum + t.collection, 0);
|
|
||||||
const totalDeposit = employeeTransactions.reduce((sum, t) => sum + t.deposit, 0);
|
|
||||||
const outstandingAmount = totalCollection - totalDeposit;
|
|
||||||
|
|
||||||
// Find the most recent transaction date
|
|
||||||
const lastTransactionDate = employeeTransactions
|
|
||||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0]?.date || null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalCollection,
|
|
||||||
totalDeposit,
|
|
||||||
outstandingAmount,
|
|
||||||
lastTransactionDate
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
employees,
|
employees,
|
||||||
transactions,
|
transactions: src.transactions,
|
||||||
addTransaction,
|
addTransaction,
|
||||||
getEmployeeTransactions,
|
getEmployeeTransactions,
|
||||||
getEmployeeSummary
|
getEmployeeSummary,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useState, useEffect } from 'react';
|
import { Currency, convert } from "@/lib/currency";
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
export interface Employee {
|
export interface Employee {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,6 +8,7 @@ export interface Employee {
|
|||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
department: string;
|
department: string;
|
||||||
|
location?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
@@ -16,6 +17,7 @@ export interface Transaction {
|
|||||||
transaction_date: string;
|
transaction_date: string;
|
||||||
collection_amount: number;
|
collection_amount: number;
|
||||||
deposit_amount: number;
|
deposit_amount: number;
|
||||||
|
currency: Currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProcessedTransaction extends Transaction {
|
export interface ProcessedTransaction extends Transaction {
|
||||||
@@ -24,154 +26,265 @@ export interface ProcessedTransaction extends Transaction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface EmployeeSummary {
|
export interface EmployeeSummary {
|
||||||
|
totalCollectionUSD: number;
|
||||||
|
totalCollectionLBP: number;
|
||||||
|
totalDepositUSD: number;
|
||||||
|
totalDepositLBP: number;
|
||||||
|
outstandingUSD: number;
|
||||||
|
outstandingLBP: number;
|
||||||
totalCollection: number;
|
totalCollection: number;
|
||||||
totalDeposit: number;
|
totalDeposit: number;
|
||||||
outstandingAmount: number;
|
outstandingAmount: number;
|
||||||
lastTransactionDate: string | null;
|
lastTransactionDate: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSupabaseEmployeeData = () => {
|
export interface EmployeeOutstandingBalance {
|
||||||
|
employee_id: string;
|
||||||
|
emp_id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
department: string | null;
|
||||||
|
location: string | null;
|
||||||
|
currency: Currency;
|
||||||
|
manual_collection: number;
|
||||||
|
manual_deposit: number;
|
||||||
|
shift_shortage: number;
|
||||||
|
shift_overage: number;
|
||||||
|
total_collection: number;
|
||||||
|
total_deposit: number;
|
||||||
|
outstanding_amount: number;
|
||||||
|
last_activity_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DbEmployee {
|
||||||
|
id: string;
|
||||||
|
emp_id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
department: string | null;
|
||||||
|
location: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DbTransaction {
|
||||||
|
id: string;
|
||||||
|
employee_id: string;
|
||||||
|
transaction_date: string;
|
||||||
|
collection_amount: string | number;
|
||||||
|
deposit_amount: string | number;
|
||||||
|
currency: Currency;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DbOutstandingBalance {
|
||||||
|
employee_id: string;
|
||||||
|
emp_id: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
department: string | null;
|
||||||
|
location: string | null;
|
||||||
|
currency: Currency;
|
||||||
|
manual_collection: string | number;
|
||||||
|
manual_deposit: string | number;
|
||||||
|
shift_shortage: string | number;
|
||||||
|
shift_overage: string | number;
|
||||||
|
total_collection: string | number;
|
||||||
|
total_deposit: string | number;
|
||||||
|
outstanding_amount: string | number;
|
||||||
|
last_activity_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEmployee(e: DbEmployee): Employee {
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
emp_id: e.emp_id,
|
||||||
|
name: e.name,
|
||||||
|
email: e.email ?? "",
|
||||||
|
department: e.department ?? "",
|
||||||
|
location: e.location ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTx(t: DbTransaction): Transaction {
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
employee_id: t.employee_id,
|
||||||
|
transaction_date: typeof t.transaction_date === "string"
|
||||||
|
? t.transaction_date.slice(0, 10)
|
||||||
|
: t.transaction_date,
|
||||||
|
collection_amount: Number(t.collection_amount) || 0,
|
||||||
|
deposit_amount: Number(t.deposit_amount) || 0,
|
||||||
|
currency: (t.currency || "USD") as Currency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBalance(row: DbOutstandingBalance): EmployeeOutstandingBalance {
|
||||||
|
return {
|
||||||
|
employee_id: row.employee_id,
|
||||||
|
emp_id: row.emp_id,
|
||||||
|
name: row.name,
|
||||||
|
email: row.email,
|
||||||
|
department: row.department,
|
||||||
|
location: row.location,
|
||||||
|
currency: (row.currency || "USD") as Currency,
|
||||||
|
manual_collection: Number(row.manual_collection) || 0,
|
||||||
|
manual_deposit: Number(row.manual_deposit) || 0,
|
||||||
|
shift_shortage: Number(row.shift_shortage) || 0,
|
||||||
|
shift_overage: Number(row.shift_overage) || 0,
|
||||||
|
total_collection: Number(row.total_collection) || 0,
|
||||||
|
total_deposit: Number(row.total_deposit) || 0,
|
||||||
|
outstanding_amount: Number(row.outstanding_amount) || 0,
|
||||||
|
last_activity_at: row.last_activity_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSupabaseEmployeeData = (displayCurrency: Currency = "USD") => {
|
||||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||||
|
const [outstandingBalances, setOutstandingBalances] = useState<EmployeeOutstandingBalance[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Fetch employees
|
const refreshData = useCallback(async () => {
|
||||||
const fetchEmployees = async () => {
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('employees' as any)
|
|
||||||
.select('*')
|
|
||||||
.order('emp_id');
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error fetching employees:', error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setEmployees(data || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching employees:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch transactions
|
|
||||||
const fetchTransactions = async () => {
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from('transactions' as any)
|
|
||||||
.select('*')
|
|
||||||
.order('transaction_date');
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
console.error('Error fetching transactions:', error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTransactions(data || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching transactions:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load data on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const loadData = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await Promise.all([fetchEmployees(), fetchTransactions()]);
|
const [eRes, tRes, bRes] = await Promise.all([
|
||||||
|
api.get<DbEmployee[]>("/employees"),
|
||||||
|
api.get<DbTransaction[]>("/employee_transactions"),
|
||||||
|
api.fromView<DbOutstandingBalance>("v_employee_outstanding_balances"),
|
||||||
|
]);
|
||||||
|
setEmployees((eRes.data ?? []).map(normalizeEmployee));
|
||||||
|
setTransactions((tRes.data ?? []).map(normalizeTx));
|
||||||
|
setOutstandingBalances((bRes.data ?? []).map(normalizeBalance));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Add transaction
|
useEffect(() => { refreshData(); }, [refreshData]);
|
||||||
const addTransaction = async (employeeId: string, transactionData: {
|
|
||||||
|
const addTransaction = useCallback(
|
||||||
|
async (
|
||||||
|
employeeId: string,
|
||||||
|
data: {
|
||||||
transaction_date: string;
|
transaction_date: string;
|
||||||
collection_amount: number;
|
collection_amount: number;
|
||||||
deposit_amount: number;
|
deposit_amount: number;
|
||||||
}) => {
|
currency?: Currency;
|
||||||
try {
|
},
|
||||||
const { data, error } = await supabase
|
) => {
|
||||||
.from('transactions' as any)
|
const { data: created, error } = await api.post<DbTransaction>(
|
||||||
.insert([{
|
"/employee_transactions",
|
||||||
|
{
|
||||||
employee_id: employeeId,
|
employee_id: employeeId,
|
||||||
...transactionData
|
transaction_date: data.transaction_date,
|
||||||
}] as any)
|
collection_amount: data.collection_amount,
|
||||||
.select();
|
deposit_amount: data.deposit_amount,
|
||||||
|
currency: data.currency ?? "USD",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error || !created) throw new Error(error?.message ?? "Insert failed");
|
||||||
|
const tx = normalizeTx(created);
|
||||||
|
setTransactions((cur) => [...cur, tx]);
|
||||||
|
return [tx];
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
const addEmployee = useCallback(
|
||||||
console.error('Error adding transaction:', error);
|
async (emp: Omit<Employee, "id">) => {
|
||||||
throw error;
|
const { data: created, error } = await api.post<DbEmployee>("/employees", {
|
||||||
|
emp_id: emp.emp_id,
|
||||||
|
name: emp.name,
|
||||||
|
email: emp.email,
|
||||||
|
department: emp.department,
|
||||||
|
location: emp.location,
|
||||||
|
});
|
||||||
|
if (error || !created) throw new Error(error?.message ?? "Insert failed");
|
||||||
|
const e = normalizeEmployee(created);
|
||||||
|
setEmployees((cur) => {
|
||||||
|
const idx = cur.findIndex((x) => x.emp_id === e.emp_id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const next = cur.slice();
|
||||||
|
next[idx] = e;
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
return [...cur, e];
|
||||||
|
});
|
||||||
|
return e;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Refresh transactions
|
|
||||||
await fetchTransactions();
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error adding transaction:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Process transactions for an employee with proper clearing logic
|
|
||||||
const processTransactions = (employeeTransactions: Transaction[]): ProcessedTransaction[] => {
|
const processTransactions = (employeeTransactions: Transaction[]): ProcessedTransaction[] => {
|
||||||
let runningBalance = 0;
|
let runningBalance = 0;
|
||||||
|
|
||||||
return employeeTransactions
|
return employeeTransactions
|
||||||
|
.slice()
|
||||||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime())
|
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime())
|
||||||
.map(transaction => {
|
.map((t) => {
|
||||||
const difference = transaction.deposit_amount - transaction.collection_amount;
|
const collectionDisp = convert(t.collection_amount, t.currency, displayCurrency);
|
||||||
|
const depositDisp = convert(t.deposit_amount, t.currency, displayCurrency);
|
||||||
|
const difference = depositDisp - collectionDisp;
|
||||||
runningBalance += difference;
|
runningBalance += difference;
|
||||||
|
return { ...t, difference, runningBalance };
|
||||||
return {
|
|
||||||
...transaction,
|
|
||||||
difference,
|
|
||||||
runningBalance
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get transactions for a specific employee
|
const getEmployeeTransactions = (employeeId: string): ProcessedTransaction[] =>
|
||||||
const getEmployeeTransactions = (employeeId: string): ProcessedTransaction[] => {
|
processTransactions(transactions.filter((t) => t.employee_id === employeeId));
|
||||||
const employeeTransactions = transactions.filter(t => t.employee_id === employeeId);
|
|
||||||
return processTransactions(employeeTransactions);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get employee summary
|
|
||||||
const getEmployeeSummary = (employeeId: string): EmployeeSummary => {
|
const getEmployeeSummary = (employeeId: string): EmployeeSummary => {
|
||||||
const employeeTransactions = transactions.filter(t => t.employee_id === employeeId);
|
const employeeTransactions = transactions.filter((t) => t.employee_id === employeeId);
|
||||||
|
|
||||||
if (employeeTransactions.length === 0) {
|
let totalCollectionUSD = 0;
|
||||||
return {
|
let totalCollectionLBP = 0;
|
||||||
totalCollection: 0,
|
let totalDepositUSD = 0;
|
||||||
totalDeposit: 0,
|
let totalDepositLBP = 0;
|
||||||
outstandingAmount: 0,
|
|
||||||
lastTransactionDate: null
|
for (const t of employeeTransactions) {
|
||||||
};
|
if (t.currency === "USD") {
|
||||||
|
totalCollectionUSD += t.collection_amount;
|
||||||
|
totalDepositUSD += t.deposit_amount;
|
||||||
|
} else {
|
||||||
|
totalCollectionLBP += t.collection_amount;
|
||||||
|
totalDepositLBP += t.deposit_amount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCollection = employeeTransactions.reduce((sum, t) => sum + t.collection_amount, 0);
|
const outstandingUSD = totalCollectionUSD - totalDepositUSD;
|
||||||
const totalDeposit = employeeTransactions.reduce((sum, t) => sum + t.deposit_amount, 0);
|
const outstandingLBP = totalCollectionLBP - totalDepositLBP;
|
||||||
|
|
||||||
|
const totalCollection =
|
||||||
|
convert(totalCollectionUSD, "USD", displayCurrency) +
|
||||||
|
convert(totalCollectionLBP, "LBP", displayCurrency);
|
||||||
|
const totalDeposit =
|
||||||
|
convert(totalDepositUSD, "USD", displayCurrency) +
|
||||||
|
convert(totalDepositLBP, "LBP", displayCurrency);
|
||||||
const outstandingAmount = totalCollection - totalDeposit;
|
const outstandingAmount = totalCollection - totalDeposit;
|
||||||
|
|
||||||
const lastTransactionDate = employeeTransactions
|
const lastTransactionDate =
|
||||||
.sort((a, b) => new Date(b.transaction_date).getTime() - new Date(a.transaction_date).getTime())[0]?.transaction_date || null;
|
employeeTransactions
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => new Date(b.transaction_date).getTime() - new Date(a.transaction_date).getTime())[0]
|
||||||
|
?.transaction_date || null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
totalCollectionUSD,
|
||||||
|
totalCollectionLBP,
|
||||||
|
totalDepositUSD,
|
||||||
|
totalDepositLBP,
|
||||||
|
outstandingUSD,
|
||||||
|
outstandingLBP,
|
||||||
totalCollection,
|
totalCollection,
|
||||||
totalDeposit,
|
totalDeposit,
|
||||||
outstandingAmount,
|
outstandingAmount,
|
||||||
lastTransactionDate
|
lastTransactionDate,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
employees,
|
employees,
|
||||||
transactions,
|
transactions,
|
||||||
|
outstandingBalances,
|
||||||
loading,
|
loading,
|
||||||
addTransaction,
|
addTransaction,
|
||||||
|
addEmployee,
|
||||||
getEmployeeTransactions,
|
getEmployeeTransactions,
|
||||||
getEmployeeSummary,
|
getEmployeeSummary,
|
||||||
refreshData: () => Promise.all([fetchEmployees(), fetchTransactions()])
|
refreshData,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,59 @@
|
|||||||
// This file is automatically generated. Do not edit it directly.
|
/**
|
||||||
import { createClient } from '@supabase/supabase-js';
|
* Backward-compatible shim that exposes a supabase-shaped client backed
|
||||||
import type { Database } from './types';
|
* by the local Express API (`src/lib/api.ts`). Existing components that
|
||||||
|
* use `supabase.rpc(...)`, `supabase.from(view).select(...).eq(...)` and
|
||||||
|
* `supabase.auth.*` keep working unchanged.
|
||||||
|
*/
|
||||||
|
import { api, type ApiSession } from '@/lib/api';
|
||||||
|
|
||||||
const SUPABASE_URL = "https://ypdikqcljvradgavpibk.supabase.co";
|
type Result<T> = Promise<{ data: T | null; error: { message: string } | null }>;
|
||||||
const SUPABASE_PUBLISHABLE_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlwZGlrcWNsanZyYWRnYXZwaWJrIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDgzNDIwOTgsImV4cCI6MjA2MzkxODA5OH0.TTgI8f_5IFWUVPgGpDTVzNSqIGB-6VzrxFIKfbZrTzg";
|
|
||||||
|
|
||||||
// Import the supabase client like this:
|
interface SelectBuilder<T> extends Result<T[]> {
|
||||||
// import { supabase } from "@/integrations/supabase/client";
|
eq(column: string, value: string | number | boolean): SelectBuilder<T>;
|
||||||
|
}
|
||||||
|
|
||||||
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY);
|
function makeSelectBuilder<T>(view: string): SelectBuilder<T> {
|
||||||
|
const filters: Record<string, string | number | boolean> = {};
|
||||||
|
const exec = () => api.fromView<T>(view, filters);
|
||||||
|
const builder = {
|
||||||
|
eq(column: string, value: string | number | boolean) {
|
||||||
|
filters[column] = value;
|
||||||
|
return builder as SelectBuilder<T>;
|
||||||
|
},
|
||||||
|
then(onFulfilled?: Parameters<Promise<unknown>['then']>[0],
|
||||||
|
onRejected?: Parameters<Promise<unknown>['then']>[1]) {
|
||||||
|
return exec().then(onFulfilled as never, onRejected as never);
|
||||||
|
},
|
||||||
|
} as unknown as SelectBuilder<T>;
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const supabase = {
|
||||||
|
auth: {
|
||||||
|
async signInWithPassword(args: { email: string; password: string }) {
|
||||||
|
const { data, error } = await api.auth.signInWithPassword(args);
|
||||||
|
return { data: { session: data.session as ApiSession | null }, error };
|
||||||
|
},
|
||||||
|
async signOut() { return api.auth.signOut(); },
|
||||||
|
async getSession() { return api.auth.getSession(); },
|
||||||
|
onAuthStateChange(
|
||||||
|
cb: (event: string, session: ApiSession | null) => void,
|
||||||
|
) {
|
||||||
|
return api.auth.onAuthStateChange((event, session) => cb(event, session));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
rpc<T = unknown>(fn: string, args?: Record<string, unknown>) {
|
||||||
|
return api.rpc<T>(fn, args);
|
||||||
|
},
|
||||||
|
|
||||||
|
from<T = unknown>(view: string) {
|
||||||
|
return {
|
||||||
|
select(_cols?: string) {
|
||||||
|
return makeSelectBuilder<T>(view);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default supabase;
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
export type Json =
|
|
||||||
| string
|
|
||||||
| number
|
|
||||||
| boolean
|
|
||||||
| null
|
|
||||||
| { [key: string]: Json | undefined }
|
|
||||||
| Json[]
|
|
||||||
|
|
||||||
export type Database = {
|
|
||||||
public: {
|
|
||||||
Tables: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
|
||||||
Views: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
|
||||||
Functions: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
|
||||||
Enums: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
|
||||||
CompositeTypes: {
|
|
||||||
[_ in never]: never
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type DefaultSchema = Database[Extract<keyof Database, "public">]
|
|
||||||
|
|
||||||
export type Tables<
|
|
||||||
DefaultSchemaTableNameOrOptions extends
|
|
||||||
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
|
|
||||||
| { schema: keyof Database },
|
|
||||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
|
||||||
schema: keyof Database
|
|
||||||
}
|
|
||||||
? keyof (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
|
||||||
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
|
|
||||||
: never = never,
|
|
||||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
|
||||||
? (Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
|
||||||
Database[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
|
||||||
Row: infer R
|
|
||||||
}
|
|
||||||
? R
|
|
||||||
: never
|
|
||||||
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
|
|
||||||
DefaultSchema["Views"])
|
|
||||||
? (DefaultSchema["Tables"] &
|
|
||||||
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
|
|
||||||
Row: infer R
|
|
||||||
}
|
|
||||||
? R
|
|
||||||
: never
|
|
||||||
: never
|
|
||||||
|
|
||||||
export type TablesInsert<
|
|
||||||
DefaultSchemaTableNameOrOptions extends
|
|
||||||
| keyof DefaultSchema["Tables"]
|
|
||||||
| { schema: keyof Database },
|
|
||||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
|
||||||
schema: keyof Database
|
|
||||||
}
|
|
||||||
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
|
||||||
: never = never,
|
|
||||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
|
||||||
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
|
||||||
Insert: infer I
|
|
||||||
}
|
|
||||||
? I
|
|
||||||
: never
|
|
||||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
|
||||||
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
|
||||||
Insert: infer I
|
|
||||||
}
|
|
||||||
? I
|
|
||||||
: never
|
|
||||||
: never
|
|
||||||
|
|
||||||
export type TablesUpdate<
|
|
||||||
DefaultSchemaTableNameOrOptions extends
|
|
||||||
| keyof DefaultSchema["Tables"]
|
|
||||||
| { schema: keyof Database },
|
|
||||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
|
||||||
schema: keyof Database
|
|
||||||
}
|
|
||||||
? keyof Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
|
||||||
: never = never,
|
|
||||||
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
|
|
||||||
? Database[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
|
||||||
Update: infer U
|
|
||||||
}
|
|
||||||
? U
|
|
||||||
: never
|
|
||||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
|
||||||
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
|
||||||
Update: infer U
|
|
||||||
}
|
|
||||||
? U
|
|
||||||
: never
|
|
||||||
: never
|
|
||||||
|
|
||||||
export type Enums<
|
|
||||||
DefaultSchemaEnumNameOrOptions extends
|
|
||||||
| keyof DefaultSchema["Enums"]
|
|
||||||
| { schema: keyof Database },
|
|
||||||
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
|
||||||
schema: keyof Database
|
|
||||||
}
|
|
||||||
? keyof Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
|
|
||||||
: never = never,
|
|
||||||
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
|
|
||||||
? Database[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
|
||||||
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
|
||||||
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
|
|
||||||
: never
|
|
||||||
|
|
||||||
export type CompositeTypes<
|
|
||||||
PublicCompositeTypeNameOrOptions extends
|
|
||||||
| keyof DefaultSchema["CompositeTypes"]
|
|
||||||
| { schema: keyof Database },
|
|
||||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
|
||||||
schema: keyof Database
|
|
||||||
}
|
|
||||||
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
|
||||||
: never = never,
|
|
||||||
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
|
|
||||||
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
|
||||||
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
|
|
||||||
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
|
||||||
: never
|
|
||||||
|
|
||||||
export const Constants = {
|
|
||||||
public: {
|
|
||||||
Enums: {},
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Lightweight API client that talks to the local Express backend.
|
||||||
|
* Returns supabase-shaped { data, error } so the existing UI keeps working.
|
||||||
|
*
|
||||||
|
* The JWT is persisted per browser tab via sessionStorage. There is one auth state listener
|
||||||
|
* pattern modeled after supabase.auth.onAuthStateChange.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function resolveApiBase(): string {
|
||||||
|
const raw = import.meta.env.VITE_API_BASE as string | undefined;
|
||||||
|
// Explicit empty string means "same origin" (the API serves the SPA in prod).
|
||||||
|
if (raw !== undefined) return raw.replace(/\/$/, '');
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return 'http://localhost:4000';
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredPort = (import.meta.env.VITE_API_PORT as string | undefined)?.trim() || '4000';
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
|
||||||
|
const hostname = window.location.hostname || 'localhost';
|
||||||
|
return `${protocol}//${hostname}:${configuredPort}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE: string = resolveApiBase();
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'crm_omt_token';
|
||||||
|
const USER_KEY = 'crm_omt_user';
|
||||||
|
|
||||||
|
function getSessionStorage(): Storage | null {
|
||||||
|
try {
|
||||||
|
return window.sessionStorage;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name?: string | null;
|
||||||
|
is_system_admin?: boolean;
|
||||||
|
}
|
||||||
|
export interface ApiSession {
|
||||||
|
access_token: string;
|
||||||
|
user: ApiUser;
|
||||||
|
}
|
||||||
|
export interface ApiError {
|
||||||
|
message: string;
|
||||||
|
code?: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Listener = (event: 'SIGNED_IN' | 'SIGNED_OUT' | 'INITIAL', session: ApiSession | null) => void;
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
function emit(event: 'SIGNED_IN' | 'SIGNED_OUT' | 'INITIAL', session: ApiSession | null) {
|
||||||
|
listeners.forEach((l) => { try { l(event, session); } catch { /* ignore */ } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken(): string | null {
|
||||||
|
return getSessionStorage()?.getItem(TOKEN_KEY) ?? null;
|
||||||
|
}
|
||||||
|
function getStoredUser(): ApiUser | null {
|
||||||
|
const raw = getSessionStorage()?.getItem(USER_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
try { return JSON.parse(raw) as ApiUser; } catch { return null; }
|
||||||
|
}
|
||||||
|
function setSession(session: ApiSession | null) {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
if (session) {
|
||||||
|
storage.setItem(TOKEN_KEY, session.access_token);
|
||||||
|
storage.setItem(USER_KEY, JSON.stringify(session.user));
|
||||||
|
} else {
|
||||||
|
storage.removeItem(TOKEN_KEY);
|
||||||
|
storage.removeItem(USER_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<{ data: T | null; error: ApiError | null }> {
|
||||||
|
const token = getToken();
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return { data: null, error: { message: (e as Error).message } };
|
||||||
|
}
|
||||||
|
let payload: unknown = null;
|
||||||
|
try { payload = await response.json(); } catch { /* empty body */ }
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
setSession(null);
|
||||||
|
emit('SIGNED_OUT', null);
|
||||||
|
}
|
||||||
|
const err = (payload as { error?: string; code?: string; detail?: string }) || {};
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
error: {
|
||||||
|
message: err.error || `HTTP ${response.status}`,
|
||||||
|
code: err.code,
|
||||||
|
detail: err.detail,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const data = (payload as { data?: T })?.data ?? (payload as T);
|
||||||
|
return { data: (data ?? null) as T | null, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- public API ------------------------------------------------------
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
baseUrl: API_BASE,
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
async signInWithPassword(args: { email: string; password: string }) {
|
||||||
|
const { data, error } = await request<{ token: string; user: ApiUser }>(
|
||||||
|
'POST', '/auth/login', args,
|
||||||
|
);
|
||||||
|
if (error || !data) return { data: { session: null }, error };
|
||||||
|
const session: ApiSession = { access_token: data.token, user: data.user };
|
||||||
|
setSession(session);
|
||||||
|
emit('SIGNED_IN', session);
|
||||||
|
return { data: { session }, error: null };
|
||||||
|
},
|
||||||
|
|
||||||
|
async signOut() {
|
||||||
|
const token = getToken();
|
||||||
|
if (token) await request('POST', '/auth/logout');
|
||||||
|
setSession(null);
|
||||||
|
emit('SIGNED_OUT', null);
|
||||||
|
return { error: null as ApiError | null };
|
||||||
|
},
|
||||||
|
|
||||||
|
getSession(): { data: { session: ApiSession | null } } {
|
||||||
|
const token = getToken();
|
||||||
|
const user = getStoredUser();
|
||||||
|
const session = token && user ? { access_token: token, user } : null;
|
||||||
|
return { data: { session } };
|
||||||
|
},
|
||||||
|
|
||||||
|
onAuthStateChange(cb: Listener) {
|
||||||
|
listeners.add(cb);
|
||||||
|
return {
|
||||||
|
data: { subscription: { unsubscribe: () => listeners.delete(cb) } },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Call a Postgres RPC. `args` are passed as named parameters. */
|
||||||
|
rpc<T = unknown>(fn: string, args?: Record<string, unknown>) {
|
||||||
|
return request<T>('POST', `/rpc/${fn}`, args ?? {});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Read from a whitelisted view, optionally filtered by exact match. */
|
||||||
|
async fromView<T = unknown>(view: string, filters: Record<string, string | number | boolean> = {}) {
|
||||||
|
const qs = new URLSearchParams(
|
||||||
|
Object.entries(filters).map(([k, v]) => [k, String(v)]),
|
||||||
|
).toString();
|
||||||
|
return request<T[]>('GET', `/from/${view}${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Plain GET against the API. */
|
||||||
|
get<T = unknown>(path: string) { return request<T>('GET', path); },
|
||||||
|
/** Plain POST against the API. */
|
||||||
|
post<T = unknown>(path: string, body?: unknown) { return request<T>('POST', path, body); },
|
||||||
|
/** Plain DELETE against the API. */
|
||||||
|
delete<T = unknown>(path: string) { return request<T>('DELETE', path); },
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Api = typeof api;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Currency utilities for the Cash Management System.
|
||||||
|
// Supports US Dollar (USD) and Lebanese Pound (LBP).
|
||||||
|
|
||||||
|
export type Currency = "USD" | "LBP";
|
||||||
|
|
||||||
|
// 1 USD = 89,500 LBP (configurable via Admin if needed later).
|
||||||
|
export const DEFAULT_USD_TO_LBP = 89500;
|
||||||
|
|
||||||
|
const RATE_KEY = "currency_usd_to_lbp_rate";
|
||||||
|
|
||||||
|
export function getUsdToLbpRate(): number {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RATE_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (Number.isFinite(n) && n > 0) return n;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
return DEFAULT_USD_TO_LBP;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUsdToLbpRate(rate: number) {
|
||||||
|
if (!Number.isFinite(rate) || rate <= 0) return;
|
||||||
|
localStorage.setItem(RATE_KEY, String(rate));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert an amount from one currency to another.
|
||||||
|
export function convert(amount: number, from: Currency, to: Currency): number {
|
||||||
|
if (from === to) return amount;
|
||||||
|
const rate = getUsdToLbpRate();
|
||||||
|
if (from === "USD" && to === "LBP") return amount * rate;
|
||||||
|
if (from === "LBP" && to === "USD") return amount / rate;
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a number as currency. LBP has no decimals and uses thousands grouping.
|
||||||
|
export function formatCurrency(amount: number, currency: Currency): string {
|
||||||
|
if (currency === "USD") {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
|
// LBP
|
||||||
|
return (
|
||||||
|
new Intl.NumberFormat("en-US", {
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(Math.round(amount)) + " LBP"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CURRENCY_OPTIONS: Currency[] = ["USD", "LBP"];
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// Service category catalog mirrored from `app.services` seed (migration 0003).
|
||||||
|
// Keep in sync with the DB; the DB is the source of truth — this list is
|
||||||
|
// only used to render the Select before the user's session is online.
|
||||||
|
//
|
||||||
|
// Categories let the UI group the dropdown sensibly: "Money transfer",
|
||||||
|
// "Telecom recharge", "Bills", "Goods & repair", "Other".
|
||||||
|
|
||||||
|
export type ServiceCategory =
|
||||||
|
| "money_transfer"
|
||||||
|
| "telecom_recharge"
|
||||||
|
| "bills"
|
||||||
|
| "goods"
|
||||||
|
| "repair"
|
||||||
|
| "refund";
|
||||||
|
|
||||||
|
export interface ServiceDef {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
category: ServiceCategory;
|
||||||
|
requiresExternalRef: boolean;
|
||||||
|
requiresBeneficiary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SERVICES: ServiceDef[] = [
|
||||||
|
// Money transfer
|
||||||
|
{ code: "OMT_SEND", label: "OMT — Send", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: true },
|
||||||
|
{ code: "OMT_RECEIVE", label: "OMT — Receive", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: false },
|
||||||
|
{ code: "WU_SEND", label: "Western Union — Send", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: true },
|
||||||
|
{ code: "WU_RECEIVE", label: "Western Union — Recv", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: false },
|
||||||
|
{ code: "WHISH_SEND", label: "Whish — Send", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: true },
|
||||||
|
{ code: "WHISH_RECEIVE",label: "Whish — Receive", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: false },
|
||||||
|
|
||||||
|
// Telecom recharge
|
||||||
|
{ code: "ALFA_RECHARGE", label: "Alfa recharge", category: "telecom_recharge", requiresExternalRef: false, requiresBeneficiary: false },
|
||||||
|
{ code: "TOUCH_RECHARGE", label: "touch recharge", category: "telecom_recharge", requiresExternalRef: false, requiresBeneficiary: false },
|
||||||
|
{ code: "OGERO_RECHARGE", label: "Ogero recharge", category: "telecom_recharge", requiresExternalRef: false, requiresBeneficiary: false },
|
||||||
|
{ code: "INTERNET_RECHARGE", label: "Internet voucher", category: "telecom_recharge", requiresExternalRef: false, requiresBeneficiary: false },
|
||||||
|
|
||||||
|
// Bills
|
||||||
|
{ code: "OMT_BILL", label: "Bill payment (via OMT)", category: "bills", requiresExternalRef: true, requiresBeneficiary: false },
|
||||||
|
{ code: "EDL_BILL", label: "EDL electricity bill", category: "bills", requiresExternalRef: true, requiresBeneficiary: false },
|
||||||
|
|
||||||
|
// Goods & repair
|
||||||
|
{ code: "GOODS_SALE", label: "Goods sale", category: "goods", requiresExternalRef: false, requiresBeneficiary: false },
|
||||||
|
{ code: "REPAIR", label: "Phone / device repair", category: "repair", requiresExternalRef: false, requiresBeneficiary: false },
|
||||||
|
|
||||||
|
// Refund (issued via app.issue_refund only — listed here so refund txns render)
|
||||||
|
{ code: "REFUND", label: "Refund", category: "refund", requiresExternalRef: false, requiresBeneficiary: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SERVICES_BY_CATEGORY: Record<ServiceCategory, ServiceDef[]> = SERVICES.reduce(
|
||||||
|
(acc, s) => {
|
||||||
|
(acc[s.category] ||= []).push(s);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<ServiceCategory, ServiceDef[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CATEGORY_LABEL: Record<ServiceCategory, string> = {
|
||||||
|
money_transfer: "Money transfer",
|
||||||
|
telecom_recharge: "Telecom recharge",
|
||||||
|
bills: "Bills",
|
||||||
|
goods: "Goods",
|
||||||
|
repair: "Repair",
|
||||||
|
refund: "Refund",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function findService(code: string): ServiceDef | undefined {
|
||||||
|
return SERVICES.find((s) => s.code === code);
|
||||||
|
}
|
||||||
+122
-7
@@ -5,14 +5,30 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { OutstandingReportDashboard } from "@/components/OutstandingReportDashboard";
|
import { OutstandingReportDashboard } from "@/components/OutstandingReportDashboard";
|
||||||
import { DetailedEmployeePaymentReport } from "@/components/DetailedEmployeePaymentReport";
|
import { DetailedEmployeePaymentReport } from "@/components/DetailedEmployeePaymentReport";
|
||||||
import { AdminDataEntryModal } from "@/components/AdminDataEntryModal";
|
import { AdminDataEntryModal } from "@/components/AdminDataEntryModal";
|
||||||
|
import { TransactionEntry } from "@/components/TransactionEntry";
|
||||||
|
import { ShiftControl } from "@/components/ShiftControl";
|
||||||
import { LoginPage } from "@/components/LoginPage";
|
import { LoginPage } from "@/components/LoginPage";
|
||||||
|
import { UserManagement } from "@/components/UserManagement";
|
||||||
|
import { ManagerConsole } from "@/components/ManagerConsole";
|
||||||
|
import { OwnerOverview } from "@/components/OwnerOverview";
|
||||||
|
import { TransactionCenter } from "@/components/TransactionCenter";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { LogOut } from "lucide-react";
|
import { LogOut } from "lucide-react";
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const { user, loading, signOut } = useAuth();
|
const { user, loading, signOut } = useAuth();
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isTxnOpen, setIsTxnOpen] = useState(false);
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
const isAdmin = user?.role === "admin";
|
||||||
|
const isOwner = user?.role === "owner";
|
||||||
|
const canEnterTransactions = user?.role === "owner" || user?.role === "employee";
|
||||||
|
const defaultTab = isAdmin ? "users" : isOwner ? "overview" : "shift";
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(defaultTab);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setActiveTab(defaultTab);
|
||||||
|
}, [defaultTab]);
|
||||||
|
|
||||||
const handleDataUpdate = () => {
|
const handleDataUpdate = () => {
|
||||||
setRefreshTrigger(prev => prev + 1);
|
setRefreshTrigger(prev => prev + 1);
|
||||||
@@ -46,15 +62,28 @@ const Index = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold text-gray-800">Dashboard</h1>
|
<h1 className="text-2xl font-semibold text-gray-800">Dashboard</h1>
|
||||||
<p className="text-sm text-gray-500">Cash Management System</p>
|
<p className="text-sm text-gray-500">
|
||||||
|
Cash Management System {user ? `· ${user.name} (${user.role})` : ""}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{canEnterTransactions && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsTxnOpen(true)}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2 rounded-lg font-medium"
|
||||||
|
>
|
||||||
|
New Transaction
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className="bg-orange-500 hover:bg-orange-600 text-white px-6 py-2 rounded-lg font-medium"
|
variant="outline"
|
||||||
|
className="px-6 py-2 rounded-lg font-medium"
|
||||||
>
|
>
|
||||||
Insert Employee Data
|
Submit Collection / Deposit
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -68,29 +97,110 @@ const Index = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="container mx-auto p-6">
|
<div className="container mx-auto p-6">
|
||||||
<Tabs defaultValue="outstanding" className="space-y-6">
|
<Tabs
|
||||||
<TabsList className="grid w-full grid-cols-2 bg-white rounded-lg shadow-sm p-1 border">
|
value={activeTab}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
if (isTxnOpen || isModalOpen) return;
|
||||||
|
setActiveTab(next);
|
||||||
|
}}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<TabsList className="flex w-full flex-wrap gap-1 h-auto justify-start bg-white rounded-lg shadow-sm p-1.5 border">
|
||||||
|
{isOwner && (
|
||||||
|
<TabsTrigger
|
||||||
|
value="overview"
|
||||||
|
className="flex-1 min-w-[120px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
|
||||||
|
>
|
||||||
|
Overview
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
{canEnterTransactions && (
|
||||||
|
<>
|
||||||
|
<TabsTrigger
|
||||||
|
value="shift"
|
||||||
|
className="flex-1 min-w-[120px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
|
||||||
|
>
|
||||||
|
Shift
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="transactions"
|
||||||
|
className="flex-1 min-w-[140px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
|
||||||
|
>
|
||||||
|
Transactions
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="outstanding"
|
value="outstanding"
|
||||||
className="data-[state=active]:bg-purple-600 data-[state=active]:text-white rounded-md py-3 text-base font-medium transition-all duration-200"
|
className="flex-1 min-w-[120px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
|
||||||
>
|
>
|
||||||
Outstanding Report
|
Outstanding Report
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="payment"
|
value="payment"
|
||||||
className="data-[state=active]:bg-purple-600 data-[state=active]:text-white rounded-md py-3 text-base font-medium transition-all duration-200"
|
className="flex-1 min-w-[160px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
|
||||||
>
|
>
|
||||||
Employee Payment Report
|
Employee Payment Report
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<TabsTrigger
|
||||||
|
value="manager"
|
||||||
|
className="flex-1 min-w-[140px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
|
||||||
|
>
|
||||||
|
Manager Console
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<TabsTrigger
|
||||||
|
value="users"
|
||||||
|
className="flex-1 min-w-[140px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
|
||||||
|
>
|
||||||
|
User Management
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
|
{canEnterTransactions && (
|
||||||
|
<TabsContent value="shift">
|
||||||
|
<ShiftControl />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEnterTransactions && (
|
||||||
|
<TabsContent value="transactions">
|
||||||
|
<TransactionCenter />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<TabsContent value="overview">
|
||||||
|
<OwnerOverview key={refreshTrigger} />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEnterTransactions && (
|
||||||
<TabsContent value="outstanding">
|
<TabsContent value="outstanding">
|
||||||
<OutstandingReportDashboard key={refreshTrigger} />
|
<OutstandingReportDashboard key={refreshTrigger} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canEnterTransactions && (
|
||||||
<TabsContent value="payment">
|
<TabsContent value="payment">
|
||||||
<DetailedEmployeePaymentReport key={refreshTrigger} />
|
<DetailedEmployeePaymentReport key={refreshTrigger} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<TabsContent value="manager">
|
||||||
|
<ManagerConsole />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<TabsContent value="users">
|
||||||
|
<UserManagement />
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<AdminDataEntryModal
|
<AdminDataEntryModal
|
||||||
@@ -98,6 +208,11 @@ const Index = () => {
|
|||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsModalOpen(false)}
|
||||||
onDataUpdate={handleDataUpdate}
|
onDataUpdate={handleDataUpdate}
|
||||||
/>
|
/>
|
||||||
|
<TransactionEntry
|
||||||
|
isOpen={isTxnOpen}
|
||||||
|
onClose={() => setIsTxnOpen(false)}
|
||||||
|
onCreated={handleDataUpdate}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0001 — Auth, organizational hierarchy, RLS foundations.
|
||||||
|
--
|
||||||
|
-- Implements roadmap Step 1 (identity & access) and Step 2 (org & master
|
||||||
|
-- data). No money tables yet; those come in 0002+. Every table is created
|
||||||
|
-- with RLS enabled and a deny-by-default posture; specific policies are
|
||||||
|
-- added inline.
|
||||||
|
--
|
||||||
|
-- Threat-model rows addressed: 9, 18, 20, 24, 25.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- Required extensions ---------------------------------------------------
|
||||||
|
create extension if not exists "pgcrypto"; -- gen_random_uuid, digest
|
||||||
|
create extension if not exists "citext"; -- case-insensitive text
|
||||||
|
|
||||||
|
-- Dedicated schema for app data (keeps `public` clean) ------------------
|
||||||
|
create schema if not exists app;
|
||||||
|
|
||||||
|
-- Revoke broad defaults; we will grant explicitly per role.
|
||||||
|
revoke all on schema app from public;
|
||||||
|
grant usage on schema app to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Roles
|
||||||
|
-- =====================================================================
|
||||||
|
-- We model business roles as an enum, separate from Postgres/Supabase
|
||||||
|
-- roles. Supabase still uses `authenticated`/`anon`; the business role is
|
||||||
|
-- read from `app.user_shop_assignments` per shop.
|
||||||
|
do $$ begin
|
||||||
|
create type app.business_role as enum ('owner', 'manager', 'cashier', 'auditor');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Shops, tills, users
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.shops (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text not null,
|
||||||
|
address text,
|
||||||
|
omt_agent_code text unique,
|
||||||
|
alfa_dealer_code text unique,
|
||||||
|
touch_dealer_code text unique,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
created_by uuid references auth.users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists app.tills (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
shop_id uuid not null references app.shops(id) on delete restrict,
|
||||||
|
name text not null,
|
||||||
|
-- Pin a till to a hardware device. New devices must be registered by an
|
||||||
|
-- owner; blocks vector #20 (second undeclared till on same machine).
|
||||||
|
device_fingerprint text unique,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
unique (shop_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Profile mirror of auth.users so we can attach business attributes
|
||||||
|
-- without granting clients access to the auth schema.
|
||||||
|
create table if not exists app.user_profiles (
|
||||||
|
user_id uuid primary key references auth.users(id) on delete cascade,
|
||||||
|
full_name text not null,
|
||||||
|
phone text,
|
||||||
|
-- 6-digit PIN, salted+hashed. Never store plaintext.
|
||||||
|
pin_hash text,
|
||||||
|
pin_set_at timestamptz,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists app.user_shop_assignments (
|
||||||
|
user_id uuid not null references auth.users(id) on delete cascade,
|
||||||
|
shop_id uuid not null references app.shops(id) on delete cascade,
|
||||||
|
role app.business_role not null,
|
||||||
|
assigned_at timestamptz not null default now(),
|
||||||
|
assigned_by uuid references auth.users(id),
|
||||||
|
primary key (user_id, shop_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_assignments_shop on app.user_shop_assignments(shop_id);
|
||||||
|
create index if not exists idx_assignments_role on app.user_shop_assignments(shop_id, role);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Helper functions (SECURITY DEFINER) used by RLS policies.
|
||||||
|
-- These run with the function owner's privileges, so they can read
|
||||||
|
-- assignment rows even when the calling user cannot read the table.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.current_user_id()
|
||||||
|
returns uuid
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $$ select auth.uid() $$;
|
||||||
|
|
||||||
|
create or replace function app.has_role_in_shop(p_shop uuid, p_role app.business_role)
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select exists (
|
||||||
|
select 1
|
||||||
|
from app.user_shop_assignments a
|
||||||
|
where a.user_id = auth.uid()
|
||||||
|
and a.shop_id = p_shop
|
||||||
|
and a.role = p_role
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function app.has_any_role_in_shop(p_shop uuid, p_roles app.business_role[])
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select exists (
|
||||||
|
select 1
|
||||||
|
from app.user_shop_assignments a
|
||||||
|
where a.user_id = auth.uid()
|
||||||
|
and a.shop_id = p_shop
|
||||||
|
and a.role = any(p_roles)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function app.is_owner_anywhere()
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select exists (
|
||||||
|
select 1 from app.user_shop_assignments a
|
||||||
|
where a.user_id = auth.uid() and a.role = 'owner'
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function app.has_role_in_shop(uuid, app.business_role) from public;
|
||||||
|
revoke all on function app.has_any_role_in_shop(uuid, app.business_role[]) from public;
|
||||||
|
revoke all on function app.is_owner_anywhere() from public;
|
||||||
|
grant execute on function app.has_role_in_shop(uuid, app.business_role) to authenticated;
|
||||||
|
grant execute on function app.has_any_role_in_shop(uuid, app.business_role[]) to authenticated;
|
||||||
|
grant execute on function app.is_owner_anywhere() to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- PIN management. Plaintext PINs never leave the server.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.set_my_pin(p_pin text)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if p_pin !~ '^[0-9]{6}$' then
|
||||||
|
raise exception 'PIN must be exactly 6 digits';
|
||||||
|
end if;
|
||||||
|
insert into app.user_profiles(user_id, full_name, pin_hash, pin_set_at)
|
||||||
|
values (auth.uid(), coalesce((select full_name from app.user_profiles where user_id = auth.uid()), 'Unnamed'),
|
||||||
|
crypt(p_pin, gen_salt('bf', 10)), now())
|
||||||
|
on conflict (user_id) do update
|
||||||
|
set pin_hash = crypt(p_pin, gen_salt('bf', 10)),
|
||||||
|
pin_set_at = now();
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function app.verify_my_pin(p_pin text)
|
||||||
|
returns boolean
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare h text;
|
||||||
|
begin
|
||||||
|
select pin_hash into h from app.user_profiles where user_id = auth.uid();
|
||||||
|
if h is null then return false; end if;
|
||||||
|
return h = crypt(p_pin, h);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function app.set_my_pin(text) from public;
|
||||||
|
revoke all on function app.verify_my_pin(text) from public;
|
||||||
|
grant execute on function app.set_my_pin(text) to authenticated;
|
||||||
|
grant execute on function app.verify_my_pin(text) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Audit log of authentication / authorization events.
|
||||||
|
-- Append-only: revoke UPDATE and DELETE; only INSERT via function.
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.auth_events (
|
||||||
|
id bigserial primary key,
|
||||||
|
occurred_at timestamptz not null default now(),
|
||||||
|
user_id uuid,
|
||||||
|
event_type text not null, -- login, pin_ok, pin_fail, role_change, device_register, ...
|
||||||
|
shop_id uuid,
|
||||||
|
device_fingerprint text,
|
||||||
|
metadata jsonb not null default '{}'::jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_auth_events_user on app.auth_events(user_id, occurred_at desc);
|
||||||
|
create index if not exists idx_auth_events_shop on app.auth_events(shop_id, occurred_at desc);
|
||||||
|
|
||||||
|
create or replace function app.log_auth_event(
|
||||||
|
p_event_type text,
|
||||||
|
p_shop uuid,
|
||||||
|
p_device text,
|
||||||
|
p_metadata jsonb
|
||||||
|
)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
insert into app.auth_events(user_id, event_type, shop_id, device_fingerprint, metadata)
|
||||||
|
values (auth.uid(), p_event_type, p_shop, p_device, coalesce(p_metadata, '{}'::jsonb));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function app.log_auth_event(text, uuid, text, jsonb) from public;
|
||||||
|
grant execute on function app.log_auth_event(text, uuid, text, jsonb) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- RLS — deny by default, then allow per role.
|
||||||
|
-- =====================================================================
|
||||||
|
alter table app.shops enable row level security;
|
||||||
|
alter table app.tills enable row level security;
|
||||||
|
alter table app.user_profiles enable row level security;
|
||||||
|
alter table app.user_shop_assignments enable row level security;
|
||||||
|
alter table app.auth_events enable row level security;
|
||||||
|
|
||||||
|
-- Force RLS even for table owners (defense in depth against insider edits,
|
||||||
|
-- threat-model row #24).
|
||||||
|
alter table app.shops force row level security;
|
||||||
|
alter table app.tills force row level security;
|
||||||
|
alter table app.user_profiles force row level security;
|
||||||
|
alter table app.user_shop_assignments force row level security;
|
||||||
|
alter table app.auth_events force row level security;
|
||||||
|
|
||||||
|
-- shops: owners and assigned users can see their shops.
|
||||||
|
drop policy if exists shops_select on app.shops;
|
||||||
|
create policy shops_select on app.shops
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
app.is_owner_anywhere()
|
||||||
|
or exists (
|
||||||
|
select 1 from app.user_shop_assignments a
|
||||||
|
where a.shop_id = shops.id and a.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Only owners can create/modify shops, and never via direct UPDATE of
|
||||||
|
-- security-relevant columns; we still permit it here but real changes
|
||||||
|
-- should go through dedicated functions later.
|
||||||
|
drop policy if exists shops_write_owner on app.shops;
|
||||||
|
create policy shops_write_owner on app.shops
|
||||||
|
for all to authenticated
|
||||||
|
using (app.is_owner_anywhere())
|
||||||
|
with check (app.is_owner_anywhere());
|
||||||
|
|
||||||
|
-- tills: visible to everyone assigned to the shop, writable only by owners.
|
||||||
|
drop policy if exists tills_select on app.tills;
|
||||||
|
create policy tills_select on app.tills
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
app.is_owner_anywhere()
|
||||||
|
or exists (
|
||||||
|
select 1 from app.user_shop_assignments a
|
||||||
|
where a.shop_id = tills.shop_id and a.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists tills_write_owner on app.tills;
|
||||||
|
create policy tills_write_owner on app.tills
|
||||||
|
for all to authenticated
|
||||||
|
using (app.has_role_in_shop(tills.shop_id, 'owner'))
|
||||||
|
with check (app.has_role_in_shop(tills.shop_id, 'owner'));
|
||||||
|
|
||||||
|
-- user_profiles: a user can read/update their own profile (but PIN is
|
||||||
|
-- changed only via the set_my_pin function). Owners can read all.
|
||||||
|
drop policy if exists profiles_select_self_or_owner on app.user_profiles;
|
||||||
|
create policy profiles_select_self_or_owner on app.user_profiles
|
||||||
|
for select to authenticated
|
||||||
|
using (user_id = auth.uid() or app.is_owner_anywhere());
|
||||||
|
|
||||||
|
drop policy if exists profiles_update_self on app.user_profiles;
|
||||||
|
create policy profiles_update_self on app.user_profiles
|
||||||
|
for update to authenticated
|
||||||
|
using (user_id = auth.uid())
|
||||||
|
with check (user_id = auth.uid());
|
||||||
|
|
||||||
|
-- user_shop_assignments: a user can see their own assignments; owners can
|
||||||
|
-- see/manage all assignments in their own shops.
|
||||||
|
drop policy if exists assignments_select on app.user_shop_assignments;
|
||||||
|
create policy assignments_select on app.user_shop_assignments
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
user_id = auth.uid()
|
||||||
|
or app.has_role_in_shop(shop_id, 'owner')
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists assignments_write_owner on app.user_shop_assignments;
|
||||||
|
create policy assignments_write_owner on app.user_shop_assignments
|
||||||
|
for all to authenticated
|
||||||
|
using (app.has_role_in_shop(shop_id, 'owner'))
|
||||||
|
with check (app.has_role_in_shop(shop_id, 'owner'));
|
||||||
|
|
||||||
|
-- auth_events: nobody writes directly; only the log_auth_event function.
|
||||||
|
-- Reads: a user sees their own events; owners see all in their shops.
|
||||||
|
revoke insert, update, delete on app.auth_events from authenticated;
|
||||||
|
|
||||||
|
drop policy if exists auth_events_select on app.auth_events;
|
||||||
|
create policy auth_events_select on app.auth_events
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
user_id = auth.uid()
|
||||||
|
or (shop_id is not null and app.has_role_in_shop(shop_id, 'owner'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Hard prohibitions — no DELETE on auth_events from anyone (including
|
||||||
|
-- service role used by the app). Only DBA at psql can DELETE, and that
|
||||||
|
-- itself should be audited at the infrastructure level.
|
||||||
|
-- =====================================================================
|
||||||
|
revoke delete on app.auth_events from authenticated;
|
||||||
|
-- Note: in Supabase, the `service_role` bypasses RLS but still respects
|
||||||
|
-- table grants. Revoke explicitly:
|
||||||
|
do $$ begin
|
||||||
|
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
||||||
|
execute 'revoke delete on app.auth_events from service_role';
|
||||||
|
execute 'revoke update on app.auth_events from service_role';
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Grants for ordinary table access (RLS still applies).
|
||||||
|
-- =====================================================================
|
||||||
|
grant select on app.shops to authenticated;
|
||||||
|
grant insert, update on app.shops to authenticated;
|
||||||
|
grant select on app.tills to authenticated;
|
||||||
|
grant insert, update on app.tills to authenticated;
|
||||||
|
grant select, update on app.user_profiles to authenticated;
|
||||||
|
grant select, insert, update, delete on app.user_shop_assignments to authenticated;
|
||||||
|
grant select on app.auth_events to authenticated;
|
||||||
|
|
||||||
|
-- End migration 0001 ----------------------------------------------------
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0002 — Shifts and cash drawer (roadmap Step 3).
|
||||||
|
--
|
||||||
|
-- Implements the cash-control backbone:
|
||||||
|
-- * One open shift per till at any time (vector #20).
|
||||||
|
-- * Blind close: cashier declares cash, expected is computed by the
|
||||||
|
-- system; both are stored, with variance (vectors #7, #14).
|
||||||
|
-- * Cash movements typed and append-only (vector #2, #22).
|
||||||
|
-- * No backdating: occurred_at = now() server-side (vector #25).
|
||||||
|
-- * Forced shift close before next shift opens (vector #8).
|
||||||
|
--
|
||||||
|
-- Threat-model rows addressed: 2, 7, 8, 14, 20, 22, 25.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Enums
|
||||||
|
-- =====================================================================
|
||||||
|
do $$ begin
|
||||||
|
create type app.shift_status as enum ('open', 'declared', 'closed');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type app.cash_movement_type as enum (
|
||||||
|
'sale_in', -- cash received from a customer
|
||||||
|
'payout_out', -- cash paid to a customer (e.g. OMT receive)
|
||||||
|
'drop_to_safe', -- cashier removes cash from till to safe
|
||||||
|
'bank_deposit', -- cash leaves the shop to the bank
|
||||||
|
'expense', -- petty cash spent
|
||||||
|
'fx_swap_in', -- one leg of a currency exchange
|
||||||
|
'fx_swap_out', -- the other leg
|
||||||
|
'opening_float', -- recorded at shift open
|
||||||
|
'adjustment' -- manager-approved correction (always audited)
|
||||||
|
);
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type app.currency_code as enum ('USD', 'LBP');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Shifts
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.shifts (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
till_id uuid not null references app.tills(id) on delete restrict,
|
||||||
|
shop_id uuid not null references app.shops(id) on delete restrict,
|
||||||
|
user_id uuid not null references auth.users(id) on delete restrict,
|
||||||
|
|
||||||
|
status app.shift_status not null default 'open',
|
||||||
|
|
||||||
|
opened_at timestamptz not null default now(),
|
||||||
|
opened_by uuid not null references auth.users(id),
|
||||||
|
opening_usd numeric(14,2) not null check (opening_usd >= 0),
|
||||||
|
opening_lbp numeric(18,0) not null check (opening_lbp >= 0),
|
||||||
|
|
||||||
|
-- Phase 1 of close: cashier declares the count.
|
||||||
|
declared_at timestamptz,
|
||||||
|
declared_close_usd numeric(14,2) check (declared_close_usd >= 0),
|
||||||
|
declared_close_lbp numeric(18,0) check (declared_close_lbp >= 0),
|
||||||
|
|
||||||
|
-- Phase 2 of close: system computes expected; cashier cannot edit.
|
||||||
|
closed_at timestamptz,
|
||||||
|
closed_by uuid references auth.users(id),
|
||||||
|
expected_close_usd numeric(14,2),
|
||||||
|
expected_close_lbp numeric(18,0),
|
||||||
|
variance_usd numeric(14,2),
|
||||||
|
variance_lbp numeric(18,0),
|
||||||
|
|
||||||
|
-- One open or declared shift per till at any time.
|
||||||
|
constraint shifts_status_dates_ok check (
|
||||||
|
(status = 'open' and declared_at is null and closed_at is null)
|
||||||
|
or (status = 'declared' and declared_at is not null and closed_at is null)
|
||||||
|
or (status = 'closed' and declared_at is not null and closed_at is not null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_shifts_till_status on app.shifts(till_id, status);
|
||||||
|
create index if not exists idx_shifts_shop_open on app.shifts(shop_id, opened_at desc);
|
||||||
|
create index if not exists idx_shifts_user on app.shifts(user_id, opened_at desc);
|
||||||
|
|
||||||
|
-- Partial unique index: at most one non-closed shift per till.
|
||||||
|
create unique index if not exists uq_one_active_shift_per_till
|
||||||
|
on app.shifts(till_id) where status <> 'closed';
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Cash movements (append-only)
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.cash_movements (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
shift_id uuid not null references app.shifts(id) on delete restrict,
|
||||||
|
occurred_at timestamptz not null default now(),
|
||||||
|
type app.cash_movement_type not null,
|
||||||
|
currency app.currency_code not null,
|
||||||
|
-- Signed amount: positive = cash into the till, negative = cash out.
|
||||||
|
amount numeric(18,2) not null check (amount <> 0),
|
||||||
|
ref_txn_id uuid, -- filled later when ledger table exists (FK added in 0003)
|
||||||
|
note text,
|
||||||
|
created_by uuid not null references auth.users(id) default auth.uid(),
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_cash_mov_shift on app.cash_movements(shift_id, occurred_at);
|
||||||
|
create index if not exists idx_cash_mov_txn on app.cash_movements(ref_txn_id);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Triggers — block edits and back-dating
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- Cash movements: insert-only.
|
||||||
|
create or replace function app.cash_movements_no_update_delete()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
raise exception 'cash_movements is append-only';
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_cash_mov_no_update on app.cash_movements;
|
||||||
|
create trigger trg_cash_mov_no_update
|
||||||
|
before update or delete on app.cash_movements
|
||||||
|
for each row execute function app.cash_movements_no_update_delete();
|
||||||
|
|
||||||
|
-- Force occurred_at = now() and created_by = auth.uid() on insert.
|
||||||
|
create or replace function app.cash_movements_stamp()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
new.occurred_at := now(); -- vector #25: no backdating
|
||||||
|
new.created_at := now();
|
||||||
|
new.created_by := auth.uid();
|
||||||
|
-- The shift must be open and belong to the same user / till must be active.
|
||||||
|
if not exists (
|
||||||
|
select 1 from app.shifts s
|
||||||
|
where s.id = new.shift_id and s.status = 'open'
|
||||||
|
) then
|
||||||
|
raise exception 'cash movement requires an OPEN shift (got shift %)', new.shift_id;
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_cash_mov_stamp on app.cash_movements;
|
||||||
|
create trigger trg_cash_mov_stamp
|
||||||
|
before insert on app.cash_movements
|
||||||
|
for each row execute function app.cash_movements_stamp();
|
||||||
|
|
||||||
|
-- Shifts: tightly constrain UPDATE paths. Only specific transitions are
|
||||||
|
-- allowed and they must come through the SECURITY DEFINER functions
|
||||||
|
-- below (which set a session GUC the trigger checks for).
|
||||||
|
create or replace function app.shifts_guard_update()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
if current_setting('app.shift_internal', true) is distinct from 'on' then
|
||||||
|
raise exception 'direct UPDATE on app.shifts is not allowed; use app.declare_close / app.finalize_close';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_shifts_guard_update on app.shifts;
|
||||||
|
create trigger trg_shifts_guard_update
|
||||||
|
before update on app.shifts
|
||||||
|
for each row execute function app.shifts_guard_update();
|
||||||
|
|
||||||
|
create or replace function app.shifts_no_delete()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
raise exception 'shifts cannot be deleted';
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_shifts_no_delete on app.shifts;
|
||||||
|
create trigger trg_shifts_no_delete
|
||||||
|
before delete on app.shifts
|
||||||
|
for each row execute function app.shifts_no_delete();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- SECURITY DEFINER functions — the only legal way to mutate shifts.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- Open a new shift on a till for the current user.
|
||||||
|
create or replace function app.open_shift(
|
||||||
|
p_till_id uuid,
|
||||||
|
p_opening_usd numeric,
|
||||||
|
p_opening_lbp numeric
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_shop uuid;
|
||||||
|
v_shift uuid;
|
||||||
|
begin
|
||||||
|
if p_opening_usd is null or p_opening_lbp is null then
|
||||||
|
raise exception 'opening counts are required';
|
||||||
|
end if;
|
||||||
|
if p_opening_usd < 0 or p_opening_lbp < 0 then
|
||||||
|
raise exception 'opening counts must be non-negative';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select shop_id into v_shop from app.tills where id = p_till_id and is_active;
|
||||||
|
if v_shop is null then
|
||||||
|
raise exception 'till % not found or inactive', p_till_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Caller must be a cashier or manager in this shop.
|
||||||
|
if not app.has_any_role_in_shop(v_shop, array['cashier','manager']::app.business_role[]) then
|
||||||
|
raise exception 'not authorized to open a shift on this till';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Reject if any non-closed shift exists on this till.
|
||||||
|
if exists (select 1 from app.shifts where till_id = p_till_id and status <> 'closed') then
|
||||||
|
raise exception 'till % already has an active shift; close it first', p_till_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into app.shifts(till_id, shop_id, user_id, opened_by, opening_usd, opening_lbp)
|
||||||
|
values (p_till_id, v_shop, auth.uid(), auth.uid(), p_opening_usd, p_opening_lbp)
|
||||||
|
returning id into v_shift;
|
||||||
|
|
||||||
|
-- Record the opening float as a cash movement for clean ledgers.
|
||||||
|
insert into app.cash_movements(shift_id, type, currency, amount, note)
|
||||||
|
values (v_shift, 'opening_float', 'USD', p_opening_usd, 'opening float'),
|
||||||
|
(v_shift, 'opening_float', 'LBP', p_opening_lbp, 'opening float');
|
||||||
|
|
||||||
|
perform app.log_auth_event('shift_opened', v_shop, null,
|
||||||
|
jsonb_build_object('shift_id', v_shift, 'till_id', p_till_id));
|
||||||
|
return v_shift;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Phase 1 of close: cashier declares the cash count. Expected is NOT
|
||||||
|
-- revealed until phase 2 (vector #14: blind close).
|
||||||
|
create or replace function app.declare_close(
|
||||||
|
p_shift_id uuid,
|
||||||
|
p_declared_close_usd numeric,
|
||||||
|
p_declared_close_lbp numeric
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare s record;
|
||||||
|
begin
|
||||||
|
select * into s from app.shifts where id = p_shift_id;
|
||||||
|
if s.id is null then raise exception 'shift not found'; end if;
|
||||||
|
if s.user_id <> auth.uid() and not app.has_role_in_shop(s.shop_id, 'manager') then
|
||||||
|
raise exception 'only the shift owner or a manager may declare close';
|
||||||
|
end if;
|
||||||
|
if s.status <> 'open' then
|
||||||
|
raise exception 'shift % is not open (status=%)', p_shift_id, s.status;
|
||||||
|
end if;
|
||||||
|
if p_declared_close_usd is null or p_declared_close_lbp is null
|
||||||
|
or p_declared_close_usd < 0 or p_declared_close_lbp < 0 then
|
||||||
|
raise exception 'declared counts must be non-negative numbers';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform set_config('app.shift_internal', 'on', true);
|
||||||
|
update app.shifts
|
||||||
|
set status = 'declared',
|
||||||
|
declared_at = now(),
|
||||||
|
declared_close_usd = p_declared_close_usd,
|
||||||
|
declared_close_lbp = p_declared_close_lbp
|
||||||
|
where id = p_shift_id;
|
||||||
|
perform set_config('app.shift_internal', 'off', true);
|
||||||
|
|
||||||
|
perform app.log_auth_event('shift_declared', s.shop_id, null,
|
||||||
|
jsonb_build_object('shift_id', p_shift_id));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Phase 2 of close: compute expected and variance, lock the shift.
|
||||||
|
create or replace function app.finalize_close(p_shift_id uuid)
|
||||||
|
returns table (
|
||||||
|
expected_usd numeric,
|
||||||
|
expected_lbp numeric,
|
||||||
|
variance_usd numeric,
|
||||||
|
variance_lbp numeric
|
||||||
|
)
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
s record;
|
||||||
|
v_exp_usd numeric;
|
||||||
|
v_exp_lbp numeric;
|
||||||
|
begin
|
||||||
|
select * into s from app.shifts where id = p_shift_id;
|
||||||
|
if s.id is null then raise exception 'shift not found'; end if;
|
||||||
|
if s.status <> 'declared' then
|
||||||
|
raise exception 'shift % must be in DECLARED state to finalize (was %)', p_shift_id, s.status;
|
||||||
|
end if;
|
||||||
|
if s.user_id <> auth.uid() and not app.has_role_in_shop(s.shop_id, 'manager') then
|
||||||
|
raise exception 'only the shift owner or a manager may finalize close';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Expected = sum of signed cash movements in each currency.
|
||||||
|
-- opening_float rows are already part of cash_movements, so the sum is
|
||||||
|
-- the full expected drawer count.
|
||||||
|
select
|
||||||
|
coalesce(sum(case when currency = 'USD' then amount end), 0),
|
||||||
|
coalesce(sum(case when currency = 'LBP' then amount end), 0)
|
||||||
|
into v_exp_usd, v_exp_lbp
|
||||||
|
from app.cash_movements
|
||||||
|
where shift_id = p_shift_id;
|
||||||
|
|
||||||
|
perform set_config('app.shift_internal', 'on', true);
|
||||||
|
update app.shifts
|
||||||
|
set status = 'closed',
|
||||||
|
closed_at = now(),
|
||||||
|
closed_by = auth.uid(),
|
||||||
|
expected_close_usd = v_exp_usd,
|
||||||
|
expected_close_lbp = v_exp_lbp,
|
||||||
|
variance_usd = s.declared_close_usd - v_exp_usd,
|
||||||
|
variance_lbp = s.declared_close_lbp - v_exp_lbp
|
||||||
|
where id = p_shift_id;
|
||||||
|
perform set_config('app.shift_internal', 'off', true);
|
||||||
|
|
||||||
|
perform app.log_auth_event('shift_closed', s.shop_id, null,
|
||||||
|
jsonb_build_object(
|
||||||
|
'shift_id', p_shift_id,
|
||||||
|
'variance_usd', s.declared_close_usd - v_exp_usd,
|
||||||
|
'variance_lbp', s.declared_close_lbp - v_exp_lbp
|
||||||
|
));
|
||||||
|
|
||||||
|
return query
|
||||||
|
select v_exp_usd, v_exp_lbp,
|
||||||
|
s.declared_close_usd - v_exp_usd,
|
||||||
|
s.declared_close_lbp - v_exp_lbp;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function app.open_shift(uuid, numeric, numeric) from public;
|
||||||
|
revoke all on function app.declare_close(uuid, numeric, numeric) from public;
|
||||||
|
revoke all on function app.finalize_close(uuid) from public;
|
||||||
|
grant execute on function app.open_shift(uuid, numeric, numeric) to authenticated;
|
||||||
|
grant execute on function app.declare_close(uuid, numeric, numeric) to authenticated;
|
||||||
|
grant execute on function app.finalize_close(uuid) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- RLS
|
||||||
|
-- =====================================================================
|
||||||
|
alter table app.shifts enable row level security;
|
||||||
|
alter table app.cash_movements enable row level security;
|
||||||
|
alter table app.shifts force row level security;
|
||||||
|
alter table app.cash_movements force row level security;
|
||||||
|
|
||||||
|
-- Block direct INSERT/UPDATE on shifts; only the SECURITY DEFINER
|
||||||
|
-- functions above (which run as the function owner) may write.
|
||||||
|
revoke insert, update, delete on app.shifts from authenticated;
|
||||||
|
|
||||||
|
drop policy if exists shifts_select on app.shifts;
|
||||||
|
create policy shifts_select on app.shifts
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
user_id = auth.uid()
|
||||||
|
or app.has_any_role_in_shop(shop_id, array['owner','manager','auditor']::app.business_role[])
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Cash movements: direct INSERT permitted (with RLS check) so cashiers
|
||||||
|
-- can record sale_in / payout_out from the txn flow; UPDATE/DELETE are
|
||||||
|
-- already blocked by triggers.
|
||||||
|
drop policy if exists cash_mov_select on app.cash_movements;
|
||||||
|
create policy cash_mov_select on app.cash_movements
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1 from app.shifts s
|
||||||
|
where s.id = cash_movements.shift_id
|
||||||
|
and (
|
||||||
|
s.user_id = auth.uid()
|
||||||
|
or app.has_any_role_in_shop(s.shop_id, array['owner','manager','auditor']::app.business_role[])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists cash_mov_insert_in_open_shift on app.cash_movements;
|
||||||
|
create policy cash_mov_insert_in_open_shift on app.cash_movements
|
||||||
|
for insert to authenticated
|
||||||
|
with check (
|
||||||
|
exists (
|
||||||
|
select 1 from app.shifts s
|
||||||
|
where s.id = cash_movements.shift_id
|
||||||
|
and s.status = 'open'
|
||||||
|
and s.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
grant select on app.shifts to authenticated;
|
||||||
|
grant select, insert on app.cash_movements to authenticated;
|
||||||
|
|
||||||
|
-- End migration 0002 ----------------------------------------------------
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0003 — Universal transaction ledger (roadmap Step 4).
|
||||||
|
--
|
||||||
|
-- One append-only table for every customer-facing transaction
|
||||||
|
-- (OMT send/receive, bill payment, recharge, goods sale, etc.).
|
||||||
|
-- Service-specific detail tables are added in 0004.
|
||||||
|
--
|
||||||
|
-- Design choices and the threats they kill:
|
||||||
|
--
|
||||||
|
-- * INSERT-only at the SQL level. UPDATE is allowed only by the
|
||||||
|
-- dedicated `void_transaction` function, and it can only flip the
|
||||||
|
-- status to 'voided' plus set void fields. Triggers enforce this even
|
||||||
|
-- against superuser app roles. (vectors #1, #2, #11, #18)
|
||||||
|
--
|
||||||
|
-- * Sequential `reference_no` per shop, allocated by a Postgres
|
||||||
|
-- sequence inside a SECURITY DEFINER function — gaps are visible and
|
||||||
|
-- a daily report can flag missing numbers. (vector #1)
|
||||||
|
--
|
||||||
|
-- * Row hash chain: each row stores a sha256 of its own canonical
|
||||||
|
-- content + the previous row's hash for the same shop. Anchored
|
||||||
|
-- daily off-site, this detects silent edits even by an insider DBA.
|
||||||
|
-- (vector #24)
|
||||||
|
--
|
||||||
|
-- * `external_ref` (OMT code, recharge confirmation, etc.) is unique
|
||||||
|
-- per provider — blocks replay of an old receipt to a new customer.
|
||||||
|
-- (vector #19)
|
||||||
|
--
|
||||||
|
-- * Server-stamped `occurred_at`, `created_by`, and shift/shop/till
|
||||||
|
-- ids — cashier can not backdate or attribute to someone else.
|
||||||
|
-- (vectors #20, #25)
|
||||||
|
--
|
||||||
|
-- * Voids are bound to a 10-minute window (configurable) for cashier
|
||||||
|
-- self-service, and require a manager `void_approved_by` after that.
|
||||||
|
-- (vector #11)
|
||||||
|
--
|
||||||
|
-- * Cash movements (0002) get an FK to this ledger so every cash
|
||||||
|
-- in/out is traceable to a transaction or to an explicit non-sale
|
||||||
|
-- movement (drop, expense, swap...).
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Enums and reference data
|
||||||
|
-- =====================================================================
|
||||||
|
do $$ begin
|
||||||
|
create type app.txn_status as enum ('completed', 'voided');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type app.payment_method as enum (
|
||||||
|
'cash_usd', 'cash_lbp', 'whish', 'omt_wallet', 'card', 'bank_transfer'
|
||||||
|
);
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
-- Service catalog (seeded at the bottom of this file).
|
||||||
|
create table if not exists app.services (
|
||||||
|
code text primary key,
|
||||||
|
name text not null,
|
||||||
|
category text not null,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Per-shop receipt-number sequences ------------------------------------
|
||||||
|
create table if not exists app.shop_sequences (
|
||||||
|
shop_id uuid primary key references app.shops(id) on delete cascade,
|
||||||
|
next_value bigint not null default 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- The ledger
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.transactions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
|
||||||
|
-- Routing
|
||||||
|
shift_id uuid not null references app.shifts(id) on delete restrict,
|
||||||
|
shop_id uuid not null references app.shops(id) on delete restrict,
|
||||||
|
till_id uuid not null references app.tills(id) on delete restrict,
|
||||||
|
user_id uuid not null references auth.users(id) on delete restrict,
|
||||||
|
service_code text not null references app.services(code),
|
||||||
|
|
||||||
|
-- Identifiers
|
||||||
|
occurred_at timestamptz not null default now(),
|
||||||
|
reference_no bigint not null, -- per shop, sequential
|
||||||
|
external_ref text, -- OMT code, recharge id...
|
||||||
|
external_ref_provider text, -- 'OMT','ALFA','TOUCH','OGERO',...
|
||||||
|
|
||||||
|
status app.txn_status not null default 'completed',
|
||||||
|
|
||||||
|
-- Money (dual-currency on the same row; either side may be 0)
|
||||||
|
gross_usd numeric(14,2) not null default 0 check (gross_usd >= 0),
|
||||||
|
gross_lbp numeric(18,0) not null default 0 check (gross_lbp >= 0),
|
||||||
|
fee_usd numeric(14,2) not null default 0 check (fee_usd >= 0),
|
||||||
|
fee_lbp numeric(18,0) not null default 0 check (fee_lbp >= 0),
|
||||||
|
commission_usd numeric(14,2) not null default 0 check (commission_usd >= 0),
|
||||||
|
commission_lbp numeric(18,0) not null default 0 check (commission_lbp >= 0),
|
||||||
|
fx_rate_used numeric(18,4), -- USD/LBP at moment of txn
|
||||||
|
payment_method app.payment_method not null,
|
||||||
|
|
||||||
|
-- Counterparty (used by various services; child tables hold the rest)
|
||||||
|
customer_id uuid, -- FK added in 0005 (KYC module)
|
||||||
|
beneficiary_name text,
|
||||||
|
beneficiary_phone text,
|
||||||
|
msisdn text,
|
||||||
|
operator text,
|
||||||
|
product_code text,
|
||||||
|
voucher_serial text,
|
||||||
|
|
||||||
|
notes text,
|
||||||
|
receipt_url text,
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
created_by uuid not null references auth.users(id),
|
||||||
|
voided_at timestamptz,
|
||||||
|
voided_by uuid references auth.users(id),
|
||||||
|
void_reason text,
|
||||||
|
void_approved_by uuid references auth.users(id),
|
||||||
|
|
||||||
|
-- Hash chain (per shop)
|
||||||
|
row_hash bytea not null,
|
||||||
|
prev_row_hash bytea,
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
constraint txn_unique_per_shop_ref unique (shop_id, reference_no),
|
||||||
|
constraint txn_unique_external_ref unique (external_ref_provider, external_ref),
|
||||||
|
constraint txn_void_consistency check (
|
||||||
|
(status = 'completed' and voided_at is null and voided_by is null and void_reason is null)
|
||||||
|
or (status = 'voided' and voided_at is not null and voided_by is not null and void_reason is not null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_txn_shop_time on app.transactions(shop_id, occurred_at desc);
|
||||||
|
create index if not exists idx_txn_shift on app.transactions(shift_id, occurred_at);
|
||||||
|
create index if not exists idx_txn_user_time on app.transactions(user_id, occurred_at desc);
|
||||||
|
create index if not exists idx_txn_service on app.transactions(service_code, occurred_at desc);
|
||||||
|
create index if not exists idx_txn_status on app.transactions(status) where status = 'voided';
|
||||||
|
create index if not exists idx_txn_msisdn on app.transactions(msisdn) where msisdn is not null;
|
||||||
|
create index if not exists idx_txn_external on app.transactions(external_ref_provider, external_ref);
|
||||||
|
|
||||||
|
-- Now that transactions exists, attach the deferred FK from cash_movements.
|
||||||
|
alter table app.cash_movements
|
||||||
|
drop constraint if exists cash_movements_ref_txn_fk;
|
||||||
|
alter table app.cash_movements
|
||||||
|
add constraint cash_movements_ref_txn_fk
|
||||||
|
foreign key (ref_txn_id) references app.transactions(id) on delete restrict;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Hash-chain helpers
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.txn_canonical_payload(t app.transactions)
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
as $$
|
||||||
|
select jsonb_build_object(
|
||||||
|
'id', t.id,
|
||||||
|
'shop_id', t.shop_id,
|
||||||
|
'till_id', t.till_id,
|
||||||
|
'shift_id', t.shift_id,
|
||||||
|
'user_id', t.user_id,
|
||||||
|
'service_code', t.service_code,
|
||||||
|
'occurred_at', to_char(t.occurred_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MSOF'),
|
||||||
|
'reference_no', t.reference_no,
|
||||||
|
'external_ref', t.external_ref,
|
||||||
|
'external_ref_provider', t.external_ref_provider,
|
||||||
|
'status', t.status,
|
||||||
|
'gross_usd', t.gross_usd,
|
||||||
|
'gross_lbp', t.gross_lbp,
|
||||||
|
'fee_usd', t.fee_usd,
|
||||||
|
'fee_lbp', t.fee_lbp,
|
||||||
|
'commission_usd', t.commission_usd,
|
||||||
|
'commission_lbp', t.commission_lbp,
|
||||||
|
'fx_rate_used', t.fx_rate_used,
|
||||||
|
'payment_method', t.payment_method,
|
||||||
|
'customer_id', t.customer_id,
|
||||||
|
'beneficiary_name', t.beneficiary_name,
|
||||||
|
'beneficiary_phone', t.beneficiary_phone,
|
||||||
|
'msisdn', t.msisdn,
|
||||||
|
'operator', t.operator,
|
||||||
|
'product_code', t.product_code,
|
||||||
|
'voucher_serial', t.voucher_serial,
|
||||||
|
'notes', t.notes,
|
||||||
|
'receipt_url', t.receipt_url,
|
||||||
|
'created_by', t.created_by,
|
||||||
|
'voided_at', t.voided_at,
|
||||||
|
'voided_by', t.voided_by,
|
||||||
|
'void_reason', t.void_reason,
|
||||||
|
'void_approved_by', t.void_approved_by
|
||||||
|
)::text;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function app.txn_compute_hash(t app.transactions, prev bytea)
|
||||||
|
returns bytea
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
as $$
|
||||||
|
select digest(coalesce(prev, '\x'::bytea) || convert_to(app.txn_canonical_payload(t), 'UTF8'), 'sha256');
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Triggers — block raw writes; allow only what we sanction
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- Block direct UPDATE/DELETE except when our SECURITY DEFINER void
|
||||||
|
-- function turns on the session GUC.
|
||||||
|
create or replace function app.txn_guard_update_delete()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
if (tg_op = 'DELETE') then
|
||||||
|
raise exception 'transactions cannot be deleted';
|
||||||
|
end if;
|
||||||
|
if current_setting('app.txn_internal', true) is distinct from 'on' then
|
||||||
|
raise exception 'direct UPDATE on app.transactions is not allowed; use app.void_transaction';
|
||||||
|
end if;
|
||||||
|
-- Even via the void path, only the void/status fields may change.
|
||||||
|
if (new.id <> old.id
|
||||||
|
or new.shop_id <> old.shop_id
|
||||||
|
or new.till_id <> old.till_id
|
||||||
|
or new.shift_id <> old.shift_id
|
||||||
|
or new.user_id <> old.user_id
|
||||||
|
or new.service_code <> old.service_code
|
||||||
|
or new.occurred_at <> old.occurred_at
|
||||||
|
or new.reference_no <> old.reference_no
|
||||||
|
or coalesce(new.external_ref,'') <> coalesce(old.external_ref,'')
|
||||||
|
or coalesce(new.external_ref_provider,'') <> coalesce(old.external_ref_provider,'')
|
||||||
|
or new.gross_usd <> old.gross_usd
|
||||||
|
or new.gross_lbp <> old.gross_lbp
|
||||||
|
or new.fee_usd <> old.fee_usd
|
||||||
|
or new.fee_lbp <> old.fee_lbp
|
||||||
|
or new.commission_usd <> old.commission_usd
|
||||||
|
or new.commission_lbp <> old.commission_lbp
|
||||||
|
or coalesce(new.fx_rate_used, -1) <> coalesce(old.fx_rate_used, -1)
|
||||||
|
or new.payment_method <> old.payment_method
|
||||||
|
or new.created_by <> old.created_by
|
||||||
|
or new.created_at <> old.created_at) then
|
||||||
|
raise exception 'only status/void fields may change on a transaction';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_txn_guard_update on app.transactions;
|
||||||
|
create trigger trg_txn_guard_update
|
||||||
|
before update on app.transactions
|
||||||
|
for each row execute function app.txn_guard_update_delete();
|
||||||
|
|
||||||
|
drop trigger if exists trg_txn_guard_delete on app.transactions;
|
||||||
|
create trigger trg_txn_guard_delete
|
||||||
|
before delete on app.transactions
|
||||||
|
for each row execute function app.txn_guard_update_delete();
|
||||||
|
|
||||||
|
-- Server stamping + hash chain on insert.
|
||||||
|
create or replace function app.txn_before_insert()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_prev_hash bytea;
|
||||||
|
v_seq bigint;
|
||||||
|
v_shift app.shifts%rowtype;
|
||||||
|
begin
|
||||||
|
-- Caller identity / time are server-controlled.
|
||||||
|
new.created_by := auth.uid();
|
||||||
|
new.created_at := now();
|
||||||
|
new.occurred_at := now();
|
||||||
|
new.status := 'completed';
|
||||||
|
new.voided_at := null;
|
||||||
|
new.voided_by := null;
|
||||||
|
new.void_reason := null;
|
||||||
|
new.void_approved_by := null;
|
||||||
|
|
||||||
|
-- Shift must be open and owned by the caller; shop/till derived from it.
|
||||||
|
select * into v_shift from app.shifts where id = new.shift_id;
|
||||||
|
if v_shift.id is null then
|
||||||
|
raise exception 'shift % not found', new.shift_id;
|
||||||
|
end if;
|
||||||
|
if v_shift.status <> 'open' then
|
||||||
|
raise exception 'cannot post a transaction to a % shift', v_shift.status;
|
||||||
|
end if;
|
||||||
|
if v_shift.user_id <> auth.uid() then
|
||||||
|
raise exception 'only the shift owner may post transactions to it';
|
||||||
|
end if;
|
||||||
|
new.shop_id := v_shift.shop_id;
|
||||||
|
new.till_id := v_shift.till_id;
|
||||||
|
new.user_id := v_shift.user_id;
|
||||||
|
|
||||||
|
-- Allocate the shop's next reference number (advisory lock keeps it
|
||||||
|
-- gap-free under concurrency).
|
||||||
|
perform pg_advisory_xact_lock(hashtext('shop_seq:' || new.shop_id::text));
|
||||||
|
insert into app.shop_sequences(shop_id, next_value)
|
||||||
|
values (new.shop_id, 1)
|
||||||
|
on conflict (shop_id) do nothing;
|
||||||
|
update app.shop_sequences
|
||||||
|
set next_value = next_value + 1
|
||||||
|
where shop_id = new.shop_id
|
||||||
|
returning next_value - 1 into v_seq;
|
||||||
|
new.reference_no := v_seq;
|
||||||
|
|
||||||
|
-- Compute hash linking to previous row in this shop.
|
||||||
|
select row_hash into v_prev_hash
|
||||||
|
from app.transactions
|
||||||
|
where shop_id = new.shop_id
|
||||||
|
order by reference_no desc
|
||||||
|
limit 1;
|
||||||
|
new.prev_row_hash := v_prev_hash;
|
||||||
|
new.row_hash := app.txn_compute_hash(new, v_prev_hash);
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_txn_before_insert on app.transactions;
|
||||||
|
create trigger trg_txn_before_insert
|
||||||
|
before insert on app.transactions
|
||||||
|
for each row execute function app.txn_before_insert();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Void
|
||||||
|
-- =====================================================================
|
||||||
|
-- Configurable self-service void window (minutes).
|
||||||
|
create table if not exists app.system_settings (
|
||||||
|
key text primary key,
|
||||||
|
value text not null
|
||||||
|
);
|
||||||
|
insert into app.system_settings(key, value)
|
||||||
|
values ('void_self_window_minutes', '10')
|
||||||
|
on conflict (key) do nothing;
|
||||||
|
|
||||||
|
create or replace function app.void_transaction(
|
||||||
|
p_txn_id uuid,
|
||||||
|
p_reason text,
|
||||||
|
p_approver_pin text default null -- required for manager approval path
|
||||||
|
)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
t app.transactions%rowtype;
|
||||||
|
s app.shifts%rowtype;
|
||||||
|
window_min int;
|
||||||
|
needs_manager boolean;
|
||||||
|
begin
|
||||||
|
select * into t from app.transactions where id = p_txn_id;
|
||||||
|
if t.id is null then raise exception 'transaction not found'; end if;
|
||||||
|
if t.status = 'voided' then raise exception 'transaction already voided'; end if;
|
||||||
|
if p_reason is null or length(btrim(p_reason)) < 5 then
|
||||||
|
raise exception 'a reason of at least 5 characters is required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into s from app.shifts where id = t.shift_id;
|
||||||
|
if s.status <> 'open' then
|
||||||
|
raise exception 'cannot void a transaction whose shift is no longer open';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select coalesce(value::int, 10) into window_min
|
||||||
|
from app.system_settings where key = 'void_self_window_minutes';
|
||||||
|
|
||||||
|
needs_manager := (auth.uid() <> t.user_id)
|
||||||
|
or (now() - t.created_at > make_interval(mins => window_min));
|
||||||
|
|
||||||
|
if needs_manager then
|
||||||
|
-- Caller must be a manager in this shop AND prove it with PIN.
|
||||||
|
if not app.has_role_in_shop(t.shop_id, 'manager') then
|
||||||
|
raise exception 'manager approval required to void this transaction';
|
||||||
|
end if;
|
||||||
|
if p_approver_pin is null or not app.verify_my_pin(p_approver_pin) then
|
||||||
|
raise exception 'manager PIN required and must be valid';
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Apply the void (only allowed via this function thanks to the guard).
|
||||||
|
perform set_config('app.txn_internal', 'on', true);
|
||||||
|
update app.transactions
|
||||||
|
set status = 'voided',
|
||||||
|
voided_at = now(),
|
||||||
|
voided_by = auth.uid(),
|
||||||
|
void_reason = p_reason,
|
||||||
|
void_approved_by = case when needs_manager then auth.uid() else null end
|
||||||
|
where id = p_txn_id;
|
||||||
|
perform set_config('app.txn_internal', 'off', true);
|
||||||
|
|
||||||
|
-- Recompute the row's hash so the chain reflects the new state.
|
||||||
|
perform set_config('app.txn_internal', 'on', true);
|
||||||
|
update app.transactions tt
|
||||||
|
set row_hash = app.txn_compute_hash(tt, tt.prev_row_hash)
|
||||||
|
where id = p_txn_id;
|
||||||
|
perform set_config('app.txn_internal', 'off', true);
|
||||||
|
|
||||||
|
perform app.log_auth_event('txn_voided', t.shop_id, null,
|
||||||
|
jsonb_build_object('txn_id', p_txn_id, 'manager_path', needs_manager));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function app.void_transaction(uuid, text, text) from public;
|
||||||
|
grant execute on function app.void_transaction(uuid, text, text) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Daily integrity checks (callable by an owner cron)
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.verify_chain(p_shop uuid)
|
||||||
|
returns table (txn_id uuid, reference_no bigint, ok boolean)
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
declare prev bytea;
|
||||||
|
rec app.transactions%rowtype;
|
||||||
|
begin
|
||||||
|
if not app.has_role_in_shop(p_shop, 'owner')
|
||||||
|
and not app.has_role_in_shop(p_shop, 'auditor') then
|
||||||
|
raise exception 'not authorized';
|
||||||
|
end if;
|
||||||
|
prev := null;
|
||||||
|
for rec in
|
||||||
|
select * from app.transactions
|
||||||
|
where shop_id = p_shop
|
||||||
|
order by reference_no
|
||||||
|
loop
|
||||||
|
txn_id := rec.id;
|
||||||
|
reference_no := rec.reference_no;
|
||||||
|
ok := (rec.prev_row_hash is not distinct from prev)
|
||||||
|
and (rec.row_hash = app.txn_compute_hash(rec, prev));
|
||||||
|
prev := rec.row_hash;
|
||||||
|
return next;
|
||||||
|
end loop;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function app.verify_chain(uuid) from public;
|
||||||
|
grant execute on function app.verify_chain(uuid) to authenticated;
|
||||||
|
|
||||||
|
-- Reference-number gap detector
|
||||||
|
create or replace view app.v_reference_gaps as
|
||||||
|
select shop_id,
|
||||||
|
reference_no + 1 as gap_starts_at,
|
||||||
|
next_ref - 1 as gap_ends_at
|
||||||
|
from (
|
||||||
|
select shop_id, reference_no,
|
||||||
|
lead(reference_no) over (partition by shop_id order by reference_no) as next_ref
|
||||||
|
from app.transactions
|
||||||
|
) s
|
||||||
|
where next_ref is not null and next_ref <> reference_no + 1;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- RLS
|
||||||
|
-- =====================================================================
|
||||||
|
alter table app.transactions enable row level security;
|
||||||
|
alter table app.transactions force row level security;
|
||||||
|
alter table app.services enable row level security;
|
||||||
|
alter table app.services force row level security;
|
||||||
|
alter table app.shop_sequences enable row level security;
|
||||||
|
alter table app.shop_sequences force row level security;
|
||||||
|
alter table app.system_settings enable row level security;
|
||||||
|
alter table app.system_settings force row level security;
|
||||||
|
|
||||||
|
-- Direct UPDATE/DELETE blocked by triggers, but also revoke at SQL level.
|
||||||
|
revoke update, delete on app.transactions from authenticated;
|
||||||
|
revoke insert, update, delete on app.shop_sequences from authenticated;
|
||||||
|
revoke insert, update, delete on app.system_settings from authenticated;
|
||||||
|
|
||||||
|
drop policy if exists txn_select on app.transactions;
|
||||||
|
create policy txn_select on app.transactions
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
user_id = auth.uid()
|
||||||
|
or app.has_any_role_in_shop(shop_id, array['owner','manager','auditor']::app.business_role[])
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists txn_insert_in_open_shift on app.transactions;
|
||||||
|
create policy txn_insert_in_open_shift on app.transactions
|
||||||
|
for insert to authenticated
|
||||||
|
with check (
|
||||||
|
exists (
|
||||||
|
select 1 from app.shifts s
|
||||||
|
where s.id = transactions.shift_id
|
||||||
|
and s.status = 'open'
|
||||||
|
and s.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Services: readable by all authenticated users; only owners may modify
|
||||||
|
-- (via direct grants kept off, future migration will add a function).
|
||||||
|
drop policy if exists services_select on app.services;
|
||||||
|
create policy services_select on app.services
|
||||||
|
for select to authenticated using (true);
|
||||||
|
|
||||||
|
-- Shop sequences and system settings: readable by owner/auditor.
|
||||||
|
drop policy if exists shop_seq_select on app.shop_sequences;
|
||||||
|
create policy shop_seq_select on app.shop_sequences
|
||||||
|
for select to authenticated
|
||||||
|
using (app.has_any_role_in_shop(shop_id, array['owner','auditor']::app.business_role[]));
|
||||||
|
|
||||||
|
drop policy if exists settings_select on app.system_settings;
|
||||||
|
create policy settings_select on app.system_settings
|
||||||
|
for select to authenticated using (true);
|
||||||
|
|
||||||
|
grant select, insert on app.transactions to authenticated;
|
||||||
|
grant select on app.services to authenticated;
|
||||||
|
grant select on app.shop_sequences to authenticated;
|
||||||
|
grant select on app.system_settings to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Seed services
|
||||||
|
-- =====================================================================
|
||||||
|
insert into app.services(code, name, category) values
|
||||||
|
('OMT_SEND', 'OMT Send', 'transfer'),
|
||||||
|
('OMT_RECEIVE', 'OMT Receive/Payout', 'transfer'),
|
||||||
|
('OMT_BILL', 'OMT Bill Payment', 'bill'),
|
||||||
|
('WU_SEND', 'Western Union Send', 'transfer'),
|
||||||
|
('WU_RECEIVE', 'Western Union Pay', 'transfer'),
|
||||||
|
('ALFA_RECHARGE', 'Alfa Recharge', 'recharge'),
|
||||||
|
('TOUCH_RECHARGE', 'touch Recharge', 'recharge'),
|
||||||
|
('OGERO_RECHARGE', 'Ogero Recharge', 'recharge'),
|
||||||
|
('INTERNET_RECHARGE','Internet Recharge', 'recharge'),
|
||||||
|
('SIM_SALE', 'SIM Sale', 'goods'),
|
||||||
|
('PHONE_SALE', 'Phone Sale', 'goods'),
|
||||||
|
('ACCESSORY_SALE', 'Accessory Sale', 'goods'),
|
||||||
|
('REPAIR', 'Repair Service', 'service')
|
||||||
|
on conflict (code) do nothing;
|
||||||
|
|
||||||
|
-- End migration 0003 ----------------------------------------------------
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0004 — Service-specific detail tables (roadmap Step 5).
|
||||||
|
--
|
||||||
|
-- Each child row is 1-to-1 with a row in app.transactions and is
|
||||||
|
-- mandatory for its service. A check trigger blocks completing a txn of
|
||||||
|
-- a given service without the matching detail row.
|
||||||
|
--
|
||||||
|
-- Threat-model rows addressed: 1, 3, 5, 16, 19, 21.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Common helpers
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do $$ begin
|
||||||
|
create type app.id_doc_type as enum (
|
||||||
|
'lebanese_id', 'passport', 'residence_permit', 'driver_license', 'other'
|
||||||
|
);
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type app.transfer_direction as enum ('domestic', 'international');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
-- A small helper used by all child triggers: the parent txn must exist,
|
||||||
|
-- be 'completed' (we attach detail at insert time only), and match the
|
||||||
|
-- expected service_code.
|
||||||
|
create or replace function app._require_txn_service(p_txn uuid, p_service text)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
declare svc text; st app.txn_status;
|
||||||
|
begin
|
||||||
|
select service_code, status into svc, st
|
||||||
|
from app.transactions where id = p_txn;
|
||||||
|
if svc is null then raise exception 'transaction % not found', p_txn; end if;
|
||||||
|
if svc <> p_service then
|
||||||
|
raise exception 'detail mismatch: txn service is % but detail row is for %',
|
||||||
|
svc, p_service;
|
||||||
|
end if;
|
||||||
|
if st <> 'completed' then
|
||||||
|
raise exception 'cannot attach detail to a % transaction', st;
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- OMT — Send
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.omt_send_details (
|
||||||
|
txn_id uuid primary key references app.transactions(id) on delete restrict,
|
||||||
|
direction app.transfer_direction not null,
|
||||||
|
|
||||||
|
sender_full_name text not null,
|
||||||
|
sender_id_type app.id_doc_type not null,
|
||||||
|
sender_id_number text not null,
|
||||||
|
sender_phone text not null,
|
||||||
|
sender_dob date,
|
||||||
|
sender_nationality text,
|
||||||
|
|
||||||
|
beneficiary_full_name text not null,
|
||||||
|
beneficiary_phone text,
|
||||||
|
destination_country text, -- ISO-3166 alpha-2 expected for international
|
||||||
|
|
||||||
|
purpose_code text not null, -- 'family_support','salary','goods','services',...
|
||||||
|
purpose_note text,
|
||||||
|
kyc_doc_url text, -- ID photo / declaration
|
||||||
|
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
constraint omt_send_intl_country_required check (
|
||||||
|
direction = 'domestic' or destination_country is not null
|
||||||
|
),
|
||||||
|
constraint omt_send_id_format check (length(btrim(sender_id_number)) >= 4)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- OMT — Receive / Payout
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.omt_receive_details (
|
||||||
|
txn_id uuid primary key references app.transactions(id) on delete restrict,
|
||||||
|
payout_code text not null, -- the customer-presented code
|
||||||
|
|
||||||
|
beneficiary_full_name text not null,
|
||||||
|
beneficiary_id_type app.id_doc_type not null,
|
||||||
|
beneficiary_id_number text not null,
|
||||||
|
beneficiary_phone text,
|
||||||
|
|
||||||
|
origin_country text,
|
||||||
|
kyc_doc_url text,
|
||||||
|
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
constraint omt_recv_id_format check (length(btrim(beneficiary_id_number)) >= 4),
|
||||||
|
constraint omt_recv_code_format check (length(btrim(payout_code)) >= 6)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Bill payment (EDL, water, internet bills, gov fees, etc.)
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.bill_payment_details (
|
||||||
|
txn_id uuid primary key references app.transactions(id) on delete restrict,
|
||||||
|
biller_code text not null, -- 'EDL','OGERO','MOF','NSSF',...
|
||||||
|
account_number text not null,
|
||||||
|
period text, -- '2026-04', invoice id, etc.
|
||||||
|
customer_name text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
constraint bill_account_format check (length(btrim(account_number)) >= 3)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Recharge (Alfa / touch / Ogero / Internet)
|
||||||
|
-- Either a voucher_serial (physical scratch card) OR an
|
||||||
|
-- e_recharge_provider_ref (provider confirmation id) is mandatory.
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.recharge_details (
|
||||||
|
txn_id uuid primary key references app.transactions(id) on delete restrict,
|
||||||
|
operator text not null, -- 'ALFA','TOUCH','OGERO','IDM','CYBERIA','TERRANET'
|
||||||
|
msisdn text not null, -- subscriber number being recharged
|
||||||
|
product_code text not null, -- 'U-CARD-22USD','MAGIC-11USD','DATA-5GB',...
|
||||||
|
voucher_serial text, -- if scratch card
|
||||||
|
e_recharge_provider_ref text, -- if e-recharge
|
||||||
|
unit_face_value_usd numeric(14,2),
|
||||||
|
unit_cost_usd numeric(14,2), -- cost to shop (margin = price - cost)
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
constraint recharge_msisdn_format check (msisdn ~ '^\+?\d{6,15}$'),
|
||||||
|
constraint recharge_must_have_evidence check (
|
||||||
|
(voucher_serial is not null) or (e_recharge_provider_ref is not null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- A given voucher serial may only ever be sold once across the whole
|
||||||
|
-- system (vector #4: skim a card, claim "lost").
|
||||||
|
create unique index if not exists uq_recharge_voucher_serial
|
||||||
|
on app.recharge_details(voucher_serial)
|
||||||
|
where voucher_serial is not null;
|
||||||
|
|
||||||
|
-- An e-recharge provider reference is unique per operator.
|
||||||
|
create unique index if not exists uq_recharge_provider_ref
|
||||||
|
on app.recharge_details(operator, e_recharge_provider_ref)
|
||||||
|
where e_recharge_provider_ref is not null;
|
||||||
|
|
||||||
|
create index if not exists idx_recharge_msisdn on app.recharge_details(msisdn);
|
||||||
|
create index if not exists idx_recharge_operator on app.recharge_details(operator);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Goods sale (SIM / phone / accessory) and repair
|
||||||
|
-- Real inventory FK comes in 0006; for now we capture sku + qty.
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.goods_sale_details (
|
||||||
|
txn_id uuid primary key references app.transactions(id) on delete restrict,
|
||||||
|
sku text not null,
|
||||||
|
qty integer not null check (qty > 0),
|
||||||
|
unit_cost_usd numeric(14,2) not null check (unit_cost_usd >= 0),
|
||||||
|
unit_price_usd numeric(14,2) not null check (unit_price_usd >= 0),
|
||||||
|
serial_number text, -- IMEI for phones
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists app.repair_details (
|
||||||
|
txn_id uuid primary key references app.transactions(id) on delete restrict,
|
||||||
|
device_type text not null,
|
||||||
|
device_imei text,
|
||||||
|
issue_summary text not null,
|
||||||
|
warranty_days integer not null default 0 check (warranty_days >= 0),
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Triggers — service consistency + append-only on details
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app._detail_no_update_delete()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin raise exception 'transaction detail rows are append-only'; end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- per-table: stamp + service-code check + immutability
|
||||||
|
create or replace function app.omt_send_check()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin perform app._require_txn_service(new.txn_id, 'OMT_SEND'); return new; end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_omt_send_check on app.omt_send_details;
|
||||||
|
create trigger trg_omt_send_check before insert on app.omt_send_details
|
||||||
|
for each row execute function app.omt_send_check();
|
||||||
|
drop trigger if exists trg_omt_send_freeze on app.omt_send_details;
|
||||||
|
create trigger trg_omt_send_freeze before update or delete on app.omt_send_details
|
||||||
|
for each row execute function app._detail_no_update_delete();
|
||||||
|
|
||||||
|
create or replace function app.omt_recv_check()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin perform app._require_txn_service(new.txn_id, 'OMT_RECEIVE'); return new; end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_omt_recv_check on app.omt_receive_details;
|
||||||
|
create trigger trg_omt_recv_check before insert on app.omt_receive_details
|
||||||
|
for each row execute function app.omt_recv_check();
|
||||||
|
drop trigger if exists trg_omt_recv_freeze on app.omt_receive_details;
|
||||||
|
create trigger trg_omt_recv_freeze before update or delete on app.omt_receive_details
|
||||||
|
for each row execute function app._detail_no_update_delete();
|
||||||
|
|
||||||
|
create or replace function app.bill_pay_check()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin perform app._require_txn_service(new.txn_id, 'OMT_BILL'); return new; end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_bill_pay_check on app.bill_payment_details;
|
||||||
|
create trigger trg_bill_pay_check before insert on app.bill_payment_details
|
||||||
|
for each row execute function app.bill_pay_check();
|
||||||
|
drop trigger if exists trg_bill_pay_freeze on app.bill_payment_details;
|
||||||
|
create trigger trg_bill_pay_freeze before update or delete on app.bill_payment_details
|
||||||
|
for each row execute function app._detail_no_update_delete();
|
||||||
|
|
||||||
|
-- Recharge: any of the four recharge service codes is acceptable.
|
||||||
|
create or replace function app.recharge_check()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
declare svc text;
|
||||||
|
begin
|
||||||
|
select service_code into svc from app.transactions where id = new.txn_id;
|
||||||
|
if svc not in ('ALFA_RECHARGE','TOUCH_RECHARGE','OGERO_RECHARGE','INTERNET_RECHARGE') then
|
||||||
|
raise exception 'recharge_details only valid for recharge services (got %)', svc;
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_recharge_check on app.recharge_details;
|
||||||
|
create trigger trg_recharge_check before insert on app.recharge_details
|
||||||
|
for each row execute function app.recharge_check();
|
||||||
|
drop trigger if exists trg_recharge_freeze on app.recharge_details;
|
||||||
|
create trigger trg_recharge_freeze before update or delete on app.recharge_details
|
||||||
|
for each row execute function app._detail_no_update_delete();
|
||||||
|
|
||||||
|
create or replace function app.goods_sale_check()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
declare svc text;
|
||||||
|
begin
|
||||||
|
select service_code into svc from app.transactions where id = new.txn_id;
|
||||||
|
if svc not in ('SIM_SALE','PHONE_SALE','ACCESSORY_SALE') then
|
||||||
|
raise exception 'goods_sale_details only valid for goods services (got %)', svc;
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_goods_sale_check on app.goods_sale_details;
|
||||||
|
create trigger trg_goods_sale_check before insert on app.goods_sale_details
|
||||||
|
for each row execute function app.goods_sale_check();
|
||||||
|
drop trigger if exists trg_goods_sale_freeze on app.goods_sale_details;
|
||||||
|
create trigger trg_goods_sale_freeze before update or delete on app.goods_sale_details
|
||||||
|
for each row execute function app._detail_no_update_delete();
|
||||||
|
|
||||||
|
create or replace function app.repair_check()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin perform app._require_txn_service(new.txn_id, 'REPAIR'); return new; end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_repair_check on app.repair_details;
|
||||||
|
create trigger trg_repair_check before insert on app.repair_details
|
||||||
|
for each row execute function app.repair_check();
|
||||||
|
drop trigger if exists trg_repair_freeze on app.repair_details;
|
||||||
|
create trigger trg_repair_freeze before update or delete on app.repair_details
|
||||||
|
for each row execute function app._detail_no_update_delete();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Cross-row check: a completed transaction must have its matching
|
||||||
|
-- detail row. Implemented as a deferred constraint trigger that fires
|
||||||
|
-- at COMMIT time on app.transactions, so client code can do
|
||||||
|
-- BEGIN; INSERT txn; INSERT detail; COMMIT;
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.txn_require_detail()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare ok boolean;
|
||||||
|
begin
|
||||||
|
if new.status <> 'completed' then return null; end if;
|
||||||
|
case new.service_code
|
||||||
|
when 'OMT_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
|
||||||
|
when 'OMT_RECEIVE' then select exists(select 1 from app.omt_receive_details where txn_id = new.id) into ok;
|
||||||
|
when 'OMT_BILL' then select exists(select 1 from app.bill_payment_details where txn_id = new.id) into ok;
|
||||||
|
when 'WU_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
|
||||||
|
when 'WU_RECEIVE' then select exists(select 1 from app.omt_receive_details where txn_id = new.id) into ok;
|
||||||
|
when 'ALFA_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
|
||||||
|
when 'TOUCH_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
|
||||||
|
when 'OGERO_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
|
||||||
|
when 'INTERNET_RECHARGE'then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
|
||||||
|
when 'SIM_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
|
||||||
|
when 'PHONE_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
|
||||||
|
when 'ACCESSORY_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
|
||||||
|
when 'REPAIR' then select exists(select 1 from app.repair_details where txn_id = new.id) into ok;
|
||||||
|
else ok := true; -- unknown / future services: allow until a child is added
|
||||||
|
end case;
|
||||||
|
if not ok then
|
||||||
|
raise exception 'transaction % (service %) is missing its detail row',
|
||||||
|
new.id, new.service_code;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_txn_require_detail on app.transactions;
|
||||||
|
create constraint trigger trg_txn_require_detail
|
||||||
|
after insert on app.transactions
|
||||||
|
deferrable initially deferred
|
||||||
|
for each row execute function app.txn_require_detail();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- RLS — visibility follows the parent transaction.
|
||||||
|
-- =====================================================================
|
||||||
|
alter table app.omt_send_details enable row level security;
|
||||||
|
alter table app.omt_receive_details enable row level security;
|
||||||
|
alter table app.bill_payment_details enable row level security;
|
||||||
|
alter table app.recharge_details enable row level security;
|
||||||
|
alter table app.goods_sale_details enable row level security;
|
||||||
|
alter table app.repair_details enable row level security;
|
||||||
|
|
||||||
|
alter table app.omt_send_details force row level security;
|
||||||
|
alter table app.omt_receive_details force row level security;
|
||||||
|
alter table app.bill_payment_details force row level security;
|
||||||
|
alter table app.recharge_details force row level security;
|
||||||
|
alter table app.goods_sale_details force row level security;
|
||||||
|
alter table app.repair_details force row level security;
|
||||||
|
|
||||||
|
revoke update, delete on
|
||||||
|
app.omt_send_details, app.omt_receive_details, app.bill_payment_details,
|
||||||
|
app.recharge_details, app.goods_sale_details, app.repair_details
|
||||||
|
from authenticated;
|
||||||
|
|
||||||
|
-- Helper: visibility predicate based on parent txn.
|
||||||
|
create or replace function app._can_see_txn(p_txn uuid)
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select exists (
|
||||||
|
select 1 from app.transactions t
|
||||||
|
where t.id = p_txn
|
||||||
|
and (
|
||||||
|
t.user_id = auth.uid()
|
||||||
|
or app.has_any_role_in_shop(t.shop_id,
|
||||||
|
array['owner','manager','auditor']::app.business_role[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
revoke all on function app._can_see_txn(uuid) from public;
|
||||||
|
grant execute on function app._can_see_txn(uuid) to authenticated;
|
||||||
|
|
||||||
|
-- Insert allowed if the user owns the parent txn's open shift.
|
||||||
|
create or replace function app._can_write_detail(p_txn uuid)
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select exists (
|
||||||
|
select 1 from app.transactions t
|
||||||
|
join app.shifts s on s.id = t.shift_id
|
||||||
|
where t.id = p_txn
|
||||||
|
and t.user_id = auth.uid()
|
||||||
|
and s.status = 'open'
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
revoke all on function app._can_write_detail(uuid) from public;
|
||||||
|
grant execute on function app._can_write_detail(uuid) to authenticated;
|
||||||
|
|
||||||
|
-- Apply identical select/insert policies to all six child tables.
|
||||||
|
do $$
|
||||||
|
declare tbl text;
|
||||||
|
begin
|
||||||
|
foreach tbl in array array[
|
||||||
|
'omt_send_details','omt_receive_details','bill_payment_details',
|
||||||
|
'recharge_details','goods_sale_details','repair_details'
|
||||||
|
] loop
|
||||||
|
execute format('drop policy if exists %I_select on app.%I;', tbl, tbl);
|
||||||
|
execute format($p$
|
||||||
|
create policy %I_select on app.%I
|
||||||
|
for select to authenticated
|
||||||
|
using (app._can_see_txn(txn_id));
|
||||||
|
$p$, tbl, tbl);
|
||||||
|
|
||||||
|
execute format('drop policy if exists %I_insert on app.%I;', tbl, tbl);
|
||||||
|
execute format($p$
|
||||||
|
create policy %I_insert on app.%I
|
||||||
|
for insert to authenticated
|
||||||
|
with check (app._can_write_detail(txn_id));
|
||||||
|
$p$, tbl, tbl);
|
||||||
|
|
||||||
|
execute format('grant select, insert on app.%I to authenticated;', tbl);
|
||||||
|
end loop;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- End migration 0004 ----------------------------------------------------
|
||||||
@@ -0,0 +1,618 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0005 — Inventory and e-float (roadmap Step 6).
|
||||||
|
--
|
||||||
|
-- Two parallel stock systems for a cell shop:
|
||||||
|
--
|
||||||
|
-- 1. Physical inventory: scratch cards (with serials), SIMs, phones,
|
||||||
|
-- accessories. Voucher serials track per-card lifecycle so the
|
||||||
|
-- same card can never be sold twice and "lost" cards are visible.
|
||||||
|
--
|
||||||
|
-- 2. Electronic float: OMT cash float, Alfa/touch e-recharge wallet,
|
||||||
|
-- whish, etc. Every recharge or transfer must move e-float in
|
||||||
|
-- lockstep with cash, otherwise reconciliation fails.
|
||||||
|
--
|
||||||
|
-- Threat-model rows addressed: 3, 4, 5, 13, 21, 22.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Items and physical stock
|
||||||
|
-- =====================================================================
|
||||||
|
do $$ begin
|
||||||
|
create type app.item_type as enum (
|
||||||
|
'scratch_card', 'sim', 'phone', 'accessory', 'consumable'
|
||||||
|
);
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type app.stock_movement_type as enum (
|
||||||
|
'purchase_in', -- received from distributor
|
||||||
|
'sale_out', -- linked to a transaction
|
||||||
|
'return_in', -- customer return
|
||||||
|
'damaged_out', -- write-off (manager approval)
|
||||||
|
'lost_out', -- write-off (manager approval)
|
||||||
|
'transfer_in', -- between shops
|
||||||
|
'transfer_out',
|
||||||
|
'adjustment_in', -- audited correction
|
||||||
|
'adjustment_out'
|
||||||
|
);
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type app.voucher_status as enum (
|
||||||
|
'in_stock', 'sold', 'damaged', 'lost', 'returned'
|
||||||
|
);
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
create table if not exists app.items (
|
||||||
|
sku text primary key,
|
||||||
|
name text not null,
|
||||||
|
type app.item_type not null,
|
||||||
|
operator text, -- 'ALFA','TOUCH', null for non-recharge
|
||||||
|
face_value_usd numeric(14,2), -- recharge denomination if applicable
|
||||||
|
cost_usd numeric(14,2) not null check (cost_usd >= 0),
|
||||||
|
price_usd numeric(14,2) not null check (price_usd >= 0),
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Per-shop stock-on-hand counter (denormalized, kept in sync by trigger).
|
||||||
|
create table if not exists app.stock_on_hand (
|
||||||
|
sku text not null references app.items(sku),
|
||||||
|
shop_id uuid not null references app.shops(id),
|
||||||
|
qty integer not null default 0 check (qty >= 0),
|
||||||
|
primary key (sku, shop_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists app.stock_lots (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
sku text not null references app.items(sku),
|
||||||
|
shop_id uuid not null references app.shops(id),
|
||||||
|
received_at timestamptz not null default now(),
|
||||||
|
qty_received integer not null check (qty_received > 0),
|
||||||
|
unit_cost_usd numeric(14,2) not null check (unit_cost_usd >= 0),
|
||||||
|
supplier text,
|
||||||
|
invoice_no text,
|
||||||
|
received_by uuid not null references auth.users(id) default auth.uid(),
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
create index if not exists idx_stock_lots_sku_shop on app.stock_lots(sku, shop_id);
|
||||||
|
|
||||||
|
-- Append-only stock movements ledger -----------------------------------
|
||||||
|
create table if not exists app.stock_movements (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
sku text not null references app.items(sku),
|
||||||
|
shop_id uuid not null references app.shops(id),
|
||||||
|
shift_id uuid references app.shifts(id),
|
||||||
|
type app.stock_movement_type not null,
|
||||||
|
-- Signed: positive = +stock (purchase_in, return_in, transfer_in, adjustment_in)
|
||||||
|
-- negative = -stock (sale_out, damaged_out, lost_out, transfer_out, adjustment_out)
|
||||||
|
qty_delta integer not null check (qty_delta <> 0),
|
||||||
|
ref_txn_id uuid references app.transactions(id),
|
||||||
|
ref_lot_id uuid references app.stock_lots(id),
|
||||||
|
approved_by uuid references auth.users(id), -- required for damaged/lost/adjustment
|
||||||
|
reason text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
created_by uuid not null references auth.users(id) default auth.uid()
|
||||||
|
);
|
||||||
|
create index if not exists idx_stock_mov_sku_shop on app.stock_movements(sku, shop_id, created_at desc);
|
||||||
|
create index if not exists idx_stock_mov_txn on app.stock_movements(ref_txn_id);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Voucher inventory (per-serial lifecycle)
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.voucher_inventory (
|
||||||
|
serial text primary key,
|
||||||
|
sku text not null references app.items(sku),
|
||||||
|
shop_id uuid not null references app.shops(id),
|
||||||
|
lot_id uuid references app.stock_lots(id),
|
||||||
|
status app.voucher_status not null default 'in_stock',
|
||||||
|
received_at timestamptz not null default now(),
|
||||||
|
sold_txn_id uuid references app.transactions(id),
|
||||||
|
sold_at timestamptz,
|
||||||
|
status_changed_by uuid references auth.users(id),
|
||||||
|
status_change_reason text,
|
||||||
|
constraint voucher_status_consistency check (
|
||||||
|
(status = 'in_stock' and sold_txn_id is null and sold_at is null)
|
||||||
|
or (status = 'sold' and sold_txn_id is not null and sold_at is not null)
|
||||||
|
or (status in ('damaged','lost','returned')
|
||||||
|
and sold_txn_id is null and sold_at is null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
create index if not exists idx_voucher_status on app.voucher_inventory(status);
|
||||||
|
create index if not exists idx_voucher_sku_shop on app.voucher_inventory(sku, shop_id);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- E-float (OMT cash float, Alfa e-recharge wallet, etc.)
|
||||||
|
-- =====================================================================
|
||||||
|
do $$ begin
|
||||||
|
create type app.float_provider as enum (
|
||||||
|
'OMT_CASH', 'OMT_DIGITAL', 'ALFA_ERECHARGE', 'TOUCH_ERECHARGE',
|
||||||
|
'OGERO_ERECHARGE', 'WHISH', 'CARD_TERMINAL', 'BANK'
|
||||||
|
);
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
create table if not exists app.floats (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
shop_id uuid not null references app.shops(id) on delete restrict,
|
||||||
|
provider app.float_provider not null,
|
||||||
|
currency app.currency_code not null,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
unique (shop_id, provider, currency)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Cached balance per float, kept in sync by the movements trigger.
|
||||||
|
create table if not exists app.float_balances (
|
||||||
|
float_id uuid primary key references app.floats(id) on delete cascade,
|
||||||
|
balance numeric(20,2) not null default 0,
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists app.float_movements (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
float_id uuid not null references app.floats(id) on delete restrict,
|
||||||
|
shift_id uuid references app.shifts(id),
|
||||||
|
occurred_at timestamptz not null default now(),
|
||||||
|
-- Signed: + adds to e-float, - removes from it.
|
||||||
|
amount numeric(20,2) not null check (amount <> 0),
|
||||||
|
ref_txn_id uuid references app.transactions(id),
|
||||||
|
ref_settlement_id uuid, -- FK added in 0007
|
||||||
|
reason text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
created_by uuid not null references auth.users(id) default auth.uid()
|
||||||
|
);
|
||||||
|
create index if not exists idx_float_mov_float on app.float_movements(float_id, occurred_at);
|
||||||
|
create index if not exists idx_float_mov_txn on app.float_movements(ref_txn_id);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Triggers — append-only, balance maintenance, no negative stock
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- Stock movements: append-only.
|
||||||
|
create or replace function app._stock_mov_no_update_delete()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin raise exception 'stock_movements is append-only'; end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_stock_mov_freeze on app.stock_movements;
|
||||||
|
create trigger trg_stock_mov_freeze before update or delete on app.stock_movements
|
||||||
|
for each row execute function app._stock_mov_no_update_delete();
|
||||||
|
|
||||||
|
-- Stock movements: server-stamped, sign matches type, optional manager
|
||||||
|
-- approval enforced for write-offs.
|
||||||
|
create or replace function app._stock_mov_before_insert()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
new.created_at := now();
|
||||||
|
new.created_by := auth.uid();
|
||||||
|
|
||||||
|
-- Sign / type consistency.
|
||||||
|
if new.type in ('purchase_in','return_in','transfer_in','adjustment_in')
|
||||||
|
and new.qty_delta <= 0 then
|
||||||
|
raise exception '% must have qty_delta > 0', new.type;
|
||||||
|
end if;
|
||||||
|
if new.type in ('sale_out','damaged_out','lost_out','transfer_out','adjustment_out')
|
||||||
|
and new.qty_delta >= 0 then
|
||||||
|
raise exception '% must have qty_delta < 0', new.type;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Write-offs and adjustments need manager approval.
|
||||||
|
if new.type in ('damaged_out','lost_out','adjustment_in','adjustment_out')
|
||||||
|
and new.approved_by is null then
|
||||||
|
raise exception '% requires manager approval (approved_by)', new.type;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- sale_out must reference a real, completed sale of the same shop.
|
||||||
|
if new.type = 'sale_out' then
|
||||||
|
if new.ref_txn_id is null then
|
||||||
|
raise exception 'sale_out requires ref_txn_id';
|
||||||
|
end if;
|
||||||
|
if not exists (
|
||||||
|
select 1 from app.transactions
|
||||||
|
where id = new.ref_txn_id and shop_id = new.shop_id and status = 'completed'
|
||||||
|
) then
|
||||||
|
raise exception 'sale_out must reference a completed txn in the same shop';
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_stock_mov_before_insert on app.stock_movements;
|
||||||
|
create trigger trg_stock_mov_before_insert before insert on app.stock_movements
|
||||||
|
for each row execute function app._stock_mov_before_insert();
|
||||||
|
|
||||||
|
-- Maintain stock_on_hand. No negative balance allowed.
|
||||||
|
create or replace function app._stock_on_hand_apply()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
insert into app.stock_on_hand(sku, shop_id, qty)
|
||||||
|
values (new.sku, new.shop_id, new.qty_delta)
|
||||||
|
on conflict (sku, shop_id) do update
|
||||||
|
set qty = app.stock_on_hand.qty + new.qty_delta;
|
||||||
|
-- Re-check; the CHECK on the table will already reject negatives but
|
||||||
|
-- give a clearer error here.
|
||||||
|
if (select qty from app.stock_on_hand
|
||||||
|
where sku = new.sku and shop_id = new.shop_id) < 0 then
|
||||||
|
raise exception 'stock would go negative for sku=% shop=%', new.sku, new.shop_id;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_stock_on_hand_apply on app.stock_movements;
|
||||||
|
create trigger trg_stock_on_hand_apply after insert on app.stock_movements
|
||||||
|
for each row execute function app._stock_on_hand_apply();
|
||||||
|
|
||||||
|
-- Stock_lots: receiving stock auto-creates a purchase_in movement.
|
||||||
|
create or replace function app._stock_lot_after_insert()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
insert into app.stock_movements(sku, shop_id, type, qty_delta, ref_lot_id, reason)
|
||||||
|
values (new.sku, new.shop_id, 'purchase_in', new.qty_received, new.id,
|
||||||
|
coalesce('lot ' || new.invoice_no, 'lot received'));
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_stock_lot_after_insert on app.stock_lots;
|
||||||
|
create trigger trg_stock_lot_after_insert after insert on app.stock_lots
|
||||||
|
for each row execute function app._stock_lot_after_insert();
|
||||||
|
|
||||||
|
-- Float movements: append-only + balance.
|
||||||
|
create or replace function app._float_mov_no_update_delete()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin raise exception 'float_movements is append-only'; end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_float_mov_freeze on app.float_movements;
|
||||||
|
create trigger trg_float_mov_freeze before update or delete on app.float_movements
|
||||||
|
for each row execute function app._float_mov_no_update_delete();
|
||||||
|
|
||||||
|
create or replace function app._float_balance_apply()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
insert into app.float_balances(float_id, balance, updated_at)
|
||||||
|
values (new.float_id, new.amount, now())
|
||||||
|
on conflict (float_id) do update
|
||||||
|
set balance = app.float_balances.balance + new.amount,
|
||||||
|
updated_at = now();
|
||||||
|
if (select balance from app.float_balances where float_id = new.float_id) < 0 then
|
||||||
|
raise exception 'float would go negative for float_id=%', new.float_id;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_float_balance_apply on app.float_movements;
|
||||||
|
create trigger trg_float_balance_apply after insert on app.float_movements
|
||||||
|
for each row execute function app._float_balance_apply();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Recharge ↔ stock/float coupling
|
||||||
|
-- A recharge_details row MUST move either physical stock (voucher) or
|
||||||
|
-- e-float, otherwise it is a free recharge — exactly the fraud we want
|
||||||
|
-- to make impossible (vectors #3, #21).
|
||||||
|
-- Implemented as a deferred constraint trigger so the client can write
|
||||||
|
-- the recharge row first, then the movement, in a single transaction.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app._recharge_require_movement()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
has_voucher_movement boolean;
|
||||||
|
has_float_movement boolean;
|
||||||
|
v_provider app.float_provider;
|
||||||
|
begin
|
||||||
|
if new.voucher_serial is not null then
|
||||||
|
-- The voucher must be marked sold and tied to this txn.
|
||||||
|
select exists(
|
||||||
|
select 1 from app.voucher_inventory
|
||||||
|
where serial = new.voucher_serial
|
||||||
|
and status = 'sold'
|
||||||
|
and sold_txn_id = new.txn_id
|
||||||
|
) into has_voucher_movement;
|
||||||
|
if not has_voucher_movement then
|
||||||
|
raise exception
|
||||||
|
'recharge with voucher_serial=% must be paired with a sold voucher',
|
||||||
|
new.voucher_serial;
|
||||||
|
end if;
|
||||||
|
else
|
||||||
|
-- E-recharge: an e-float debit must exist for this txn against the
|
||||||
|
-- matching operator's e-float account.
|
||||||
|
v_provider := case new.operator
|
||||||
|
when 'ALFA' then 'ALFA_ERECHARGE'::app.float_provider
|
||||||
|
when 'TOUCH' then 'TOUCH_ERECHARGE'::app.float_provider
|
||||||
|
when 'OGERO' then 'OGERO_ERECHARGE'::app.float_provider
|
||||||
|
else null
|
||||||
|
end;
|
||||||
|
if v_provider is null then
|
||||||
|
-- Unmapped operator (IDM, CYBERIA, TERRANET): require any negative
|
||||||
|
-- float movement for this txn.
|
||||||
|
select exists(
|
||||||
|
select 1 from app.float_movements
|
||||||
|
where ref_txn_id = new.txn_id and amount < 0
|
||||||
|
) into has_float_movement;
|
||||||
|
else
|
||||||
|
select exists(
|
||||||
|
select 1
|
||||||
|
from app.float_movements fm
|
||||||
|
join app.floats f on f.id = fm.float_id
|
||||||
|
join app.transactions t on t.id = fm.ref_txn_id
|
||||||
|
where fm.ref_txn_id = new.txn_id
|
||||||
|
and fm.amount < 0
|
||||||
|
and f.provider = v_provider
|
||||||
|
and f.shop_id = t.shop_id
|
||||||
|
) into has_float_movement;
|
||||||
|
end if;
|
||||||
|
if not has_float_movement then
|
||||||
|
raise exception
|
||||||
|
'e-recharge txn % must be paired with a negative e-float movement',
|
||||||
|
new.txn_id;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_recharge_require_movement on app.recharge_details;
|
||||||
|
create constraint trigger trg_recharge_require_movement
|
||||||
|
after insert on app.recharge_details
|
||||||
|
deferrable initially deferred
|
||||||
|
for each row execute function app._recharge_require_movement();
|
||||||
|
|
||||||
|
-- Goods sale ↔ stock_movement coupling (same idea).
|
||||||
|
create or replace function app._goods_sale_require_movement()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare ok boolean;
|
||||||
|
begin
|
||||||
|
select exists(
|
||||||
|
select 1 from app.stock_movements sm
|
||||||
|
where sm.ref_txn_id = new.txn_id
|
||||||
|
and sm.sku = new.sku
|
||||||
|
and sm.type = 'sale_out'
|
||||||
|
and -sm.qty_delta = new.qty
|
||||||
|
) into ok;
|
||||||
|
if not ok then
|
||||||
|
raise exception 'goods sale txn % must be paired with a sale_out stock movement', new.txn_id;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_goods_sale_require_movement on app.goods_sale_details;
|
||||||
|
create constraint trigger trg_goods_sale_require_movement
|
||||||
|
after insert on app.goods_sale_details
|
||||||
|
deferrable initially deferred
|
||||||
|
for each row execute function app._goods_sale_require_movement();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- SECURITY DEFINER helpers used by the cashier UI
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- Sell a scratch card: marks the voucher sold + creates the stock_out.
|
||||||
|
-- Called inside the same transaction as inserting the txn + recharge_details.
|
||||||
|
create or replace function app.sell_voucher(
|
||||||
|
p_txn_id uuid,
|
||||||
|
p_serial text
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare v app.voucher_inventory%rowtype;
|
||||||
|
t app.transactions%rowtype;
|
||||||
|
begin
|
||||||
|
select * into t from app.transactions where id = p_txn_id;
|
||||||
|
if t.id is null then raise exception 'txn not found'; end if;
|
||||||
|
if t.user_id <> auth.uid() then
|
||||||
|
raise exception 'only the txn owner may sell a voucher against it';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into v from app.voucher_inventory where serial = p_serial for update;
|
||||||
|
if v.serial is null then raise exception 'voucher % not found', p_serial; end if;
|
||||||
|
if v.shop_id <> t.shop_id then
|
||||||
|
raise exception 'voucher belongs to a different shop';
|
||||||
|
end if;
|
||||||
|
if v.status <> 'in_stock' then
|
||||||
|
raise exception 'voucher % is not in_stock (status=%)', p_serial, v.status;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update app.voucher_inventory
|
||||||
|
set status = 'sold', sold_txn_id = p_txn_id, sold_at = now(),
|
||||||
|
status_changed_by = auth.uid()
|
||||||
|
where serial = p_serial;
|
||||||
|
|
||||||
|
insert into app.stock_movements(sku, shop_id, shift_id, type, qty_delta, ref_txn_id, reason)
|
||||||
|
values (v.sku, v.shop_id, t.shift_id, 'sale_out', -1, p_txn_id, 'voucher ' || p_serial);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Mark a voucher damaged or lost (manager only, with PIN).
|
||||||
|
create or replace function app.write_off_voucher(
|
||||||
|
p_serial text,
|
||||||
|
p_status app.voucher_status,
|
||||||
|
p_reason text,
|
||||||
|
p_manager_pin text
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare v app.voucher_inventory%rowtype;
|
||||||
|
begin
|
||||||
|
if p_status not in ('damaged','lost') then
|
||||||
|
raise exception 'only damaged/lost are valid write-off statuses';
|
||||||
|
end if;
|
||||||
|
if p_reason is null or length(btrim(p_reason)) < 5 then
|
||||||
|
raise exception 'reason >= 5 chars required';
|
||||||
|
end if;
|
||||||
|
select * into v from app.voucher_inventory where serial = p_serial for update;
|
||||||
|
if v.serial is null then raise exception 'voucher not found'; end if;
|
||||||
|
if v.status <> 'in_stock' then
|
||||||
|
raise exception 'voucher must be in_stock to write off (was %)', v.status;
|
||||||
|
end if;
|
||||||
|
if not app.has_role_in_shop(v.shop_id, 'manager') then
|
||||||
|
raise exception 'manager role required';
|
||||||
|
end if;
|
||||||
|
if not app.verify_my_pin(p_manager_pin) then
|
||||||
|
raise exception 'invalid manager PIN';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update app.voucher_inventory
|
||||||
|
set status = p_status, status_changed_by = auth.uid(),
|
||||||
|
status_change_reason = p_reason
|
||||||
|
where serial = p_serial;
|
||||||
|
|
||||||
|
insert into app.stock_movements(sku, shop_id, type, qty_delta, approved_by, reason)
|
||||||
|
values (v.sku, v.shop_id,
|
||||||
|
case p_status when 'damaged' then 'damaged_out'::app.stock_movement_type
|
||||||
|
when 'lost' then 'lost_out'::app.stock_movement_type end,
|
||||||
|
-1, auth.uid(), p_reason);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function app.sell_voucher(uuid, text) from public;
|
||||||
|
revoke all on function app.write_off_voucher(text, app.voucher_status, text, text) from public;
|
||||||
|
grant execute on function app.sell_voucher(uuid, text) to authenticated;
|
||||||
|
grant execute on function app.write_off_voucher(text, app.voucher_status, text, text) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- RLS
|
||||||
|
-- =====================================================================
|
||||||
|
alter table app.items enable row level security;
|
||||||
|
alter table app.stock_on_hand enable row level security;
|
||||||
|
alter table app.stock_lots enable row level security;
|
||||||
|
alter table app.stock_movements enable row level security;
|
||||||
|
alter table app.voucher_inventory enable row level security;
|
||||||
|
alter table app.floats enable row level security;
|
||||||
|
alter table app.float_balances enable row level security;
|
||||||
|
alter table app.float_movements enable row level security;
|
||||||
|
|
||||||
|
alter table app.items force row level security;
|
||||||
|
alter table app.stock_on_hand force row level security;
|
||||||
|
alter table app.stock_lots force row level security;
|
||||||
|
alter table app.stock_movements force row level security;
|
||||||
|
alter table app.voucher_inventory force row level security;
|
||||||
|
alter table app.floats force row level security;
|
||||||
|
alter table app.float_balances force row level security;
|
||||||
|
alter table app.float_movements force row level security;
|
||||||
|
|
||||||
|
-- Block direct UPDATE/DELETE on append-only tables.
|
||||||
|
revoke update, delete on app.stock_movements from authenticated;
|
||||||
|
revoke update, delete on app.float_movements from authenticated;
|
||||||
|
revoke update, delete on app.voucher_inventory from authenticated;
|
||||||
|
revoke update, delete on app.stock_on_hand from authenticated;
|
||||||
|
revoke update, delete on app.float_balances from authenticated;
|
||||||
|
-- Items are reference data: only owners may modify (handled by policy).
|
||||||
|
|
||||||
|
-- Items: readable by everyone authenticated; writes for owners only.
|
||||||
|
drop policy if exists items_select on app.items;
|
||||||
|
create policy items_select on app.items for select to authenticated using (true);
|
||||||
|
drop policy if exists items_write_owner on app.items;
|
||||||
|
create policy items_write_owner on app.items
|
||||||
|
for all to authenticated
|
||||||
|
using (app.is_owner_anywhere())
|
||||||
|
with check (app.is_owner_anywhere());
|
||||||
|
|
||||||
|
-- Shop-scoped tables: readable to anyone assigned to the shop.
|
||||||
|
drop policy if exists soh_select on app.stock_on_hand;
|
||||||
|
create policy soh_select on app.stock_on_hand
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['owner','manager','cashier','auditor']::app.business_role[])
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists lots_select on app.stock_lots;
|
||||||
|
create policy lots_select on app.stock_lots
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['owner','manager','auditor']::app.business_role[])
|
||||||
|
);
|
||||||
|
drop policy if exists lots_insert on app.stock_lots;
|
||||||
|
create policy lots_insert on app.stock_lots
|
||||||
|
for insert to authenticated
|
||||||
|
with check (app.has_any_role_in_shop(shop_id, array['owner','manager']::app.business_role[]));
|
||||||
|
grant select, insert on app.stock_lots to authenticated;
|
||||||
|
|
||||||
|
drop policy if exists smov_select on app.stock_movements;
|
||||||
|
create policy smov_select on app.stock_movements
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['owner','manager','cashier','auditor']::app.business_role[])
|
||||||
|
);
|
||||||
|
drop policy if exists smov_insert on app.stock_movements;
|
||||||
|
create policy smov_insert on app.stock_movements
|
||||||
|
for insert to authenticated
|
||||||
|
with check (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['owner','manager','cashier']::app.business_role[])
|
||||||
|
);
|
||||||
|
grant select, insert on app.stock_movements to authenticated;
|
||||||
|
|
||||||
|
drop policy if exists vouch_select on app.voucher_inventory;
|
||||||
|
create policy vouch_select on app.voucher_inventory
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['owner','manager','cashier','auditor']::app.business_role[])
|
||||||
|
);
|
||||||
|
drop policy if exists vouch_insert on app.voucher_inventory;
|
||||||
|
create policy vouch_insert on app.voucher_inventory
|
||||||
|
for insert to authenticated
|
||||||
|
with check (app.has_any_role_in_shop(shop_id, array['owner','manager']::app.business_role[]));
|
||||||
|
grant select, insert on app.voucher_inventory to authenticated;
|
||||||
|
-- Voucher status changes go through SECURITY DEFINER functions only.
|
||||||
|
|
||||||
|
drop policy if exists floats_select on app.floats;
|
||||||
|
create policy floats_select on app.floats
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['owner','manager','cashier','auditor']::app.business_role[])
|
||||||
|
);
|
||||||
|
drop policy if exists floats_write_owner on app.floats;
|
||||||
|
create policy floats_write_owner on app.floats
|
||||||
|
for all to authenticated
|
||||||
|
using (app.has_role_in_shop(shop_id, 'owner'))
|
||||||
|
with check (app.has_role_in_shop(shop_id, 'owner'));
|
||||||
|
grant select, insert, update on app.floats to authenticated;
|
||||||
|
|
||||||
|
drop policy if exists fbal_select on app.float_balances;
|
||||||
|
create policy fbal_select on app.float_balances
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1 from app.floats f
|
||||||
|
where f.id = float_balances.float_id
|
||||||
|
and app.has_any_role_in_shop(f.shop_id,
|
||||||
|
array['owner','manager','cashier','auditor']::app.business_role[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists fmov_select on app.float_movements;
|
||||||
|
create policy fmov_select on app.float_movements
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1 from app.floats f
|
||||||
|
where f.id = float_movements.float_id
|
||||||
|
and app.has_any_role_in_shop(f.shop_id,
|
||||||
|
array['owner','manager','cashier','auditor']::app.business_role[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
drop policy if exists fmov_insert on app.float_movements;
|
||||||
|
create policy fmov_insert on app.float_movements
|
||||||
|
for insert to authenticated
|
||||||
|
with check (
|
||||||
|
exists (
|
||||||
|
select 1 from app.floats f
|
||||||
|
where f.id = float_movements.float_id
|
||||||
|
and app.has_any_role_in_shop(f.shop_id,
|
||||||
|
array['owner','manager','cashier']::app.business_role[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
grant select, insert on app.float_movements to authenticated;
|
||||||
|
|
||||||
|
grant select on app.items, app.stock_on_hand, app.float_balances to authenticated;
|
||||||
|
|
||||||
|
-- End migration 0005 ----------------------------------------------------
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0006 — Customers, KYC, and AML controls (roadmap Step 7).
|
||||||
|
--
|
||||||
|
-- Builds the customer/KYC layer that backs OMT send/receive and any
|
||||||
|
-- transfer above thresholds. Aggregation views detect structuring
|
||||||
|
-- (splitting a large transfer across multiple smaller ones).
|
||||||
|
--
|
||||||
|
-- Threat-model rows addressed: 1, 5, 16.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Customers
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.customers (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
full_name text not null,
|
||||||
|
id_type app.id_doc_type not null,
|
||||||
|
id_number text not null,
|
||||||
|
dob date,
|
||||||
|
nationality text,
|
||||||
|
phone text,
|
||||||
|
address text,
|
||||||
|
pep_flag boolean not null default false, -- politically exposed person
|
||||||
|
sanctions_hit boolean not null default false,
|
||||||
|
sanctions_checked_at timestamptz,
|
||||||
|
sanctions_source text, -- which list / API
|
||||||
|
notes text,
|
||||||
|
is_blocked boolean not null default false, -- owner can hard-block a customer
|
||||||
|
blocked_reason text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
created_by uuid not null references auth.users(id) default auth.uid(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
updated_by uuid references auth.users(id),
|
||||||
|
constraint customers_id_unique unique (id_type, id_number)
|
||||||
|
);
|
||||||
|
create index if not exists idx_customers_phone on app.customers(phone);
|
||||||
|
create index if not exists idx_customers_name on app.customers(lower(full_name));
|
||||||
|
|
||||||
|
-- Stamp updated_*
|
||||||
|
create or replace function app._customers_stamp()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'INSERT' then
|
||||||
|
new.created_by := auth.uid();
|
||||||
|
new.created_at := now();
|
||||||
|
end if;
|
||||||
|
new.updated_by := auth.uid();
|
||||||
|
new.updated_at := now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_customers_stamp on app.customers;
|
||||||
|
create trigger trg_customers_stamp
|
||||||
|
before insert or update on app.customers
|
||||||
|
for each row execute function app._customers_stamp();
|
||||||
|
|
||||||
|
-- KYC documents (ID photos, declarations) -----------------------------
|
||||||
|
create table if not exists app.customer_documents (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
customer_id uuid not null references app.customers(id) on delete restrict,
|
||||||
|
doc_type text not null, -- 'id_front','id_back','passport','declaration'
|
||||||
|
file_url text not null,
|
||||||
|
uploaded_at timestamptz not null default now(),
|
||||||
|
uploaded_by uuid not null references auth.users(id) default auth.uid()
|
||||||
|
);
|
||||||
|
create index if not exists idx_customer_docs on app.customer_documents(customer_id);
|
||||||
|
|
||||||
|
-- Append-only customer-document table.
|
||||||
|
create or replace function app._customer_docs_no_update_delete()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin raise exception 'customer_documents is append-only'; end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_customer_docs_freeze on app.customer_documents;
|
||||||
|
create trigger trg_customer_docs_freeze before update or delete on app.customer_documents
|
||||||
|
for each row execute function app._customer_docs_no_update_delete();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Now that customers exists, attach the deferred FK from transactions.
|
||||||
|
-- =====================================================================
|
||||||
|
alter table app.transactions
|
||||||
|
drop constraint if exists transactions_customer_fk;
|
||||||
|
alter table app.transactions
|
||||||
|
add constraint transactions_customer_fk
|
||||||
|
foreign key (customer_id) references app.customers(id) on delete restrict;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- KYC thresholds (per service / currency). Server-controlled.
|
||||||
|
-- A txn at or above `daily_amount_warn` requires a customer record;
|
||||||
|
-- at or above `daily_amount_block` it is hard-blocked unless an owner
|
||||||
|
-- override is on file.
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.kyc_thresholds (
|
||||||
|
service_code text not null references app.services(code),
|
||||||
|
currency app.currency_code not null,
|
||||||
|
daily_amount_warn numeric(18,2) not null check (daily_amount_warn > 0),
|
||||||
|
daily_amount_block numeric(18,2) not null check (daily_amount_block > 0),
|
||||||
|
primary key (service_code, currency),
|
||||||
|
constraint kyc_thresholds_order check (daily_amount_block >= daily_amount_warn)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sensible defaults. Owners can edit later.
|
||||||
|
insert into app.kyc_thresholds(service_code, currency, daily_amount_warn, daily_amount_block) values
|
||||||
|
('OMT_SEND', 'USD', 500, 10000),
|
||||||
|
('OMT_SEND', 'LBP', 45000000, 900000000),
|
||||||
|
('OMT_RECEIVE', 'USD', 500, 10000),
|
||||||
|
('OMT_RECEIVE', 'LBP', 45000000, 900000000),
|
||||||
|
('WU_SEND', 'USD', 500, 10000),
|
||||||
|
('WU_RECEIVE', 'USD', 500, 10000)
|
||||||
|
on conflict (service_code, currency) do nothing;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Aggregation helper: customer's running daily total in a service
|
||||||
|
-- across the network (all shops).
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.customer_daily_total(
|
||||||
|
p_customer uuid,
|
||||||
|
p_service text,
|
||||||
|
p_currency app.currency_code,
|
||||||
|
p_at timestamptz default now()
|
||||||
|
) returns numeric
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select coalesce(sum(
|
||||||
|
case when p_currency = 'USD' then t.gross_usd else t.gross_lbp end
|
||||||
|
), 0)
|
||||||
|
from app.transactions t
|
||||||
|
where t.customer_id = p_customer
|
||||||
|
and t.service_code = p_service
|
||||||
|
and t.status = 'completed'
|
||||||
|
and t.occurred_at >= date_trunc('day', p_at)
|
||||||
|
and t.occurred_at < date_trunc('day', p_at) + interval '1 day';
|
||||||
|
$$;
|
||||||
|
revoke all on function app.customer_daily_total(uuid, text, app.currency_code, timestamptz) from public;
|
||||||
|
grant execute on function app.customer_daily_total(uuid, text, app.currency_code, timestamptz) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- KYC enforcement: check at transaction insert.
|
||||||
|
-- For OMT/WU services, if the txn amount alone or the customer's
|
||||||
|
-- running daily total crosses warn → customer mandatory; crosses block
|
||||||
|
-- → reject unless an owner override row is in place for the day.
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.kyc_overrides (
|
||||||
|
customer_id uuid not null references app.customers(id),
|
||||||
|
service_code text not null references app.services(code),
|
||||||
|
valid_for_day date not null,
|
||||||
|
approved_by uuid not null references auth.users(id),
|
||||||
|
reason text not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
primary key (customer_id, service_code, valid_for_day)
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function app._txn_enforce_kyc()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
th app.kyc_thresholds%rowtype;
|
||||||
|
amount_usd numeric := new.gross_usd;
|
||||||
|
amount_lbp numeric := new.gross_lbp;
|
||||||
|
daily_usd numeric := 0;
|
||||||
|
daily_lbp numeric := 0;
|
||||||
|
c app.customers%rowtype;
|
||||||
|
begin
|
||||||
|
if new.service_code not in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE') then
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- USD branch
|
||||||
|
select * into th from app.kyc_thresholds
|
||||||
|
where service_code = new.service_code and currency = 'USD';
|
||||||
|
if found and amount_usd > 0 then
|
||||||
|
if new.customer_id is not null then
|
||||||
|
daily_usd := app.customer_daily_total(new.customer_id, new.service_code, 'USD', new.occurred_at);
|
||||||
|
end if;
|
||||||
|
if amount_usd + daily_usd >= th.daily_amount_warn and new.customer_id is null then
|
||||||
|
raise exception 'KYC: customer record required at or above % USD/day for %',
|
||||||
|
th.daily_amount_warn, new.service_code;
|
||||||
|
end if;
|
||||||
|
if amount_usd + daily_usd >= th.daily_amount_block then
|
||||||
|
if new.customer_id is null
|
||||||
|
or not exists (
|
||||||
|
select 1 from app.kyc_overrides
|
||||||
|
where customer_id = new.customer_id
|
||||||
|
and service_code = new.service_code
|
||||||
|
and valid_for_day = (new.occurred_at at time zone 'UTC')::date
|
||||||
|
) then
|
||||||
|
raise exception 'KYC block: % USD/day exceeded for % (owner override required)',
|
||||||
|
th.daily_amount_block, new.service_code;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- LBP branch
|
||||||
|
select * into th from app.kyc_thresholds
|
||||||
|
where service_code = new.service_code and currency = 'LBP';
|
||||||
|
if found and amount_lbp > 0 then
|
||||||
|
if new.customer_id is not null then
|
||||||
|
daily_lbp := app.customer_daily_total(new.customer_id, new.service_code, 'LBP', new.occurred_at);
|
||||||
|
end if;
|
||||||
|
if amount_lbp + daily_lbp >= th.daily_amount_warn and new.customer_id is null then
|
||||||
|
raise exception 'KYC: customer record required at or above % LBP/day for %',
|
||||||
|
th.daily_amount_warn, new.service_code;
|
||||||
|
end if;
|
||||||
|
if amount_lbp + daily_lbp >= th.daily_amount_block then
|
||||||
|
if new.customer_id is null
|
||||||
|
or not exists (
|
||||||
|
select 1 from app.kyc_overrides
|
||||||
|
where customer_id = new.customer_id
|
||||||
|
and service_code = new.service_code
|
||||||
|
and valid_for_day = (new.occurred_at at time zone 'UTC')::date
|
||||||
|
) then
|
||||||
|
raise exception 'KYC block: % LBP/day exceeded for % (owner override required)',
|
||||||
|
th.daily_amount_block, new.service_code;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Hard-blocked / sanctioned customers are never allowed.
|
||||||
|
if new.customer_id is not null then
|
||||||
|
select * into c from app.customers where id = new.customer_id;
|
||||||
|
if c.is_blocked then
|
||||||
|
raise exception 'customer is blocked: %', coalesce(c.blocked_reason, 'no reason');
|
||||||
|
end if;
|
||||||
|
if c.sanctions_hit then
|
||||||
|
raise exception 'customer is on a sanctions list; transaction refused';
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Run KYC checks after the txn_before_insert trigger has populated
|
||||||
|
-- shop_id/till_id/user_id/reference_no.
|
||||||
|
drop trigger if exists trg_txn_enforce_kyc on app.transactions;
|
||||||
|
create trigger trg_txn_enforce_kyc
|
||||||
|
before insert on app.transactions
|
||||||
|
for each row execute function app._txn_enforce_kyc();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Structuring detection (vector #16):
|
||||||
|
-- A customer running a high cumulative OMT total via repeated small
|
||||||
|
-- transfers, or the same beneficiary phone receiving from many cashiers
|
||||||
|
-- in a short window. Exposed as views for the AML dashboard.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace view app.v_aml_structuring_by_customer as
|
||||||
|
with d as (
|
||||||
|
select t.customer_id,
|
||||||
|
t.service_code,
|
||||||
|
(t.occurred_at at time zone 'UTC')::date as day,
|
||||||
|
count(*) as txn_count,
|
||||||
|
sum(t.gross_usd) as total_usd,
|
||||||
|
sum(t.gross_lbp) as total_lbp
|
||||||
|
from app.transactions t
|
||||||
|
where t.status = 'completed'
|
||||||
|
and t.service_code in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE')
|
||||||
|
and t.customer_id is not null
|
||||||
|
group by 1,2,3
|
||||||
|
)
|
||||||
|
select d.*,
|
||||||
|
(select daily_amount_warn from app.kyc_thresholds
|
||||||
|
where service_code = d.service_code and currency = 'USD') as warn_usd,
|
||||||
|
(select daily_amount_warn from app.kyc_thresholds
|
||||||
|
where service_code = d.service_code and currency = 'LBP') as warn_lbp
|
||||||
|
from d
|
||||||
|
where d.txn_count >= 3 -- 3+ same-customer txns
|
||||||
|
and (
|
||||||
|
(d.total_usd >= 0.8 * coalesce((select daily_amount_warn from app.kyc_thresholds
|
||||||
|
where service_code = d.service_code and currency = 'USD'), 1e18))
|
||||||
|
or (d.total_lbp >= 0.8 * coalesce((select daily_amount_warn from app.kyc_thresholds
|
||||||
|
where service_code = d.service_code and currency = 'LBP'), 1e18))
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace view app.v_aml_same_beneficiary_burst as
|
||||||
|
select beneficiary_phone,
|
||||||
|
date_trunc('hour', occurred_at) as hour_bucket,
|
||||||
|
count(*) as txn_count,
|
||||||
|
count(distinct user_id) as distinct_cashiers,
|
||||||
|
sum(gross_usd) as total_usd,
|
||||||
|
sum(gross_lbp) as total_lbp
|
||||||
|
from app.transactions
|
||||||
|
where status = 'completed'
|
||||||
|
and service_code in ('OMT_SEND','WU_SEND')
|
||||||
|
and beneficiary_phone is not null
|
||||||
|
group by 1,2
|
||||||
|
having count(*) >= 3
|
||||||
|
and count(distinct user_id) >= 2;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- RLS
|
||||||
|
-- =====================================================================
|
||||||
|
alter table app.customers enable row level security;
|
||||||
|
alter table app.customer_documents enable row level security;
|
||||||
|
alter table app.kyc_thresholds enable row level security;
|
||||||
|
alter table app.kyc_overrides enable row level security;
|
||||||
|
alter table app.customers force row level security;
|
||||||
|
alter table app.customer_documents force row level security;
|
||||||
|
alter table app.kyc_thresholds force row level security;
|
||||||
|
alter table app.kyc_overrides force row level security;
|
||||||
|
|
||||||
|
-- Customer rows: visible to anyone authenticated who actively uses
|
||||||
|
-- the system (cashiers need to find existing customers). Writes are
|
||||||
|
-- limited; deletion never permitted.
|
||||||
|
revoke delete on app.customers from authenticated;
|
||||||
|
revoke update, delete on app.customer_documents from authenticated;
|
||||||
|
revoke insert, update, delete on app.kyc_thresholds from authenticated;
|
||||||
|
revoke update, delete on app.kyc_overrides from authenticated;
|
||||||
|
|
||||||
|
drop policy if exists customers_select on app.customers;
|
||||||
|
create policy customers_select on app.customers
|
||||||
|
for select to authenticated using (true);
|
||||||
|
|
||||||
|
drop policy if exists customers_insert on app.customers;
|
||||||
|
create policy customers_insert on app.customers
|
||||||
|
for insert to authenticated
|
||||||
|
with check (auth.uid() is not null);
|
||||||
|
|
||||||
|
-- Restrict updates: cashiers may patch contact info; only owners may
|
||||||
|
-- toggle pep_flag, sanctions_hit, is_blocked. Enforced by trigger.
|
||||||
|
create or replace function app._customers_update_guard()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
if not app.is_owner_anywhere() then
|
||||||
|
if new.pep_flag is distinct from old.pep_flag
|
||||||
|
or new.sanctions_hit is distinct from old.sanctions_hit
|
||||||
|
or new.is_blocked is distinct from old.is_blocked
|
||||||
|
or coalesce(new.blocked_reason,'') <> coalesce(old.blocked_reason,'') then
|
||||||
|
raise exception 'only an owner may change pep_flag, sanctions_hit, or is_blocked';
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_customers_update_guard on app.customers;
|
||||||
|
create trigger trg_customers_update_guard
|
||||||
|
before update on app.customers
|
||||||
|
for each row execute function app._customers_update_guard();
|
||||||
|
|
||||||
|
drop policy if exists customers_update on app.customers;
|
||||||
|
create policy customers_update on app.customers
|
||||||
|
for update to authenticated
|
||||||
|
using (auth.uid() is not null)
|
||||||
|
with check (auth.uid() is not null);
|
||||||
|
|
||||||
|
drop policy if exists customer_docs_select on app.customer_documents;
|
||||||
|
create policy customer_docs_select on app.customer_documents
|
||||||
|
for select to authenticated using (true);
|
||||||
|
drop policy if exists customer_docs_insert on app.customer_documents;
|
||||||
|
create policy customer_docs_insert on app.customer_documents
|
||||||
|
for insert to authenticated
|
||||||
|
with check (auth.uid() is not null);
|
||||||
|
grant select, insert on app.customer_documents to authenticated;
|
||||||
|
|
||||||
|
drop policy if exists kyc_thr_select on app.kyc_thresholds;
|
||||||
|
create policy kyc_thr_select on app.kyc_thresholds
|
||||||
|
for select to authenticated using (true);
|
||||||
|
-- thresholds are owner-only; until an owner-edit function lands, only
|
||||||
|
-- DBA can change them.
|
||||||
|
|
||||||
|
drop policy if exists kyc_ovr_select on app.kyc_overrides;
|
||||||
|
create policy kyc_ovr_select on app.kyc_overrides
|
||||||
|
for select to authenticated
|
||||||
|
using (app.is_owner_anywhere() or approved_by = auth.uid());
|
||||||
|
drop policy if exists kyc_ovr_insert on app.kyc_overrides;
|
||||||
|
create policy kyc_ovr_insert on app.kyc_overrides
|
||||||
|
for insert to authenticated
|
||||||
|
with check (app.is_owner_anywhere() and approved_by = auth.uid());
|
||||||
|
grant select, insert on app.kyc_overrides to authenticated;
|
||||||
|
|
||||||
|
grant select, insert, update on app.customers to authenticated;
|
||||||
|
grant select on app.kyc_thresholds to authenticated;
|
||||||
|
|
||||||
|
-- End migration 0006 ----------------------------------------------------
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0007 — Receipts, signatures, evidence (roadmap Step 8).
|
||||||
|
--
|
||||||
|
-- Goals:
|
||||||
|
-- * Every receipt carries a server-signed token (HMAC-SHA256) so a
|
||||||
|
-- scanner / owner spot-check can verify it really came from this
|
||||||
|
-- system and was not printed by a side-printer or hand-edited
|
||||||
|
-- (vector #10).
|
||||||
|
-- * Customer notifications (SMS / email) are logged so the owner can
|
||||||
|
-- confirm that beneficiaries actually got their reference number,
|
||||||
|
-- exposing pocketed transactions (vector #1).
|
||||||
|
-- * Evidence (signature pad image, ID photo, voided-paper photo, OMT
|
||||||
|
-- POS slip scan) is attached append-only to a transaction.
|
||||||
|
--
|
||||||
|
-- Threat-model rows addressed: 1, 10, 11, 15.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- HMAC secret
|
||||||
|
-- The signing key lives in app.system_secrets and is never returned to
|
||||||
|
-- clients (the `select` policy denies all non-DBA access). Functions
|
||||||
|
-- below are SECURITY DEFINER so they can read it.
|
||||||
|
-- =====================================================================
|
||||||
|
create table if not exists app.system_secrets (
|
||||||
|
key text primary key,
|
||||||
|
value text not null,
|
||||||
|
rotated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Generate an initial random key on first install. Owners should rotate
|
||||||
|
-- it via app.rotate_receipt_key() (added below) on a schedule.
|
||||||
|
insert into app.system_secrets(key, value)
|
||||||
|
values ('receipt_hmac_key', encode(gen_random_bytes(32), 'hex'))
|
||||||
|
on conflict (key) do nothing;
|
||||||
|
|
||||||
|
alter table app.system_secrets enable row level security;
|
||||||
|
alter table app.system_secrets force row level security;
|
||||||
|
revoke all on app.system_secrets from authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Receipt token: HMAC over (txn_id || reference_no || shop_id || row_hash)
|
||||||
|
-- Embedded in the printed QR. Anyone holding a receipt + the public
|
||||||
|
-- verifier function can prove (or disprove) authenticity.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app._receipt_hmac_key()
|
||||||
|
returns bytea
|
||||||
|
language sql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select decode(value, 'hex') from app.system_secrets where key = 'receipt_hmac_key';
|
||||||
|
$$;
|
||||||
|
revoke all on function app._receipt_hmac_key() from public;
|
||||||
|
-- Not granted to anyone; only callable from inside other SECURITY DEFINER
|
||||||
|
-- functions in this schema.
|
||||||
|
|
||||||
|
create or replace function app.receipt_token(p_txn uuid)
|
||||||
|
returns text
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
t app.transactions%rowtype;
|
||||||
|
msg bytea;
|
||||||
|
sig bytea;
|
||||||
|
begin
|
||||||
|
select * into t from app.transactions where id = p_txn;
|
||||||
|
if t.id is null then raise exception 'txn not found'; end if;
|
||||||
|
-- Visibility check: caller must be allowed to see the txn.
|
||||||
|
if not app._can_see_txn(p_txn) then
|
||||||
|
raise exception 'not authorized';
|
||||||
|
end if;
|
||||||
|
msg := convert_to(
|
||||||
|
t.id::text || '|' || t.shop_id::text || '|' || t.reference_no::text
|
||||||
|
|| '|' || encode(t.row_hash, 'hex'),
|
||||||
|
'UTF8');
|
||||||
|
sig := hmac(msg, app._receipt_hmac_key(), 'sha256');
|
||||||
|
-- Token format: v1.<txn_id>.<reference_no>.<sig_b64>
|
||||||
|
return 'v1.' || t.id::text || '.' || t.reference_no::text || '.' ||
|
||||||
|
translate(encode(sig, 'base64'), E'+/=\n', '-_');
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.receipt_token(uuid) from public;
|
||||||
|
grant execute on function app.receipt_token(uuid) to authenticated;
|
||||||
|
|
||||||
|
-- Public verifier: takes a token, returns the txn row + ok flag.
|
||||||
|
-- Anyone authenticated may call (so an owner can scan any receipt) but
|
||||||
|
-- the row is only returned if the signature checks out AND the caller
|
||||||
|
-- is allowed to see the txn under RLS.
|
||||||
|
create or replace function app.verify_receipt(p_token text)
|
||||||
|
returns table (
|
||||||
|
ok boolean,
|
||||||
|
txn_id uuid,
|
||||||
|
shop_id uuid,
|
||||||
|
reference_no bigint,
|
||||||
|
service_code text,
|
||||||
|
occurred_at timestamptz,
|
||||||
|
status app.txn_status
|
||||||
|
)
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
parts text[];
|
||||||
|
v_txn uuid;
|
||||||
|
v_ref bigint;
|
||||||
|
v_sig_b64 text;
|
||||||
|
expected text;
|
||||||
|
t app.transactions%rowtype;
|
||||||
|
begin
|
||||||
|
parts := string_to_array(p_token, '.');
|
||||||
|
if array_length(parts, 1) <> 4 or parts[1] <> 'v1' then
|
||||||
|
ok := false; return next; return;
|
||||||
|
end if;
|
||||||
|
v_txn := parts[2]::uuid;
|
||||||
|
v_ref := parts[3]::bigint;
|
||||||
|
v_sig_b64 := parts[4];
|
||||||
|
|
||||||
|
select * into t from app.transactions where id = v_txn and reference_no = v_ref;
|
||||||
|
if t.id is null then
|
||||||
|
ok := false; return next; return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
expected := translate(
|
||||||
|
encode(
|
||||||
|
hmac(
|
||||||
|
convert_to(t.id::text || '|' || t.shop_id::text || '|' ||
|
||||||
|
t.reference_no::text || '|' || encode(t.row_hash, 'hex'), 'UTF8'),
|
||||||
|
app._receipt_hmac_key(), 'sha256'),
|
||||||
|
'base64'),
|
||||||
|
E'+/=\n', '-_');
|
||||||
|
|
||||||
|
if expected <> v_sig_b64 then
|
||||||
|
ok := false; return next; return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not app._can_see_txn(t.id) then
|
||||||
|
ok := false; return next; return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
ok := true;
|
||||||
|
txn_id := t.id;
|
||||||
|
shop_id := t.shop_id;
|
||||||
|
reference_no := t.reference_no;
|
||||||
|
service_code := t.service_code;
|
||||||
|
occurred_at := t.occurred_at;
|
||||||
|
status := t.status;
|
||||||
|
return next;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.verify_receipt(text) from public;
|
||||||
|
grant execute on function app.verify_receipt(text) to authenticated;
|
||||||
|
|
||||||
|
-- Key rotation (owner-only).
|
||||||
|
create or replace function app.rotate_receipt_key()
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not app.is_owner_anywhere() then
|
||||||
|
raise exception 'only an owner may rotate the receipt key';
|
||||||
|
end if;
|
||||||
|
update app.system_secrets
|
||||||
|
set value = encode(gen_random_bytes(32), 'hex'),
|
||||||
|
rotated_at = now()
|
||||||
|
where key = 'receipt_hmac_key';
|
||||||
|
perform app.log_auth_event('receipt_key_rotated', null, null, '{}'::jsonb);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.rotate_receipt_key() from public;
|
||||||
|
grant execute on function app.rotate_receipt_key() to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Receipts table: one row per print of a receipt (originals + reprints).
|
||||||
|
-- Append-only.
|
||||||
|
-- =====================================================================
|
||||||
|
do $$ begin
|
||||||
|
create type app.receipt_kind as enum ('original', 'reprint', 'duplicate');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
create table if not exists app.receipts (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
txn_id uuid not null references app.transactions(id) on delete restrict,
|
||||||
|
kind app.receipt_kind not null default 'original',
|
||||||
|
pdf_url text, -- server-rendered PDF
|
||||||
|
qr_token text not null, -- embedded HMAC token
|
||||||
|
printed_at timestamptz not null default now(),
|
||||||
|
printed_by uuid not null references auth.users(id) default auth.uid(),
|
||||||
|
device_fingerprint text
|
||||||
|
);
|
||||||
|
create index if not exists idx_receipts_txn on app.receipts(txn_id);
|
||||||
|
|
||||||
|
create or replace function app._receipts_no_update_delete()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin raise exception 'receipts is append-only'; end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_receipts_freeze on app.receipts;
|
||||||
|
create trigger trg_receipts_freeze before update or delete on app.receipts
|
||||||
|
for each row execute function app._receipts_no_update_delete();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Customer notifications (SMS/email). Logged so an owner can confirm
|
||||||
|
-- the customer actually heard about the transaction.
|
||||||
|
-- =====================================================================
|
||||||
|
do $$ begin
|
||||||
|
create type app.notification_channel as enum ('sms','email','push');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type app.notification_status as enum
|
||||||
|
('queued','sent','delivered','failed');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
create table if not exists app.customer_notifications (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
txn_id uuid not null references app.transactions(id) on delete restrict,
|
||||||
|
channel app.notification_channel not null,
|
||||||
|
recipient text not null, -- phone or email
|
||||||
|
body_template text not null, -- 'omt_send_v1', 'recharge_v1', ...
|
||||||
|
status app.notification_status not null default 'queued',
|
||||||
|
provider_ref text, -- gateway message id
|
||||||
|
queued_at timestamptz not null default now(),
|
||||||
|
sent_at timestamptz,
|
||||||
|
delivered_at timestamptz,
|
||||||
|
failed_reason text,
|
||||||
|
created_by uuid not null references auth.users(id) default auth.uid()
|
||||||
|
);
|
||||||
|
create index if not exists idx_notif_txn on app.customer_notifications(txn_id);
|
||||||
|
create index if not exists idx_notif_recipient on app.customer_notifications(recipient, queued_at desc);
|
||||||
|
create index if not exists idx_notif_status on app.customer_notifications(status);
|
||||||
|
|
||||||
|
-- Append-only except for status transitions, which only the gateway
|
||||||
|
-- (running as a dedicated DB role outside `authenticated`) may apply.
|
||||||
|
create or replace function app._notif_guard()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
raise exception 'notifications cannot be deleted';
|
||||||
|
end if;
|
||||||
|
-- Allow status / timestamps / provider_ref / failed_reason updates.
|
||||||
|
if (new.id <> old.id
|
||||||
|
or new.txn_id <> old.txn_id
|
||||||
|
or new.channel <> old.channel
|
||||||
|
or new.recipient <> old.recipient
|
||||||
|
or new.body_template <> old.body_template
|
||||||
|
or new.queued_at <> old.queued_at
|
||||||
|
or new.created_by <> old.created_by) then
|
||||||
|
raise exception 'only delivery fields may change on a notification row';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_notif_guard on app.customer_notifications;
|
||||||
|
create trigger trg_notif_guard before update or delete on app.customer_notifications
|
||||||
|
for each row execute function app._notif_guard();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Evidence attachments (signatures, photos, ID scans).
|
||||||
|
-- Append-only. Visible to anyone who can see the parent txn.
|
||||||
|
-- =====================================================================
|
||||||
|
do $$ begin
|
||||||
|
create type app.evidence_kind as enum (
|
||||||
|
'customer_signature',
|
||||||
|
'id_photo',
|
||||||
|
'voided_paper_photo',
|
||||||
|
'omt_pos_slip',
|
||||||
|
'cancellation_photo',
|
||||||
|
'other'
|
||||||
|
);
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
create table if not exists app.transaction_evidence (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
txn_id uuid not null references app.transactions(id) on delete restrict,
|
||||||
|
kind app.evidence_kind not null,
|
||||||
|
file_url text not null,
|
||||||
|
file_sha256 text, -- hex digest of stored bytes
|
||||||
|
note text,
|
||||||
|
uploaded_at timestamptz not null default now(),
|
||||||
|
uploaded_by uuid not null references auth.users(id) default auth.uid()
|
||||||
|
);
|
||||||
|
create index if not exists idx_evidence_txn on app.transaction_evidence(txn_id, uploaded_at);
|
||||||
|
|
||||||
|
create or replace function app._evidence_no_update_delete()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin raise exception 'transaction_evidence is append-only'; end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_evidence_freeze on app.transaction_evidence;
|
||||||
|
create trigger trg_evidence_freeze before update or delete on app.transaction_evidence
|
||||||
|
for each row execute function app._evidence_no_update_delete();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- High-value evidence policy (vector #15 — fake cancellations,
|
||||||
|
-- vector #11 — manager-approved void of a printed receipt):
|
||||||
|
-- a deferred constraint trigger enforces, at COMMIT, that:
|
||||||
|
-- * any voided txn whose original status was 'completed' has at
|
||||||
|
-- least one evidence row of kind 'voided_paper_photo'.
|
||||||
|
-- * any large OMT_SEND / OMT_RECEIVE has a 'customer_signature' or
|
||||||
|
-- 'id_photo' evidence row.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app._txn_require_evidence()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
declare
|
||||||
|
th_warn_usd numeric;
|
||||||
|
th_warn_lbp numeric;
|
||||||
|
has_sig boolean;
|
||||||
|
has_void boolean;
|
||||||
|
begin
|
||||||
|
-- Only check on UPDATE-to-voided or on relevant high-value services.
|
||||||
|
if tg_op = 'UPDATE' and new.status = 'voided' and old.status = 'completed' then
|
||||||
|
select exists(
|
||||||
|
select 1 from app.transaction_evidence
|
||||||
|
where txn_id = new.id and kind = 'voided_paper_photo'
|
||||||
|
) into has_void;
|
||||||
|
if not has_void then
|
||||||
|
raise exception 'void of txn % requires a voided_paper_photo evidence row', new.id;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if (tg_op = 'INSERT')
|
||||||
|
and new.service_code in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE') then
|
||||||
|
select daily_amount_warn into th_warn_usd from app.kyc_thresholds
|
||||||
|
where service_code = new.service_code and currency = 'USD';
|
||||||
|
select daily_amount_warn into th_warn_lbp from app.kyc_thresholds
|
||||||
|
where service_code = new.service_code and currency = 'LBP';
|
||||||
|
if (new.gross_usd >= coalesce(th_warn_usd, 1e18))
|
||||||
|
or (new.gross_lbp >= coalesce(th_warn_lbp, 1e18)) then
|
||||||
|
select exists(
|
||||||
|
select 1 from app.transaction_evidence
|
||||||
|
where txn_id = new.id
|
||||||
|
and kind in ('customer_signature','id_photo','omt_pos_slip')
|
||||||
|
) into has_sig;
|
||||||
|
if not has_sig then
|
||||||
|
raise exception
|
||||||
|
'high-value % txn % requires customer_signature or id_photo evidence',
|
||||||
|
new.service_code, new.id;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_txn_require_evidence on app.transactions;
|
||||||
|
create constraint trigger trg_txn_require_evidence
|
||||||
|
after insert or update on app.transactions
|
||||||
|
deferrable initially deferred
|
||||||
|
for each row execute function app._txn_require_evidence();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Convenience: a SECURITY DEFINER `record_receipt_print` so the
|
||||||
|
-- printing service inside the app issues a fresh QR token and logs the
|
||||||
|
-- print in one go.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.record_receipt_print(
|
||||||
|
p_txn_id uuid,
|
||||||
|
p_kind app.receipt_kind default 'original',
|
||||||
|
p_device text default null
|
||||||
|
)
|
||||||
|
returns table (receipt_id uuid, qr_token text, pdf_url text)
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
tok text;
|
||||||
|
rid uuid;
|
||||||
|
begin
|
||||||
|
if not app._can_see_txn(p_txn_id) then
|
||||||
|
raise exception 'not authorized';
|
||||||
|
end if;
|
||||||
|
tok := app.receipt_token(p_txn_id);
|
||||||
|
insert into app.receipts(txn_id, kind, qr_token, device_fingerprint)
|
||||||
|
values (p_txn_id, p_kind, tok, p_device)
|
||||||
|
returning id into rid;
|
||||||
|
receipt_id := rid;
|
||||||
|
qr_token := tok;
|
||||||
|
pdf_url := null; -- the PDF rendering service will patch this
|
||||||
|
-- via record_receipt_pdf below.
|
||||||
|
return next;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.record_receipt_print(uuid, app.receipt_kind, text) from public;
|
||||||
|
grant execute on function app.record_receipt_print(uuid, app.receipt_kind, text) to authenticated;
|
||||||
|
|
||||||
|
-- The PDF renderer fills in pdf_url after upload to storage. The
|
||||||
|
-- `receipts` table is append-only via trigger, so we expose a tiny
|
||||||
|
-- definer function that allows just this one column update.
|
||||||
|
create or replace function app.record_receipt_pdf(
|
||||||
|
p_receipt_id uuid,
|
||||||
|
p_pdf_url text
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
-- Allow direct UPDATE only via this function.
|
||||||
|
perform set_config('app.receipts_internal', 'on', true);
|
||||||
|
update app.receipts set pdf_url = p_pdf_url where id = p_receipt_id and pdf_url is null;
|
||||||
|
perform set_config('app.receipts_internal', 'off', true);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.record_receipt_pdf(uuid, text) from public;
|
||||||
|
grant execute on function app.record_receipt_pdf(uuid, text) to authenticated;
|
||||||
|
|
||||||
|
-- Adjust the receipts-freeze trigger to allow the definer path through.
|
||||||
|
create or replace function app._receipts_no_update_delete()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
raise exception 'receipts cannot be deleted';
|
||||||
|
end if;
|
||||||
|
if current_setting('app.receipts_internal', true) is distinct from 'on' then
|
||||||
|
raise exception 'direct UPDATE on receipts is not allowed';
|
||||||
|
end if;
|
||||||
|
if (new.id <> old.id or new.txn_id <> old.txn_id or new.kind <> old.kind
|
||||||
|
or new.qr_token <> old.qr_token or new.printed_at <> old.printed_at
|
||||||
|
or new.printed_by <> old.printed_by) then
|
||||||
|
raise exception 'only pdf_url may change on a receipt row';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Customer-facing notification queue helper. The actual SMS gateway
|
||||||
|
-- (a worker process running as a dedicated role) will pick up rows
|
||||||
|
-- where status='queued' and update status to sent/delivered/failed.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.queue_customer_notification(
|
||||||
|
p_txn uuid,
|
||||||
|
p_channel app.notification_channel,
|
||||||
|
p_recipient text,
|
||||||
|
p_template text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare nid uuid;
|
||||||
|
begin
|
||||||
|
if not app._can_see_txn(p_txn) then
|
||||||
|
raise exception 'not authorized';
|
||||||
|
end if;
|
||||||
|
insert into app.customer_notifications(txn_id, channel, recipient, body_template)
|
||||||
|
values (p_txn, p_channel, p_recipient, p_template)
|
||||||
|
returning id into nid;
|
||||||
|
return nid;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.queue_customer_notification(uuid, app.notification_channel, text, text) from public;
|
||||||
|
grant execute on function app.queue_customer_notification(uuid, app.notification_channel, text, text) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- RLS
|
||||||
|
-- =====================================================================
|
||||||
|
alter table app.receipts enable row level security;
|
||||||
|
alter table app.customer_notifications enable row level security;
|
||||||
|
alter table app.transaction_evidence enable row level security;
|
||||||
|
alter table app.receipts force row level security;
|
||||||
|
alter table app.customer_notifications force row level security;
|
||||||
|
alter table app.transaction_evidence force row level security;
|
||||||
|
|
||||||
|
revoke update, delete on app.receipts from authenticated;
|
||||||
|
revoke delete on app.customer_notifications from authenticated;
|
||||||
|
revoke update, delete on app.transaction_evidence from authenticated;
|
||||||
|
|
||||||
|
drop policy if exists receipts_select on app.receipts;
|
||||||
|
create policy receipts_select on app.receipts
|
||||||
|
for select to authenticated using (app._can_see_txn(txn_id));
|
||||||
|
|
||||||
|
drop policy if exists receipts_insert on app.receipts;
|
||||||
|
create policy receipts_insert on app.receipts
|
||||||
|
for insert to authenticated
|
||||||
|
with check (app._can_see_txn(txn_id));
|
||||||
|
grant select, insert on app.receipts to authenticated;
|
||||||
|
|
||||||
|
drop policy if exists notif_select on app.customer_notifications;
|
||||||
|
create policy notif_select on app.customer_notifications
|
||||||
|
for select to authenticated using (app._can_see_txn(txn_id));
|
||||||
|
drop policy if exists notif_insert on app.customer_notifications;
|
||||||
|
create policy notif_insert on app.customer_notifications
|
||||||
|
for insert to authenticated with check (app._can_see_txn(txn_id));
|
||||||
|
grant select, insert on app.customer_notifications to authenticated;
|
||||||
|
-- The gateway worker role gets UPDATE separately; not here.
|
||||||
|
|
||||||
|
drop policy if exists evidence_select on app.transaction_evidence;
|
||||||
|
create policy evidence_select on app.transaction_evidence
|
||||||
|
for select to authenticated using (app._can_see_txn(txn_id));
|
||||||
|
|
||||||
|
-- Evidence insert allowed for: shift owner during open shift OR any
|
||||||
|
-- manager/owner of the shop (so a manager can attach voided-paper
|
||||||
|
-- photos when approving a void after the cashier has closed shift).
|
||||||
|
drop policy if exists evidence_insert on app.transaction_evidence;
|
||||||
|
create policy evidence_insert on app.transaction_evidence
|
||||||
|
for insert to authenticated
|
||||||
|
with check (
|
||||||
|
exists (
|
||||||
|
select 1 from app.transactions t
|
||||||
|
join app.shifts s on s.id = t.shift_id
|
||||||
|
where t.id = transaction_evidence.txn_id
|
||||||
|
and (
|
||||||
|
(t.user_id = auth.uid() and s.status = 'open')
|
||||||
|
or app.has_any_role_in_shop(t.shop_id,
|
||||||
|
array['manager','owner']::app.business_role[])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
grant select, insert on app.transaction_evidence to authenticated;
|
||||||
|
|
||||||
|
-- End migration 0007 ----------------------------------------------------
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0008 — Refunds, price overrides, void hardening
|
||||||
|
-- (roadmap Step 9).
|
||||||
|
--
|
||||||
|
-- Voids already exist (0003). This migration adds:
|
||||||
|
-- * Refunds as their own ledger row, never as a reverse-edit of the
|
||||||
|
-- original (vector #11).
|
||||||
|
-- * Price overrides on goods sales: only manager + PIN, capped at
|
||||||
|
-- a per-shop `max_discount_pct`, fully audited (vector #12).
|
||||||
|
-- * Void/refund/override summary views per cashier and per
|
||||||
|
-- (cashier, manager) pair to expose collusion (vector #18).
|
||||||
|
--
|
||||||
|
-- Threat-model rows addressed: 11, 12, 18.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Refunds
|
||||||
|
-- =====================================================================
|
||||||
|
-- A refund is recorded as a transaction with service_code 'REFUND'
|
||||||
|
-- linked back to the original txn via app.refunds. Money signs are kept
|
||||||
|
-- positive on the row; cash flows are negative for the shop and are
|
||||||
|
-- reflected via paired cash_movements / float_movements just like sales.
|
||||||
|
insert into app.services(code, name, category) values
|
||||||
|
('REFUND', 'Customer Refund', 'refund')
|
||||||
|
on conflict (code) do nothing;
|
||||||
|
|
||||||
|
create table if not exists app.refunds (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
refund_txn_id uuid not null references app.transactions(id) on delete restrict,
|
||||||
|
original_txn_id uuid not null references app.transactions(id) on delete restrict,
|
||||||
|
reason text not null,
|
||||||
|
manager_approved_by uuid not null references auth.users(id),
|
||||||
|
amount_usd numeric(14,2) not null default 0 check (amount_usd >= 0),
|
||||||
|
amount_lbp numeric(18,0) not null default 0 check (amount_lbp >= 0),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
constraint refunds_no_self check (refund_txn_id <> original_txn_id),
|
||||||
|
constraint refunds_unique_refund_txn unique (refund_txn_id)
|
||||||
|
);
|
||||||
|
create index if not exists idx_refunds_original on app.refunds(original_txn_id);
|
||||||
|
|
||||||
|
create or replace function app._refunds_no_update_delete()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin raise exception 'refunds is append-only'; end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_refunds_freeze on app.refunds;
|
||||||
|
create trigger trg_refunds_freeze before update or delete on app.refunds
|
||||||
|
for each row execute function app._refunds_no_update_delete();
|
||||||
|
|
||||||
|
-- The single legal way to issue a refund. Enforces manager role + PIN,
|
||||||
|
-- amount ≤ original (minus any prior refunds), original is completed,
|
||||||
|
-- and creates the refund txn + linkage atomically.
|
||||||
|
create or replace function app.issue_refund(
|
||||||
|
p_original_txn uuid,
|
||||||
|
p_amount_usd numeric,
|
||||||
|
p_amount_lbp numeric,
|
||||||
|
p_reason text,
|
||||||
|
p_manager_pin text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
o app.transactions%rowtype;
|
||||||
|
s app.shifts%rowtype;
|
||||||
|
prior_usd numeric := 0;
|
||||||
|
prior_lbp numeric := 0;
|
||||||
|
refund_id uuid;
|
||||||
|
refund_txn uuid;
|
||||||
|
begin
|
||||||
|
if p_amount_usd is null or p_amount_lbp is null
|
||||||
|
or p_amount_usd < 0 or p_amount_lbp < 0
|
||||||
|
or (p_amount_usd = 0 and p_amount_lbp = 0) then
|
||||||
|
raise exception 'refund amount must be >= 0 and at least one currency > 0';
|
||||||
|
end if;
|
||||||
|
if p_reason is null or length(btrim(p_reason)) < 5 then
|
||||||
|
raise exception 'reason >= 5 chars required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into o from app.transactions where id = p_original_txn;
|
||||||
|
if o.id is null then raise exception 'original txn not found'; end if;
|
||||||
|
if o.status <> 'completed' then
|
||||||
|
raise exception 'cannot refund a % transaction', o.status;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Caller must be a manager in the same shop and prove it via PIN.
|
||||||
|
if not app.has_role_in_shop(o.shop_id, 'manager') then
|
||||||
|
raise exception 'manager role required to issue a refund';
|
||||||
|
end if;
|
||||||
|
if not app.verify_my_pin(p_manager_pin) then
|
||||||
|
raise exception 'invalid manager PIN';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Refund must be issued on the manager's currently open shift in
|
||||||
|
-- this shop (so the cash leaves the right till).
|
||||||
|
select * into s from app.shifts
|
||||||
|
where shop_id = o.shop_id and status = 'open' and user_id = auth.uid()
|
||||||
|
limit 1;
|
||||||
|
if s.id is null then
|
||||||
|
raise exception 'manager has no open shift in shop % to issue the refund from', o.shop_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Prior refunds against this original.
|
||||||
|
select coalesce(sum(amount_usd),0), coalesce(sum(amount_lbp),0)
|
||||||
|
into prior_usd, prior_lbp
|
||||||
|
from app.refunds where original_txn_id = p_original_txn;
|
||||||
|
|
||||||
|
if (prior_usd + p_amount_usd) > o.gross_usd then
|
||||||
|
raise exception 'refund USD exceeds remaining refundable amount (% > %)',
|
||||||
|
prior_usd + p_amount_usd, o.gross_usd;
|
||||||
|
end if;
|
||||||
|
if (prior_lbp + p_amount_lbp) > o.gross_lbp then
|
||||||
|
raise exception 'refund LBP exceeds remaining refundable amount (% > %)',
|
||||||
|
prior_lbp + p_amount_lbp, o.gross_lbp;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Create the refund transaction. The standard txn triggers (server
|
||||||
|
-- stamping, hash chain, sequence) all apply.
|
||||||
|
insert into app.transactions(
|
||||||
|
shift_id, shop_id, till_id, user_id, service_code,
|
||||||
|
gross_usd, gross_lbp, fee_usd, fee_lbp,
|
||||||
|
payment_method, notes
|
||||||
|
) values (
|
||||||
|
s.id, o.shop_id, s.till_id, auth.uid(), 'REFUND',
|
||||||
|
p_amount_usd, p_amount_lbp, 0, 0,
|
||||||
|
o.payment_method, 'refund of ' || o.id::text || ' — ' || p_reason
|
||||||
|
) returning id into refund_txn;
|
||||||
|
|
||||||
|
insert into app.refunds(refund_txn_id, original_txn_id, reason,
|
||||||
|
manager_approved_by, amount_usd, amount_lbp)
|
||||||
|
values (refund_txn, p_original_txn, p_reason, auth.uid(),
|
||||||
|
p_amount_usd, p_amount_lbp)
|
||||||
|
returning id into refund_id;
|
||||||
|
|
||||||
|
-- Cash leaves the till (negative cash_movements). Currency split.
|
||||||
|
if p_amount_usd > 0 then
|
||||||
|
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
|
||||||
|
values (s.id, 'payout_out', 'USD', -p_amount_usd, refund_txn, 'refund');
|
||||||
|
end if;
|
||||||
|
if p_amount_lbp > 0 then
|
||||||
|
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
|
||||||
|
values (s.id, 'payout_out', 'LBP', -p_amount_lbp, refund_txn, 'refund');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform app.log_auth_event('refund_issued', o.shop_id, null,
|
||||||
|
jsonb_build_object('original', p_original_txn, 'refund_txn', refund_txn,
|
||||||
|
'amount_usd', p_amount_usd, 'amount_lbp', p_amount_lbp));
|
||||||
|
|
||||||
|
return refund_txn;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function app.issue_refund(uuid, numeric, numeric, text, text) from public;
|
||||||
|
grant execute on function app.issue_refund(uuid, numeric, numeric, text, text) to authenticated;
|
||||||
|
|
||||||
|
-- The REFUND service does not need a child detail row; teach the
|
||||||
|
-- detail-required check to skip it.
|
||||||
|
create or replace function app.txn_require_detail()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare ok boolean;
|
||||||
|
begin
|
||||||
|
if new.status <> 'completed' then return null; end if;
|
||||||
|
case new.service_code
|
||||||
|
when 'OMT_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
|
||||||
|
when 'OMT_RECEIVE' then select exists(select 1 from app.omt_receive_details where txn_id = new.id) into ok;
|
||||||
|
when 'OMT_BILL' then select exists(select 1 from app.bill_payment_details where txn_id = new.id) into ok;
|
||||||
|
when 'WU_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
|
||||||
|
when 'WU_RECEIVE' then select exists(select 1 from app.omt_receive_details where txn_id = new.id) into ok;
|
||||||
|
when 'ALFA_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
|
||||||
|
when 'TOUCH_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
|
||||||
|
when 'OGERO_RECHARGE' then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
|
||||||
|
when 'INTERNET_RECHARGE'then select exists(select 1 from app.recharge_details where txn_id = new.id) into ok;
|
||||||
|
when 'SIM_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
|
||||||
|
when 'PHONE_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
|
||||||
|
when 'ACCESSORY_SALE' then select exists(select 1 from app.goods_sale_details where txn_id = new.id) into ok;
|
||||||
|
when 'REPAIR' then select exists(select 1 from app.repair_details where txn_id = new.id) into ok;
|
||||||
|
when 'REFUND' then select exists(select 1 from app.refunds where refund_txn_id = new.id) into ok;
|
||||||
|
else ok := true;
|
||||||
|
end case;
|
||||||
|
if not ok then
|
||||||
|
raise exception 'transaction % (service %) is missing its detail/refund row',
|
||||||
|
new.id, new.service_code;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Price overrides on goods sales
|
||||||
|
-- =====================================================================
|
||||||
|
-- Per-shop policy: maximum discount % a manager can authorize without
|
||||||
|
-- escalating to owner.
|
||||||
|
create table if not exists app.shop_pricing_policy (
|
||||||
|
shop_id uuid primary key references app.shops(id) on delete cascade,
|
||||||
|
max_discount_pct numeric(5,2) not null default 10.00 check (max_discount_pct between 0 and 50),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
updated_by uuid references auth.users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Append-only audit table for every override.
|
||||||
|
create table if not exists app.price_overrides (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
txn_id uuid not null references app.transactions(id) on delete restrict,
|
||||||
|
sku text not null references app.items(sku),
|
||||||
|
list_price_usd numeric(14,2) not null check (list_price_usd > 0),
|
||||||
|
sold_price_usd numeric(14,2) not null check (sold_price_usd >= 0),
|
||||||
|
discount_pct numeric(6,2) not null,
|
||||||
|
reason text not null,
|
||||||
|
approved_by uuid not null references auth.users(id),
|
||||||
|
approver_role app.business_role not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
constraint price_override_unique_per_txn_sku unique (txn_id, sku)
|
||||||
|
);
|
||||||
|
create index if not exists idx_price_ovr_txn on app.price_overrides(txn_id);
|
||||||
|
|
||||||
|
create or replace function app._price_overrides_no_update_delete()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin raise exception 'price_overrides is append-only'; end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_price_ovr_freeze on app.price_overrides;
|
||||||
|
create trigger trg_price_ovr_freeze before update or delete on app.price_overrides
|
||||||
|
for each row execute function app._price_overrides_no_update_delete();
|
||||||
|
|
||||||
|
-- Definer function: the only legal way to authorize a discount.
|
||||||
|
-- Returns the approved sold_price; caller passes it into the goods
|
||||||
|
-- sale flow.
|
||||||
|
create or replace function app.authorize_price_override(
|
||||||
|
p_txn_id uuid,
|
||||||
|
p_sku text,
|
||||||
|
p_sold_price numeric,
|
||||||
|
p_reason text,
|
||||||
|
p_manager_pin text
|
||||||
|
) returns numeric
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
t app.transactions%rowtype;
|
||||||
|
i app.items%rowtype;
|
||||||
|
pol app.shop_pricing_policy%rowtype;
|
||||||
|
pct numeric;
|
||||||
|
role_used app.business_role;
|
||||||
|
begin
|
||||||
|
if p_sold_price is null or p_sold_price < 0 then
|
||||||
|
raise exception 'sold price must be >= 0';
|
||||||
|
end if;
|
||||||
|
if p_reason is null or length(btrim(p_reason)) < 5 then
|
||||||
|
raise exception 'reason >= 5 chars required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into t from app.transactions where id = p_txn_id;
|
||||||
|
if t.id is null then raise exception 'txn not found'; end if;
|
||||||
|
if t.status <> 'completed' then
|
||||||
|
raise exception 'cannot override price on a % transaction', t.status;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into i from app.items where sku = p_sku;
|
||||||
|
if i.sku is null then raise exception 'sku not found'; end if;
|
||||||
|
|
||||||
|
if p_sold_price > i.price_usd then
|
||||||
|
raise exception 'sold price > list price; not an override';
|
||||||
|
end if;
|
||||||
|
pct := round(((i.price_usd - p_sold_price) / nullif(i.price_usd,0)) * 100.0, 2);
|
||||||
|
|
||||||
|
-- Caller must be manager or owner in this shop AND give a valid PIN.
|
||||||
|
if app.has_role_in_shop(t.shop_id, 'owner') then
|
||||||
|
role_used := 'owner';
|
||||||
|
elsif app.has_role_in_shop(t.shop_id, 'manager') then
|
||||||
|
role_used := 'manager';
|
||||||
|
else
|
||||||
|
raise exception 'manager or owner role required to override price';
|
||||||
|
end if;
|
||||||
|
if not app.verify_my_pin(p_manager_pin) then
|
||||||
|
raise exception 'invalid PIN';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Check shop policy ceiling for managers. Owners can go beyond.
|
||||||
|
select * into pol from app.shop_pricing_policy where shop_id = t.shop_id;
|
||||||
|
if not found then
|
||||||
|
insert into app.shop_pricing_policy(shop_id) values (t.shop_id)
|
||||||
|
on conflict (shop_id) do nothing;
|
||||||
|
select * into pol from app.shop_pricing_policy where shop_id = t.shop_id;
|
||||||
|
end if;
|
||||||
|
if role_used = 'manager' and pct > pol.max_discount_pct then
|
||||||
|
raise exception 'discount % %% exceeds shop ceiling % %% (owner approval needed)',
|
||||||
|
pct, pol.max_discount_pct;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into app.price_overrides(
|
||||||
|
txn_id, sku, list_price_usd, sold_price_usd, discount_pct,
|
||||||
|
reason, approved_by, approver_role
|
||||||
|
) values (
|
||||||
|
p_txn_id, p_sku, i.price_usd, p_sold_price, pct,
|
||||||
|
p_reason, auth.uid(), role_used
|
||||||
|
);
|
||||||
|
|
||||||
|
perform app.log_auth_event('price_override', t.shop_id, null,
|
||||||
|
jsonb_build_object('txn', p_txn_id, 'sku', p_sku, 'pct', pct,
|
||||||
|
'role', role_used));
|
||||||
|
|
||||||
|
return p_sold_price;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.authorize_price_override(uuid, text, numeric, text, text) from public;
|
||||||
|
grant execute on function app.authorize_price_override(uuid, text, numeric, text, text) to authenticated;
|
||||||
|
|
||||||
|
-- A goods_sale_details row priced below list price MUST have a matching
|
||||||
|
-- price_overrides row (deferred so the override can be inserted in the
|
||||||
|
-- same transaction).
|
||||||
|
create or replace function app._goods_sale_require_override_if_discounted()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
declare i app.items%rowtype;
|
||||||
|
has_ovr boolean;
|
||||||
|
begin
|
||||||
|
select * into i from app.items where sku = new.sku;
|
||||||
|
if i.sku is null then return null; end if; -- FK will catch it
|
||||||
|
if new.unit_price_usd < i.price_usd then
|
||||||
|
select exists(
|
||||||
|
select 1 from app.price_overrides
|
||||||
|
where txn_id = new.txn_id and sku = new.sku
|
||||||
|
and sold_price_usd = new.unit_price_usd
|
||||||
|
) into has_ovr;
|
||||||
|
if not has_ovr then
|
||||||
|
raise exception
|
||||||
|
'goods sale of % below list price (% < %) requires an authorized price override',
|
||||||
|
new.sku, new.unit_price_usd, i.price_usd;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_goods_sale_require_override on app.goods_sale_details;
|
||||||
|
create constraint trigger trg_goods_sale_require_override
|
||||||
|
after insert on app.goods_sale_details
|
||||||
|
deferrable initially deferred
|
||||||
|
for each row execute function app._goods_sale_require_override_if_discounted();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Reporting views — collusion / abuse signals (vector #18)
|
||||||
|
-- =====================================================================
|
||||||
|
-- Daily voids per cashier
|
||||||
|
create or replace view app.v_voids_by_cashier_day as
|
||||||
|
select t.shop_id,
|
||||||
|
(t.occurred_at at time zone 'UTC')::date as day,
|
||||||
|
t.user_id as cashier_id,
|
||||||
|
count(*) as void_count,
|
||||||
|
sum(t.gross_usd) as voided_usd,
|
||||||
|
sum(t.gross_lbp) as voided_lbp
|
||||||
|
from app.transactions t
|
||||||
|
where t.status = 'voided'
|
||||||
|
group by 1,2,3;
|
||||||
|
|
||||||
|
-- Daily refunds per cashier (the cashier of the original txn)
|
||||||
|
create or replace view app.v_refunds_by_original_cashier_day as
|
||||||
|
select o.shop_id,
|
||||||
|
(r.created_at at time zone 'UTC')::date as day,
|
||||||
|
o.user_id as original_cashier_id,
|
||||||
|
r.manager_approved_by as approving_manager_id,
|
||||||
|
count(*) as refund_count,
|
||||||
|
sum(r.amount_usd) as refunded_usd,
|
||||||
|
sum(r.amount_lbp) as refunded_lbp
|
||||||
|
from app.refunds r
|
||||||
|
join app.transactions o on o.id = r.original_txn_id
|
||||||
|
group by 1,2,3,4;
|
||||||
|
|
||||||
|
-- Cashier–manager pairs with high void+refund volume (collusion signal)
|
||||||
|
create or replace view app.v_void_refund_pairs as
|
||||||
|
select t.shop_id,
|
||||||
|
t.user_id as cashier_id,
|
||||||
|
t.void_approved_by as manager_id,
|
||||||
|
date_trunc('week', t.voided_at) as week_bucket,
|
||||||
|
count(*) as void_count,
|
||||||
|
sum(t.gross_usd) as voided_usd
|
||||||
|
from app.transactions t
|
||||||
|
where t.status = 'voided' and t.void_approved_by is not null
|
||||||
|
group by 1,2,3,4
|
||||||
|
having count(*) >= 5;
|
||||||
|
|
||||||
|
-- Price-override volume by approver
|
||||||
|
create or replace view app.v_overrides_by_approver_day as
|
||||||
|
select t.shop_id,
|
||||||
|
(po.created_at at time zone 'UTC')::date as day,
|
||||||
|
po.approved_by,
|
||||||
|
po.approver_role,
|
||||||
|
count(*) as override_count,
|
||||||
|
sum(po.list_price_usd - po.sold_price_usd) as discount_total_usd,
|
||||||
|
avg(po.discount_pct) as avg_discount_pct
|
||||||
|
from app.price_overrides po
|
||||||
|
join app.transactions t on t.id = po.txn_id
|
||||||
|
group by 1,2,3,4;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- RLS
|
||||||
|
-- =====================================================================
|
||||||
|
alter table app.refunds enable row level security;
|
||||||
|
alter table app.shop_pricing_policy enable row level security;
|
||||||
|
alter table app.price_overrides enable row level security;
|
||||||
|
alter table app.refunds force row level security;
|
||||||
|
alter table app.shop_pricing_policy force row level security;
|
||||||
|
alter table app.price_overrides force row level security;
|
||||||
|
|
||||||
|
revoke insert, update, delete on app.refunds from authenticated;
|
||||||
|
revoke insert, update, delete on app.price_overrides from authenticated;
|
||||||
|
revoke insert, update, delete on app.shop_pricing_policy from authenticated;
|
||||||
|
|
||||||
|
drop policy if exists refunds_select on app.refunds;
|
||||||
|
create policy refunds_select on app.refunds
|
||||||
|
for select to authenticated
|
||||||
|
using (app._can_see_txn(refund_txn_id));
|
||||||
|
grant select on app.refunds to authenticated;
|
||||||
|
|
||||||
|
drop policy if exists pricing_policy_select on app.shop_pricing_policy;
|
||||||
|
create policy pricing_policy_select on app.shop_pricing_policy
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['owner','manager','auditor']::app.business_role[])
|
||||||
|
);
|
||||||
|
grant select on app.shop_pricing_policy to authenticated;
|
||||||
|
|
||||||
|
drop policy if exists price_ovr_select on app.price_overrides;
|
||||||
|
create policy price_ovr_select on app.price_overrides
|
||||||
|
for select to authenticated
|
||||||
|
using (app._can_see_txn(txn_id));
|
||||||
|
grant select on app.price_overrides to authenticated;
|
||||||
|
|
||||||
|
-- End migration 0008 ----------------------------------------------------
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0009 — External reconciliation (roadmap Step 10).
|
||||||
|
--
|
||||||
|
-- The strongest fraud control is an external source of truth. Every
|
||||||
|
-- provider (OMT, Alfa, touch, Ogero, the bank, whish, the card terminal)
|
||||||
|
-- publishes a settlement statement; we import it line by line and match
|
||||||
|
-- each line to a local transaction by `external_ref`. Mismatches go to
|
||||||
|
-- `reconciliation_exceptions` and block month-close.
|
||||||
|
--
|
||||||
|
-- Threat-model rows addressed: 1, 5, 8, 21, 24.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Settlement headers + lines
|
||||||
|
-- =====================================================================
|
||||||
|
do $$ begin
|
||||||
|
create type app.settlement_provider as enum (
|
||||||
|
'OMT', 'ALFA', 'TOUCH', 'OGERO', 'WHISH', 'CARD_TERMINAL', 'BANK', 'WU'
|
||||||
|
);
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type app.settlement_status as enum (
|
||||||
|
'imported', -- file parsed; matching not started
|
||||||
|
'matching', -- run is in progress
|
||||||
|
'matched', -- all lines matched, ready for sign-off
|
||||||
|
'has_exceptions', -- at least one line still unresolved
|
||||||
|
'closed' -- owner-signed off, immutable
|
||||||
|
);
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type app.settlement_line_status as enum (
|
||||||
|
'unmatched', -- no candidate found yet
|
||||||
|
'matched', -- exactly one candidate, amounts agree
|
||||||
|
'amount_mismatch', -- candidate found but money differs
|
||||||
|
'duplicate', -- the same external_ref already used elsewhere
|
||||||
|
'missing_local', -- provider has it; we don't
|
||||||
|
'extra_local' -- we have it; provider doesn't
|
||||||
|
);
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
create table if not exists app.settlements (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
shop_id uuid not null references app.shops(id) on delete restrict,
|
||||||
|
provider app.settlement_provider not null,
|
||||||
|
period_start date not null,
|
||||||
|
period_end date not null,
|
||||||
|
file_url text,
|
||||||
|
file_sha256 text,
|
||||||
|
total_amount numeric(20,2), -- as reported by provider
|
||||||
|
total_currency app.currency_code,
|
||||||
|
status app.settlement_status not null default 'imported',
|
||||||
|
imported_at timestamptz not null default now(),
|
||||||
|
imported_by uuid not null references auth.users(id) default auth.uid(),
|
||||||
|
closed_at timestamptz,
|
||||||
|
closed_by uuid references auth.users(id),
|
||||||
|
notes text,
|
||||||
|
constraint settlements_period_ok check (period_end >= period_start)
|
||||||
|
);
|
||||||
|
create index if not exists idx_settlements_shop_period
|
||||||
|
on app.settlements(shop_id, provider, period_start);
|
||||||
|
|
||||||
|
create table if not exists app.settlement_lines (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
settlement_id uuid not null references app.settlements(id) on delete cascade,
|
||||||
|
-- Raw fields as parsed from the provider file:
|
||||||
|
external_ref text not null, -- provider txn id / receipt no
|
||||||
|
occurred_at timestamptz,
|
||||||
|
amount numeric(20,2) not null,
|
||||||
|
currency app.currency_code not null,
|
||||||
|
fee numeric(20,2),
|
||||||
|
commission numeric(20,2),
|
||||||
|
raw jsonb, -- the original parsed row
|
||||||
|
|
||||||
|
-- Match output:
|
||||||
|
matched_txn_id uuid references app.transactions(id),
|
||||||
|
status app.settlement_line_status not null default 'unmatched',
|
||||||
|
matched_at timestamptz,
|
||||||
|
-- A natural key per provider statement keeps imports idempotent.
|
||||||
|
unique (settlement_id, external_ref)
|
||||||
|
);
|
||||||
|
create index if not exists idx_settle_line_status on app.settlement_lines(settlement_id, status);
|
||||||
|
create index if not exists idx_settle_line_ref on app.settlement_lines(external_ref);
|
||||||
|
|
||||||
|
-- Exceptions queue. Every non-matched line generates a row here so the
|
||||||
|
-- owner has a single place to clear before closing the period.
|
||||||
|
create table if not exists app.reconciliation_exceptions (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
settlement_id uuid not null references app.settlements(id) on delete cascade,
|
||||||
|
line_id uuid references app.settlement_lines(id) on delete cascade,
|
||||||
|
txn_id uuid references app.transactions(id),
|
||||||
|
type app.settlement_line_status not null,
|
||||||
|
detail text,
|
||||||
|
resolved_at timestamptz,
|
||||||
|
resolved_by uuid references auth.users(id),
|
||||||
|
resolution_note text,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
create index if not exists idx_recon_exc_open
|
||||||
|
on app.reconciliation_exceptions(settlement_id) where resolved_at is null;
|
||||||
|
|
||||||
|
-- Late FK from float_movements (declared in 0005).
|
||||||
|
alter table app.float_movements
|
||||||
|
drop constraint if exists float_mov_settlement_fk;
|
||||||
|
alter table app.float_movements
|
||||||
|
add constraint float_mov_settlement_fk
|
||||||
|
foreign key (ref_settlement_id) references app.settlements(id) on delete restrict;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Append-only behaviour where it matters
|
||||||
|
-- =====================================================================
|
||||||
|
-- Settlements: status moves are owner-driven via functions below.
|
||||||
|
create or replace function app._settlements_guard()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
raise exception 'settlements cannot be deleted';
|
||||||
|
end if;
|
||||||
|
if old.status = 'closed' then
|
||||||
|
raise exception 'settlement % is closed and immutable', old.id;
|
||||||
|
end if;
|
||||||
|
if current_setting('app.settle_internal', true) is distinct from 'on' then
|
||||||
|
raise exception 'direct UPDATE on settlements is not allowed; use app.* functions';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_settlements_guard on app.settlements;
|
||||||
|
create trigger trg_settlements_guard before update or delete on app.settlements
|
||||||
|
for each row execute function app._settlements_guard();
|
||||||
|
|
||||||
|
-- Lines: insert at import time, matched in place by definer functions.
|
||||||
|
create or replace function app._settle_lines_guard()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
declare s app.settlements%rowtype;
|
||||||
|
begin
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
raise exception 'settlement_lines cannot be deleted';
|
||||||
|
end if;
|
||||||
|
select * into s from app.settlements where id = coalesce(new.settlement_id, old.settlement_id);
|
||||||
|
if s.status = 'closed' then
|
||||||
|
raise exception 'cannot modify lines of a closed settlement';
|
||||||
|
end if;
|
||||||
|
if current_setting('app.settle_internal', true) is distinct from 'on' then
|
||||||
|
raise exception 'direct UPDATE on settlement_lines is not allowed';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_settle_lines_guard on app.settlement_lines;
|
||||||
|
create trigger trg_settle_lines_guard before update or delete on app.settlement_lines
|
||||||
|
for each row execute function app._settle_lines_guard();
|
||||||
|
|
||||||
|
-- Exceptions: insert by matcher; resolution via definer.
|
||||||
|
create or replace function app._recon_exc_guard()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
raise exception 'reconciliation_exceptions cannot be deleted';
|
||||||
|
end if;
|
||||||
|
if current_setting('app.settle_internal', true) is distinct from 'on' then
|
||||||
|
raise exception 'direct UPDATE on reconciliation_exceptions is not allowed';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_recon_exc_guard on app.reconciliation_exceptions;
|
||||||
|
create trigger trg_recon_exc_guard before update or delete on app.reconciliation_exceptions
|
||||||
|
for each row execute function app._recon_exc_guard();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Provider → service code map. Used by the matcher to know which local
|
||||||
|
-- service rows are eligible candidates for a given settlement file.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app._provider_service_codes(p app.settlement_provider)
|
||||||
|
returns text[]
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
as $$
|
||||||
|
select case p
|
||||||
|
when 'OMT' then array['OMT_SEND','OMT_RECEIVE','OMT_BILL']
|
||||||
|
when 'ALFA' then array['ALFA_RECHARGE']
|
||||||
|
when 'TOUCH' then array['TOUCH_RECHARGE']
|
||||||
|
when 'OGERO' then array['OGERO_RECHARGE','INTERNET_RECHARGE']
|
||||||
|
when 'WU' then array['WU_SEND','WU_RECEIVE']
|
||||||
|
-- BANK / WHISH / CARD_TERMINAL match by payment_method instead.
|
||||||
|
else null
|
||||||
|
end::text[];
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Import + match
|
||||||
|
-- =====================================================================
|
||||||
|
-- Insert one parsed row from the provider file. Idempotent by
|
||||||
|
-- (settlement_id, external_ref).
|
||||||
|
create or replace function app.add_settlement_line(
|
||||||
|
p_settlement uuid,
|
||||||
|
p_external_ref text,
|
||||||
|
p_occurred_at timestamptz,
|
||||||
|
p_amount numeric,
|
||||||
|
p_currency app.currency_code,
|
||||||
|
p_fee numeric,
|
||||||
|
p_commission numeric,
|
||||||
|
p_raw jsonb
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare s app.settlements%rowtype; lid uuid;
|
||||||
|
begin
|
||||||
|
select * into s from app.settlements where id = p_settlement;
|
||||||
|
if s.id is null then raise exception 'settlement not found'; end if;
|
||||||
|
if not app.has_any_role_in_shop(s.shop_id, array['owner','manager']::app.business_role[]) then
|
||||||
|
raise exception 'owner or manager required';
|
||||||
|
end if;
|
||||||
|
if s.status = 'closed' then
|
||||||
|
raise exception 'settlement is closed';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into app.settlement_lines(settlement_id, external_ref, occurred_at,
|
||||||
|
amount, currency, fee, commission, raw)
|
||||||
|
values (p_settlement, p_external_ref, p_occurred_at,
|
||||||
|
p_amount, p_currency, p_fee, p_commission, p_raw)
|
||||||
|
on conflict (settlement_id, external_ref) do nothing
|
||||||
|
returning id into lid;
|
||||||
|
|
||||||
|
return lid;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.add_settlement_line(uuid, text, timestamptz, numeric, app.currency_code, numeric, numeric, jsonb) from public;
|
||||||
|
grant execute on function app.add_settlement_line(uuid, text, timestamptz, numeric, app.currency_code, numeric, numeric, jsonb) to authenticated;
|
||||||
|
|
||||||
|
-- Run the matcher across all unmatched lines of a settlement.
|
||||||
|
create or replace function app.run_match(p_settlement uuid)
|
||||||
|
returns table (matched int, exceptions int)
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
s app.settlements%rowtype;
|
||||||
|
svc_codes text[];
|
||||||
|
ln app.settlement_lines%rowtype;
|
||||||
|
cand uuid;
|
||||||
|
cand_count int;
|
||||||
|
cand_amount_usd numeric;
|
||||||
|
cand_amount_lbp numeric;
|
||||||
|
cand_amount numeric;
|
||||||
|
ok_amount boolean;
|
||||||
|
m_count int := 0;
|
||||||
|
e_count int := 0;
|
||||||
|
begin
|
||||||
|
select * into s from app.settlements where id = p_settlement;
|
||||||
|
if s.id is null then raise exception 'settlement not found'; end if;
|
||||||
|
if not app.has_any_role_in_shop(s.shop_id, array['owner','manager']::app.business_role[]) then
|
||||||
|
raise exception 'owner or manager required';
|
||||||
|
end if;
|
||||||
|
if s.status = 'closed' then raise exception 'settlement is closed'; end if;
|
||||||
|
|
||||||
|
svc_codes := app._provider_service_codes(s.provider);
|
||||||
|
|
||||||
|
perform set_config('app.settle_internal', 'on', true);
|
||||||
|
|
||||||
|
update app.settlements set status = 'matching' where id = p_settlement;
|
||||||
|
|
||||||
|
for ln in
|
||||||
|
select * from app.settlement_lines
|
||||||
|
where settlement_id = p_settlement and status = 'unmatched'
|
||||||
|
loop
|
||||||
|
-- Find candidate(s) by external_ref + provider service codes (when
|
||||||
|
-- known) within the same shop, in completed state.
|
||||||
|
select count(*),
|
||||||
|
coalesce(min(t.id), null)
|
||||||
|
into cand_count, cand
|
||||||
|
from app.transactions t
|
||||||
|
where t.shop_id = s.shop_id
|
||||||
|
and t.status = 'completed'
|
||||||
|
and t.external_ref = ln.external_ref
|
||||||
|
and (svc_codes is null or t.service_code = any(svc_codes));
|
||||||
|
|
||||||
|
if cand_count = 0 then
|
||||||
|
update app.settlement_lines
|
||||||
|
set status = 'missing_local'
|
||||||
|
where id = ln.id;
|
||||||
|
insert into app.reconciliation_exceptions(settlement_id, line_id, type, detail)
|
||||||
|
values (p_settlement, ln.id, 'missing_local',
|
||||||
|
format('provider lists external_ref % but no local txn found', ln.external_ref));
|
||||||
|
e_count := e_count + 1;
|
||||||
|
|
||||||
|
elsif cand_count > 1 then
|
||||||
|
update app.settlement_lines
|
||||||
|
set status = 'duplicate'
|
||||||
|
where id = ln.id;
|
||||||
|
insert into app.reconciliation_exceptions(settlement_id, line_id, type, detail)
|
||||||
|
values (p_settlement, ln.id, 'duplicate',
|
||||||
|
format('% local txns share external_ref %', cand_count, ln.external_ref));
|
||||||
|
e_count := e_count + 1;
|
||||||
|
|
||||||
|
else
|
||||||
|
-- Compare amounts within the matching currency. Allow 0.01 USD /
|
||||||
|
-- 100 LBP rounding tolerance.
|
||||||
|
select t.gross_usd, t.gross_lbp into cand_amount_usd, cand_amount_lbp
|
||||||
|
from app.transactions t where t.id = cand;
|
||||||
|
|
||||||
|
cand_amount := case ln.currency
|
||||||
|
when 'USD' then cand_amount_usd
|
||||||
|
when 'LBP' then cand_amount_lbp
|
||||||
|
end;
|
||||||
|
ok_amount := abs(coalesce(cand_amount,0) - coalesce(ln.amount,0))
|
||||||
|
<= case ln.currency when 'USD' then 0.01 else 100 end;
|
||||||
|
|
||||||
|
if ok_amount then
|
||||||
|
update app.settlement_lines
|
||||||
|
set status = 'matched',
|
||||||
|
matched_txn_id = cand,
|
||||||
|
matched_at = now()
|
||||||
|
where id = ln.id;
|
||||||
|
m_count := m_count + 1;
|
||||||
|
else
|
||||||
|
update app.settlement_lines
|
||||||
|
set status = 'amount_mismatch',
|
||||||
|
matched_txn_id = cand,
|
||||||
|
matched_at = now()
|
||||||
|
where id = ln.id;
|
||||||
|
insert into app.reconciliation_exceptions(settlement_id, line_id, txn_id, type, detail)
|
||||||
|
values (p_settlement, ln.id, cand, 'amount_mismatch',
|
||||||
|
format('local % %s vs provider % %s for ref %',
|
||||||
|
cand_amount, ln.currency, ln.amount, ln.currency, ln.external_ref));
|
||||||
|
e_count := e_count + 1;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- Now check for `extra_local`: completed local transactions in the
|
||||||
|
-- period whose external_ref is not present on the provider statement.
|
||||||
|
if svc_codes is not null then
|
||||||
|
insert into app.reconciliation_exceptions(settlement_id, txn_id, type, detail)
|
||||||
|
select p_settlement, t.id, 'extra_local',
|
||||||
|
format('local txn % has external_ref % but provider did not list it',
|
||||||
|
t.id, t.external_ref)
|
||||||
|
from app.transactions t
|
||||||
|
where t.shop_id = s.shop_id
|
||||||
|
and t.status = 'completed'
|
||||||
|
and t.service_code = any(svc_codes)
|
||||||
|
and t.external_ref is not null
|
||||||
|
and (t.occurred_at at time zone 'UTC')::date between s.period_start and s.period_end
|
||||||
|
and not exists (
|
||||||
|
select 1 from app.settlement_lines sl
|
||||||
|
where sl.settlement_id = p_settlement
|
||||||
|
and sl.external_ref = t.external_ref
|
||||||
|
)
|
||||||
|
and not exists (
|
||||||
|
select 1 from app.reconciliation_exceptions r
|
||||||
|
where r.settlement_id = p_settlement
|
||||||
|
and r.txn_id = t.id
|
||||||
|
and r.type = 'extra_local'
|
||||||
|
);
|
||||||
|
get diagnostics e_count = row_count; -- approximate increment
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Final status
|
||||||
|
if exists (
|
||||||
|
select 1 from app.reconciliation_exceptions
|
||||||
|
where settlement_id = p_settlement and resolved_at is null
|
||||||
|
) then
|
||||||
|
update app.settlements set status = 'has_exceptions' where id = p_settlement;
|
||||||
|
else
|
||||||
|
update app.settlements set status = 'matched' where id = p_settlement;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform set_config('app.settle_internal', 'off', true);
|
||||||
|
|
||||||
|
perform app.log_auth_event('settlement_matched', s.shop_id, null,
|
||||||
|
jsonb_build_object('settlement_id', p_settlement,
|
||||||
|
'matched', m_count, 'exceptions', e_count));
|
||||||
|
|
||||||
|
matched := m_count; exceptions := e_count; return next;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.run_match(uuid) from public;
|
||||||
|
grant execute on function app.run_match(uuid) to authenticated;
|
||||||
|
|
||||||
|
-- Resolve a single exception. Owner-only with mandatory note.
|
||||||
|
create or replace function app.resolve_exception(
|
||||||
|
p_exception uuid,
|
||||||
|
p_note text
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
e app.reconciliation_exceptions%rowtype;
|
||||||
|
s app.settlements%rowtype;
|
||||||
|
begin
|
||||||
|
if p_note is null or length(btrim(p_note)) < 5 then
|
||||||
|
raise exception 'resolution note >= 5 chars required';
|
||||||
|
end if;
|
||||||
|
select * into e from app.reconciliation_exceptions where id = p_exception;
|
||||||
|
if e.id is null then raise exception 'exception not found'; end if;
|
||||||
|
select * into s from app.settlements where id = e.settlement_id;
|
||||||
|
if not app.has_role_in_shop(s.shop_id, 'owner') then
|
||||||
|
raise exception 'owner role required';
|
||||||
|
end if;
|
||||||
|
if e.resolved_at is not null then
|
||||||
|
raise exception 'exception already resolved';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform set_config('app.settle_internal', 'on', true);
|
||||||
|
update app.reconciliation_exceptions
|
||||||
|
set resolved_at = now(),
|
||||||
|
resolved_by = auth.uid(),
|
||||||
|
resolution_note = p_note
|
||||||
|
where id = p_exception;
|
||||||
|
|
||||||
|
-- If no open exceptions remain on this settlement, flip back to matched.
|
||||||
|
if not exists (
|
||||||
|
select 1 from app.reconciliation_exceptions
|
||||||
|
where settlement_id = s.id and resolved_at is null
|
||||||
|
) then
|
||||||
|
update app.settlements set status = 'matched' where id = s.id;
|
||||||
|
end if;
|
||||||
|
perform set_config('app.settle_internal', 'off', true);
|
||||||
|
|
||||||
|
perform app.log_auth_event('settlement_exception_resolved', s.shop_id, null,
|
||||||
|
jsonb_build_object('exception_id', p_exception));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.resolve_exception(uuid, text) from public;
|
||||||
|
grant execute on function app.resolve_exception(uuid, text) to authenticated;
|
||||||
|
|
||||||
|
-- Close the settlement once all exceptions are resolved.
|
||||||
|
create or replace function app.close_settlement(p_settlement uuid)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare s app.settlements%rowtype;
|
||||||
|
begin
|
||||||
|
select * into s from app.settlements where id = p_settlement;
|
||||||
|
if s.id is null then raise exception 'settlement not found'; end if;
|
||||||
|
if not app.has_role_in_shop(s.shop_id, 'owner') then
|
||||||
|
raise exception 'owner role required';
|
||||||
|
end if;
|
||||||
|
if s.status not in ('matched') then
|
||||||
|
raise exception 'settlement must be in MATCHED state to close (was %)', s.status;
|
||||||
|
end if;
|
||||||
|
if exists (
|
||||||
|
select 1 from app.reconciliation_exceptions
|
||||||
|
where settlement_id = p_settlement and resolved_at is null
|
||||||
|
) then
|
||||||
|
raise exception 'cannot close: open exceptions remain';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform set_config('app.settle_internal', 'on', true);
|
||||||
|
update app.settlements
|
||||||
|
set status = 'closed', closed_at = now(), closed_by = auth.uid()
|
||||||
|
where id = p_settlement;
|
||||||
|
perform set_config('app.settle_internal', 'off', true);
|
||||||
|
|
||||||
|
perform app.log_auth_event('settlement_closed', s.shop_id, null,
|
||||||
|
jsonb_build_object('settlement_id', p_settlement));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.close_settlement(uuid) from public;
|
||||||
|
grant execute on function app.close_settlement(uuid) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Reporting views
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace view app.v_unmatched_external as
|
||||||
|
select s.shop_id, s.provider, s.period_start, s.period_end,
|
||||||
|
sl.external_ref, sl.amount, sl.currency, sl.status
|
||||||
|
from app.settlement_lines sl
|
||||||
|
join app.settlements s on s.id = sl.settlement_id
|
||||||
|
where sl.status <> 'matched';
|
||||||
|
|
||||||
|
create or replace view app.v_open_exceptions as
|
||||||
|
select s.shop_id, s.provider, s.period_start, s.period_end,
|
||||||
|
e.id as exception_id, e.type, e.detail, e.created_at
|
||||||
|
from app.reconciliation_exceptions e
|
||||||
|
join app.settlements s on s.id = e.settlement_id
|
||||||
|
where e.resolved_at is null;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- RLS
|
||||||
|
-- =====================================================================
|
||||||
|
alter table app.settlements enable row level security;
|
||||||
|
alter table app.settlement_lines enable row level security;
|
||||||
|
alter table app.reconciliation_exceptions enable row level security;
|
||||||
|
alter table app.settlements force row level security;
|
||||||
|
alter table app.settlement_lines force row level security;
|
||||||
|
alter table app.reconciliation_exceptions force row level security;
|
||||||
|
|
||||||
|
revoke insert, update, delete on app.settlements from authenticated;
|
||||||
|
revoke insert, update, delete on app.settlement_lines from authenticated;
|
||||||
|
revoke insert, update, delete on app.reconciliation_exceptions from authenticated;
|
||||||
|
|
||||||
|
drop policy if exists settle_select on app.settlements;
|
||||||
|
create policy settle_select on app.settlements
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['owner','manager','auditor']::app.business_role[])
|
||||||
|
);
|
||||||
|
grant select on app.settlements to authenticated;
|
||||||
|
|
||||||
|
drop policy if exists settle_lines_select on app.settlement_lines;
|
||||||
|
create policy settle_lines_select on app.settlement_lines
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1 from app.settlements s
|
||||||
|
where s.id = settlement_lines.settlement_id
|
||||||
|
and app.has_any_role_in_shop(s.shop_id,
|
||||||
|
array['owner','manager','auditor']::app.business_role[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
grant select on app.settlement_lines to authenticated;
|
||||||
|
|
||||||
|
drop policy if exists recon_exc_select on app.reconciliation_exceptions;
|
||||||
|
create policy recon_exc_select on app.reconciliation_exceptions
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1 from app.settlements s
|
||||||
|
where s.id = reconciliation_exceptions.settlement_id
|
||||||
|
and app.has_any_role_in_shop(s.shop_id,
|
||||||
|
array['owner','manager','auditor']::app.business_role[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
grant select on app.reconciliation_exceptions to authenticated;
|
||||||
|
|
||||||
|
-- A small helper to create a settlement (owner/manager only).
|
||||||
|
create or replace function app.create_settlement(
|
||||||
|
p_shop uuid,
|
||||||
|
p_provider app.settlement_provider,
|
||||||
|
p_period_start date,
|
||||||
|
p_period_end date,
|
||||||
|
p_file_url text,
|
||||||
|
p_file_sha256 text,
|
||||||
|
p_total_amount numeric,
|
||||||
|
p_total_currency app.currency_code
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare sid uuid;
|
||||||
|
begin
|
||||||
|
if not app.has_any_role_in_shop(p_shop, array['owner','manager']::app.business_role[]) then
|
||||||
|
raise exception 'owner or manager required';
|
||||||
|
end if;
|
||||||
|
if p_period_end < p_period_start then
|
||||||
|
raise exception 'period_end before period_start';
|
||||||
|
end if;
|
||||||
|
insert into app.settlements(shop_id, provider, period_start, period_end,
|
||||||
|
file_url, file_sha256, total_amount, total_currency)
|
||||||
|
values (p_shop, p_provider, p_period_start, p_period_end,
|
||||||
|
p_file_url, p_file_sha256, p_total_amount, p_total_currency)
|
||||||
|
returning id into sid;
|
||||||
|
perform app.log_auth_event('settlement_imported', p_shop, null,
|
||||||
|
jsonb_build_object('settlement_id', sid, 'provider', p_provider));
|
||||||
|
return sid;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.create_settlement(uuid, app.settlement_provider, date, date, text, text, numeric, app.currency_code) from public;
|
||||||
|
grant execute on function app.create_settlement(uuid, app.settlement_provider, date, date, text, text, numeric, app.currency_code) to authenticated;
|
||||||
|
|
||||||
|
-- End migration 0009 ----------------------------------------------------
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0010 — Reporting and alerting (roadmap Step 11).
|
||||||
|
--
|
||||||
|
-- Owner-facing read model: Z-reports, daily P&L per service, employee
|
||||||
|
-- scorecards, and a persistent alerts table fed by detector functions.
|
||||||
|
--
|
||||||
|
-- Threat-model rows addressed: 2, 3, 4, 6, 9, 10, 11, 12, 13, 14, 17,
|
||||||
|
-- 18, 20, 22, 23, 24, 25.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Z-report: one row per closed shift, what the system says vs what the
|
||||||
|
-- cashier declared vs what was found in the drawer.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace view app.v_z_report as
|
||||||
|
with cm as (
|
||||||
|
select sh.id as shift_id,
|
||||||
|
coalesce(sum(amount) filter (where currency='USD'),0) as net_usd,
|
||||||
|
coalesce(sum(amount) filter (where currency='LBP'),0) as net_lbp
|
||||||
|
from app.shifts sh
|
||||||
|
left join app.cash_movements m on m.shift_id = sh.id
|
||||||
|
group by sh.id
|
||||||
|
),
|
||||||
|
txn as (
|
||||||
|
select sh.id as shift_id,
|
||||||
|
count(*) filter (where t.status='completed') as txn_count,
|
||||||
|
count(*) filter (where t.status='voided') as void_count,
|
||||||
|
coalesce(sum(t.gross_usd) filter (where t.status='completed'),0) as gross_usd,
|
||||||
|
coalesce(sum(t.gross_lbp) filter (where t.status='completed'),0) as gross_lbp,
|
||||||
|
coalesce(sum(t.fee_usd) filter (where t.status='completed'),0) as fee_usd,
|
||||||
|
coalesce(sum(t.fee_lbp) filter (where t.status='completed'),0) as fee_lbp,
|
||||||
|
coalesce(sum(t.commission_usd) filter (where t.status='completed'),0) as comm_usd,
|
||||||
|
coalesce(sum(t.commission_lbp) filter (where t.status='completed'),0) as comm_lbp
|
||||||
|
from app.shifts sh
|
||||||
|
left join app.transactions t
|
||||||
|
on t.shift_id = sh.id
|
||||||
|
group by sh.id
|
||||||
|
)
|
||||||
|
select
|
||||||
|
sh.id as shift_id,
|
||||||
|
sh.shop_id,
|
||||||
|
sh.till_id,
|
||||||
|
sh.user_id as cashier_id,
|
||||||
|
sh.opened_at,
|
||||||
|
sh.closed_at,
|
||||||
|
sh.status,
|
||||||
|
sh.opening_usd,
|
||||||
|
sh.opening_lbp,
|
||||||
|
cm.net_usd as expected_close_usd, -- = sum(cash_movements USD)
|
||||||
|
cm.net_lbp as expected_close_lbp,
|
||||||
|
sh.declared_close_usd,
|
||||||
|
sh.declared_close_lbp,
|
||||||
|
sh.declared_close_usd - cm.net_usd as variance_usd,
|
||||||
|
sh.declared_close_lbp - cm.net_lbp as variance_lbp,
|
||||||
|
txn.txn_count,
|
||||||
|
txn.void_count,
|
||||||
|
txn.gross_usd,
|
||||||
|
txn.gross_lbp,
|
||||||
|
txn.fee_usd + txn.comm_usd as revenue_usd,
|
||||||
|
txn.fee_lbp + txn.comm_lbp as revenue_lbp
|
||||||
|
from app.shifts sh
|
||||||
|
join cm on cm.shift_id = sh.id
|
||||||
|
join txn on txn.shift_id = sh.id;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Daily P&L per shop / service.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace view app.v_daily_pnl as
|
||||||
|
select
|
||||||
|
t.shop_id,
|
||||||
|
(t.occurred_at at time zone 'UTC')::date as day,
|
||||||
|
t.service_code,
|
||||||
|
count(*) filter (where t.status='completed') as txn_count,
|
||||||
|
sum(t.gross_usd) filter (where t.status='completed') as gross_usd,
|
||||||
|
sum(t.gross_lbp) filter (where t.status='completed') as gross_lbp,
|
||||||
|
sum(t.fee_usd) filter (where t.status='completed') as fee_usd,
|
||||||
|
sum(t.fee_lbp) filter (where t.status='completed') as fee_lbp,
|
||||||
|
sum(t.commission_usd) filter (where t.status='completed') as comm_usd,
|
||||||
|
sum(t.commission_lbp) filter (where t.status='completed') as comm_lbp,
|
||||||
|
count(*) filter (where t.status='voided') as void_count
|
||||||
|
from app.transactions t
|
||||||
|
group by t.shop_id, (t.occurred_at at time zone 'UTC')::date, t.service_code;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Per-employee scorecard (last 30 days). Owner uses this to spot the
|
||||||
|
-- cashier whose numbers always look just slightly off.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace view app.v_employee_scorecard_30d as
|
||||||
|
with base as (
|
||||||
|
select sh.user_id as cashier_id, sh.shop_id, sh.id as shift_id,
|
||||||
|
(sh.declared_close_usd - z.expected_close_usd) as var_usd,
|
||||||
|
(sh.declared_close_lbp - z.expected_close_lbp) as var_lbp
|
||||||
|
from app.shifts sh
|
||||||
|
join app.v_z_report z on z.shift_id = sh.id
|
||||||
|
where sh.closed_at >= now() - interval '30 days'
|
||||||
|
and sh.status = 'closed'
|
||||||
|
),
|
||||||
|
voids as (
|
||||||
|
select t.shop_id, t.user_id as cashier_id,
|
||||||
|
count(*) as voids_30d,
|
||||||
|
count(*) filter (where t.voided_at - t.occurred_at > interval '10 minutes') as late_voids_30d
|
||||||
|
from app.transactions t
|
||||||
|
where t.status = 'voided'
|
||||||
|
and t.voided_at >= now() - interval '30 days'
|
||||||
|
group by t.shop_id, t.user_id
|
||||||
|
),
|
||||||
|
overrides as (
|
||||||
|
select t.shop_id, t.user_id as cashier_id,
|
||||||
|
count(*) as overrides_30d
|
||||||
|
from app.price_overrides p
|
||||||
|
join app.transactions t on t.id = p.txn_id
|
||||||
|
where p.created_at >= now() - interval '30 days'
|
||||||
|
group by t.shop_id, t.user_id
|
||||||
|
)
|
||||||
|
select
|
||||||
|
b.cashier_id,
|
||||||
|
b.shop_id,
|
||||||
|
count(*) as shifts_30d,
|
||||||
|
count(*) filter (where b.var_usd < 0) as short_shifts_usd,
|
||||||
|
count(*) filter (where b.var_lbp < 0) as short_shifts_lbp,
|
||||||
|
sum(b.var_usd) as total_var_usd,
|
||||||
|
sum(b.var_lbp) as total_var_lbp,
|
||||||
|
avg(b.var_usd) as avg_var_usd,
|
||||||
|
avg(b.var_lbp) as avg_var_lbp,
|
||||||
|
coalesce(v.voids_30d,0) as voids_30d,
|
||||||
|
coalesce(v.late_voids_30d,0) as late_voids_30d,
|
||||||
|
coalesce(o.overrides_30d,0) as overrides_30d
|
||||||
|
from base b
|
||||||
|
left join voids v on v.cashier_id = b.cashier_id and v.shop_id = b.shop_id
|
||||||
|
left join overrides o on o.cashier_id = b.cashier_id and o.shop_id = b.shop_id
|
||||||
|
group by b.cashier_id, b.shop_id, v.voids_30d, v.late_voids_30d, o.overrides_30d;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Detector views (raw signals used by the alert engine).
|
||||||
|
-- =====================================================================
|
||||||
|
-- Recon backlog (vector #24)
|
||||||
|
create or replace view app.v_alert_recon_backlog as
|
||||||
|
select s.shop_id,
|
||||||
|
s.id as settlement_id,
|
||||||
|
s.provider,
|
||||||
|
s.period_start,
|
||||||
|
s.period_end,
|
||||||
|
count(e.id) as open_exceptions
|
||||||
|
from app.settlements s
|
||||||
|
join app.reconciliation_exceptions e on e.settlement_id = s.id and e.resolved_at is null
|
||||||
|
where s.status = 'has_exceptions'
|
||||||
|
group by s.shop_id, s.id, s.provider, s.period_start, s.period_end;
|
||||||
|
|
||||||
|
-- After-hours activity (vector #22)
|
||||||
|
create or replace view app.v_alert_after_hours as
|
||||||
|
select t.shop_id,
|
||||||
|
t.id as txn_id,
|
||||||
|
t.user_id as cashier_id,
|
||||||
|
t.occurred_at,
|
||||||
|
t.gross_usd, t.gross_lbp
|
||||||
|
from app.transactions t
|
||||||
|
where t.status = 'completed'
|
||||||
|
and (extract(hour from (t.occurred_at at time zone 'Asia/Beirut')) < 7
|
||||||
|
or extract(hour from (t.occurred_at at time zone 'Asia/Beirut')) >= 23);
|
||||||
|
|
||||||
|
-- Chronic short cashier (vector #2)
|
||||||
|
create or replace view app.v_alert_chronic_shorts as
|
||||||
|
select cashier_id, shop_id,
|
||||||
|
short_shifts_usd, short_shifts_lbp,
|
||||||
|
total_var_usd, total_var_lbp
|
||||||
|
from app.v_employee_scorecard_30d
|
||||||
|
where short_shifts_usd >= 5 or short_shifts_lbp >= 5
|
||||||
|
or total_var_usd <= -50 or total_var_lbp <= -1000000;
|
||||||
|
|
||||||
|
-- Void spike (vector #10) — >5 voids/day per cashier or any cashier with
|
||||||
|
-- voids_30d > 20.
|
||||||
|
create or replace view app.v_alert_void_spikes as
|
||||||
|
select t.shop_id, t.user_id as cashier_id,
|
||||||
|
(t.occurred_at at time zone 'Asia/Beirut')::date as day,
|
||||||
|
count(*) as void_count
|
||||||
|
from app.transactions t
|
||||||
|
where t.status = 'voided'
|
||||||
|
and t.voided_at >= now() - interval '30 days'
|
||||||
|
group by t.shop_id, t.user_id, (t.occurred_at at time zone 'Asia/Beirut')::date
|
||||||
|
having count(*) >= 5;
|
||||||
|
|
||||||
|
-- Override spike (vector #12)
|
||||||
|
create or replace view app.v_alert_override_spikes as
|
||||||
|
select t.shop_id, t.user_id as cashier_id,
|
||||||
|
(p.created_at at time zone 'Asia/Beirut')::date as day,
|
||||||
|
count(*) as override_count
|
||||||
|
from app.price_overrides p
|
||||||
|
join app.transactions t on t.id = p.txn_id
|
||||||
|
where p.created_at >= now() - interval '30 days'
|
||||||
|
group by t.shop_id, t.user_id, (p.created_at at time zone 'Asia/Beirut')::date
|
||||||
|
having count(*) >= 3;
|
||||||
|
|
||||||
|
-- Stock shrinkage (vector #13)
|
||||||
|
create or replace view app.v_alert_stock_shrinkage as
|
||||||
|
select s.shop_id, s.sku,
|
||||||
|
sum(case when m.type in ('damaged_out','lost_out','adjustment_out')
|
||||||
|
then -m.qty_delta else 0 end) as shrink_qty_30d,
|
||||||
|
sum(case when m.type = 'sale_out' then -m.qty_delta else 0 end) as sales_qty_30d
|
||||||
|
from app.stock_movements m
|
||||||
|
join app.stock_on_hand s on s.shop_id = m.shop_id and s.sku = m.sku
|
||||||
|
where m.created_at >= now() - interval '30 days'
|
||||||
|
group by s.shop_id, s.sku
|
||||||
|
having sum(case when m.type in ('damaged_out','lost_out','adjustment_out')
|
||||||
|
then -m.qty_delta else 0 end) >= 5;
|
||||||
|
|
||||||
|
-- Voucher loss / damage spike (vector #14)
|
||||||
|
create or replace view app.v_alert_voucher_writeoffs as
|
||||||
|
select v.shop_id,
|
||||||
|
v.sku,
|
||||||
|
count(*) filter (where v.status in ('damaged','lost')) as bad_30d,
|
||||||
|
count(*) as total_30d
|
||||||
|
from app.voucher_inventory v
|
||||||
|
where coalesce(v.sold_at, v.received_at) >= now() - interval '30 days'
|
||||||
|
group by v.shop_id, v.sku
|
||||||
|
having count(*) filter (where v.status in ('damaged','lost'))::numeric
|
||||||
|
/ nullif(count(*),0)::numeric > 0.02; -- > 2 %
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Persistent alerts table + detector engine
|
||||||
|
-- =====================================================================
|
||||||
|
do $$ begin
|
||||||
|
create type app.alert_severity as enum ('info','warn','critical');
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create type app.alert_kind as enum (
|
||||||
|
'chronic_short',
|
||||||
|
'void_spike',
|
||||||
|
'override_spike',
|
||||||
|
'voucher_writeoffs',
|
||||||
|
'stock_shrinkage',
|
||||||
|
'after_hours',
|
||||||
|
'recon_backlog',
|
||||||
|
'aml_structuring',
|
||||||
|
'aml_burst',
|
||||||
|
'shift_unclosed',
|
||||||
|
'chain_break',
|
||||||
|
'reference_gap'
|
||||||
|
);
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
create table if not exists app.alerts (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
shop_id uuid not null references app.shops(id) on delete restrict,
|
||||||
|
kind app.alert_kind not null,
|
||||||
|
severity app.alert_severity not null default 'warn',
|
||||||
|
subject_id uuid, -- cashier / txn / settlement / shift
|
||||||
|
payload jsonb not null,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
acknowledged_at timestamptz,
|
||||||
|
acknowledged_by uuid references auth.users(id),
|
||||||
|
ack_note text,
|
||||||
|
-- Avoid duplicate alerts for the same condition on the same day:
|
||||||
|
dedupe_key text not null unique
|
||||||
|
);
|
||||||
|
create index if not exists idx_alerts_open on app.alerts(shop_id, kind)
|
||||||
|
where acknowledged_at is null;
|
||||||
|
|
||||||
|
-- Append-only / controlled update.
|
||||||
|
create or replace function app._alerts_guard()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
raise exception 'alerts cannot be deleted';
|
||||||
|
end if;
|
||||||
|
if current_setting('app.alerts_internal', true) is distinct from 'on' then
|
||||||
|
raise exception 'alerts can only be modified via app.* functions';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_alerts_guard on app.alerts;
|
||||||
|
create trigger trg_alerts_guard before update or delete on app.alerts
|
||||||
|
for each row execute function app._alerts_guard();
|
||||||
|
|
||||||
|
create or replace function app._raise_alert(
|
||||||
|
p_shop uuid, p_kind app.alert_kind, p_severity app.alert_severity,
|
||||||
|
p_subject uuid, p_payload jsonb, p_dedupe text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare aid uuid;
|
||||||
|
begin
|
||||||
|
insert into app.alerts(shop_id, kind, severity, subject_id, payload, dedupe_key)
|
||||||
|
values (p_shop, p_kind, p_severity, p_subject, p_payload, p_dedupe)
|
||||||
|
on conflict (dedupe_key) do nothing
|
||||||
|
returning id into aid;
|
||||||
|
return aid;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- The detector. Idempotent: each rule produces a deterministic
|
||||||
|
-- `dedupe_key` so re-running it doesn't multiply alerts.
|
||||||
|
create or replace function app.run_alert_detectors()
|
||||||
|
returns int
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare n int := 0; r record;
|
||||||
|
begin
|
||||||
|
-- Chronic shorts (vector #2)
|
||||||
|
for r in select * from app.v_alert_chronic_shorts loop
|
||||||
|
if app._raise_alert(r.shop_id, 'chronic_short', 'critical',
|
||||||
|
r.cashier_id,
|
||||||
|
jsonb_build_object('short_usd_shifts', r.short_shifts_usd,
|
||||||
|
'short_lbp_shifts', r.short_shifts_lbp,
|
||||||
|
'total_var_usd', r.total_var_usd,
|
||||||
|
'total_var_lbp', r.total_var_lbp),
|
||||||
|
format('chronic_short:%s:%s:%s', r.shop_id, r.cashier_id, to_char(now(),'YYYYMMDD'))
|
||||||
|
) is not null then n := n + 1; end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- Void spikes (vector #10)
|
||||||
|
for r in select * from app.v_alert_void_spikes loop
|
||||||
|
if app._raise_alert(r.shop_id, 'void_spike', 'warn',
|
||||||
|
r.cashier_id,
|
||||||
|
jsonb_build_object('day', r.day, 'count', r.void_count),
|
||||||
|
format('void_spike:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
|
||||||
|
) is not null then n := n + 1; end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- Override spikes (vector #12)
|
||||||
|
for r in select * from app.v_alert_override_spikes loop
|
||||||
|
if app._raise_alert(r.shop_id, 'override_spike', 'warn',
|
||||||
|
r.cashier_id,
|
||||||
|
jsonb_build_object('day', r.day, 'count', r.override_count),
|
||||||
|
format('override_spike:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
|
||||||
|
) is not null then n := n + 1; end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- Voucher write-off rate (vector #14)
|
||||||
|
for r in select * from app.v_alert_voucher_writeoffs loop
|
||||||
|
if app._raise_alert(r.shop_id, 'voucher_writeoffs', 'critical',
|
||||||
|
null,
|
||||||
|
jsonb_build_object('sku', r.sku, 'bad_30d', r.bad_30d, 'total_30d', r.total_30d),
|
||||||
|
format('voucher_writeoffs:%s:%s:%s', r.shop_id, r.sku, to_char(now(),'YYYYMMDD'))
|
||||||
|
) is not null then n := n + 1; end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- Stock shrinkage (vector #13)
|
||||||
|
for r in select * from app.v_alert_stock_shrinkage loop
|
||||||
|
if app._raise_alert(r.shop_id, 'stock_shrinkage', 'warn',
|
||||||
|
null,
|
||||||
|
jsonb_build_object('sku', r.sku, 'shrink_qty_30d', r.shrink_qty_30d, 'sales_qty_30d', r.sales_qty_30d),
|
||||||
|
format('stock_shrinkage:%s:%s:%s', r.shop_id, r.sku, to_char(now(),'YYYYMMDD'))
|
||||||
|
) is not null then n := n + 1; end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- After-hours (vector #22) — bucket per cashier per day
|
||||||
|
for r in
|
||||||
|
select shop_id, cashier_id,
|
||||||
|
(occurred_at at time zone 'Asia/Beirut')::date as day,
|
||||||
|
count(*) as cnt,
|
||||||
|
sum(coalesce(gross_usd,0)) as g_usd,
|
||||||
|
sum(coalesce(gross_lbp,0)) as g_lbp
|
||||||
|
from app.v_alert_after_hours
|
||||||
|
where occurred_at >= now() - interval '7 days'
|
||||||
|
group by shop_id, cashier_id, (occurred_at at time zone 'Asia/Beirut')::date
|
||||||
|
loop
|
||||||
|
if app._raise_alert(r.shop_id, 'after_hours', 'warn',
|
||||||
|
r.cashier_id,
|
||||||
|
jsonb_build_object('day', r.day, 'count', r.cnt,
|
||||||
|
'gross_usd', r.g_usd, 'gross_lbp', r.g_lbp),
|
||||||
|
format('after_hours:%s:%s:%s', r.shop_id, r.cashier_id, r.day)
|
||||||
|
) is not null then n := n + 1; end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- Recon backlog (vector #24)
|
||||||
|
for r in select * from app.v_alert_recon_backlog loop
|
||||||
|
if app._raise_alert(r.shop_id, 'recon_backlog', 'critical',
|
||||||
|
r.settlement_id,
|
||||||
|
jsonb_build_object('provider', r.provider,
|
||||||
|
'period_start', r.period_start,
|
||||||
|
'period_end', r.period_end,
|
||||||
|
'open_exceptions', r.open_exceptions),
|
||||||
|
format('recon_backlog:%s', r.settlement_id)
|
||||||
|
) is not null then n := n + 1; end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- AML signals (from 0006)
|
||||||
|
for r in select * from app.v_aml_structuring_by_customer loop
|
||||||
|
if app._raise_alert(r.shop_id, 'aml_structuring', 'critical',
|
||||||
|
r.customer_id,
|
||||||
|
jsonb_build_object('day', r.day, 'service', r.service_code,
|
||||||
|
'cnt', r.cnt, 'sum_usd', r.sum_usd, 'sum_lbp', r.sum_lbp),
|
||||||
|
format('aml_structuring:%s:%s:%s:%s', r.shop_id, r.customer_id, r.service_code, r.day)
|
||||||
|
) is not null then n := n + 1; end if;
|
||||||
|
end loop;
|
||||||
|
for r in select * from app.v_aml_same_beneficiary_burst loop
|
||||||
|
if app._raise_alert(r.shop_id, 'aml_burst', 'critical',
|
||||||
|
null,
|
||||||
|
jsonb_build_object('beneficiary_phone', r.beneficiary_phone,
|
||||||
|
'window_hour', r.window_hour,
|
||||||
|
'cashier_count', r.cashier_count, 'cnt', r.cnt),
|
||||||
|
format('aml_burst:%s:%s:%s', r.shop_id, r.beneficiary_phone, r.window_hour)
|
||||||
|
) is not null then n := n + 1; end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- Shift left open > 18 hours (vector #4)
|
||||||
|
for r in
|
||||||
|
select id, shop_id, cashier_id, opened_at
|
||||||
|
from app.shifts
|
||||||
|
where status = 'open' and opened_at < now() - interval '18 hours'
|
||||||
|
loop
|
||||||
|
if app._raise_alert(r.shop_id, 'shift_unclosed', 'warn',
|
||||||
|
r.cashier_id,
|
||||||
|
jsonb_build_object('shift_id', r.id, 'opened_at', r.opened_at),
|
||||||
|
format('shift_unclosed:%s', r.id)
|
||||||
|
) is not null then n := n + 1; end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- Reference number gaps (vector #20)
|
||||||
|
for r in select * from app.v_reference_gaps loop
|
||||||
|
if app._raise_alert(r.shop_id, 'reference_gap', 'critical',
|
||||||
|
null,
|
||||||
|
jsonb_build_object('expected', r.expected_ref, 'actual', r.actual_ref),
|
||||||
|
format('reference_gap:%s:%s', r.shop_id, r.expected_ref)
|
||||||
|
) is not null then n := n + 1; end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- Hash chain break (vector #25) — verify per shop, raise if any row fails
|
||||||
|
for r in
|
||||||
|
select s.id as shop_id
|
||||||
|
from app.shops s
|
||||||
|
where exists (select 1 from app.verify_chain(s.id) v where v.ok = false)
|
||||||
|
loop
|
||||||
|
if app._raise_alert(r.shop_id, 'chain_break', 'critical',
|
||||||
|
null,
|
||||||
|
jsonb_build_object('detected_at', now()),
|
||||||
|
format('chain_break:%s:%s', r.shop_id, to_char(now(),'YYYYMMDDHH24'))
|
||||||
|
) is not null then n := n + 1; end if;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
return n;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.run_alert_detectors() from public;
|
||||||
|
grant execute on function app.run_alert_detectors() to authenticated;
|
||||||
|
|
||||||
|
-- Acknowledge an alert (owner only, audited).
|
||||||
|
create or replace function app.ack_alert(p_alert uuid, p_note text)
|
||||||
|
returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare a app.alerts%rowtype;
|
||||||
|
begin
|
||||||
|
if p_note is null or length(btrim(p_note)) < 5 then
|
||||||
|
raise exception 'ack note >= 5 chars required';
|
||||||
|
end if;
|
||||||
|
select * into a from app.alerts where id = p_alert;
|
||||||
|
if a.id is null then raise exception 'alert not found'; end if;
|
||||||
|
if not app.has_role_in_shop(a.shop_id, 'owner') then
|
||||||
|
raise exception 'owner role required';
|
||||||
|
end if;
|
||||||
|
if a.acknowledged_at is not null then
|
||||||
|
raise exception 'alert already acknowledged';
|
||||||
|
end if;
|
||||||
|
perform set_config('app.alerts_internal', 'on', true);
|
||||||
|
update app.alerts
|
||||||
|
set acknowledged_at = now(), acknowledged_by = auth.uid(), ack_note = p_note
|
||||||
|
where id = p_alert;
|
||||||
|
perform set_config('app.alerts_internal', 'off', true);
|
||||||
|
perform app.log_auth_event('alert_ack', a.shop_id, null,
|
||||||
|
jsonb_build_object('alert_id', p_alert, 'kind', a.kind));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.ack_alert(uuid, text) from public;
|
||||||
|
grant execute on function app.ack_alert(uuid, text) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Owner dashboard rollup
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace view app.v_owner_dashboard as
|
||||||
|
select
|
||||||
|
s.id as shop_id,
|
||||||
|
s.name as shop_name,
|
||||||
|
(select count(*) from app.shifts where shop_id=s.id and status='open') as open_shifts,
|
||||||
|
(select count(*) from app.alerts where shop_id=s.id and acknowledged_at is null) as open_alerts,
|
||||||
|
(select count(*) from app.alerts where shop_id=s.id and acknowledged_at is null
|
||||||
|
and severity='critical') as critical_alerts,
|
||||||
|
(select count(*) from app.reconciliation_exceptions e
|
||||||
|
join app.settlements st on st.id=e.settlement_id
|
||||||
|
where st.shop_id=s.id and e.resolved_at is null) as open_recon_exceptions,
|
||||||
|
(select coalesce(sum(gross_usd),0) from app.v_daily_pnl
|
||||||
|
where shop_id=s.id and day = (now() at time zone 'Asia/Beirut')::date) as today_gross_usd,
|
||||||
|
(select coalesce(sum(gross_lbp),0) from app.v_daily_pnl
|
||||||
|
where shop_id=s.id and day = (now() at time zone 'Asia/Beirut')::date) as today_gross_lbp
|
||||||
|
from app.shops s;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- RLS
|
||||||
|
-- =====================================================================
|
||||||
|
alter table app.alerts enable row level security;
|
||||||
|
alter table app.alerts force row level security;
|
||||||
|
revoke insert, update, delete on app.alerts from authenticated;
|
||||||
|
|
||||||
|
drop policy if exists alerts_select on app.alerts;
|
||||||
|
create policy alerts_select on app.alerts
|
||||||
|
for select to authenticated
|
||||||
|
using (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['owner','manager','auditor']::app.business_role[])
|
||||||
|
);
|
||||||
|
grant select on app.alerts to authenticated;
|
||||||
|
|
||||||
|
-- End migration 0010 ----------------------------------------------------
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0011 — Hardening & ops (roadmap Step 12).
|
||||||
|
--
|
||||||
|
-- 1. pg_cron schedules: alert detector + chain verifier.
|
||||||
|
-- 2. Daily off-site hash anchor (writes the day's last row_hash per shop
|
||||||
|
-- to app.daily_anchors; an external job copies these to S3/Glacier).
|
||||||
|
-- 3. HMAC + PIN secret rotation procedures with audit.
|
||||||
|
-- 4. DDL lockdown advisory (event trigger blocking DDL by anyone other
|
||||||
|
-- than the migration role).
|
||||||
|
-- 5. NTP / clock-skew guard at INSERT time on app.transactions.
|
||||||
|
--
|
||||||
|
-- Threat-model rows: 4, 7, 17, 22, 23, 25.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 1. pg_cron schedules. Supabase ships pg_cron in the `extensions`
|
||||||
|
-- schema. Each task runs as the table owner thanks to SECURITY DEFINER.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create extension if not exists pg_cron;
|
||||||
|
|
||||||
|
-- Run alert detectors every 5 minutes.
|
||||||
|
do $$ begin
|
||||||
|
perform cron.schedule('app_alert_detectors_5m',
|
||||||
|
'*/5 * * * *',
|
||||||
|
$cmd$ select app.run_alert_detectors(); $cmd$);
|
||||||
|
exception when others then null; -- already scheduled
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- Verify the hash chain hourly per shop. We don't bail loudly here;
|
||||||
|
-- run_alert_detectors() raises a chain_break alert if verify_chain fails.
|
||||||
|
do $$ begin
|
||||||
|
perform cron.schedule('app_chain_verify_hourly',
|
||||||
|
'7 * * * *',
|
||||||
|
$cmd$ select 1 from (
|
||||||
|
select s.id, (select bool_and(ok) from app.verify_chain(s.id))
|
||||||
|
from app.shops s
|
||||||
|
) v; $cmd$);
|
||||||
|
exception when others then null;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 2. Daily off-site anchor.
|
||||||
|
-- The most important fraud control after recon: every night, copy
|
||||||
|
-- the last row_hash per shop into a row that is written ONCE,
|
||||||
|
-- timestamped, and exported to immutable storage. If anyone tampers
|
||||||
|
-- with history, today's anchor will not chain back to yesterday's.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create table if not exists app.daily_anchors (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
shop_id uuid not null references app.shops(id) on delete restrict,
|
||||||
|
anchor_date date not null,
|
||||||
|
last_txn_id uuid,
|
||||||
|
last_ref_no bigint,
|
||||||
|
last_row_hash bytea,
|
||||||
|
txn_count_to_date bigint not null,
|
||||||
|
computed_at timestamptz not null default now(),
|
||||||
|
unique (shop_id, anchor_date)
|
||||||
|
);
|
||||||
|
alter table app.daily_anchors enable row level security;
|
||||||
|
alter table app.daily_anchors force row level security;
|
||||||
|
revoke insert, update, delete on app.daily_anchors from authenticated;
|
||||||
|
|
||||||
|
create or replace function app._daily_anchors_guard()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
raise exception 'daily_anchors are append-only';
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_daily_anchors_guard on app.daily_anchors;
|
||||||
|
create trigger trg_daily_anchors_guard before update or delete on app.daily_anchors
|
||||||
|
for each row execute function app._daily_anchors_guard();
|
||||||
|
|
||||||
|
drop policy if exists daily_anchors_select on app.daily_anchors;
|
||||||
|
create policy daily_anchors_select on app.daily_anchors
|
||||||
|
for select to authenticated
|
||||||
|
using (app.has_any_role_in_shop(shop_id,
|
||||||
|
array['owner','auditor']::app.business_role[]));
|
||||||
|
grant select on app.daily_anchors to authenticated;
|
||||||
|
|
||||||
|
create or replace function app.write_daily_anchors()
|
||||||
|
returns int
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare n int := 0; r record;
|
||||||
|
begin
|
||||||
|
for r in
|
||||||
|
with last_row as (
|
||||||
|
select distinct on (shop_id)
|
||||||
|
shop_id, id, reference_no, row_hash, occurred_at
|
||||||
|
from app.transactions
|
||||||
|
where (occurred_at at time zone 'UTC')::date
|
||||||
|
= (now() at time zone 'UTC')::date - 1
|
||||||
|
order by shop_id, reference_no desc
|
||||||
|
)
|
||||||
|
select lr.shop_id, lr.id, lr.reference_no, lr.row_hash,
|
||||||
|
(now() at time zone 'UTC')::date - 1 as anchor_date,
|
||||||
|
(select count(*) from app.transactions t
|
||||||
|
where t.shop_id = lr.shop_id
|
||||||
|
and t.reference_no <= lr.reference_no) as cnt
|
||||||
|
from last_row lr
|
||||||
|
loop
|
||||||
|
insert into app.daily_anchors(shop_id, anchor_date, last_txn_id,
|
||||||
|
last_ref_no, last_row_hash, txn_count_to_date)
|
||||||
|
values (r.shop_id, r.anchor_date, r.id,
|
||||||
|
r.reference_no, r.row_hash, r.cnt)
|
||||||
|
on conflict (shop_id, anchor_date) do nothing;
|
||||||
|
n := n + 1;
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
perform app.log_auth_event('daily_anchor_written', null, null,
|
||||||
|
jsonb_build_object('rows', n));
|
||||||
|
return n;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.write_daily_anchors() from public;
|
||||||
|
grant execute on function app.write_daily_anchors() to authenticated;
|
||||||
|
|
||||||
|
-- 02:15 Beirut time = 23:15 UTC the previous day; at that hour the till
|
||||||
|
-- is closed and the day's last txn already exists.
|
||||||
|
do $$ begin
|
||||||
|
perform cron.schedule('app_daily_anchor',
|
||||||
|
'15 23 * * *',
|
||||||
|
$cmd$ select app.write_daily_anchors(); $cmd$);
|
||||||
|
exception when others then null;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 3. Secret rotation with audit. The HMAC key was created in 0007;
|
||||||
|
-- rotating it invalidates all printed receipts but new ones become
|
||||||
|
-- forgery-resistant. PIN rotation is per-user.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create table if not exists app.secret_rotations (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
secret_name text not null,
|
||||||
|
rotated_by uuid not null references auth.users(id) default auth.uid(),
|
||||||
|
rotated_at timestamptz not null default now(),
|
||||||
|
reason text not null check (length(btrim(reason)) >= 5)
|
||||||
|
);
|
||||||
|
alter table app.secret_rotations enable row level security;
|
||||||
|
alter table app.secret_rotations force row level security;
|
||||||
|
revoke insert, update, delete on app.secret_rotations from authenticated;
|
||||||
|
drop policy if exists secret_rotations_select on app.secret_rotations;
|
||||||
|
create policy secret_rotations_select on app.secret_rotations
|
||||||
|
for select to authenticated
|
||||||
|
using (app.is_owner_anywhere());
|
||||||
|
grant select on app.secret_rotations to authenticated;
|
||||||
|
|
||||||
|
create or replace function app._secret_rotations_guard()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
raise exception 'secret_rotations are append-only';
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_secret_rotations_guard on app.secret_rotations;
|
||||||
|
create trigger trg_secret_rotations_guard before update or delete on app.secret_rotations
|
||||||
|
for each row execute function app._secret_rotations_guard();
|
||||||
|
|
||||||
|
create or replace function app.log_secret_rotation(p_name text, p_reason text)
|
||||||
|
returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare rid uuid;
|
||||||
|
begin
|
||||||
|
if not app.is_owner_anywhere() then
|
||||||
|
raise exception 'owner role required';
|
||||||
|
end if;
|
||||||
|
insert into app.secret_rotations(secret_name, reason)
|
||||||
|
values (p_name, p_reason) returning id into rid;
|
||||||
|
return rid;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.log_secret_rotation(text, text) from public;
|
||||||
|
grant execute on function app.log_secret_rotation(text, text) to authenticated;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 4. DDL lockdown advisory.
|
||||||
|
-- Anyone with `authenticated` should not be able to issue DDL anyway,
|
||||||
|
-- but Supabase ships a `service_role` key. This event trigger raises
|
||||||
|
-- if DDL is attempted from anything other than the migration owner.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app._ddl_lock()
|
||||||
|
returns event_trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
-- Allow the role that owns the schema (typically `postgres` running
|
||||||
|
-- supabase migrations) and the cron worker. Block everyone else.
|
||||||
|
if current_user not in ('postgres', 'supabase_admin') then
|
||||||
|
raise exception 'DDL is locked: caller % may not modify schema', current_user;
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop event trigger if exists app_ddl_lock;
|
||||||
|
create event trigger app_ddl_lock
|
||||||
|
on ddl_command_start
|
||||||
|
execute function app._ddl_lock();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- 5. Clock-skew guard. A till with a manipulated clock can backdate or
|
||||||
|
-- pre-date transactions to hide them from a shift. Reject inserts
|
||||||
|
-- whose `occurred_at` is more than 5 minutes off server `now()`.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app._txn_clock_guard()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
if abs(extract(epoch from (new.occurred_at - now()))) > 300 then
|
||||||
|
raise exception
|
||||||
|
'clock skew rejected: occurred_at=% server now()=%', new.occurred_at, now();
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_txn_clock_guard on app.transactions;
|
||||||
|
-- Fires before the existing txn_before_insert (alphabetical 'a' < 't').
|
||||||
|
create trigger trg_a_txn_clock_guard before insert on app.transactions
|
||||||
|
for each row execute function app._txn_clock_guard();
|
||||||
|
|
||||||
|
-- End migration 0011 ----------------------------------------------------
|
||||||
@@ -0,0 +1,667 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0013 — Step 13a corrections + Step 13b record-RPCs.
|
||||||
|
--
|
||||||
|
-- 1. Replaces views in 0010 / 0006 / 0012 that referenced columns that
|
||||||
|
-- don't exist in the actual schema (cashier_id, opening_float_*,
|
||||||
|
-- services.requires_*, AML view shop_id, etc.).
|
||||||
|
-- 2. Defines the public API the React UI actually needs:
|
||||||
|
-- - app.me() -> current user profile + roles
|
||||||
|
-- - app.v_my_shops -> shops the caller belongs to
|
||||||
|
-- - app.my_open_shift(shop) -> the caller's open shift
|
||||||
|
-- - app.v_services_active -> service catalog
|
||||||
|
-- - app.v_my_recent_transactions
|
||||||
|
-- 3. Defines `record_*` SECURITY DEFINER helpers, one per service
|
||||||
|
-- family, that insert the parent transaction row AND the matching
|
||||||
|
-- detail row in a single round-trip. The deferred constraint trigger
|
||||||
|
-- from 0004 fires at COMMIT and would otherwise reject any client
|
||||||
|
-- pattern that tried to do those two writes in separate HTTP calls.
|
||||||
|
-- 4. Seeds app.services with the 12 codes the UI uses.
|
||||||
|
-- 5. Revokes INSERT on app.transactions from authenticated; the only
|
||||||
|
-- legal path is one of the record_* functions defined here.
|
||||||
|
--
|
||||||
|
-- Threat-model rows: 1, 5, 6, 8, 19, 21.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Service catalog seed (idempotent)
|
||||||
|
-- =====================================================================
|
||||||
|
insert into app.services(code, name, category) values
|
||||||
|
('OMT_SEND', 'OMT — Send', 'money_transfer'),
|
||||||
|
('OMT_RECEIVE', 'OMT — Receive', 'money_transfer'),
|
||||||
|
('OMT_BILL', 'Bill payment via OMT', 'bills'),
|
||||||
|
('WU_SEND', 'Western Union — Send', 'money_transfer'),
|
||||||
|
('WU_RECEIVE', 'Western Union — Recv', 'money_transfer'),
|
||||||
|
('WHISH_SEND', 'Whish — Send', 'money_transfer'),
|
||||||
|
('ALFA_RECHARGE', 'Alfa recharge', 'telecom_recharge'),
|
||||||
|
('TOUCH_RECHARGE', 'touch recharge', 'telecom_recharge'),
|
||||||
|
('OGERO_RECHARGE', 'Ogero recharge', 'telecom_recharge'),
|
||||||
|
('INTERNET_RECHARGE', 'Internet voucher', 'telecom_recharge'),
|
||||||
|
('EDL_BILL', 'EDL electricity bill', 'bills'),
|
||||||
|
('GOODS_SALE', 'Goods sale', 'goods'),
|
||||||
|
('REPAIR', 'Phone / device repair', 'repair'),
|
||||||
|
('REFUND', 'Refund', 'refund')
|
||||||
|
on conflict (code) do update set name = excluded.name, category = excluded.category;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Read views the UI consumes
|
||||||
|
-- =====================================================================
|
||||||
|
drop view if exists app.v_services_active;
|
||||||
|
create view app.v_services_active as
|
||||||
|
select code, name, category, is_active
|
||||||
|
from app.services
|
||||||
|
where is_active = true
|
||||||
|
order by category, name;
|
||||||
|
grant select on app.v_services_active to authenticated;
|
||||||
|
|
||||||
|
drop view if exists app.v_my_shops;
|
||||||
|
create view app.v_my_shops as
|
||||||
|
select s.id as shop_id, s.name, a.role
|
||||||
|
from app.shops s
|
||||||
|
join app.user_shop_assignments a
|
||||||
|
on a.shop_id = s.id and a.user_id = auth.uid();
|
||||||
|
grant select on app.v_my_shops to authenticated;
|
||||||
|
|
||||||
|
drop view if exists app.v_my_tills;
|
||||||
|
create view app.v_my_tills as
|
||||||
|
select t.id as till_id, t.shop_id, t.name, t.is_active
|
||||||
|
from app.tills t
|
||||||
|
where t.is_active
|
||||||
|
and exists (
|
||||||
|
select 1 from app.user_shop_assignments a
|
||||||
|
where a.shop_id = t.shop_id and a.user_id = auth.uid()
|
||||||
|
);
|
||||||
|
grant select on app.v_my_tills to authenticated;
|
||||||
|
|
||||||
|
drop view if exists app.v_my_recent_transactions;
|
||||||
|
create view app.v_my_recent_transactions as
|
||||||
|
select t.id, t.reference_no, t.shop_id, t.till_id, t.shift_id,
|
||||||
|
t.service_code, s.name as service_name, s.category,
|
||||||
|
t.payment_method,
|
||||||
|
t.gross_usd, t.gross_lbp,
|
||||||
|
t.fee_usd + t.commission_usd as revenue_usd,
|
||||||
|
t.fee_lbp + t.commission_lbp as revenue_lbp,
|
||||||
|
t.external_ref, t.external_ref_provider,
|
||||||
|
t.beneficiary_name, t.beneficiary_phone,
|
||||||
|
t.status, t.occurred_at, t.user_id
|
||||||
|
from app.transactions t
|
||||||
|
join app.services s on s.code = t.service_code
|
||||||
|
where t.user_id = auth.uid()
|
||||||
|
or app.has_any_role_in_shop(t.shop_id,
|
||||||
|
array['owner','manager','auditor']::app.business_role[]);
|
||||||
|
grant select on app.v_my_recent_transactions to authenticated;
|
||||||
|
|
||||||
|
-- "Who am I" — single round-trip for the auth bootstrap.
|
||||||
|
create or replace function app.me()
|
||||||
|
returns table (
|
||||||
|
user_id uuid,
|
||||||
|
full_name text,
|
||||||
|
is_active boolean,
|
||||||
|
is_owner_anywhere boolean,
|
||||||
|
shops jsonb
|
||||||
|
) language sql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select
|
||||||
|
auth.uid() as user_id,
|
||||||
|
coalesce(p.full_name, '') as full_name,
|
||||||
|
coalesce(p.is_active, true) as is_active,
|
||||||
|
app.is_owner_anywhere() as is_owner_anywhere,
|
||||||
|
coalesce((
|
||||||
|
select jsonb_agg(jsonb_build_object(
|
||||||
|
'shop_id', a.shop_id, 'shop_name', s.name, 'role', a.role))
|
||||||
|
from app.user_shop_assignments a
|
||||||
|
join app.shops s on s.id = a.shop_id
|
||||||
|
where a.user_id = auth.uid()
|
||||||
|
), '[]'::jsonb) as shops
|
||||||
|
from app.user_profiles p
|
||||||
|
where p.user_id = auth.uid()
|
||||||
|
union all
|
||||||
|
-- profile may not exist yet; return one row anyway
|
||||||
|
select auth.uid(), '', true,
|
||||||
|
app.is_owner_anywhere(),
|
||||||
|
coalesce((
|
||||||
|
select jsonb_agg(jsonb_build_object(
|
||||||
|
'shop_id', a.shop_id, 'shop_name', s.name, 'role', a.role))
|
||||||
|
from app.user_shop_assignments a
|
||||||
|
join app.shops s on s.id = a.shop_id
|
||||||
|
where a.user_id = auth.uid()
|
||||||
|
), '[]'::jsonb)
|
||||||
|
where not exists (select 1 from app.user_profiles where user_id = auth.uid())
|
||||||
|
limit 1;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.me() from public;
|
||||||
|
grant execute on function app.me() to authenticated;
|
||||||
|
|
||||||
|
-- The caller's open shift in a given shop. Used by the till UI to
|
||||||
|
-- decide whether the "New transaction" button is enabled.
|
||||||
|
create or replace function app.my_open_shift(p_shop uuid)
|
||||||
|
returns table (
|
||||||
|
shift_id uuid,
|
||||||
|
till_id uuid,
|
||||||
|
opened_at timestamptz,
|
||||||
|
status app.shift_status,
|
||||||
|
opening_usd numeric,
|
||||||
|
opening_lbp numeric
|
||||||
|
) language sql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select id, till_id, opened_at, status, opening_usd, opening_lbp
|
||||||
|
from app.shifts
|
||||||
|
where shop_id = p_shop
|
||||||
|
and user_id = auth.uid()
|
||||||
|
and status = 'open'
|
||||||
|
order by opened_at desc
|
||||||
|
limit 1;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.my_open_shift(uuid) from public;
|
||||||
|
grant execute on function app.my_open_shift(uuid) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Internal: insert a parent transaction row.
|
||||||
|
-- All record_* wrappers below call this and then insert the detail row
|
||||||
|
-- in the same DB transaction so the deferred constraint trigger from
|
||||||
|
-- 0004 (txn must have a detail at COMMIT) is satisfied.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app._insert_txn(
|
||||||
|
p_shop uuid,
|
||||||
|
p_till uuid,
|
||||||
|
p_service_code text,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_gross_usd numeric,
|
||||||
|
p_gross_lbp numeric,
|
||||||
|
p_fee_usd numeric,
|
||||||
|
p_fee_lbp numeric,
|
||||||
|
p_commission_usd numeric,
|
||||||
|
p_commission_lbp numeric,
|
||||||
|
p_fx_rate_used numeric,
|
||||||
|
p_external_ref_provider text,
|
||||||
|
p_external_ref text,
|
||||||
|
p_beneficiary_name text,
|
||||||
|
p_beneficiary_phone text,
|
||||||
|
p_customer_id uuid,
|
||||||
|
p_notes text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_shift uuid; v_tid uuid;
|
||||||
|
begin
|
||||||
|
-- Role check
|
||||||
|
if not app.has_any_role_in_shop(p_shop,
|
||||||
|
array['owner','manager','cashier']::app.business_role[]) then
|
||||||
|
raise exception 'no role in shop %', p_shop;
|
||||||
|
end if;
|
||||||
|
-- Service must exist & be active
|
||||||
|
if not exists (select 1 from app.services
|
||||||
|
where code = p_service_code and is_active) then
|
||||||
|
raise exception 'unknown or inactive service %', p_service_code;
|
||||||
|
end if;
|
||||||
|
-- Amounts
|
||||||
|
if coalesce(p_gross_usd,0) < 0 or coalesce(p_gross_lbp,0) < 0
|
||||||
|
or coalesce(p_fee_usd,0) < 0 or coalesce(p_fee_lbp,0) < 0
|
||||||
|
or coalesce(p_commission_usd,0) < 0 or coalesce(p_commission_lbp,0) < 0 then
|
||||||
|
raise exception 'amounts must be non-negative';
|
||||||
|
end if;
|
||||||
|
-- Open shift
|
||||||
|
select id into v_shift
|
||||||
|
from app.shifts
|
||||||
|
where shop_id = p_shop and till_id = p_till
|
||||||
|
and user_id = auth.uid() and status = 'open'
|
||||||
|
order by opened_at desc limit 1;
|
||||||
|
if v_shift is null then
|
||||||
|
raise exception 'no open shift for caller in shop %, till %', p_shop, p_till;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into app.transactions(
|
||||||
|
shop_id, till_id, shift_id, user_id, service_code, status,
|
||||||
|
payment_method,
|
||||||
|
gross_usd, gross_lbp, fee_usd, fee_lbp,
|
||||||
|
commission_usd, commission_lbp, fx_rate_used,
|
||||||
|
external_ref_provider, external_ref,
|
||||||
|
beneficiary_name, beneficiary_phone,
|
||||||
|
customer_id, notes,
|
||||||
|
created_by,
|
||||||
|
-- placeholder; the BEFORE INSERT hash trigger from 0003 fills these
|
||||||
|
row_hash
|
||||||
|
) values (
|
||||||
|
p_shop, p_till, v_shift, auth.uid(), p_service_code, 'completed',
|
||||||
|
p_payment_method,
|
||||||
|
coalesce(p_gross_usd,0), coalesce(p_gross_lbp,0),
|
||||||
|
coalesce(p_fee_usd,0), coalesce(p_fee_lbp,0),
|
||||||
|
coalesce(p_commission_usd,0), coalesce(p_commission_lbp,0),
|
||||||
|
p_fx_rate_used,
|
||||||
|
p_external_ref_provider, p_external_ref,
|
||||||
|
p_beneficiary_name, p_beneficiary_phone,
|
||||||
|
p_customer_id, p_notes,
|
||||||
|
auth.uid(),
|
||||||
|
decode('00','hex') -- the BEFORE INSERT trigger overwrites this
|
||||||
|
) returning id into v_tid;
|
||||||
|
|
||||||
|
return v_tid;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
-- Internal helper; not granted to clients.
|
||||||
|
revoke all on function app._insert_txn(
|
||||||
|
uuid, uuid, text, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, numeric,
|
||||||
|
numeric, text, text, text, text, uuid, text) from public;
|
||||||
|
|
||||||
|
-- Lock down direct INSERT — only the record_* wrappers may write.
|
||||||
|
revoke insert on app.transactions from authenticated;
|
||||||
|
revoke insert on app.omt_send_details, app.omt_receive_details,
|
||||||
|
app.bill_payment_details, app.recharge_details,
|
||||||
|
app.goods_sale_details, app.repair_details
|
||||||
|
from authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- record_recharge: Alfa / touch / Ogero / Internet voucher OR e-recharge
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.record_recharge(
|
||||||
|
p_shop uuid, p_till uuid, p_service_code text,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_gross_usd numeric, p_gross_lbp numeric,
|
||||||
|
p_fee_usd numeric, p_fee_lbp numeric,
|
||||||
|
p_fx_rate numeric,
|
||||||
|
p_operator text, p_msisdn text, p_product_code text,
|
||||||
|
p_voucher_serial text, p_e_recharge_ref text,
|
||||||
|
p_unit_face_usd numeric, p_unit_cost_usd numeric,
|
||||||
|
p_notes text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare v_txn uuid;
|
||||||
|
begin
|
||||||
|
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
|
||||||
|
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp, 0, 0, p_fx_rate,
|
||||||
|
p_operator, p_voucher_serial, null, p_msisdn, null, p_notes);
|
||||||
|
|
||||||
|
insert into app.recharge_details(
|
||||||
|
txn_id, operator, msisdn, product_code,
|
||||||
|
voucher_serial, e_recharge_provider_ref,
|
||||||
|
unit_face_value_usd, unit_cost_usd
|
||||||
|
) values (
|
||||||
|
v_txn, p_operator, p_msisdn, p_product_code,
|
||||||
|
nullif(btrim(p_voucher_serial),''),
|
||||||
|
nullif(btrim(p_e_recharge_ref),''),
|
||||||
|
p_unit_face_usd, p_unit_cost_usd
|
||||||
|
);
|
||||||
|
return v_txn;
|
||||||
|
end; $$;
|
||||||
|
revoke all on function app.record_recharge(uuid, uuid, text, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric,
|
||||||
|
text, text, text, text, text, numeric, numeric, text) from public;
|
||||||
|
grant execute on function app.record_recharge(uuid, uuid, text, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric,
|
||||||
|
text, text, text, text, text, numeric, numeric, text) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- record_omt_send
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.record_omt_send(
|
||||||
|
p_shop uuid, p_till uuid,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_gross_usd numeric, p_gross_lbp numeric,
|
||||||
|
p_fee_usd numeric, p_fee_lbp numeric,
|
||||||
|
p_commission_usd numeric, p_commission_lbp numeric,
|
||||||
|
p_fx_rate numeric,
|
||||||
|
p_external_ref text,
|
||||||
|
p_direction app.transfer_direction,
|
||||||
|
p_sender_full_name text, p_sender_id_type app.id_doc_type,
|
||||||
|
p_sender_id_number text, p_sender_phone text,
|
||||||
|
p_sender_dob date, p_sender_nationality text,
|
||||||
|
p_beneficiary_full_name text, p_beneficiary_phone text,
|
||||||
|
p_destination_country text,
|
||||||
|
p_purpose_code text, p_purpose_note text,
|
||||||
|
p_kyc_doc_url text,
|
||||||
|
p_customer_id uuid,
|
||||||
|
p_notes text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare v_txn uuid;
|
||||||
|
begin
|
||||||
|
v_txn := app._insert_txn(p_shop, p_till, 'OMT_SEND', p_payment_method,
|
||||||
|
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
|
||||||
|
p_commission_usd, p_commission_lbp, p_fx_rate,
|
||||||
|
'OMT', p_external_ref,
|
||||||
|
p_beneficiary_full_name, p_beneficiary_phone,
|
||||||
|
p_customer_id, p_notes);
|
||||||
|
|
||||||
|
insert into app.omt_send_details(
|
||||||
|
txn_id, direction,
|
||||||
|
sender_full_name, sender_id_type, sender_id_number, sender_phone,
|
||||||
|
sender_dob, sender_nationality,
|
||||||
|
beneficiary_full_name, beneficiary_phone, destination_country,
|
||||||
|
purpose_code, purpose_note, kyc_doc_url
|
||||||
|
) values (
|
||||||
|
v_txn, p_direction,
|
||||||
|
p_sender_full_name, p_sender_id_type, p_sender_id_number, p_sender_phone,
|
||||||
|
p_sender_dob, p_sender_nationality,
|
||||||
|
p_beneficiary_full_name, p_beneficiary_phone, p_destination_country,
|
||||||
|
p_purpose_code, p_purpose_note, p_kyc_doc_url
|
||||||
|
);
|
||||||
|
return v_txn;
|
||||||
|
end; $$;
|
||||||
|
revoke all on function app.record_omt_send(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
|
||||||
|
text, app.transfer_direction,
|
||||||
|
text, app.id_doc_type, text, text, date, text,
|
||||||
|
text, text, text, text, text, text, uuid, text) from public;
|
||||||
|
grant execute on function app.record_omt_send(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
|
||||||
|
text, app.transfer_direction,
|
||||||
|
text, app.id_doc_type, text, text, date, text,
|
||||||
|
text, text, text, text, text, text, uuid, text) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- record_omt_receive (payout)
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.record_omt_receive(
|
||||||
|
p_shop uuid, p_till uuid,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_gross_usd numeric, p_gross_lbp numeric,
|
||||||
|
p_fee_usd numeric, p_fee_lbp numeric,
|
||||||
|
p_commission_usd numeric, p_commission_lbp numeric,
|
||||||
|
p_fx_rate numeric,
|
||||||
|
p_payout_code text,
|
||||||
|
p_beneficiary_full_name text,
|
||||||
|
p_beneficiary_id_type app.id_doc_type,
|
||||||
|
p_beneficiary_id_number text,
|
||||||
|
p_beneficiary_phone text,
|
||||||
|
p_origin_country text,
|
||||||
|
p_kyc_doc_url text,
|
||||||
|
p_customer_id uuid,
|
||||||
|
p_notes text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare v_txn uuid;
|
||||||
|
begin
|
||||||
|
v_txn := app._insert_txn(p_shop, p_till, 'OMT_RECEIVE', p_payment_method,
|
||||||
|
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
|
||||||
|
p_commission_usd, p_commission_lbp, p_fx_rate,
|
||||||
|
'OMT', p_payout_code,
|
||||||
|
p_beneficiary_full_name, p_beneficiary_phone,
|
||||||
|
p_customer_id, p_notes);
|
||||||
|
|
||||||
|
insert into app.omt_receive_details(
|
||||||
|
txn_id, payout_code,
|
||||||
|
beneficiary_full_name, beneficiary_id_type, beneficiary_id_number,
|
||||||
|
beneficiary_phone, origin_country, kyc_doc_url
|
||||||
|
) values (
|
||||||
|
v_txn, p_payout_code,
|
||||||
|
p_beneficiary_full_name, p_beneficiary_id_type, p_beneficiary_id_number,
|
||||||
|
p_beneficiary_phone, p_origin_country, p_kyc_doc_url
|
||||||
|
);
|
||||||
|
return v_txn;
|
||||||
|
end; $$;
|
||||||
|
revoke all on function app.record_omt_receive(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
|
||||||
|
text, text, app.id_doc_type, text, text, text, text, uuid, text) from public;
|
||||||
|
grant execute on function app.record_omt_receive(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
|
||||||
|
text, text, app.id_doc_type, text, text, text, text, uuid, text) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- record_bill (OMT_BILL / EDL_BILL)
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.record_bill(
|
||||||
|
p_shop uuid, p_till uuid, p_service_code text,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_gross_usd numeric, p_gross_lbp numeric,
|
||||||
|
p_fee_usd numeric, p_fee_lbp numeric,
|
||||||
|
p_fx_rate numeric,
|
||||||
|
p_external_ref text,
|
||||||
|
p_biller_code text, p_account_number text,
|
||||||
|
p_period text, p_customer_name text,
|
||||||
|
p_customer_id uuid, p_notes text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare v_txn uuid;
|
||||||
|
begin
|
||||||
|
if p_service_code not in ('OMT_BILL','EDL_BILL') then
|
||||||
|
raise exception 'record_bill only for bill services, got %', p_service_code;
|
||||||
|
end if;
|
||||||
|
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
|
||||||
|
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp, 0, 0, p_fx_rate,
|
||||||
|
p_biller_code, p_external_ref,
|
||||||
|
p_customer_name, null,
|
||||||
|
p_customer_id, p_notes);
|
||||||
|
|
||||||
|
insert into app.bill_payment_details(
|
||||||
|
txn_id, biller_code, account_number, period, customer_name
|
||||||
|
) values (
|
||||||
|
v_txn, p_biller_code, p_account_number, p_period, p_customer_name
|
||||||
|
);
|
||||||
|
return v_txn;
|
||||||
|
end; $$;
|
||||||
|
revoke all on function app.record_bill(uuid, uuid, text, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, text, text, text, text, text, uuid, text) from public;
|
||||||
|
grant execute on function app.record_bill(uuid, uuid, text, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, text, text, text, text, text, uuid, text) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- record_goods_sale (single line)
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.record_goods_sale(
|
||||||
|
p_shop uuid, p_till uuid,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_gross_usd numeric, p_gross_lbp numeric,
|
||||||
|
p_fx_rate numeric,
|
||||||
|
p_sku text, p_qty integer,
|
||||||
|
p_unit_cost_usd numeric, p_unit_price_usd numeric,
|
||||||
|
p_serial_number text,
|
||||||
|
p_customer_id uuid, p_notes text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare v_txn uuid;
|
||||||
|
begin
|
||||||
|
if p_qty is null or p_qty <= 0 then
|
||||||
|
raise exception 'qty must be > 0';
|
||||||
|
end if;
|
||||||
|
v_txn := app._insert_txn(p_shop, p_till, 'GOODS_SALE', p_payment_method,
|
||||||
|
p_gross_usd, p_gross_lbp, 0, 0, 0, 0, p_fx_rate,
|
||||||
|
null, null, null, null, p_customer_id, p_notes);
|
||||||
|
|
||||||
|
insert into app.goods_sale_details(
|
||||||
|
txn_id, sku, qty, unit_cost_usd, unit_price_usd, serial_number
|
||||||
|
) values (
|
||||||
|
v_txn, p_sku, p_qty, p_unit_cost_usd, p_unit_price_usd, p_serial_number
|
||||||
|
);
|
||||||
|
return v_txn;
|
||||||
|
end; $$;
|
||||||
|
revoke all on function app.record_goods_sale(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, text, integer, numeric, numeric, text, uuid, text) from public;
|
||||||
|
grant execute on function app.record_goods_sale(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, text, integer, numeric, numeric, text, uuid, text) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- record_repair
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.record_repair(
|
||||||
|
p_shop uuid, p_till uuid,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_gross_usd numeric, p_gross_lbp numeric,
|
||||||
|
p_fx_rate numeric,
|
||||||
|
p_device_type text, p_device_imei text,
|
||||||
|
p_issue_summary text, p_warranty_days integer,
|
||||||
|
p_customer_id uuid, p_notes text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare v_txn uuid;
|
||||||
|
begin
|
||||||
|
v_txn := app._insert_txn(p_shop, p_till, 'REPAIR', p_payment_method,
|
||||||
|
p_gross_usd, p_gross_lbp, 0, 0, 0, 0, p_fx_rate,
|
||||||
|
null, null, null, null, p_customer_id, p_notes);
|
||||||
|
|
||||||
|
insert into app.repair_details(
|
||||||
|
txn_id, device_type, device_imei, issue_summary, warranty_days
|
||||||
|
) values (
|
||||||
|
v_txn, p_device_type, p_device_imei, p_issue_summary,
|
||||||
|
coalesce(p_warranty_days, 0)
|
||||||
|
);
|
||||||
|
return v_txn;
|
||||||
|
end; $$;
|
||||||
|
revoke all on function app.record_repair(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, text, text, text, integer, uuid, text) from public;
|
||||||
|
grant execute on function app.record_repair(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, text, text, text, integer, uuid, text) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Re-define the broken views from 0010 against the actual schema.
|
||||||
|
-- =====================================================================
|
||||||
|
drop view if exists app.v_z_report cascade;
|
||||||
|
create view app.v_z_report as
|
||||||
|
with cm as (
|
||||||
|
select sh.id as shift_id,
|
||||||
|
coalesce(sum(amount) filter (where currency='USD'),0) as net_usd,
|
||||||
|
coalesce(sum(amount) filter (where currency='LBP'),0) as net_lbp
|
||||||
|
from app.shifts sh
|
||||||
|
left join app.cash_movements m on m.shift_id = sh.id
|
||||||
|
group by sh.id
|
||||||
|
),
|
||||||
|
txn as (
|
||||||
|
select sh.id as shift_id,
|
||||||
|
count(*) filter (where t.status='completed') as txn_count,
|
||||||
|
count(*) filter (where t.status='voided') as void_count,
|
||||||
|
coalesce(sum(t.gross_usd) filter (where t.status='completed'),0) as gross_usd,
|
||||||
|
coalesce(sum(t.gross_lbp) filter (where t.status='completed'),0) as gross_lbp,
|
||||||
|
coalesce(sum(t.fee_usd) filter (where t.status='completed'),0) as fee_usd,
|
||||||
|
coalesce(sum(t.fee_lbp) filter (where t.status='completed'),0) as fee_lbp,
|
||||||
|
coalesce(sum(t.commission_usd) filter (where t.status='completed'),0) as comm_usd,
|
||||||
|
coalesce(sum(t.commission_lbp) filter (where t.status='completed'),0) as comm_lbp
|
||||||
|
from app.shifts sh
|
||||||
|
left join app.transactions t on t.shift_id = sh.id
|
||||||
|
group by sh.id
|
||||||
|
)
|
||||||
|
select
|
||||||
|
sh.id as shift_id, sh.shop_id, sh.till_id,
|
||||||
|
sh.user_id as cashier_id,
|
||||||
|
sh.opened_at, sh.closed_at, sh.status,
|
||||||
|
sh.opening_usd, sh.opening_lbp,
|
||||||
|
cm.net_usd as expected_close_usd,
|
||||||
|
cm.net_lbp as expected_close_lbp,
|
||||||
|
sh.declared_close_usd, sh.declared_close_lbp,
|
||||||
|
coalesce(sh.declared_close_usd, 0) - cm.net_usd as variance_usd,
|
||||||
|
coalesce(sh.declared_close_lbp, 0) - cm.net_lbp as variance_lbp,
|
||||||
|
txn.txn_count, txn.void_count,
|
||||||
|
txn.gross_usd, txn.gross_lbp,
|
||||||
|
txn.fee_usd + txn.comm_usd as revenue_usd,
|
||||||
|
txn.fee_lbp + txn.comm_lbp as revenue_lbp
|
||||||
|
from app.shifts sh
|
||||||
|
join cm on cm.shift_id = sh.id
|
||||||
|
join txn on txn.shift_id = sh.id;
|
||||||
|
grant select on app.v_z_report to authenticated;
|
||||||
|
|
||||||
|
drop view if exists app.v_employee_scorecard_30d cascade;
|
||||||
|
create view app.v_employee_scorecard_30d as
|
||||||
|
with base as (
|
||||||
|
select sh.user_id as cashier_id, sh.shop_id, sh.id as shift_id,
|
||||||
|
(coalesce(sh.declared_close_usd,0) - z.expected_close_usd) as var_usd,
|
||||||
|
(coalesce(sh.declared_close_lbp,0) - z.expected_close_lbp) as var_lbp
|
||||||
|
from app.shifts sh
|
||||||
|
join app.v_z_report z on z.shift_id = sh.id
|
||||||
|
where sh.closed_at >= now() - interval '30 days'
|
||||||
|
and sh.status = 'closed'
|
||||||
|
),
|
||||||
|
voids as (
|
||||||
|
select t.shop_id, t.user_id as cashier_id,
|
||||||
|
count(*) as voids_30d,
|
||||||
|
count(*) filter (where t.voided_at - t.occurred_at > interval '10 minutes')
|
||||||
|
as late_voids_30d
|
||||||
|
from app.transactions t
|
||||||
|
where t.status = 'voided' and t.voided_at >= now() - interval '30 days'
|
||||||
|
group by t.shop_id, t.user_id
|
||||||
|
),
|
||||||
|
ovr as (
|
||||||
|
select t.shop_id, t.user_id as cashier_id,
|
||||||
|
count(*) as overrides_30d
|
||||||
|
from app.price_overrides p
|
||||||
|
join app.transactions t on t.id = p.txn_id
|
||||||
|
where p.created_at >= now() - interval '30 days'
|
||||||
|
group by t.shop_id, t.user_id
|
||||||
|
)
|
||||||
|
select
|
||||||
|
b.cashier_id, b.shop_id,
|
||||||
|
count(*) as shifts_30d,
|
||||||
|
count(*) filter (where b.var_usd < 0) as short_shifts_usd,
|
||||||
|
count(*) filter (where b.var_lbp < 0) as short_shifts_lbp,
|
||||||
|
sum(b.var_usd) as total_var_usd,
|
||||||
|
sum(b.var_lbp) as total_var_lbp,
|
||||||
|
avg(b.var_usd) as avg_var_usd,
|
||||||
|
avg(b.var_lbp) as avg_var_lbp,
|
||||||
|
coalesce(v.voids_30d, 0) as voids_30d,
|
||||||
|
coalesce(v.late_voids_30d, 0) as late_voids_30d,
|
||||||
|
coalesce(o.overrides_30d, 0) as overrides_30d
|
||||||
|
from base b
|
||||||
|
left join voids v on v.cashier_id = b.cashier_id and v.shop_id = b.shop_id
|
||||||
|
left join ovr o on o.cashier_id = b.cashier_id and o.shop_id = b.shop_id
|
||||||
|
group by b.cashier_id, b.shop_id, v.voids_30d, v.late_voids_30d, o.overrides_30d;
|
||||||
|
grant select on app.v_employee_scorecard_30d to authenticated;
|
||||||
|
|
||||||
|
-- AML views: re-create with shop_id and aliases the detector loop expects.
|
||||||
|
drop view if exists app.v_aml_structuring_by_customer cascade;
|
||||||
|
create view app.v_aml_structuring_by_customer as
|
||||||
|
with d as (
|
||||||
|
select t.shop_id, t.customer_id, t.service_code,
|
||||||
|
(t.occurred_at at time zone 'UTC')::date as day,
|
||||||
|
count(*) as cnt,
|
||||||
|
sum(t.gross_usd) as sum_usd,
|
||||||
|
sum(t.gross_lbp) as sum_lbp
|
||||||
|
from app.transactions t
|
||||||
|
where t.status = 'completed'
|
||||||
|
and t.service_code in ('OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE')
|
||||||
|
and t.customer_id is not null
|
||||||
|
group by 1,2,3,4
|
||||||
|
)
|
||||||
|
select d.*
|
||||||
|
from d
|
||||||
|
where d.cnt >= 3
|
||||||
|
and (
|
||||||
|
d.sum_usd >= 0.8 * coalesce(
|
||||||
|
(select daily_amount_warn from app.kyc_thresholds
|
||||||
|
where service_code = d.service_code and currency = 'USD'), 1e18)
|
||||||
|
or d.sum_lbp >= 0.8 * coalesce(
|
||||||
|
(select daily_amount_warn from app.kyc_thresholds
|
||||||
|
where service_code = d.service_code and currency = 'LBP'), 1e18));
|
||||||
|
grant select on app.v_aml_structuring_by_customer to authenticated;
|
||||||
|
|
||||||
|
drop view if exists app.v_aml_same_beneficiary_burst cascade;
|
||||||
|
create view app.v_aml_same_beneficiary_burst as
|
||||||
|
select t.shop_id,
|
||||||
|
t.beneficiary_phone,
|
||||||
|
date_trunc('hour', t.occurred_at) as window_hour,
|
||||||
|
count(*) as cnt,
|
||||||
|
count(distinct t.user_id) as cashier_count,
|
||||||
|
sum(t.gross_usd) as sum_usd,
|
||||||
|
sum(t.gross_lbp) as sum_lbp
|
||||||
|
from app.transactions t
|
||||||
|
where t.status = 'completed'
|
||||||
|
and t.service_code in ('OMT_SEND','WU_SEND')
|
||||||
|
and t.beneficiary_phone is not null
|
||||||
|
group by 1,2,3
|
||||||
|
having count(*) >= 3 and count(distinct t.user_id) >= 2;
|
||||||
|
grant select on app.v_aml_same_beneficiary_burst to authenticated;
|
||||||
|
|
||||||
|
-- End migration 0013 ----------------------------------------------------
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0014 — Product Catalog for fixed-price commodities
|
||||||
|
--
|
||||||
|
-- Replaces manual cost/face value entry for standard recharges, goods,
|
||||||
|
-- and fixed-fee services with a controlled catalog managed by owners.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
create table if not exists app.products (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
shop_id uuid not null references app.shops(id) on delete cascade,
|
||||||
|
service_code text not null, -- e.g., 'ALFA_RECHARGE', 'TOUCH_RECHARGE'
|
||||||
|
product_code text not null, -- e.g., 'ALFA_10', 'TOUCH_22.73'
|
||||||
|
name citext not null, -- User-facing display name
|
||||||
|
unit_cost_usd numeric(12,2) not null default 0 constraint ck_cost_usd_nonneg check (unit_cost_usd >= 0),
|
||||||
|
unit_face_usd numeric(12,2) not null default 0 constraint ck_face_usd_nonneg check (unit_face_usd >= 0),
|
||||||
|
unit_cost_lbp numeric(16,0) not null default 0 constraint ck_cost_lbp_nonneg check (unit_cost_lbp >= 0),
|
||||||
|
unit_face_lbp numeric(16,0) not null default 0 constraint ck_face_lbp_nonneg check (unit_face_lbp >= 0),
|
||||||
|
is_active boolean not null default true,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
|
||||||
|
constraint unq_product_code_per_shop unique (shop_id, service_code, product_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_products_shop_service on app.products(shop_id, service_code) where is_active = true;
|
||||||
|
|
||||||
|
-- Update trigger
|
||||||
|
create or replace function app.trg_products_updated_at()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
new.updated_at = now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
do $$ begin
|
||||||
|
create trigger trg_products_updated_at
|
||||||
|
before update on app.products
|
||||||
|
for each row execute function app.trg_products_updated_at();
|
||||||
|
exception when duplicate_object then null; end $$;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- RLS
|
||||||
|
-- =====================================================================
|
||||||
|
alter table app.products enable row level security;
|
||||||
|
alter table app.products force row level security;
|
||||||
|
|
||||||
|
-- Cashiers and managers can select active products
|
||||||
|
create policy "Staff can read active products"
|
||||||
|
on app.products for select
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
app.has_any_role_in_shop(shop_id, array['owner', 'manager', 'cashier', 'auditor']::app.business_role[])
|
||||||
|
and (is_active = true or app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[]))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Owners and admins can manage products globally or per shop
|
||||||
|
create policy "Owners and managers can insert products"
|
||||||
|
on app.products for insert
|
||||||
|
to authenticated
|
||||||
|
with check (
|
||||||
|
app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[])
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy "Owners and managers can update products"
|
||||||
|
on app.products for update
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[])
|
||||||
|
)
|
||||||
|
with check (
|
||||||
|
app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[])
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy "Owners and managers can delete products"
|
||||||
|
on app.products for delete
|
||||||
|
to authenticated
|
||||||
|
using (
|
||||||
|
app.has_any_role_in_shop(shop_id, array['owner', 'manager']::app.business_role[])
|
||||||
|
);
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Seed with initial safety defaults
|
||||||
|
-- =====================================================================
|
||||||
|
insert into app.products (shop_id, service_code, product_code, name, unit_face_usd, unit_cost_usd)
|
||||||
|
select s.id, 'ALFA_RECHARGE', 'ALFA_10', 'Alfa $10', 10.00, 9.50
|
||||||
|
from app.shops s
|
||||||
|
on conflict on constraint unq_product_code_per_shop do nothing;
|
||||||
|
|
||||||
|
insert into app.products (shop_id, service_code, product_code, name, unit_face_usd, unit_cost_usd)
|
||||||
|
select s.id, 'ALFA_RECHARGE', 'ALFA_22.73', 'Alfa $22.73', 22.73, 22.50
|
||||||
|
from app.shops s
|
||||||
|
on conflict on constraint unq_product_code_per_shop do nothing;
|
||||||
|
|
||||||
|
insert into app.products (shop_id, service_code, product_code, name, unit_face_usd, unit_cost_usd)
|
||||||
|
select s.id, 'TOUCH_RECHARGE', 'TOUCH_10', 'Touch $10', 10.00, 9.50
|
||||||
|
from app.shops s
|
||||||
|
on conflict on constraint unq_product_code_per_shop do nothing;
|
||||||
|
|
||||||
|
insert into app.products (shop_id, service_code, product_code, name, unit_face_usd, unit_cost_usd)
|
||||||
|
select s.id, 'TOUCH_RECHARGE', 'TOUCH_22.73', 'Touch $22.73', 22.73, 22.50
|
||||||
|
from app.shops s
|
||||||
|
on conflict on constraint unq_product_code_per_shop do nothing;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0015 — Mid-Day Shift Cash Drops
|
||||||
|
--
|
||||||
|
-- Enables cashiers to "drop" large sums of accumulated cash (esp USD payout cash)
|
||||||
|
-- into a safe midway through a shift, removing their liability without
|
||||||
|
-- requiring them to close out and open a brand new shift.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
create or replace function app.record_cash_drop(
|
||||||
|
p_shift_id uuid,
|
||||||
|
p_drop_usd numeric,
|
||||||
|
p_drop_lbp numeric,
|
||||||
|
p_notes text default null
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_shop uuid;
|
||||||
|
v_till uuid;
|
||||||
|
v_status text;
|
||||||
|
v_user uuid;
|
||||||
|
begin
|
||||||
|
select shop_id, till_id, status, user_id
|
||||||
|
into v_shop, v_till, v_status, v_user
|
||||||
|
from app.shifts
|
||||||
|
where id = p_shift_id;
|
||||||
|
|
||||||
|
if v_shop is null then
|
||||||
|
raise exception 'Shift not found';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_user <> auth.uid() and not app.has_role_in_shop(v_shop, 'manager') then
|
||||||
|
raise exception 'Only the shift owner or a manager may record a drop';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_status <> 'open' then
|
||||||
|
raise exception 'Must have an open shift to record a soft drop';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_drop_usd < 0 or p_drop_lbp < 0 then
|
||||||
|
raise exception 'Drop amounts cannot be negative';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_drop_usd = 0 and p_drop_lbp = 0 then
|
||||||
|
raise exception 'Must drop > 0 in at least one currency';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Create a matching cash_movements record reducing the drawer balance
|
||||||
|
insert into app.cash_movements(
|
||||||
|
shift_id, movement_type, currency, amount, external_ref
|
||||||
|
)
|
||||||
|
select
|
||||||
|
p_shift_id,
|
||||||
|
'safe_drop',
|
||||||
|
case when d.idx = 1 then 'USD' else 'LBP' end,
|
||||||
|
case when d.idx = 1 then p_drop_usd else p_drop_lbp end,
|
||||||
|
p_notes
|
||||||
|
from (values (1), (2)) as d(idx)
|
||||||
|
where (d.idx = 1 and p_drop_usd > 0)
|
||||||
|
or (d.idx = 2 and p_drop_lbp > 0);
|
||||||
|
|
||||||
|
perform app.log_auth_event('safe_drop_recorded', v_shop, null,
|
||||||
|
jsonb_build_object('shift_id', p_shift_id, 'usd', p_drop_usd, 'lbp', p_drop_lbp));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function app.record_cash_drop(uuid, numeric, numeric, text) from public;
|
||||||
|
grant execute on function app.record_cash_drop(uuid, numeric, numeric, text) to authenticated;
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0016: Shift Assignments
|
||||||
|
-- Extends open_shift so managers/owners can assign a shift to any employee.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
drop function if exists app.open_shift(uuid, numeric, numeric);
|
||||||
|
|
||||||
|
create or replace function app.open_shift(
|
||||||
|
p_till_id uuid,
|
||||||
|
p_opening_usd numeric,
|
||||||
|
p_opening_lbp numeric,
|
||||||
|
p_assigned_user_id uuid default null
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_shop uuid;
|
||||||
|
v_shift uuid;
|
||||||
|
v_target_user uuid;
|
||||||
|
begin
|
||||||
|
if p_opening_usd is null or p_opening_lbp is null then
|
||||||
|
raise exception 'opening counts are required';
|
||||||
|
end if;
|
||||||
|
if p_opening_usd < 0 or p_opening_lbp < 0 then
|
||||||
|
raise exception 'opening counts must be non-negative';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select shop_id into v_shop from app.tills where id = p_till_id and is_active;
|
||||||
|
if v_shop is null then
|
||||||
|
raise exception 'till % not found or inactive', p_till_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_target_user := coalesce(p_assigned_user_id, auth.uid());
|
||||||
|
|
||||||
|
-- Caller must have a role to open.
|
||||||
|
if not app.has_any_role_in_shop(v_shop, array['owner','manager','cashier']::app.business_role[]) then
|
||||||
|
raise exception 'not authorized to open a shift on this till';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- If trying to open for someone else, must be owner or manager
|
||||||
|
if v_target_user <> auth.uid() then
|
||||||
|
if not app.has_any_role_in_shop(v_shop, array['owner','manager']::app.business_role[]) then
|
||||||
|
raise exception 'only managers or owners can assign shifts to other users';
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Target user must have a role in the shop
|
||||||
|
if not exists (
|
||||||
|
select 1 from app.user_shop_assignments
|
||||||
|
where user_id = v_target_user and shop_id = v_shop
|
||||||
|
) then
|
||||||
|
raise exception 'target user does not have a role in this shop';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Reject if any non-closed shift exists on this till.
|
||||||
|
if exists (select 1 from app.shifts where till_id = p_till_id and status <> 'closed') then
|
||||||
|
raise exception 'till % already has an active shift; close it first', p_till_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into app.shifts(till_id, shop_id, user_id, opened_by, opening_usd, opening_lbp)
|
||||||
|
values (p_till_id, v_shop, v_target_user, auth.uid(), p_opening_usd, p_opening_lbp)
|
||||||
|
returning id into v_shift;
|
||||||
|
|
||||||
|
-- Record the opening float as a cash movement for clean ledgers.
|
||||||
|
insert into app.cash_movements(shift_id, type, currency, amount, note)
|
||||||
|
values (v_shift, 'opening_float', 'USD', p_opening_usd, 'opening float'),
|
||||||
|
(v_shift, 'opening_float', 'LBP', p_opening_lbp, 'opening float');
|
||||||
|
|
||||||
|
perform app.log_auth_event('shift_opened', v_shop, null,
|
||||||
|
jsonb_build_object('shift_id', v_shift, 'till_id', p_till_id, 'assigned_user_id', v_target_user));
|
||||||
|
return v_shift;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function app.open_shift(uuid, numeric, numeric, uuid) from public;
|
||||||
|
grant execute on function app.open_shift(uuid, numeric, numeric, uuid) to authenticated;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
create or replace function app.get_shop_users(p_shop_id uuid)
|
||||||
|
returns table (
|
||||||
|
user_id uuid,
|
||||||
|
full_name text,
|
||||||
|
role text
|
||||||
|
) language sql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
select
|
||||||
|
usa.user_id,
|
||||||
|
up.full_name,
|
||||||
|
usa.role::text
|
||||||
|
from app.user_shop_assignments usa
|
||||||
|
join app.user_profiles up on up.user_id = usa.user_id
|
||||||
|
where usa.shop_id = p_shop_id;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function app.get_shop_users(uuid) from public;
|
||||||
|
grant execute on function app.get_shop_users(uuid) to authenticated;
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0018 — Money-transfer cash + float coupling.
|
||||||
|
--
|
||||||
|
-- Closes the largest hole in the cash-control model: until now,
|
||||||
|
-- record_omt_send / record_omt_receive / record_bill only inserted into
|
||||||
|
-- app.transactions and the detail table. They did NOT post anything to
|
||||||
|
-- app.cash_movements or app.float_movements, so:
|
||||||
|
--
|
||||||
|
-- * v_z_report.expected_close_usd = sum(cash_movements) was wrong by
|
||||||
|
-- the entire transfer turnover, hiding cashier shortages.
|
||||||
|
-- * The OMT/Whish/WU/biller float balance never moved, so we couldn't
|
||||||
|
-- tell who owed whom and the matcher could only compare by
|
||||||
|
-- external_ref, never by money.
|
||||||
|
-- * Threat-model rows #1, #5, #21 had no DB-level enforcement for
|
||||||
|
-- money transfers (only recharges had the deferred-trigger
|
||||||
|
-- constraint).
|
||||||
|
--
|
||||||
|
-- This migration:
|
||||||
|
-- 1. Adds app._post_cash_for_txn / app._post_float_for_txn helpers.
|
||||||
|
-- 2. Adds app._get_or_create_float(shop, provider, currency).
|
||||||
|
-- 3. Re-defines record_omt_send / record_omt_receive / record_bill so
|
||||||
|
-- each posts the cash leg (when payment_method is cash_*) and the
|
||||||
|
-- provider-float leg in the same SECURITY DEFINER body.
|
||||||
|
-- 4. Adds record_whish_send, record_wu_send, record_wu_receive so the
|
||||||
|
-- UI does not silently file Whish/WU under provider='OMT'.
|
||||||
|
-- 5. Adds a deferred constraint trigger that requires every completed
|
||||||
|
-- money-transfer / bill txn to have at least one float_movement
|
||||||
|
-- row. Recharges already have their own coupling trigger from
|
||||||
|
-- 0005; goods sales have one too.
|
||||||
|
--
|
||||||
|
-- Threat-model rows: 1, 5, 7, 8, 14, 21, 24.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Helper: get_or_create the float account the txn should debit/credit.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app._get_or_create_float(
|
||||||
|
p_shop uuid,
|
||||||
|
p_provider app.float_provider,
|
||||||
|
p_currency app.currency_code
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare v_id uuid;
|
||||||
|
begin
|
||||||
|
select id into v_id from app.floats
|
||||||
|
where shop_id = p_shop and provider = p_provider and currency = p_currency;
|
||||||
|
if v_id is null then
|
||||||
|
insert into app.floats(shop_id, provider, currency)
|
||||||
|
values (p_shop, p_provider, p_currency)
|
||||||
|
returning id into v_id;
|
||||||
|
-- Initialise the cached balance row at zero.
|
||||||
|
insert into app.float_balances(float_id, balance) values (v_id, 0)
|
||||||
|
on conflict (float_id) do nothing;
|
||||||
|
end if;
|
||||||
|
return v_id;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app._get_or_create_float(uuid, app.float_provider, app.currency_code)
|
||||||
|
from public;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Helper: post the cash leg for a customer-facing transaction.
|
||||||
|
--
|
||||||
|
-- Convention (matches 0002 cash_movements):
|
||||||
|
-- * Positive amount = cash into till.
|
||||||
|
-- * Negative amount = cash out of till.
|
||||||
|
-- This function takes a "customer movement" sign:
|
||||||
|
-- * p_customer_paid > 0 -> cash_in (sale_in) amount = +p_customer_paid
|
||||||
|
-- * p_customer_paid < 0 -> cash_out (payout_out) amount = p_customer_paid
|
||||||
|
-- For non-cash payment methods (whish, omt_wallet, card, bank_transfer)
|
||||||
|
-- the cash leg is skipped — those balances live on their own floats.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app._post_cash_for_txn(
|
||||||
|
p_txn_id uuid,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_customer_usd numeric,
|
||||||
|
p_customer_lbp numeric
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_shift uuid;
|
||||||
|
v_type app.cash_movement_type;
|
||||||
|
begin
|
||||||
|
-- Only cash-in-till payment methods produce a cash leg.
|
||||||
|
if p_payment_method not in ('cash_usd','cash_lbp') then
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select shift_id into v_shift from app.transactions where id = p_txn_id;
|
||||||
|
if v_shift is null then
|
||||||
|
raise exception 'txn % not found while posting cash leg', p_txn_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- For cash_usd payment method, only USD leg may move; same for LBP.
|
||||||
|
if p_payment_method = 'cash_usd' then
|
||||||
|
if coalesce(p_customer_lbp, 0) <> 0 then
|
||||||
|
raise exception 'cash_usd payment must not move LBP (got %)', p_customer_lbp;
|
||||||
|
end if;
|
||||||
|
if coalesce(p_customer_usd, 0) = 0 then return; end if;
|
||||||
|
v_type := case when p_customer_usd > 0 then 'sale_in'::app.cash_movement_type
|
||||||
|
else 'payout_out'::app.cash_movement_type end;
|
||||||
|
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
|
||||||
|
values (v_shift, v_type, 'USD', p_customer_usd, p_txn_id, 'auto: txn cash leg');
|
||||||
|
else -- cash_lbp
|
||||||
|
if coalesce(p_customer_usd, 0) <> 0 then
|
||||||
|
raise exception 'cash_lbp payment must not move USD (got %)', p_customer_usd;
|
||||||
|
end if;
|
||||||
|
if coalesce(p_customer_lbp, 0) = 0 then return; end if;
|
||||||
|
v_type := case when p_customer_lbp > 0 then 'sale_in'::app.cash_movement_type
|
||||||
|
else 'payout_out'::app.cash_movement_type end;
|
||||||
|
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
|
||||||
|
values (v_shift, v_type, 'LBP', p_customer_lbp, p_txn_id, 'auto: txn cash leg');
|
||||||
|
end if;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app._post_cash_for_txn(uuid, app.payment_method, numeric, numeric)
|
||||||
|
from public;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Helper: post the provider-float leg for a customer-facing transaction.
|
||||||
|
--
|
||||||
|
-- p_amount sign convention on app.float_movements:
|
||||||
|
-- + : float increases (provider owes shop more, e-recharge wallet
|
||||||
|
-- topped up, OMT credits us at settlement, ...)
|
||||||
|
-- - : float decreases (we used it up, we owe provider more cash, ...)
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app._post_float_for_txn(
|
||||||
|
p_txn_id uuid,
|
||||||
|
p_provider app.float_provider,
|
||||||
|
p_currency app.currency_code,
|
||||||
|
p_amount numeric,
|
||||||
|
p_reason text
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_shop uuid; v_shift uuid; v_float uuid;
|
||||||
|
begin
|
||||||
|
if coalesce(p_amount, 0) = 0 then return; end if;
|
||||||
|
select shop_id, shift_id into v_shop, v_shift
|
||||||
|
from app.transactions where id = p_txn_id;
|
||||||
|
if v_shop is null then
|
||||||
|
raise exception 'txn % not found while posting float leg', p_txn_id;
|
||||||
|
end if;
|
||||||
|
v_float := app._get_or_create_float(v_shop, p_provider, p_currency);
|
||||||
|
insert into app.float_movements(float_id, shift_id, amount, ref_txn_id, reason)
|
||||||
|
values (v_float, v_shift, p_amount, p_txn_id, p_reason);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app._post_float_for_txn(uuid, app.float_provider,
|
||||||
|
app.currency_code, numeric, text) from public;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Map a money-transfer service code to its float provider.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app._money_transfer_provider(p_service text)
|
||||||
|
returns app.float_provider
|
||||||
|
language sql
|
||||||
|
immutable
|
||||||
|
as $$
|
||||||
|
select case p_service
|
||||||
|
when 'OMT_SEND' then 'OMT_CASH'::app.float_provider
|
||||||
|
when 'OMT_RECEIVE' then 'OMT_CASH'::app.float_provider
|
||||||
|
when 'OMT_BILL' then 'OMT_CASH'::app.float_provider
|
||||||
|
when 'WU_SEND' then 'OMT_CASH'::app.float_provider -- WU runs on the OMT cash pool in LB
|
||||||
|
when 'WU_RECEIVE' then 'OMT_CASH'::app.float_provider
|
||||||
|
when 'WHISH_SEND' then 'WHISH'::app.float_provider
|
||||||
|
when 'EDL_BILL' then 'OMT_CASH'::app.float_provider -- EDL paid via OMT counter
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Re-define record_omt_send to post cash + float in one go.
|
||||||
|
-- Provider is stamped from the service code, not hard-coded.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.record_omt_send(
|
||||||
|
p_shop uuid, p_till uuid,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_gross_usd numeric, p_gross_lbp numeric,
|
||||||
|
p_fee_usd numeric, p_fee_lbp numeric,
|
||||||
|
p_commission_usd numeric, p_commission_lbp numeric,
|
||||||
|
p_fx_rate numeric,
|
||||||
|
p_external_ref text,
|
||||||
|
p_direction app.transfer_direction,
|
||||||
|
p_sender_full_name text, p_sender_id_type app.id_doc_type,
|
||||||
|
p_sender_id_number text, p_sender_phone text,
|
||||||
|
p_sender_dob date, p_sender_nationality text,
|
||||||
|
p_beneficiary_full_name text, p_beneficiary_phone text,
|
||||||
|
p_destination_country text,
|
||||||
|
p_purpose_code text, p_purpose_note text,
|
||||||
|
p_kyc_doc_url text,
|
||||||
|
p_customer_id uuid,
|
||||||
|
p_notes text,
|
||||||
|
p_service_code text default 'OMT_SEND' -- 'OMT_SEND' | 'WU_SEND' | 'WHISH_SEND'
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_txn uuid;
|
||||||
|
v_provider_lbl text;
|
||||||
|
v_float_prov app.float_provider;
|
||||||
|
begin
|
||||||
|
if p_service_code not in ('OMT_SEND','WU_SEND','WHISH_SEND') then
|
||||||
|
raise exception 'record_omt_send: unsupported service %', p_service_code;
|
||||||
|
end if;
|
||||||
|
v_provider_lbl := case p_service_code
|
||||||
|
when 'OMT_SEND' then 'OMT'
|
||||||
|
when 'WU_SEND' then 'WU'
|
||||||
|
when 'WHISH_SEND' then 'WHISH'
|
||||||
|
end;
|
||||||
|
|
||||||
|
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
|
||||||
|
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
|
||||||
|
p_commission_usd, p_commission_lbp, p_fx_rate,
|
||||||
|
v_provider_lbl, p_external_ref,
|
||||||
|
p_beneficiary_full_name, p_beneficiary_phone,
|
||||||
|
p_customer_id, p_notes);
|
||||||
|
|
||||||
|
insert into app.omt_send_details(
|
||||||
|
txn_id, direction,
|
||||||
|
sender_full_name, sender_id_type, sender_id_number, sender_phone,
|
||||||
|
sender_dob, sender_nationality,
|
||||||
|
beneficiary_full_name, beneficiary_phone, destination_country,
|
||||||
|
purpose_code, purpose_note, kyc_doc_url
|
||||||
|
) values (
|
||||||
|
v_txn, p_direction,
|
||||||
|
p_sender_full_name, p_sender_id_type, p_sender_id_number, p_sender_phone,
|
||||||
|
p_sender_dob, p_sender_nationality,
|
||||||
|
p_beneficiary_full_name, p_beneficiary_phone, p_destination_country,
|
||||||
|
p_purpose_code, p_purpose_note, p_kyc_doc_url
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Cash leg: customer hands over gross + fee in cash.
|
||||||
|
perform app._post_cash_for_txn(v_txn, p_payment_method,
|
||||||
|
coalesce(p_gross_usd,0) + coalesce(p_fee_usd,0),
|
||||||
|
coalesce(p_gross_lbp,0) + coalesce(p_fee_lbp,0));
|
||||||
|
|
||||||
|
-- Float leg: shop now owes the provider gross (we keep fee+comm).
|
||||||
|
v_float_prov := app._money_transfer_provider(p_service_code);
|
||||||
|
perform app._post_float_for_txn(v_txn, v_float_prov, 'USD',
|
||||||
|
-coalesce(p_gross_usd,0), 'send: shop owes provider gross');
|
||||||
|
perform app._post_float_for_txn(v_txn, v_float_prov, 'LBP',
|
||||||
|
-coalesce(p_gross_lbp,0), 'send: shop owes provider gross');
|
||||||
|
|
||||||
|
return v_txn;
|
||||||
|
end; $$;
|
||||||
|
revoke all on function app.record_omt_send(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
|
||||||
|
text, app.transfer_direction,
|
||||||
|
text, app.id_doc_type, text, text, date, text,
|
||||||
|
text, text, text, text, text, text, uuid, text, text) from public;
|
||||||
|
grant execute on function app.record_omt_send(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
|
||||||
|
text, app.transfer_direction,
|
||||||
|
text, app.id_doc_type, text, text, date, text,
|
||||||
|
text, text, text, text, text, text, uuid, text, text) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Re-define record_omt_receive (also serves WU_RECEIVE).
|
||||||
|
-- Customer presents code, cashier hands them gross.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.record_omt_receive(
|
||||||
|
p_shop uuid, p_till uuid,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_gross_usd numeric, p_gross_lbp numeric,
|
||||||
|
p_fee_usd numeric, p_fee_lbp numeric,
|
||||||
|
p_commission_usd numeric, p_commission_lbp numeric,
|
||||||
|
p_fx_rate numeric,
|
||||||
|
p_payout_code text,
|
||||||
|
p_beneficiary_full_name text,
|
||||||
|
p_beneficiary_id_type app.id_doc_type,
|
||||||
|
p_beneficiary_id_number text,
|
||||||
|
p_beneficiary_phone text,
|
||||||
|
p_origin_country text,
|
||||||
|
p_kyc_doc_url text,
|
||||||
|
p_customer_id uuid,
|
||||||
|
p_notes text,
|
||||||
|
p_service_code text default 'OMT_RECEIVE' -- or 'WU_RECEIVE'
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_txn uuid;
|
||||||
|
v_provider_lbl text;
|
||||||
|
v_float_prov app.float_provider;
|
||||||
|
v_net_usd numeric;
|
||||||
|
v_net_lbp numeric;
|
||||||
|
begin
|
||||||
|
if p_service_code not in ('OMT_RECEIVE','WU_RECEIVE') then
|
||||||
|
raise exception 'record_omt_receive: unsupported service %', p_service_code;
|
||||||
|
end if;
|
||||||
|
v_provider_lbl := case p_service_code
|
||||||
|
when 'OMT_RECEIVE' then 'OMT'
|
||||||
|
when 'WU_RECEIVE' then 'WU'
|
||||||
|
end;
|
||||||
|
|
||||||
|
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
|
||||||
|
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
|
||||||
|
p_commission_usd, p_commission_lbp, p_fx_rate,
|
||||||
|
v_provider_lbl, p_payout_code,
|
||||||
|
p_beneficiary_full_name, p_beneficiary_phone,
|
||||||
|
p_customer_id, p_notes);
|
||||||
|
|
||||||
|
insert into app.omt_receive_details(
|
||||||
|
txn_id, payout_code,
|
||||||
|
beneficiary_full_name, beneficiary_id_type, beneficiary_id_number,
|
||||||
|
beneficiary_phone, origin_country, kyc_doc_url
|
||||||
|
) values (
|
||||||
|
v_txn, p_payout_code,
|
||||||
|
p_beneficiary_full_name, p_beneficiary_id_type, p_beneficiary_id_number,
|
||||||
|
p_beneficiary_phone, p_origin_country, p_kyc_doc_url
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Cash leg: shop pays gross out, may collect a small fee from beneficiary.
|
||||||
|
-- net cash to till = -gross + fee
|
||||||
|
-- (Most LB payouts have no beneficiary-side fee; if fee=0 this just
|
||||||
|
-- becomes -gross.)
|
||||||
|
v_net_usd := -coalesce(p_gross_usd,0) + coalesce(p_fee_usd,0);
|
||||||
|
v_net_lbp := -coalesce(p_gross_lbp,0) + coalesce(p_fee_lbp,0);
|
||||||
|
perform app._post_cash_for_txn(v_txn, p_payment_method, v_net_usd, v_net_lbp);
|
||||||
|
|
||||||
|
-- Float leg: provider now owes the shop gross + commission.
|
||||||
|
v_float_prov := app._money_transfer_provider(p_service_code);
|
||||||
|
perform app._post_float_for_txn(v_txn, v_float_prov, 'USD',
|
||||||
|
coalesce(p_gross_usd,0) + coalesce(p_commission_usd,0),
|
||||||
|
'receive: provider owes shop gross + commission');
|
||||||
|
perform app._post_float_for_txn(v_txn, v_float_prov, 'LBP',
|
||||||
|
coalesce(p_gross_lbp,0) + coalesce(p_commission_lbp,0),
|
||||||
|
'receive: provider owes shop gross + commission');
|
||||||
|
|
||||||
|
return v_txn;
|
||||||
|
end; $$;
|
||||||
|
revoke all on function app.record_omt_receive(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
|
||||||
|
text, text, app.id_doc_type, text, text, text, text, uuid, text, text) from public;
|
||||||
|
grant execute on function app.record_omt_receive(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
|
||||||
|
text, text, app.id_doc_type, text, text, text, text, uuid, text, text) to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Re-define record_bill (OMT_BILL / EDL_BILL) with cash + float legs.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.record_bill(
|
||||||
|
p_shop uuid, p_till uuid, p_service_code text,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_gross_usd numeric, p_gross_lbp numeric,
|
||||||
|
p_fee_usd numeric, p_fee_lbp numeric,
|
||||||
|
p_fx_rate numeric,
|
||||||
|
p_external_ref text,
|
||||||
|
p_biller_code text, p_account_number text,
|
||||||
|
p_period text, p_customer_name text,
|
||||||
|
p_customer_id uuid, p_notes text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_txn uuid;
|
||||||
|
v_float_prov app.float_provider;
|
||||||
|
begin
|
||||||
|
if p_service_code not in ('OMT_BILL','EDL_BILL') then
|
||||||
|
raise exception 'record_bill only for bill services, got %', p_service_code;
|
||||||
|
end if;
|
||||||
|
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
|
||||||
|
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp, 0, 0, p_fx_rate,
|
||||||
|
p_biller_code, p_external_ref,
|
||||||
|
p_customer_name, null,
|
||||||
|
p_customer_id, p_notes);
|
||||||
|
|
||||||
|
insert into app.bill_payment_details(
|
||||||
|
txn_id, biller_code, account_number, period, customer_name
|
||||||
|
) values (
|
||||||
|
v_txn, p_biller_code, p_account_number, p_period, p_customer_name
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Cash leg: customer pays gross + fee.
|
||||||
|
perform app._post_cash_for_txn(v_txn, p_payment_method,
|
||||||
|
coalesce(p_gross_usd,0) + coalesce(p_fee_usd,0),
|
||||||
|
coalesce(p_gross_lbp,0) + coalesce(p_fee_lbp,0));
|
||||||
|
|
||||||
|
-- Float leg: shop now owes the biller's settlement counterparty
|
||||||
|
-- gross. EDL/OMT_BILL settle through the OMT cash pool in our model.
|
||||||
|
v_float_prov := app._money_transfer_provider(p_service_code);
|
||||||
|
if v_float_prov is not null then
|
||||||
|
perform app._post_float_for_txn(v_txn, v_float_prov, 'USD',
|
||||||
|
-coalesce(p_gross_usd,0), 'bill: shop owes biller gross');
|
||||||
|
perform app._post_float_for_txn(v_txn, v_float_prov, 'LBP',
|
||||||
|
-coalesce(p_gross_lbp,0), 'bill: shop owes biller gross');
|
||||||
|
end if;
|
||||||
|
return v_txn;
|
||||||
|
end; $$;
|
||||||
|
revoke all on function app.record_bill(uuid, uuid, text, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, text, text, text, text, text, uuid, text)
|
||||||
|
from public;
|
||||||
|
grant execute on function app.record_bill(uuid, uuid, text, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, text, text, text, text, text, uuid, text)
|
||||||
|
to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Convenience wrapper for Whish — same shape as omt_send_details for
|
||||||
|
-- now (sender + beneficiary). The UI sends WHISH_SEND and gets a
|
||||||
|
-- correctly tagged provider on the txn row.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.record_whish_send(
|
||||||
|
p_shop uuid, p_till uuid,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_gross_usd numeric, p_gross_lbp numeric,
|
||||||
|
p_fee_usd numeric, p_fee_lbp numeric,
|
||||||
|
p_commission_usd numeric, p_commission_lbp numeric,
|
||||||
|
p_fx_rate numeric,
|
||||||
|
p_external_ref text,
|
||||||
|
p_direction app.transfer_direction,
|
||||||
|
p_sender_full_name text, p_sender_id_type app.id_doc_type,
|
||||||
|
p_sender_id_number text, p_sender_phone text,
|
||||||
|
p_beneficiary_full_name text, p_beneficiary_phone text,
|
||||||
|
p_purpose_code text, p_purpose_note text,
|
||||||
|
p_kyc_doc_url text,
|
||||||
|
p_customer_id uuid, p_notes text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
return app.record_omt_send(
|
||||||
|
p_shop, p_till, p_payment_method,
|
||||||
|
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp,
|
||||||
|
p_commission_usd, p_commission_lbp, p_fx_rate,
|
||||||
|
p_external_ref, p_direction,
|
||||||
|
p_sender_full_name, p_sender_id_type, p_sender_id_number, p_sender_phone,
|
||||||
|
null, null,
|
||||||
|
p_beneficiary_full_name, p_beneficiary_phone, null,
|
||||||
|
p_purpose_code, p_purpose_note, p_kyc_doc_url,
|
||||||
|
p_customer_id, p_notes,
|
||||||
|
'WHISH_SEND'
|
||||||
|
);
|
||||||
|
end; $$;
|
||||||
|
revoke all on function app.record_whish_send(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
|
||||||
|
text, app.transfer_direction,
|
||||||
|
text, app.id_doc_type, text, text, text, text, text, text, text, uuid, text)
|
||||||
|
from public;
|
||||||
|
grant execute on function app.record_whish_send(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric, numeric, numeric,
|
||||||
|
text, app.transfer_direction,
|
||||||
|
text, app.id_doc_type, text, text, text, text, text, text, text, uuid, text)
|
||||||
|
to authenticated;
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Deferred constraint trigger: every completed money-transfer / bill
|
||||||
|
-- transaction must end up with at least one float_movement row. The
|
||||||
|
-- trigger fires at COMMIT, so the record_* functions above can post the
|
||||||
|
-- float leg after the txn insert in the same transaction.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app._money_transfer_require_movement()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
ok boolean;
|
||||||
|
is_money_transfer boolean;
|
||||||
|
begin
|
||||||
|
if new.status <> 'completed' then return null; end if;
|
||||||
|
|
||||||
|
is_money_transfer := new.service_code in
|
||||||
|
('OMT_SEND','OMT_RECEIVE','OMT_BILL','EDL_BILL',
|
||||||
|
'WU_SEND','WU_RECEIVE','WHISH_SEND');
|
||||||
|
if not is_money_transfer then return null; end if;
|
||||||
|
|
||||||
|
-- If both gross sides are 0, no money moved -> nothing to require.
|
||||||
|
if coalesce(new.gross_usd,0) = 0 and coalesce(new.gross_lbp,0) = 0 then
|
||||||
|
return null;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select exists (
|
||||||
|
select 1 from app.float_movements
|
||||||
|
where ref_txn_id = new.id
|
||||||
|
) into ok;
|
||||||
|
if not ok then
|
||||||
|
raise exception 'money-transfer txn % (service %) has no float_movement leg',
|
||||||
|
new.id, new.service_code;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_money_transfer_require_movement on app.transactions;
|
||||||
|
create constraint trigger trg_money_transfer_require_movement
|
||||||
|
after insert on app.transactions
|
||||||
|
deferrable initially deferred
|
||||||
|
for each row execute function app._money_transfer_require_movement();
|
||||||
|
|
||||||
|
-- End migration 0018 ----------------------------------------------------
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- Migration 0019 — Cash-movement sign guard + fix record_cash_drop.
|
||||||
|
--
|
||||||
|
-- Two bugs in 0015 + 0002:
|
||||||
|
--
|
||||||
|
-- (a) record_cash_drop in 0015 inserted into app.cash_movements using
|
||||||
|
-- column names that do not exist (`movement_type`, `external_ref`)
|
||||||
|
-- and an enum value that doesn't exist (`safe_drop`). The real
|
||||||
|
-- schema is `type` / `note` and the enum value is `drop_to_safe`.
|
||||||
|
-- Worse, it inserted the drop amount as POSITIVE, which would make
|
||||||
|
-- `expected_close_usd` go UP when cash physically left the till.
|
||||||
|
--
|
||||||
|
-- (b) app.cash_movements has no constraint that the sign of `amount`
|
||||||
|
-- matches the movement `type`. A buggy or malicious insert with
|
||||||
|
-- type='drop_to_safe' amount=+1000 would silently increase the
|
||||||
|
-- expected drawer balance.
|
||||||
|
--
|
||||||
|
-- This migration:
|
||||||
|
-- 1. Adds a BEFORE INSERT trigger on app.cash_movements enforcing the
|
||||||
|
-- sign-vs-type rule.
|
||||||
|
-- 2. Replaces app.record_cash_drop with a correct implementation
|
||||||
|
-- using the real columns and a negative sign.
|
||||||
|
--
|
||||||
|
-- Threat-model rows: 7, 14, 22, 25.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Sign-vs-type guard
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app._cash_mov_sign_check()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
-- + cash into till
|
||||||
|
if new.type in ('opening_float','sale_in','fx_swap_in') then
|
||||||
|
if new.amount <= 0 then
|
||||||
|
raise exception 'cash_movements.type=% must have positive amount (got %)',
|
||||||
|
new.type, new.amount;
|
||||||
|
end if;
|
||||||
|
-- - cash out of till
|
||||||
|
elsif new.type in ('payout_out','drop_to_safe','bank_deposit',
|
||||||
|
'expense','fx_swap_out') then
|
||||||
|
if new.amount >= 0 then
|
||||||
|
raise exception 'cash_movements.type=% must have negative amount (got %)',
|
||||||
|
new.type, new.amount;
|
||||||
|
end if;
|
||||||
|
-- 'adjustment' is the only type that may legitimately go either way
|
||||||
|
-- (manager-approved correction). It must still be non-zero (already
|
||||||
|
-- enforced by the table CHECK).
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_cash_mov_sign_check on app.cash_movements;
|
||||||
|
create trigger trg_cash_mov_sign_check
|
||||||
|
before insert on app.cash_movements
|
||||||
|
for each row execute function app._cash_mov_sign_check();
|
||||||
|
|
||||||
|
-- =====================================================================
|
||||||
|
-- Replace record_cash_drop with a correct implementation.
|
||||||
|
-- Drops are negative cash_movement rows of type 'drop_to_safe'.
|
||||||
|
-- =====================================================================
|
||||||
|
create or replace function app.record_cash_drop(
|
||||||
|
p_shift_id uuid,
|
||||||
|
p_drop_usd numeric,
|
||||||
|
p_drop_lbp numeric,
|
||||||
|
p_notes text default null
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_shop uuid;
|
||||||
|
v_status app.shift_status;
|
||||||
|
v_user uuid;
|
||||||
|
begin
|
||||||
|
if p_drop_usd is null or p_drop_lbp is null
|
||||||
|
or p_drop_usd < 0 or p_drop_lbp < 0 then
|
||||||
|
raise exception 'drop amounts must be non-negative numbers';
|
||||||
|
end if;
|
||||||
|
if coalesce(p_drop_usd,0) = 0 and coalesce(p_drop_lbp,0) = 0 then
|
||||||
|
raise exception 'must drop > 0 in at least one currency';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select shop_id, status, user_id
|
||||||
|
into v_shop, v_status, v_user
|
||||||
|
from app.shifts
|
||||||
|
where id = p_shift_id;
|
||||||
|
if v_shop is null then
|
||||||
|
raise exception 'shift % not found', p_shift_id;
|
||||||
|
end if;
|
||||||
|
if v_status <> 'open' then
|
||||||
|
raise exception 'shift must be open to record a drop (got %)', v_status;
|
||||||
|
end if;
|
||||||
|
if v_user <> auth.uid() and not app.has_role_in_shop(v_shop, 'manager') then
|
||||||
|
raise exception 'only the shift owner or a manager may record a drop';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if p_drop_usd > 0 then
|
||||||
|
insert into app.cash_movements(shift_id, type, currency, amount, note)
|
||||||
|
values (p_shift_id, 'drop_to_safe', 'USD', -p_drop_usd,
|
||||||
|
coalesce(p_notes, 'mid-day safe drop'));
|
||||||
|
end if;
|
||||||
|
if p_drop_lbp > 0 then
|
||||||
|
insert into app.cash_movements(shift_id, type, currency, amount, note)
|
||||||
|
values (p_shift_id, 'drop_to_safe', 'LBP', -p_drop_lbp,
|
||||||
|
coalesce(p_notes, 'mid-day safe drop'));
|
||||||
|
end if;
|
||||||
|
|
||||||
|
perform app.log_auth_event('safe_drop_recorded', v_shop, null,
|
||||||
|
jsonb_build_object('shift_id', p_shift_id,
|
||||||
|
'usd', p_drop_usd, 'lbp', p_drop_lbp));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.record_cash_drop(uuid, numeric, numeric, text) from public;
|
||||||
|
grant execute on function app.record_cash_drop(uuid, numeric, numeric, text) to authenticated;
|
||||||
|
|
||||||
|
-- End migration 0019 ----------------------------------------------------
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 0020_void_reverses_movements.sql
|
||||||
|
--
|
||||||
|
-- Up to and including 0019, app.void_transaction() only flipped the
|
||||||
|
-- transactions.status flag to 'voided'. The original cash_movements,
|
||||||
|
-- float_movements, stock_movements and voucher_inventory rows that the
|
||||||
|
-- record_* functions had posted stayed in place, so the till expected
|
||||||
|
-- balance, OMT/Whish float balance and stock-on-hand were never
|
||||||
|
-- corrected. A cashier could record a $500 OMT_SEND, pocket the $500,
|
||||||
|
-- then void the txn five minutes later and the books would still show
|
||||||
|
-- $500 received in the till.
|
||||||
|
--
|
||||||
|
-- This migration makes void a true accounting reversal:
|
||||||
|
-- * for every cash_movements row tied to the txn we post an opposite-
|
||||||
|
-- signed `adjustment` row (sign guard from 0019 allows either sign
|
||||||
|
-- for adjustment),
|
||||||
|
-- * for every float_movements row we post an opposite-signed row,
|
||||||
|
-- * for every stock_movements row we post an opposite type
|
||||||
|
-- (sale_out -> adjustment_in, return_in -> adjustment_out, etc.)
|
||||||
|
-- with the void approver as approved_by,
|
||||||
|
-- * any voucher_inventory marked sold by the txn is returned to
|
||||||
|
-- `in_stock` so the serial can be re-sold.
|
||||||
|
--
|
||||||
|
-- The reversals reference the same ref_txn_id so reconciliation views
|
||||||
|
-- and the receipts trail keep them paired with the original posting.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
set search_path = app, public;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Helper: post compensating cash + float + stock + voucher rows for a
|
||||||
|
-- transaction that is being voided. Returns nothing; the caller is
|
||||||
|
-- responsible for flipping the txn status itself.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app._reverse_movements_for_txn(
|
||||||
|
p_txn_id uuid,
|
||||||
|
p_approver uuid,
|
||||||
|
p_reason text
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
t app.transactions%rowtype;
|
||||||
|
r_cash record;
|
||||||
|
r_flt record;
|
||||||
|
r_stk record;
|
||||||
|
v_reverse_type app.stock_movement_type;
|
||||||
|
v_note text;
|
||||||
|
begin
|
||||||
|
select * into t from app.transactions where id = p_txn_id;
|
||||||
|
if t.id is null then
|
||||||
|
raise exception 'reverse: txn % not found', p_txn_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_note := 'void reversal: ' || coalesce(p_reason, '');
|
||||||
|
|
||||||
|
-- ----- Cash legs --------------------------------------------------
|
||||||
|
-- Re-post each existing cash_movements row with opposite sign as an
|
||||||
|
-- 'adjustment' (the only cash_movement_type that the 0019 sign guard
|
||||||
|
-- lets carry either sign).
|
||||||
|
for r_cash in
|
||||||
|
select id, shift_id, currency, amount
|
||||||
|
from app.cash_movements
|
||||||
|
where ref_txn_id = p_txn_id
|
||||||
|
and type <> 'adjustment' -- don't reverse prior reversals
|
||||||
|
loop
|
||||||
|
insert into app.cash_movements(
|
||||||
|
shift_id, type, currency, amount, ref_txn_id, note, created_by
|
||||||
|
) values (
|
||||||
|
r_cash.shift_id,
|
||||||
|
'adjustment'::app.cash_movement_type,
|
||||||
|
r_cash.currency,
|
||||||
|
-r_cash.amount,
|
||||||
|
p_txn_id,
|
||||||
|
v_note,
|
||||||
|
p_approver
|
||||||
|
);
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- ----- Float legs -------------------------------------------------
|
||||||
|
for r_flt in
|
||||||
|
select id, float_id, shift_id, amount
|
||||||
|
from app.float_movements
|
||||||
|
where ref_txn_id = p_txn_id
|
||||||
|
and coalesce(reason,'') not like 'void reversal%'
|
||||||
|
loop
|
||||||
|
insert into app.float_movements(
|
||||||
|
float_id, shift_id, amount, ref_txn_id, reason, created_by
|
||||||
|
) values (
|
||||||
|
r_flt.float_id,
|
||||||
|
r_flt.shift_id,
|
||||||
|
-r_flt.amount,
|
||||||
|
p_txn_id,
|
||||||
|
v_note,
|
||||||
|
p_approver
|
||||||
|
);
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- ----- Stock legs -------------------------------------------------
|
||||||
|
-- For physical goods sold via record_goods_sale, reverse the
|
||||||
|
-- sale_out by posting an adjustment_in of equal magnitude (positive),
|
||||||
|
-- and vice versa for any in-bound rows tied to this txn.
|
||||||
|
for r_stk in
|
||||||
|
select id, sku, shop_id, shift_id, type, qty_delta, ref_lot_id
|
||||||
|
from app.stock_movements
|
||||||
|
where ref_txn_id = p_txn_id
|
||||||
|
and type not in ('adjustment_in','adjustment_out')
|
||||||
|
loop
|
||||||
|
if r_stk.qty_delta < 0 then
|
||||||
|
v_reverse_type := 'adjustment_in'::app.stock_movement_type;
|
||||||
|
else
|
||||||
|
v_reverse_type := 'adjustment_out'::app.stock_movement_type;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into app.stock_movements(
|
||||||
|
sku, shop_id, shift_id, type, qty_delta,
|
||||||
|
ref_txn_id, ref_lot_id, approved_by, reason, created_by
|
||||||
|
) values (
|
||||||
|
r_stk.sku, r_stk.shop_id, r_stk.shift_id,
|
||||||
|
v_reverse_type,
|
||||||
|
-r_stk.qty_delta,
|
||||||
|
p_txn_id, r_stk.ref_lot_id,
|
||||||
|
p_approver,
|
||||||
|
v_note,
|
||||||
|
p_approver
|
||||||
|
);
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
-- ----- Voucher serials -------------------------------------------
|
||||||
|
-- Any voucher marked sold by this txn returns to in_stock so it can
|
||||||
|
-- be sold again. (The voucher_status_consistency CHECK clears
|
||||||
|
-- sold_txn_id / sold_at when status becomes 'in_stock'.)
|
||||||
|
update app.voucher_inventory
|
||||||
|
set status = 'in_stock',
|
||||||
|
sold_txn_id = null,
|
||||||
|
sold_at = null,
|
||||||
|
status_changed_by = p_approver,
|
||||||
|
status_change_reason = v_note
|
||||||
|
where sold_txn_id = p_txn_id
|
||||||
|
and status = 'sold';
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function app._reverse_movements_for_txn(uuid, uuid, text) from public;
|
||||||
|
-- Internal helper only — callable from void_transaction (security definer).
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Re-define void_transaction to reverse movements before flipping
|
||||||
|
-- status. We keep the same signature as 0003 so the existing UI calls
|
||||||
|
-- continue to work.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app.void_transaction(
|
||||||
|
p_txn_id uuid,
|
||||||
|
p_reason text,
|
||||||
|
p_approver_pin text default null
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
t app.transactions%rowtype;
|
||||||
|
s app.shifts%rowtype;
|
||||||
|
window_min int;
|
||||||
|
needs_manager boolean;
|
||||||
|
v_approver uuid;
|
||||||
|
begin
|
||||||
|
select * into t from app.transactions where id = p_txn_id;
|
||||||
|
if t.id is null then raise exception 'transaction not found'; end if;
|
||||||
|
if t.status = 'voided' then raise exception 'transaction already voided'; end if;
|
||||||
|
if p_reason is null or length(btrim(p_reason)) < 5 then
|
||||||
|
raise exception 'a reason of at least 5 characters is required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into s from app.shifts where id = t.shift_id;
|
||||||
|
if s.status <> 'open' then
|
||||||
|
raise exception 'cannot void a transaction whose shift is no longer open';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select coalesce(value::int, 10) into window_min
|
||||||
|
from app.system_settings where key = 'void_self_window_minutes';
|
||||||
|
|
||||||
|
needs_manager := (auth.uid() <> t.user_id)
|
||||||
|
or (now() - t.created_at > make_interval(mins => window_min));
|
||||||
|
|
||||||
|
if needs_manager then
|
||||||
|
if not app.has_role_in_shop(t.shop_id, 'manager') then
|
||||||
|
raise exception 'manager approval required to void this transaction';
|
||||||
|
end if;
|
||||||
|
if p_approver_pin is null or not app.verify_my_pin(p_approver_pin) then
|
||||||
|
raise exception 'manager PIN required and must be valid';
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_approver := auth.uid();
|
||||||
|
|
||||||
|
-- Reverse the money / stock / voucher legs FIRST. If any of these
|
||||||
|
-- inserts fails (e.g. stock would go negative because more vouchers
|
||||||
|
-- have been sold from the lot since), the whole void is rolled back
|
||||||
|
-- and the books stay consistent.
|
||||||
|
if t.status = 'completed' then
|
||||||
|
perform app._reverse_movements_for_txn(p_txn_id, v_approver, p_reason);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Flip the status (only this function may UPDATE app.transactions).
|
||||||
|
perform set_config('app.txn_internal', 'on', true);
|
||||||
|
update app.transactions
|
||||||
|
set status = 'voided',
|
||||||
|
voided_at = now(),
|
||||||
|
voided_by = v_approver,
|
||||||
|
void_reason = p_reason,
|
||||||
|
void_approved_by = case when needs_manager then v_approver else null end
|
||||||
|
where id = p_txn_id;
|
||||||
|
perform set_config('app.txn_internal', 'off', true);
|
||||||
|
|
||||||
|
-- Recompute the row's hash so the chain reflects the new state.
|
||||||
|
perform set_config('app.txn_internal', 'on', true);
|
||||||
|
update app.transactions tt
|
||||||
|
set row_hash = app.txn_compute_hash(tt, tt.prev_row_hash)
|
||||||
|
where id = p_txn_id;
|
||||||
|
perform set_config('app.txn_internal', 'off', true);
|
||||||
|
|
||||||
|
perform app.log_auth_event('txn_voided', t.shop_id, null,
|
||||||
|
jsonb_build_object('txn_id', p_txn_id,
|
||||||
|
'manager_path', needs_manager,
|
||||||
|
'reversed', t.status = 'completed'));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function app.void_transaction(uuid, text, text) from public;
|
||||||
|
grant execute on function app.void_transaction(uuid, text, text) to authenticated;
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 0021_fee_schedule.sql
|
||||||
|
--
|
||||||
|
-- Today the cashier types fee_usd / fee_lbp / commission_usd / commission_lbp
|
||||||
|
-- by hand on every OMT_SEND, OMT_RECEIVE, WU_*, WHISH_SEND, EDL_BILL,
|
||||||
|
-- recharge and goods sale. There is no server-side anchor for what the
|
||||||
|
-- fee is *supposed* to be, which means a cashier can:
|
||||||
|
--
|
||||||
|
-- * pocket part of the customer's fee by recording a smaller fee
|
||||||
|
-- than they collected,
|
||||||
|
-- * record a larger fee than the official sheet to siphon shop
|
||||||
|
-- commission, then refund the excess to themselves later.
|
||||||
|
--
|
||||||
|
-- This migration adds an opt-in per-shop fee schedule:
|
||||||
|
--
|
||||||
|
-- app.fee_schedule(shop_id, service_code, currency,
|
||||||
|
-- min_amount, max_amount,
|
||||||
|
-- fee_fixed, fee_pct,
|
||||||
|
-- commission_fixed, commission_pct,
|
||||||
|
-- tolerance)
|
||||||
|
--
|
||||||
|
-- and a deferred constraint trigger that, *only when at least one row
|
||||||
|
-- exists for the shop+service+currency*, validates the fee/commission
|
||||||
|
-- on the transaction against the bracket the gross falls into. Shops
|
||||||
|
-- that don't seed the table keep working exactly as before.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
set search_path = app, public;
|
||||||
|
|
||||||
|
create table if not exists app.fee_schedule (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
shop_id uuid not null references app.shops(id) on delete cascade,
|
||||||
|
service_code text not null references app.services(code),
|
||||||
|
currency app.currency_code not null,
|
||||||
|
-- Inclusive lower bound, exclusive upper bound (use a very large
|
||||||
|
-- max_amount for the "and above" bracket).
|
||||||
|
min_amount numeric(18,2) not null check (min_amount >= 0),
|
||||||
|
max_amount numeric(18,2) not null,
|
||||||
|
fee_fixed numeric(18,2) not null default 0 check (fee_fixed >= 0),
|
||||||
|
fee_pct numeric(7,4) not null default 0 check (fee_pct >= 0 and fee_pct <= 100),
|
||||||
|
commission_fixed numeric(18,2) not null default 0 check (commission_fixed >= 0),
|
||||||
|
commission_pct numeric(7,4) not null default 0 check (commission_pct >= 0 and commission_pct <= 100),
|
||||||
|
-- Allowed absolute tolerance between scheduled and recorded fee. Set
|
||||||
|
-- non-zero for services priced in LBP rounded to nearest 1000.
|
||||||
|
tolerance numeric(18,2) not null default 0 check (tolerance >= 0),
|
||||||
|
effective_from timestamptz not null default now(),
|
||||||
|
effective_to timestamptz,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
created_by uuid not null references auth.users(id) default auth.uid(),
|
||||||
|
check (max_amount > min_amount),
|
||||||
|
check (effective_to is null or effective_to > effective_from)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_fee_schedule_lookup
|
||||||
|
on app.fee_schedule(shop_id, service_code, currency, effective_from desc);
|
||||||
|
|
||||||
|
alter table app.fee_schedule enable row level security;
|
||||||
|
alter table app.fee_schedule force row level security;
|
||||||
|
|
||||||
|
-- Owners/managers of the shop can read and edit. Cashiers can read.
|
||||||
|
drop policy if exists fee_schedule_select on app.fee_schedule;
|
||||||
|
create policy fee_schedule_select on app.fee_schedule
|
||||||
|
for select using (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['cashier','manager','owner']::app.business_role[])
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists fee_schedule_write on app.fee_schedule;
|
||||||
|
create policy fee_schedule_write on app.fee_schedule
|
||||||
|
for all using (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['manager','owner']::app.business_role[])
|
||||||
|
) with check (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['manager','owner']::app.business_role[])
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Append-only on history: once published, a row's bracket cannot be
|
||||||
|
-- mutated; managers must close it (set effective_to) and insert a new
|
||||||
|
-- one. This preserves a clean audit trail of what fees were in force.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app._fee_schedule_immutable()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
raise exception 'fee_schedule rows are append-only; close them with effective_to';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Only effective_to may move forward (close a bracket). Everything
|
||||||
|
-- else must stay put.
|
||||||
|
if (old.shop_id, old.service_code, old.currency, old.min_amount,
|
||||||
|
old.max_amount, old.fee_fixed, old.fee_pct,
|
||||||
|
old.commission_fixed, old.commission_pct, old.tolerance,
|
||||||
|
old.effective_from)
|
||||||
|
is distinct from
|
||||||
|
(new.shop_id, new.service_code, new.currency, new.min_amount,
|
||||||
|
new.max_amount, new.fee_fixed, new.fee_pct,
|
||||||
|
new.commission_fixed, new.commission_pct, new.tolerance,
|
||||||
|
new.effective_from)
|
||||||
|
then
|
||||||
|
raise exception 'fee_schedule columns are immutable; close the row and insert a new one';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if old.effective_to is not null then
|
||||||
|
raise exception 'fee_schedule row already closed';
|
||||||
|
end if;
|
||||||
|
if new.effective_to is null or new.effective_to <= now() - interval '1 minute' then
|
||||||
|
raise exception 'effective_to must be set to a current/future timestamp to close a bracket';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_fee_schedule_immutable on app.fee_schedule;
|
||||||
|
create trigger trg_fee_schedule_immutable
|
||||||
|
before update or delete on app.fee_schedule
|
||||||
|
for each row execute function app._fee_schedule_immutable();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Lookup helper: returns the active bracket for a (shop, service,
|
||||||
|
-- currency, gross). Returns NULL if no schedule applies.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app.compute_scheduled_fee(
|
||||||
|
p_shop uuid,
|
||||||
|
p_service text,
|
||||||
|
p_currency app.currency_code,
|
||||||
|
p_gross numeric
|
||||||
|
) returns table (
|
||||||
|
expected_fee numeric,
|
||||||
|
expected_commission numeric,
|
||||||
|
tolerance numeric,
|
||||||
|
bracket_id uuid
|
||||||
|
)
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
select
|
||||||
|
coalesce(fs.fee_fixed,0) + coalesce(fs.fee_pct,0) / 100.0 * p_gross,
|
||||||
|
coalesce(fs.commission_fixed,0) + coalesce(fs.commission_pct,0) / 100.0 * p_gross,
|
||||||
|
fs.tolerance,
|
||||||
|
fs.id
|
||||||
|
from app.fee_schedule fs
|
||||||
|
where fs.shop_id = p_shop
|
||||||
|
and fs.service_code = p_service
|
||||||
|
and fs.currency = p_currency
|
||||||
|
and fs.min_amount <= p_gross
|
||||||
|
and fs.max_amount > p_gross
|
||||||
|
and fs.effective_from <= now()
|
||||||
|
and (fs.effective_to is null or fs.effective_to > now())
|
||||||
|
order by fs.effective_from desc
|
||||||
|
limit 1;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
revoke all on function app.compute_scheduled_fee(uuid, text, app.currency_code, numeric) from public;
|
||||||
|
grant execute on function app.compute_scheduled_fee(uuid, text, app.currency_code, numeric) to authenticated;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Constraint trigger: validates fee/commission against the schedule
|
||||||
|
-- when a matching bracket exists. Runs on INSERT (transactions are
|
||||||
|
-- append-only). Fired DEFERRED so the txn row is fully populated before
|
||||||
|
-- we look it up.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app._fee_schedule_check()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
rec_usd record;
|
||||||
|
rec_lbp record;
|
||||||
|
diff numeric;
|
||||||
|
begin
|
||||||
|
-- Only validate completed money transactions; refunds, voids and
|
||||||
|
-- non-monetary services bypass.
|
||||||
|
if new.status <> 'completed' then return null; end if;
|
||||||
|
if new.service_code in ('REFUND','OPENING_FLOAT','SAFE_DROP','BANK_DEPOSIT')
|
||||||
|
then return null; end if;
|
||||||
|
|
||||||
|
if coalesce(new.gross_usd, 0) > 0 then
|
||||||
|
select * into rec_usd
|
||||||
|
from app.compute_scheduled_fee(new.shop_id, new.service_code, 'USD'::app.currency_code, new.gross_usd);
|
||||||
|
|
||||||
|
if rec_usd.bracket_id is not null then
|
||||||
|
diff := abs(coalesce(new.fee_usd,0) - rec_usd.expected_fee);
|
||||||
|
if diff > rec_usd.tolerance then
|
||||||
|
raise exception
|
||||||
|
'fee_usd % deviates from schedule % (tolerance %, bracket %)',
|
||||||
|
new.fee_usd, rec_usd.expected_fee, rec_usd.tolerance, rec_usd.bracket_id;
|
||||||
|
end if;
|
||||||
|
diff := abs(coalesce(new.commission_usd,0) - rec_usd.expected_commission);
|
||||||
|
if diff > rec_usd.tolerance then
|
||||||
|
raise exception
|
||||||
|
'commission_usd % deviates from schedule % (tolerance %, bracket %)',
|
||||||
|
new.commission_usd, rec_usd.expected_commission, rec_usd.tolerance, rec_usd.bracket_id;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if coalesce(new.gross_lbp, 0) > 0 then
|
||||||
|
select * into rec_lbp
|
||||||
|
from app.compute_scheduled_fee(new.shop_id, new.service_code, 'LBP'::app.currency_code, new.gross_lbp);
|
||||||
|
|
||||||
|
if rec_lbp.bracket_id is not null then
|
||||||
|
diff := abs(coalesce(new.fee_lbp,0) - rec_lbp.expected_fee);
|
||||||
|
if diff > rec_lbp.tolerance then
|
||||||
|
raise exception
|
||||||
|
'fee_lbp % deviates from schedule % (tolerance %, bracket %)',
|
||||||
|
new.fee_lbp, rec_lbp.expected_fee, rec_lbp.tolerance, rec_lbp.bracket_id;
|
||||||
|
end if;
|
||||||
|
diff := abs(coalesce(new.commission_lbp,0) - rec_lbp.expected_commission);
|
||||||
|
if diff > rec_lbp.tolerance then
|
||||||
|
raise exception
|
||||||
|
'commission_lbp % deviates from schedule % (tolerance %, bracket %)',
|
||||||
|
new.commission_lbp, rec_lbp.expected_commission, rec_lbp.tolerance, rec_lbp.bracket_id;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_fee_schedule_check on app.transactions;
|
||||||
|
create constraint trigger trg_fee_schedule_check
|
||||||
|
after insert on app.transactions
|
||||||
|
deferrable initially deferred
|
||||||
|
for each row execute function app._fee_schedule_check();
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 0022_atomic_sale_coupling.sql
|
||||||
|
--
|
||||||
|
-- Today the cashier UI calls record_recharge() and record_goods_sale()
|
||||||
|
-- but never separately calls sell_voucher() or posts the e-float debit
|
||||||
|
-- / sale_out stock movement. The deferred constraint triggers from
|
||||||
|
-- 0005 (_recharge_require_movement, _goods_sale_require_movement)
|
||||||
|
-- therefore reject every commit at end-of-transaction… *unless* the
|
||||||
|
-- trigger never fires because the RLS-protected detail row blocked
|
||||||
|
-- the INSERT, in which case the txn header silently survives without
|
||||||
|
-- any inventory or float impact.
|
||||||
|
--
|
||||||
|
-- Either way the books are wrong: a "sold" voucher serial keeps
|
||||||
|
-- showing as in_stock, the e-float balance does not drop, and a phone
|
||||||
|
-- sold off the shelf does not decrement stock_on_hand.
|
||||||
|
--
|
||||||
|
-- This migration folds the inventory/float legs INTO the record_*
|
||||||
|
-- functions themselves, in the same SECURITY DEFINER transaction:
|
||||||
|
--
|
||||||
|
-- record_recharge -> if voucher_serial: mark voucher sold + post
|
||||||
|
-- sale_out (-1) for the voucher SKU.
|
||||||
|
-- else (e-recharge): post a negative float_movement
|
||||||
|
-- for ALFA_ERECHARGE / TOUCH_ERECHARGE / OGERO_ERECHARGE
|
||||||
|
-- sized at unit_cost_usd (or gross_usd as fallback).
|
||||||
|
--
|
||||||
|
-- record_goods_sale -> post a sale_out stock_movement for the SKU
|
||||||
|
-- with -p_qty.
|
||||||
|
--
|
||||||
|
-- Both are wrapped in a single transaction so either everything posts
|
||||||
|
-- or the whole sale rolls back. The deferred coupling triggers from
|
||||||
|
-- 0005 then pass naturally.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
set search_path = app, public;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Internal helper: mark a voucher sold + post sale_out, callable from
|
||||||
|
-- inside record_recharge. Mirrors app.sell_voucher() but does not check
|
||||||
|
-- auth.uid() against the txn owner because record_recharge is itself
|
||||||
|
-- security definer running as the cashier who created the txn.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app._sell_voucher_internal(
|
||||||
|
p_txn_id uuid,
|
||||||
|
p_serial text
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v app.voucher_inventory%rowtype;
|
||||||
|
t app.transactions%rowtype;
|
||||||
|
begin
|
||||||
|
select * into t from app.transactions where id = p_txn_id;
|
||||||
|
if t.id is null then raise exception 'txn not found'; end if;
|
||||||
|
|
||||||
|
select * into v from app.voucher_inventory
|
||||||
|
where serial = p_serial for update;
|
||||||
|
if v.serial is null then
|
||||||
|
raise exception 'voucher % not found', p_serial;
|
||||||
|
end if;
|
||||||
|
if v.shop_id <> t.shop_id then
|
||||||
|
raise exception 'voucher % belongs to a different shop', p_serial;
|
||||||
|
end if;
|
||||||
|
if v.status <> 'in_stock' then
|
||||||
|
raise exception 'voucher % is not in_stock (status=%)', p_serial, v.status;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update app.voucher_inventory
|
||||||
|
set status = 'sold',
|
||||||
|
sold_txn_id = p_txn_id,
|
||||||
|
sold_at = now(),
|
||||||
|
status_changed_by = auth.uid()
|
||||||
|
where serial = p_serial;
|
||||||
|
|
||||||
|
insert into app.stock_movements(
|
||||||
|
sku, shop_id, shift_id, type, qty_delta, ref_txn_id, reason
|
||||||
|
) values (
|
||||||
|
v.sku, v.shop_id, t.shift_id,
|
||||||
|
'sale_out'::app.stock_movement_type,
|
||||||
|
-1, p_txn_id, 'voucher ' || p_serial
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app._sell_voucher_internal(uuid, text) from public;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Internal helper: post the e-float debit for an e-recharge.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app._erecharge_post_float(
|
||||||
|
p_txn_id uuid,
|
||||||
|
p_operator text,
|
||||||
|
p_amount numeric -- positive cost; the row will be negated
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_provider app.float_provider;
|
||||||
|
v_float uuid;
|
||||||
|
t app.transactions%rowtype;
|
||||||
|
begin
|
||||||
|
if p_amount is null or p_amount <= 0 then
|
||||||
|
raise exception 'e-recharge cost must be > 0 (got %)', p_amount;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into t from app.transactions where id = p_txn_id;
|
||||||
|
if t.id is null then raise exception 'txn not found'; end if;
|
||||||
|
|
||||||
|
v_provider := case upper(p_operator)
|
||||||
|
when 'ALFA' then 'ALFA_ERECHARGE'::app.float_provider
|
||||||
|
when 'TOUCH' then 'TOUCH_ERECHARGE'::app.float_provider
|
||||||
|
when 'OGERO' then 'OGERO_ERECHARGE'::app.float_provider
|
||||||
|
else null
|
||||||
|
end;
|
||||||
|
|
||||||
|
if v_provider is null then
|
||||||
|
-- Unmapped operator (IDM, CYBERIA, TERRANET): fall back to OMT_DIGITAL
|
||||||
|
-- so the recharge_require_movement trigger sees a negative leg.
|
||||||
|
v_provider := 'OMT_DIGITAL'::app.float_provider;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_float := app._get_or_create_float(t.shop_id, v_provider, 'USD'::app.currency_code);
|
||||||
|
|
||||||
|
insert into app.float_movements(
|
||||||
|
float_id, shift_id, amount, ref_txn_id, reason
|
||||||
|
) values (
|
||||||
|
v_float, t.shift_id, -p_amount, p_txn_id,
|
||||||
|
'e-recharge ' || coalesce(p_operator, '?')
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app._erecharge_post_float(uuid, text, numeric) from public;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Re-define record_recharge to fold in voucher / e-float posting, and
|
||||||
|
-- the cash leg via the helper added in 0018.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app.record_recharge(
|
||||||
|
p_shop uuid, p_till uuid, p_service_code text,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_gross_usd numeric, p_gross_lbp numeric,
|
||||||
|
p_fee_usd numeric, p_fee_lbp numeric,
|
||||||
|
p_fx_rate numeric,
|
||||||
|
p_operator text, p_msisdn text, p_product_code text,
|
||||||
|
p_voucher_serial text, p_e_recharge_ref text,
|
||||||
|
p_unit_face_usd numeric, p_unit_cost_usd numeric,
|
||||||
|
p_notes text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_txn uuid;
|
||||||
|
v_serial text := nullif(btrim(p_voucher_serial),'');
|
||||||
|
v_eref text := nullif(btrim(p_e_recharge_ref),'');
|
||||||
|
v_cost_usd numeric;
|
||||||
|
begin
|
||||||
|
if v_serial is null and v_eref is null then
|
||||||
|
raise exception 'either voucher_serial or e_recharge_ref is required';
|
||||||
|
end if;
|
||||||
|
if v_serial is not null and v_eref is not null then
|
||||||
|
raise exception 'pass either voucher_serial OR e_recharge_ref, not both';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_txn := app._insert_txn(p_shop, p_till, p_service_code, p_payment_method,
|
||||||
|
p_gross_usd, p_gross_lbp, p_fee_usd, p_fee_lbp, 0, 0, p_fx_rate,
|
||||||
|
p_operator, v_serial, null, p_msisdn, null, p_notes);
|
||||||
|
|
||||||
|
insert into app.recharge_details(
|
||||||
|
txn_id, operator, msisdn, product_code,
|
||||||
|
voucher_serial, e_recharge_provider_ref,
|
||||||
|
unit_face_value_usd, unit_cost_usd
|
||||||
|
) values (
|
||||||
|
v_txn, p_operator, p_msisdn, p_product_code,
|
||||||
|
v_serial, v_eref,
|
||||||
|
p_unit_face_usd, p_unit_cost_usd
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---- inventory / float coupling --------------------------------
|
||||||
|
if v_serial is not null then
|
||||||
|
perform app._sell_voucher_internal(v_txn, v_serial);
|
||||||
|
else
|
||||||
|
-- e-recharge: prefer recorded unit_cost_usd, fall back to gross_usd.
|
||||||
|
v_cost_usd := coalesce(nullif(p_unit_cost_usd,0), p_gross_usd);
|
||||||
|
perform app._erecharge_post_float(v_txn, p_operator, v_cost_usd);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- ---- cash leg (re-uses helper from 0018) -----------------------
|
||||||
|
perform app._post_cash_for_txn(
|
||||||
|
v_txn, p_payment_method,
|
||||||
|
coalesce(p_gross_usd,0) + coalesce(p_fee_usd,0),
|
||||||
|
coalesce(p_gross_lbp,0) + coalesce(p_fee_lbp,0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return v_txn;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.record_recharge(uuid, uuid, text, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric,
|
||||||
|
text, text, text, text, text, numeric, numeric, text) from public;
|
||||||
|
grant execute on function app.record_recharge(uuid, uuid, text, app.payment_method,
|
||||||
|
numeric, numeric, numeric, numeric, numeric,
|
||||||
|
text, text, text, text, text, numeric, numeric, text) to authenticated;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Re-define record_goods_sale to fold in the sale_out stock movement
|
||||||
|
-- and the cash leg in the same transaction.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app.record_goods_sale(
|
||||||
|
p_shop uuid, p_till uuid,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_gross_usd numeric, p_gross_lbp numeric,
|
||||||
|
p_fx_rate numeric,
|
||||||
|
p_sku text, p_qty integer,
|
||||||
|
p_unit_cost_usd numeric, p_unit_price_usd numeric,
|
||||||
|
p_serial_number text,
|
||||||
|
p_customer_id uuid, p_notes text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_txn uuid;
|
||||||
|
begin
|
||||||
|
if p_qty is null or p_qty <= 0 then
|
||||||
|
raise exception 'qty must be > 0';
|
||||||
|
end if;
|
||||||
|
if p_sku is null or btrim(p_sku) = '' then
|
||||||
|
raise exception 'sku required';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
v_txn := app._insert_txn(p_shop, p_till, 'GOODS_SALE', p_payment_method,
|
||||||
|
p_gross_usd, p_gross_lbp, 0, 0, 0, 0, p_fx_rate,
|
||||||
|
null, null, null, null, p_customer_id, p_notes);
|
||||||
|
|
||||||
|
insert into app.goods_sale_details(
|
||||||
|
txn_id, sku, qty, unit_cost_usd, unit_price_usd, serial_number
|
||||||
|
) values (
|
||||||
|
v_txn, p_sku, p_qty, p_unit_cost_usd, p_unit_price_usd, p_serial_number
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stock leg. The 0005 _stock_mov_before_insert trigger validates the
|
||||||
|
-- ref_txn_id points at a completed sale in the same shop, and the
|
||||||
|
-- _stock_on_hand_apply trigger refuses to go negative.
|
||||||
|
insert into app.stock_movements(
|
||||||
|
sku, shop_id, shift_id, type, qty_delta, ref_txn_id, reason
|
||||||
|
) values (
|
||||||
|
p_sku, p_shop, (select shift_id from app.transactions where id = v_txn),
|
||||||
|
'sale_out'::app.stock_movement_type,
|
||||||
|
-p_qty,
|
||||||
|
v_txn,
|
||||||
|
case when p_serial_number is not null
|
||||||
|
then 'goods sale serial=' || p_serial_number
|
||||||
|
else 'goods sale' end
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Cash leg.
|
||||||
|
perform app._post_cash_for_txn(
|
||||||
|
v_txn, p_payment_method,
|
||||||
|
coalesce(p_gross_usd,0),
|
||||||
|
coalesce(p_gross_lbp,0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return v_txn;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.record_goods_sale(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, text, integer, numeric, numeric, text, uuid, text) from public;
|
||||||
|
grant execute on function app.record_goods_sale(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, text, integer, numeric, numeric, text, uuid, text) to authenticated;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Repair sales also put cash in the till (parts + labour). The original
|
||||||
|
-- record_repair from 0013 inserts only the txn header + repair detail
|
||||||
|
-- and never posts cash, so REPAIR variances were silently absorbed by
|
||||||
|
-- the next cashier's drop. Wrap the existing function so it posts cash.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app.record_repair(
|
||||||
|
p_shop uuid, p_till uuid,
|
||||||
|
p_payment_method app.payment_method,
|
||||||
|
p_gross_usd numeric, p_gross_lbp numeric,
|
||||||
|
p_fx_rate numeric,
|
||||||
|
p_device_type text, p_device_imei text,
|
||||||
|
p_issue_summary text, p_warranty_days integer,
|
||||||
|
p_customer_id uuid, p_notes text
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare v_txn uuid;
|
||||||
|
begin
|
||||||
|
v_txn := app._insert_txn(p_shop, p_till, 'REPAIR', p_payment_method,
|
||||||
|
p_gross_usd, p_gross_lbp, 0, 0, 0, 0, p_fx_rate,
|
||||||
|
null, null, null, null, p_customer_id, p_notes);
|
||||||
|
|
||||||
|
insert into app.repair_details(
|
||||||
|
txn_id, device_type, device_imei, issue_summary, warranty_days
|
||||||
|
) values (
|
||||||
|
v_txn, p_device_type, p_device_imei, p_issue_summary, p_warranty_days
|
||||||
|
);
|
||||||
|
|
||||||
|
perform app._post_cash_for_txn(
|
||||||
|
v_txn, p_payment_method,
|
||||||
|
coalesce(p_gross_usd,0),
|
||||||
|
coalesce(p_gross_lbp,0)
|
||||||
|
);
|
||||||
|
return v_txn;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.record_repair(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, text, text, text, integer, uuid, text) from public;
|
||||||
|
grant execute on function app.record_repair(uuid, uuid, app.payment_method,
|
||||||
|
numeric, numeric, numeric, text, text, text, integer, uuid, text) to authenticated;
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 0023_fx_rates_and_swap.sql
|
||||||
|
--
|
||||||
|
-- Two related holes around foreign exchange:
|
||||||
|
--
|
||||||
|
-- (1) The cashier types an arbitrary `fx_rate_used` on every USD/LBP
|
||||||
|
-- transaction. Nothing on the server compares it to the daily
|
||||||
|
-- posted rate. A cashier can rate a $100 sale at 1 USD = 90,000 LBP
|
||||||
|
-- while the till uses 1 USD = 89,500 LBP and pocket the spread.
|
||||||
|
--
|
||||||
|
-- (2) `cash_movement_type` has 'fx_swap_in' and 'fx_swap_out' but no
|
||||||
|
-- function posts them as a matched pair. A cashier swapping $100
|
||||||
|
-- out of the till for 8.95M LBP today does it manually with two
|
||||||
|
-- uncoupled cash_movements rows; the sign-guard added in 0019
|
||||||
|
-- catches gross sign mistakes but not amount mismatches.
|
||||||
|
--
|
||||||
|
-- This migration:
|
||||||
|
-- * adds `app.fx_rates(shop_id, effective_from, usd_to_lbp_rate,
|
||||||
|
-- tolerance_pct)` — append-only history of the shop's posted rate.
|
||||||
|
-- * adds `app.compute_fx_window(shop, effective)` returning the
|
||||||
|
-- accepted band [low, high] for the currently-active rate.
|
||||||
|
-- * adds a constraint trigger on `app.transactions` that, only when
|
||||||
|
-- a rate is published for the shop, enforces fx_rate_used falls
|
||||||
|
-- within the band whenever both gross_usd and gross_lbp are non-zero
|
||||||
|
-- (genuine cross-currency txn) OR for explicit FX swaps.
|
||||||
|
-- * adds `app.record_fx_swap(shop, till, p_usd_amount, p_lbp_amount,
|
||||||
|
-- p_fx_rate)` which posts both legs atomically and refuses the call
|
||||||
|
-- unless |p_usd_amount * fx_rate - p_lbp_amount| <= 1 LBP.
|
||||||
|
--
|
||||||
|
-- Shops that don't seed `fx_rates` keep working unchanged.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
set search_path = app, public;
|
||||||
|
|
||||||
|
create table if not exists app.fx_rates (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
shop_id uuid not null references app.shops(id) on delete cascade,
|
||||||
|
effective_from timestamptz not null default now(),
|
||||||
|
effective_to timestamptz,
|
||||||
|
-- Number of LBP per 1 USD (e.g. 89500).
|
||||||
|
usd_to_lbp_rate numeric(14,2) not null check (usd_to_lbp_rate > 0),
|
||||||
|
-- Allowed deviation either side of the posted rate, as a percent
|
||||||
|
-- (e.g. 1.0 = ±1%). Defaults to 0.5%.
|
||||||
|
tolerance_pct numeric(6,3) not null default 0.5
|
||||||
|
check (tolerance_pct >= 0 and tolerance_pct <= 25),
|
||||||
|
note text,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
created_by uuid not null references auth.users(id) default auth.uid(),
|
||||||
|
check (effective_to is null or effective_to > effective_from)
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_fx_rates_lookup
|
||||||
|
on app.fx_rates(shop_id, effective_from desc);
|
||||||
|
|
||||||
|
alter table app.fx_rates enable row level security;
|
||||||
|
alter table app.fx_rates force row level security;
|
||||||
|
|
||||||
|
drop policy if exists fx_rates_select on app.fx_rates;
|
||||||
|
create policy fx_rates_select on app.fx_rates
|
||||||
|
for select using (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['cashier','manager','owner']::app.business_role[])
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists fx_rates_write on app.fx_rates;
|
||||||
|
create policy fx_rates_write on app.fx_rates
|
||||||
|
for all using (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['manager','owner']::app.business_role[])
|
||||||
|
) with check (
|
||||||
|
app.has_any_role_in_shop(shop_id,
|
||||||
|
array['manager','owner']::app.business_role[])
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Append-only on history: only effective_to may be moved forward. Same
|
||||||
|
-- pattern as fee_schedule in 0021.
|
||||||
|
create or replace function app._fx_rates_immutable()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'DELETE' then
|
||||||
|
raise exception 'fx_rates is append-only';
|
||||||
|
end if;
|
||||||
|
if (old.shop_id, old.usd_to_lbp_rate, old.tolerance_pct, old.effective_from)
|
||||||
|
is distinct from
|
||||||
|
(new.shop_id, new.usd_to_lbp_rate, new.tolerance_pct, new.effective_from)
|
||||||
|
then
|
||||||
|
raise exception 'fx_rates columns are immutable; close the row and insert a new one';
|
||||||
|
end if;
|
||||||
|
if old.effective_to is not null then
|
||||||
|
raise exception 'fx_rates row already closed';
|
||||||
|
end if;
|
||||||
|
if new.effective_to is null then
|
||||||
|
raise exception 'effective_to must be set to close an fx_rates row';
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_fx_rates_immutable on app.fx_rates;
|
||||||
|
create trigger trg_fx_rates_immutable
|
||||||
|
before update or delete on app.fx_rates
|
||||||
|
for each row execute function app._fx_rates_immutable();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Lookup helper: return the active rate band for a shop right now.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app.current_fx_band(p_shop uuid)
|
||||||
|
returns table (
|
||||||
|
rate numeric,
|
||||||
|
band_low numeric,
|
||||||
|
band_high numeric,
|
||||||
|
tolerance_pct numeric,
|
||||||
|
rate_id uuid
|
||||||
|
)
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
select
|
||||||
|
f.usd_to_lbp_rate,
|
||||||
|
f.usd_to_lbp_rate * (1 - f.tolerance_pct / 100.0),
|
||||||
|
f.usd_to_lbp_rate * (1 + f.tolerance_pct / 100.0),
|
||||||
|
f.tolerance_pct,
|
||||||
|
f.id
|
||||||
|
from app.fx_rates f
|
||||||
|
where f.shop_id = p_shop
|
||||||
|
and f.effective_from <= now()
|
||||||
|
and (f.effective_to is null or f.effective_to > now())
|
||||||
|
order by f.effective_from desc
|
||||||
|
limit 1;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.current_fx_band(uuid) from public;
|
||||||
|
grant execute on function app.current_fx_band(uuid) to authenticated;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Constraint trigger on app.transactions:
|
||||||
|
-- when a posted rate exists for the shop, fx_rate_used must lie within
|
||||||
|
-- the band whenever the txn is a genuine USD/LBP cross-currency event.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app._txn_fx_rate_check()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
band record;
|
||||||
|
begin
|
||||||
|
-- Skip non-money / void rows.
|
||||||
|
if new.status <> 'completed' then return null; end if;
|
||||||
|
if coalesce(new.fx_rate_used, 0) = 0 then return null; end if;
|
||||||
|
|
||||||
|
-- Only police txns that actually mix the two currencies, or where
|
||||||
|
-- the cashier deliberately recorded an fx_rate (e.g. payment in USD,
|
||||||
|
-- gross in LBP).
|
||||||
|
if not (coalesce(new.gross_usd,0) <> 0 and coalesce(new.gross_lbp,0) <> 0)
|
||||||
|
and new.service_code <> 'FX_SWAP'
|
||||||
|
then
|
||||||
|
-- Some single-currency txns also store the day's rate for
|
||||||
|
-- reporting; still validate it against the band so a wildly wrong
|
||||||
|
-- value can't slip through.
|
||||||
|
null;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
select * into band from app.current_fx_band(new.shop_id);
|
||||||
|
if band.rate_id is null then
|
||||||
|
return null; -- shop hasn't published a rate yet
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if new.fx_rate_used < band.band_low or new.fx_rate_used > band.band_high then
|
||||||
|
raise exception
|
||||||
|
'fx_rate_used % outside posted band [%, %] (rate %, tolerance % %%)',
|
||||||
|
new.fx_rate_used, band.band_low, band.band_high,
|
||||||
|
band.rate, band.tolerance_pct;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
drop trigger if exists trg_txn_fx_rate_check on app.transactions;
|
||||||
|
create constraint trigger trg_txn_fx_rate_check
|
||||||
|
after insert on app.transactions
|
||||||
|
deferrable initially deferred
|
||||||
|
for each row execute function app._txn_fx_rate_check();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- record_fx_swap: post the two cash_movements legs atomically.
|
||||||
|
-- direction:
|
||||||
|
-- p_usd_out > 0 means USD leaves the till and LBP comes in
|
||||||
|
-- -> fx_swap_out USD, fx_swap_in LBP
|
||||||
|
-- p_usd_out < 0 means USD comes into the till and LBP leaves
|
||||||
|
-- -> fx_swap_in USD, fx_swap_out LBP
|
||||||
|
-- The amounts on both sides must agree to within 1 LBP at p_fx_rate,
|
||||||
|
-- and p_fx_rate must lie within the posted band (when one exists).
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
do $$ begin
|
||||||
|
-- Add FX_SWAP service code if not already present, so the txn header
|
||||||
|
-- has a real service to attach to (the recorded txn carries no
|
||||||
|
-- product detail row).
|
||||||
|
if not exists (select 1 from app.services where code = 'FX_SWAP') then
|
||||||
|
insert into app.services(code, name, category)
|
||||||
|
values ('FX_SWAP', 'Currency Exchange', 'cash_ops');
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
create or replace function app.record_fx_swap(
|
||||||
|
p_shop uuid,
|
||||||
|
p_till uuid,
|
||||||
|
p_usd_out numeric, -- + USD leaves till, - USD enters till
|
||||||
|
p_lbp_in numeric, -- + LBP enters till when usd_out>0, must be opposite sign
|
||||||
|
p_fx_rate numeric,
|
||||||
|
p_notes text default null
|
||||||
|
) returns uuid
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_shift uuid;
|
||||||
|
v_diff numeric;
|
||||||
|
v_txn uuid;
|
||||||
|
band record;
|
||||||
|
begin
|
||||||
|
if p_usd_out is null or p_lbp_in is null or p_fx_rate is null or p_fx_rate <= 0 then
|
||||||
|
raise exception 'usd_out, lbp_in and positive fx_rate are required';
|
||||||
|
end if;
|
||||||
|
if p_usd_out = 0 then
|
||||||
|
raise exception 'usd_out cannot be 0';
|
||||||
|
end if;
|
||||||
|
if sign(p_usd_out) = sign(p_lbp_in) then
|
||||||
|
raise exception 'usd_out and lbp_in must have opposite signs (one in, one out)';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Sanity: |usd_out| * rate must equal |lbp_in| within 1 LBP.
|
||||||
|
v_diff := abs(abs(p_usd_out) * p_fx_rate - abs(p_lbp_in));
|
||||||
|
if v_diff > 1 then
|
||||||
|
raise exception
|
||||||
|
'fx swap mismatch: |usd_out|*rate = % but |lbp_in| = % (diff %)',
|
||||||
|
abs(p_usd_out) * p_fx_rate, abs(p_lbp_in), v_diff;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Rate band (only enforced when a rate is published).
|
||||||
|
select * into band from app.current_fx_band(p_shop);
|
||||||
|
if band.rate_id is not null
|
||||||
|
and (p_fx_rate < band.band_low or p_fx_rate > band.band_high)
|
||||||
|
then
|
||||||
|
raise exception
|
||||||
|
'fx_rate % outside posted band [%, %] (rate %, tolerance % %%)',
|
||||||
|
p_fx_rate, band.band_low, band.band_high, band.rate, band.tolerance_pct;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Caller must have an open shift on this till.
|
||||||
|
select id into v_shift from app.shifts
|
||||||
|
where till_id = p_till and shop_id = p_shop
|
||||||
|
and user_id = auth.uid() and status = 'open'
|
||||||
|
order by opened_at desc limit 1;
|
||||||
|
if v_shift is null then
|
||||||
|
raise exception 'no open shift for this till';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Create a header txn (gross_usd/lbp = 0 — money does not enter or
|
||||||
|
-- leave the shop, just changes currency). The fx_rate is recorded.
|
||||||
|
v_txn := app._insert_txn(p_shop, p_till, 'FX_SWAP', 'cash_usd'::app.payment_method,
|
||||||
|
0, 0, 0, 0, 0, 0, p_fx_rate,
|
||||||
|
null, null, null, null, null, p_notes);
|
||||||
|
|
||||||
|
-- USD leg.
|
||||||
|
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
|
||||||
|
values (
|
||||||
|
v_shift,
|
||||||
|
case when p_usd_out > 0
|
||||||
|
then 'fx_swap_out'::app.cash_movement_type
|
||||||
|
else 'fx_swap_in'::app.cash_movement_type end,
|
||||||
|
'USD'::app.currency_code,
|
||||||
|
-p_usd_out, -- p_usd_out is the OUTflow amount
|
||||||
|
v_txn,
|
||||||
|
'fx swap'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- LBP leg.
|
||||||
|
insert into app.cash_movements(shift_id, type, currency, amount, ref_txn_id, note)
|
||||||
|
values (
|
||||||
|
v_shift,
|
||||||
|
case when p_lbp_in > 0
|
||||||
|
then 'fx_swap_in'::app.cash_movement_type
|
||||||
|
else 'fx_swap_out'::app.cash_movement_type end,
|
||||||
|
'LBP'::app.currency_code,
|
||||||
|
p_lbp_in,
|
||||||
|
v_txn,
|
||||||
|
'fx swap'
|
||||||
|
);
|
||||||
|
|
||||||
|
return v_txn;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.record_fx_swap(uuid, uuid, numeric, numeric, numeric, text) from public;
|
||||||
|
grant execute on function app.record_fx_swap(uuid, uuid, numeric, numeric, numeric, text) to authenticated;
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
-- =====================================================================
|
||||||
|
-- 0024_idempotency_and_self_deal.sql
|
||||||
|
--
|
||||||
|
-- Two related fraud vectors not yet closed:
|
||||||
|
--
|
||||||
|
-- (A) Idempotency / replay. Today the cashier can post the same OMT
|
||||||
|
-- payout code twice in the same shift and pocket the difference,
|
||||||
|
-- or post the same WU MTCN twice and let the second one fail to
|
||||||
|
-- reconcile silently. Nothing on the server enforces uniqueness
|
||||||
|
-- of `(shop_id, external_ref_provider, external_ref)` for active
|
||||||
|
-- money-transfer transactions.
|
||||||
|
--
|
||||||
|
-- (B) Self-deal. A cashier processing transfers on their own KYC ID
|
||||||
|
-- (or as the named beneficiary of a payout, or as the sender of
|
||||||
|
-- a high-value send to themselves) is the classic skim pattern
|
||||||
|
-- across all Lebanese MFS shops. The DB has all the data — the
|
||||||
|
-- cashier's user_profiles row, plus sender_id_number /
|
||||||
|
-- beneficiary_id_number on the detail row — but never compares
|
||||||
|
-- them.
|
||||||
|
--
|
||||||
|
-- This migration:
|
||||||
|
-- * adds nullable `id_type` / `id_number` / `phone_kyc` columns to
|
||||||
|
-- `app.user_profiles` (the cashier's own KYC),
|
||||||
|
-- * unique index on (shop_id, external_ref_provider, external_ref)
|
||||||
|
-- covering only completed (or pending) money-transfer service
|
||||||
|
-- codes,
|
||||||
|
-- * deferred constraint trigger that rejects an OMT/WU/Whish/bill
|
||||||
|
-- txn whose sender or beneficiary ID matches the cashier's own
|
||||||
|
-- KYC, unless an `app.system_settings` flag explicitly allows it
|
||||||
|
-- AND the txn is approved by a manager.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
set search_path = app, public;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- (A) idempotent external_ref
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- A *partial unique* index limited to:
|
||||||
|
-- * completed or pending status (voided rows can re-use a code if
|
||||||
|
-- the original was void-reversed, which is desired),
|
||||||
|
-- * money-transfer / bill service codes (recharges and goods sales
|
||||||
|
-- don't carry meaningful external_ref uniqueness).
|
||||||
|
create unique index if not exists ux_txn_external_ref_active
|
||||||
|
on app.transactions (shop_id, external_ref_provider, external_ref)
|
||||||
|
where external_ref is not null
|
||||||
|
and external_ref_provider is not null
|
||||||
|
and status <> 'voided'
|
||||||
|
and service_code in (
|
||||||
|
'OMT_SEND','OMT_RECEIVE','WU_SEND','WU_RECEIVE',
|
||||||
|
'WHISH_SEND','OMT_BILL','EDL_BILL'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- (B) self-deal: extend user_profiles with cashier KYC
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
alter table app.user_profiles
|
||||||
|
add column if not exists id_type app.id_doc_type,
|
||||||
|
add column if not exists id_number text,
|
||||||
|
add column if not exists phone_kyc text;
|
||||||
|
|
||||||
|
create index if not exists idx_user_profiles_kyc_id
|
||||||
|
on app.user_profiles(id_type, id_number)
|
||||||
|
where id_number is not null;
|
||||||
|
|
||||||
|
-- Allow a manager to bypass self-deal blocking for a specific txn by
|
||||||
|
-- setting this knob; default is to block.
|
||||||
|
insert into app.system_settings(key, value)
|
||||||
|
values ('self_deal_block_enabled', 'true')
|
||||||
|
on conflict (key) do nothing;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Helper: does an ID belong to the cashier who created the txn?
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app._is_cashier_self(
|
||||||
|
p_user_id uuid,
|
||||||
|
p_id_type app.id_doc_type,
|
||||||
|
p_id_number text,
|
||||||
|
p_phone text
|
||||||
|
) returns boolean
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
select exists (
|
||||||
|
select 1 from app.user_profiles up
|
||||||
|
where up.user_id = p_user_id
|
||||||
|
and (
|
||||||
|
(p_id_number is not null
|
||||||
|
and up.id_number is not null
|
||||||
|
and up.id_type = p_id_type
|
||||||
|
and lower(btrim(up.id_number)) = lower(btrim(p_id_number)))
|
||||||
|
or (p_phone is not null
|
||||||
|
and up.phone_kyc is not null
|
||||||
|
and regexp_replace(up.phone_kyc, '\D', '', 'g')
|
||||||
|
= regexp_replace(p_phone, '\D', '', 'g'))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
revoke all on function app._is_cashier_self(uuid, app.id_doc_type, text, text) from public;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Constraint trigger: fired AFTER INSERT on the detail rows that carry
|
||||||
|
-- counter-party identity. Each branch checks the txn owner against
|
||||||
|
-- the recorded sender / beneficiary KYC.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app._omt_send_self_deal_check()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
t app.transactions%rowtype;
|
||||||
|
block boolean;
|
||||||
|
begin
|
||||||
|
select coalesce(value::boolean, true) into block
|
||||||
|
from app.system_settings where key = 'self_deal_block_enabled';
|
||||||
|
if not block then return null; end if;
|
||||||
|
|
||||||
|
select * into t from app.transactions where id = new.txn_id;
|
||||||
|
if t.id is null then return null; end if;
|
||||||
|
|
||||||
|
if app._is_cashier_self(t.user_id, new.sender_id_type, new.sender_id_number, new.sender_phone)
|
||||||
|
then
|
||||||
|
raise exception
|
||||||
|
'self-deal blocked: cashier (% ) is the SENDER on txn % — manager must process this transfer',
|
||||||
|
t.user_id, new.txn_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- A cashier sending to themselves as beneficiary is also self-deal.
|
||||||
|
-- We only have name+phone for the beneficiary on send rows, so match
|
||||||
|
-- on phone (most reliable) when present.
|
||||||
|
if new.beneficiary_phone is not null
|
||||||
|
and app._is_cashier_self(t.user_id, null, null, new.beneficiary_phone)
|
||||||
|
then
|
||||||
|
raise exception
|
||||||
|
'self-deal blocked: cashier is the BENEFICIARY phone on txn %',
|
||||||
|
new.txn_id;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_omt_send_self_deal on app.omt_send_details;
|
||||||
|
create constraint trigger trg_omt_send_self_deal
|
||||||
|
after insert on app.omt_send_details
|
||||||
|
deferrable initially deferred
|
||||||
|
for each row execute function app._omt_send_self_deal_check();
|
||||||
|
|
||||||
|
create or replace function app._omt_receive_self_deal_check()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
t app.transactions%rowtype;
|
||||||
|
block boolean;
|
||||||
|
begin
|
||||||
|
select coalesce(value::boolean, true) into block
|
||||||
|
from app.system_settings where key = 'self_deal_block_enabled';
|
||||||
|
if not block then return null; end if;
|
||||||
|
|
||||||
|
select * into t from app.transactions where id = new.txn_id;
|
||||||
|
if t.id is null then return null; end if;
|
||||||
|
|
||||||
|
if app._is_cashier_self(t.user_id,
|
||||||
|
new.beneficiary_id_type, new.beneficiary_id_number, new.beneficiary_phone)
|
||||||
|
then
|
||||||
|
raise exception
|
||||||
|
'self-deal blocked: cashier is the PAYOUT BENEFICIARY on txn % — manager must process',
|
||||||
|
new.txn_id;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists trg_omt_receive_self_deal on app.omt_receive_details;
|
||||||
|
create constraint trigger trg_omt_receive_self_deal
|
||||||
|
after insert on app.omt_receive_details
|
||||||
|
deferrable initially deferred
|
||||||
|
for each row execute function app._omt_receive_self_deal_check();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Manager-only knob: temporarily allow a single self-deal transfer
|
||||||
|
-- (e.g. owner sending themselves their own salary). Auto-resets after
|
||||||
|
-- one INSERT via a session GUC.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app.manager_allow_next_self_deal(
|
||||||
|
p_manager_pin text,
|
||||||
|
p_shop uuid
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not app.has_role_in_shop(p_shop, 'manager')
|
||||||
|
and not app.has_role_in_shop(p_shop, 'owner')
|
||||||
|
then
|
||||||
|
raise exception 'manager or owner role required';
|
||||||
|
end if;
|
||||||
|
if not app.verify_my_pin(p_manager_pin) then
|
||||||
|
raise exception 'invalid manager PIN';
|
||||||
|
end if;
|
||||||
|
perform set_config('app.self_deal_override', 'on', true); -- session GUC
|
||||||
|
perform app.log_auth_event('self_deal_override_granted', p_shop, null, '{}'::jsonb);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.manager_allow_next_self_deal(text, uuid) from public;
|
||||||
|
grant execute on function app.manager_allow_next_self_deal(text, uuid) to authenticated;
|
||||||
|
|
||||||
|
-- Wire the override into the self-deal checkers.
|
||||||
|
create or replace function app._self_deal_overridden()
|
||||||
|
returns boolean
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select coalesce(current_setting('app.self_deal_override', true), 'off') = 'on';
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function app._omt_send_self_deal_check()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
t app.transactions%rowtype;
|
||||||
|
block boolean;
|
||||||
|
begin
|
||||||
|
if app._self_deal_overridden() then
|
||||||
|
perform set_config('app.self_deal_override', 'off', true);
|
||||||
|
return null;
|
||||||
|
end if;
|
||||||
|
select coalesce(value::boolean, true) into block
|
||||||
|
from app.system_settings where key = 'self_deal_block_enabled';
|
||||||
|
if not block then return null; end if;
|
||||||
|
|
||||||
|
select * into t from app.transactions where id = new.txn_id;
|
||||||
|
if t.id is null then return null; end if;
|
||||||
|
|
||||||
|
if app._is_cashier_self(t.user_id, new.sender_id_type, new.sender_id_number, new.sender_phone) then
|
||||||
|
raise exception
|
||||||
|
'self-deal blocked: cashier is the SENDER on txn %', new.txn_id;
|
||||||
|
end if;
|
||||||
|
if new.beneficiary_phone is not null
|
||||||
|
and app._is_cashier_self(t.user_id, null, null, new.beneficiary_phone)
|
||||||
|
then
|
||||||
|
raise exception
|
||||||
|
'self-deal blocked: cashier is the BENEFICIARY phone on txn %', new.txn_id;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function app._omt_receive_self_deal_check()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
t app.transactions%rowtype;
|
||||||
|
block boolean;
|
||||||
|
begin
|
||||||
|
if app._self_deal_overridden() then
|
||||||
|
perform set_config('app.self_deal_override', 'off', true);
|
||||||
|
return null;
|
||||||
|
end if;
|
||||||
|
select coalesce(value::boolean, true) into block
|
||||||
|
from app.system_settings where key = 'self_deal_block_enabled';
|
||||||
|
if not block then return null; end if;
|
||||||
|
|
||||||
|
select * into t from app.transactions where id = new.txn_id;
|
||||||
|
if t.id is null then return null; end if;
|
||||||
|
|
||||||
|
if app._is_cashier_self(t.user_id,
|
||||||
|
new.beneficiary_id_type, new.beneficiary_id_number, new.beneficiary_phone)
|
||||||
|
then
|
||||||
|
raise exception
|
||||||
|
'self-deal blocked: cashier is the PAYOUT BENEFICIARY on txn %', new.txn_id;
|
||||||
|
end if;
|
||||||
|
return null;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
-- Convenience RPC for the manager UI to set/update a cashier's KYC.
|
||||||
|
-- ---------------------------------------------------------------------
|
||||||
|
create or replace function app.set_user_kyc(
|
||||||
|
p_user_id uuid,
|
||||||
|
p_shop uuid,
|
||||||
|
p_id_type app.id_doc_type,
|
||||||
|
p_id_number text,
|
||||||
|
p_phone_kyc text
|
||||||
|
) returns void
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = app, public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if not app.has_role_in_shop(p_shop, 'manager')
|
||||||
|
and not app.has_role_in_shop(p_shop, 'owner')
|
||||||
|
then
|
||||||
|
raise exception 'manager or owner role required';
|
||||||
|
end if;
|
||||||
|
if p_id_number is null or btrim(p_id_number) = '' then
|
||||||
|
raise exception 'id_number required';
|
||||||
|
end if;
|
||||||
|
-- the user must actually be assigned to this shop
|
||||||
|
if not exists(
|
||||||
|
select 1 from app.user_shop_assignments
|
||||||
|
where user_id = p_user_id and shop_id = p_shop
|
||||||
|
) then
|
||||||
|
raise exception 'user is not assigned to that shop';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
update app.user_profiles
|
||||||
|
set id_type = p_id_type,
|
||||||
|
id_number = btrim(p_id_number),
|
||||||
|
phone_kyc = nullif(btrim(p_phone_kyc),'')
|
||||||
|
where user_id = p_user_id;
|
||||||
|
|
||||||
|
perform app.log_auth_event('user_kyc_updated', p_shop, null,
|
||||||
|
jsonb_build_object('user_id', p_user_id, 'id_type', p_id_type));
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
revoke all on function app.set_user_kyc(uuid, uuid, app.id_doc_type, text, text) from public;
|
||||||
|
grant execute on function app.set_user_kyc(uuid, uuid, app.id_doc_type, text, text) to authenticated;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user