Prepare shop-ready deployment docs and fix transaction modal UI
This commit is contained in:
@@ -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/
|
||||||
|
|||||||
@@ -81,6 +81,30 @@ Default seed admin (override via env in [docker-compose.yml](docker-compose.yml)
|
|||||||
- email: `admin@local.test`
|
- email: `admin@local.test`
|
||||||
- password: `ChangeMe123!`
|
- password: `ChangeMe123!`
|
||||||
|
|
||||||
|
## Shop deployment quickstart
|
||||||
|
|
||||||
|
For a machine in the shop, use the full guide at [docs/SHOP_SETUP.md](docs/SHOP_SETUP.md).
|
||||||
|
|
||||||
|
Fast path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Krikorios/OMT-SM.git
|
||||||
|
cd OMT-SM
|
||||||
|
|
||||||
|
cp .env.example .env
|
||||||
|
cp server/.env.example server/.env
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npm --prefix server install
|
||||||
|
|
||||||
|
./scripts/start_prod.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
- 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`.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
React 18, TypeScript, Vite, shadcn/ui, Tailwind, TanStack Query, react-hook-form,
|
React 18, TypeScript, Vite, shadcn/ui, Tailwind, TanStack Query, react-hook-form,
|
||||||
|
|||||||
+9
-6
@@ -3,14 +3,16 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: crm_omt_db
|
container_name: crm_omt_db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# bind to loopback only — DB is reachable from the API on this box, not from the LAN
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "127.0.0.1:5432:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: postgres
|
# all of these MUST come from the .env file at repo root — no defaults
|
||||||
POSTGRES_DB: crm_omt
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@local.test}
|
POSTGRES_DB: ${POSTGRES_DB:-crm_omt}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-ChangeMe123!}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:?ADMIN_EMAIL is required}
|
||||||
ADMIN_NAME: ${ADMIN_NAME:-Local Admin}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}
|
||||||
|
ADMIN_NAME: ${ADMIN_NAME:-Owner}
|
||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/postgresql/data
|
- dbdata:/var/lib/postgresql/data
|
||||||
- ./supabase/migrations:/sql/migrations:ro
|
- ./supabase/migrations:/sql/migrations:ro
|
||||||
@@ -23,3 +25,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
dbdata:
|
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.
|
||||||
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
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# Server config (copy to server/.env or set in your shell)
|
|
||||||
PORT=4000
|
|
||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/crm_omt
|
|
||||||
JWT_SECRET=change-me-to-a-long-random-string
|
|
||||||
JWT_EXPIRES_IN=12h
|
|
||||||
CORS_ORIGIN=http://localhost:5173,http://localhost:8080
|
|
||||||
+9
-6
@@ -1,9 +1,12 @@
|
|||||||
# Server config (copy to server/.env or set in your shell)
|
# Production API config (copy to server/.env)
|
||||||
|
NODE_ENV=production
|
||||||
PORT=4000
|
PORT=4000
|
||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/crm_omt
|
BIND_HOST=127.0.0.1
|
||||||
JWT_SECRET=change-me-to-a-long-random-string
|
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
|
JWT_EXPIRES_IN=12h
|
||||||
CORS_ORIGIN=http://localhost:5173,http://localhost:8080
|
|
||||||
|
|
||||||
# Frontend clients on the same LAN can use the host machine's IP automatically.
|
# Same-origin when SPA is served by this API.
|
||||||
# Add fixed origins here if you want to restrict access more tightly.
|
# 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
|
||||||
|
|||||||
+79
-8
@@ -4,17 +4,39 @@ import cors from 'cors';
|
|||||||
import pkg from 'pg';
|
import pkg from 'pg';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import path from 'node:path';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const { Pool } = pkg;
|
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 PORT = Number(process.env.PORT || 4000);
|
||||||
const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/crm_omt';
|
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_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
|
||||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '12h';
|
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '12h';
|
||||||
const LOCAL_DEV_TOOLS_ENABLED = process.env.ENABLE_LOCAL_TEST_ROUTES === '1'
|
const LOCAL_DEV_TOOLS_ENABLED = !IS_PROD
|
||||||
|| (process.env.NODE_ENV !== 'production' && /localhost|127\.0\.0\.1/i.test(DATABASE_URL));
|
&& (process.env.ENABLE_LOCAL_TEST_ROUTES === '1'
|
||||||
const CORS_ORIGINS = (process.env.CORS_ORIGIN || 'http://localhost:5173,http://localhost:8080')
|
|| /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);
|
.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) {
|
function isPrivateIpv4(hostname) {
|
||||||
return /^10\./.test(hostname)
|
return /^10\./.test(hostname)
|
||||||
@@ -26,10 +48,15 @@ function isPrivateIpv4(hostname) {
|
|||||||
function isAllowedOrigin(origin) {
|
function isAllowedOrigin(origin) {
|
||||||
if (!origin) return true;
|
if (!origin) return true;
|
||||||
if (CORS_ORIGINS.includes(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 {
|
try {
|
||||||
const url = new URL(origin);
|
const url = new URL(origin);
|
||||||
return ['localhost', '127.0.0.1'].includes(url.hostname) || isPrivateIpv4(url.hostname);
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -38,12 +65,45 @@ function isAllowedOrigin(origin) {
|
|||||||
const pool = new Pool({ connectionString: DATABASE_URL, max: 10 });
|
const pool = new Pool({ connectionString: DATABASE_URL, max: 10 });
|
||||||
|
|
||||||
const app = express();
|
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({
|
app.use(cors({
|
||||||
origin(origin, callback) {
|
origin(origin, callback) {
|
||||||
callback(isAllowedOrigin(origin) ? null : new Error('Not allowed by CORS'), isAllowedOrigin(origin));
|
// 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,
|
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' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
// ---- helpers ---------------------------------------------------------
|
// ---- helpers ---------------------------------------------------------
|
||||||
@@ -405,6 +465,17 @@ app.get('/health', async (_req, res) => {
|
|||||||
catch (e) { res.status(500).json({ ok: false, error: e.message }); }
|
catch (e) { res.status(500).json({ ok: false, error: e.message }); }
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
// ---- static SPA ------------------------------------------------------
|
||||||
console.log(`[server] listening on http://localhost:${PORT}`);
|
// 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'})`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } 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";
|
||||||
@@ -112,14 +112,17 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
|
|||||||
|
|
||||||
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">
|
<DialogTitle className="text-2xl font-bold text-slate-800">
|
||||||
{isEmployee ? "Submit Transaction" : "Insert Employee Data"}
|
{isEmployee ? "Submit Transaction" : "Insert Employee Data"}
|
||||||
</DialogTitle>
|
</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">
|
||||||
{isEmployee ? "Employee" : "Select Employee"}
|
{isEmployee ? "Employee" : "Select Employee"}
|
||||||
@@ -192,7 +195,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<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 ({currency})
|
MM Collection Amount ({currency})
|
||||||
@@ -228,7 +231,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
|
|||||||
</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>
|
||||||
|
|||||||
@@ -94,9 +94,9 @@ export const FxSwapDialog: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="sm:max-w-[480px] bg-white">
|
<DialogContent className="w-[min(96vw,480px)] sm:max-w-[480px] bg-white p-4 sm:p-6">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Cash FX Swap</DialogTitle>
|
<DialogTitle className="text-xl font-semibold text-slate-800">Cash FX Swap</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Convert till cash between USD and LBP at the posted rate.
|
Convert till cash between USD and LBP at the posted rate.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -112,7 +112,7 @@ export const FxSwapDialog: React.FC<{
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>USD amount</Label>
|
<Label>USD amount</Label>
|
||||||
<Input type="number" step="0.01" value={usdAmount}
|
<Input type="number" step="0.01" value={usdAmount}
|
||||||
@@ -186,9 +186,9 @@ export const SelfDealOverrideDialog: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="sm:max-w-[420px] bg-white">
|
<DialogContent className="w-[min(96vw,420px)] sm:max-w-[420px] bg-white p-4 sm:p-6">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Manager Self-Deal Override</DialogTitle>
|
<DialogTitle className="text-xl font-semibold text-slate-800">Manager Self-Deal Override</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Enter the manager / owner PIN to allow exactly one transfer
|
Enter the manager / owner PIN to allow exactly one transfer
|
||||||
where the cashier is sender or beneficiary. The override is
|
where the cashier is sender or beneficiary. The override is
|
||||||
|
|||||||
@@ -302,11 +302,18 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
? (data[0] as OpenShift | undefined) ?? null
|
? (data[0] as OpenShift | undefined) ?? null
|
||||||
: ((data as OpenShift | null) ?? null);
|
: ((data as OpenShift | null) ?? null);
|
||||||
setShift(row);
|
setShift(row);
|
||||||
if (row) setTillId(row.till_id);
|
if (row && row.till_id) setTillId(row.till_id);
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [isOpen, shopId]);
|
}, [isOpen, shopId]);
|
||||||
|
|
||||||
|
// Belt-and-suspenders: keep tillId pinned to the open shift's till.
|
||||||
|
useEffect(() => {
|
||||||
|
if (shift && shift.till_id && tillId !== shift.till_id) {
|
||||||
|
setTillId(shift.till_id);
|
||||||
|
}
|
||||||
|
}, [shift, tillId]);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setLastRecorded(null);
|
setLastRecorded(null);
|
||||||
setServiceCode("");
|
setServiceCode("");
|
||||||
@@ -629,7 +636,9 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cat: ServiceCategory | undefined = service?.category;
|
const cat: ServiceCategory | undefined = service?.category;
|
||||||
const canStartTransaction = Boolean(shopId && tillId && shift);
|
// If an open shift is detected, the till is implicit (a shift is on a till).
|
||||||
|
// The till dropdown is informational in that case.
|
||||||
|
const canStartTransaction = Boolean(shopId && shift && (tillId || shift.till_id));
|
||||||
const serviceGuidance = service ? SERVICE_GUIDANCE[service.code] : null;
|
const serviceGuidance = service ? SERVICE_GUIDANCE[service.code] : null;
|
||||||
const submitLabel = service ? ACTION_LABELS[service.code] ?? "Record transaction" : "Record transaction";
|
const submitLabel = service ? ACTION_LABELS[service.code] ?? "Record transaction" : "Record transaction";
|
||||||
|
|
||||||
@@ -637,7 +646,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) handleClose(); }}>
|
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) handleClose(); }}>
|
||||||
<DialogContent className="sm:max-w-[820px] bg-white max-h-[90vh] overflow-y-auto">
|
<DialogContent className="w-[min(96vw,820px)] sm:max-w-[820px] bg-white max-h-[90vh] overflow-y-auto p-4 sm:p-6">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-2xl font-bold text-slate-800">
|
<DialogTitle className="text-2xl font-bold text-slate-800">
|
||||||
New transaction
|
New transaction
|
||||||
@@ -719,7 +728,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Shop / Till / Open shift */}
|
{/* Shop / Till / Open shift */}
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Shop</Label>
|
<Label>Shop</Label>
|
||||||
<Select value={shopId} onValueChange={setShopId}>
|
<Select value={shopId} onValueChange={setShopId}>
|
||||||
@@ -767,20 +776,19 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
{/* Quick Action POS UI */}
|
{/* Quick Action POS UI */}
|
||||||
{!serviceCode ? (
|
{!serviceCode ? (
|
||||||
<div className="pt-4 pb-8 slide-up-fade-in">
|
<div className="pt-4 pb-8 slide-up-fade-in">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{QUICK_SERVICES.map((quickService) => (
|
{QUICK_SERVICES.map((quickService) => (
|
||||||
<Button
|
<button
|
||||||
key={quickService.code}
|
key={quickService.code}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
|
||||||
disabled={!canStartTransaction}
|
disabled={!canStartTransaction}
|
||||||
className={`h-28 flex flex-col items-start text-left gap-2 border-2 font-bold transition-all shadow-sm ${quickService.accent} disabled:opacity-50 disabled:cursor-not-allowed`}
|
className={`h-auto min-h-[132px] w-full min-w-0 overflow-hidden whitespace-normal rounded-md flex flex-col justify-start items-start text-left gap-2 border-2 font-bold transition-all shadow-sm p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${quickService.accent} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
onClick={() => setServiceCode(quickService.code)}
|
onClick={() => setServiceCode(quickService.code)}
|
||||||
>
|
>
|
||||||
<span className="text-xl">{quickService.icon}</span>
|
<span className="text-xl">{quickService.icon}</span>
|
||||||
<span>{quickService.label}</span>
|
<span className="block w-full min-w-0 break-words whitespace-normal leading-tight">{quickService.label}</span>
|
||||||
<span className="text-xs font-medium leading-snug opacity-80">{quickService.note}</span>
|
<span className="block w-full min-w-0 break-words whitespace-normal text-[11px] font-medium leading-tight opacity-80">{quickService.note}</span>
|
||||||
</Button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 pt-6 border-t border-slate-200">
|
<div className="mt-8 pt-6 border-t border-slate-200">
|
||||||
@@ -829,7 +837,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Money */}
|
{/* Money */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Gross USD</Label>
|
<Label>Gross USD</Label>
|
||||||
<Input type="number" step="0.01" min="0"
|
<Input type="number" step="0.01" min="0"
|
||||||
@@ -877,7 +885,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Payment method</Label>
|
<Label>Payment method</Label>
|
||||||
<Select value={paymentMethod}
|
<Select value={paymentMethod}
|
||||||
@@ -898,7 +906,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
|| service?.code === "WHISH_SEND") && (
|
|| service?.code === "WHISH_SEND") && (
|
||||||
<div className="border rounded p-3 space-y-3">
|
<div className="border rounded p-3 space-y-3">
|
||||||
<div className="font-medium text-slate-700">Sender / Beneficiary</div>
|
<div className="font-medium text-slate-700">Sender / Beneficiary</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Direction</Label>
|
<Label>Direction</Label>
|
||||||
<Select value={direction}
|
<Select value={direction}
|
||||||
@@ -982,7 +990,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
{(service?.code === "OMT_RECEIVE" || service?.code === "WU_RECEIVE") && (
|
{(service?.code === "OMT_RECEIVE" || service?.code === "WU_RECEIVE") && (
|
||||||
<div className="border rounded p-3 space-y-3">
|
<div className="border rounded p-3 space-y-3">
|
||||||
<div className="font-medium text-slate-700">Beneficiary payout</div>
|
<div className="font-medium text-slate-700">Beneficiary payout</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Payout code</Label>
|
<Label>Payout code</Label>
|
||||||
<Input value={payoutCode}
|
<Input value={payoutCode}
|
||||||
@@ -1029,7 +1037,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
{(service?.code === "OMT_BILL" || service?.code === "EDL_BILL") && (
|
{(service?.code === "OMT_BILL" || service?.code === "EDL_BILL") && (
|
||||||
<div className="border rounded p-3 space-y-3">
|
<div className="border rounded p-3 space-y-3">
|
||||||
<div className="font-medium text-slate-700">Bill</div>
|
<div className="font-medium text-slate-700">Bill</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Biller code</Label>
|
<Label>Biller code</Label>
|
||||||
<Input value={billerCode}
|
<Input value={billerCode}
|
||||||
@@ -1051,7 +1059,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
<Input value={externalRef}
|
<Input value={externalRef}
|
||||||
onChange={(e) => setExternalRef(e.target.value)} />
|
onChange={(e) => setExternalRef(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Label>Customer name (on bill)</Label>
|
<Label>Customer name (on bill)</Label>
|
||||||
<Input value={beneficiaryName}
|
<Input value={beneficiaryName}
|
||||||
onChange={(e) => setBeneficiaryName(e.target.value)} />
|
onChange={(e) => setBeneficiaryName(e.target.value)} />
|
||||||
@@ -1064,7 +1072,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
{cat === "telecom_recharge" && (
|
{cat === "telecom_recharge" && (
|
||||||
<div className="border rounded p-3 space-y-3">
|
<div className="border rounded p-3 space-y-3">
|
||||||
<div className="font-medium text-slate-700">Recharge</div>
|
<div className="font-medium text-slate-700">Recharge</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Operator</Label>
|
<Label>Operator</Label>
|
||||||
<Input value={operator}
|
<Input value={operator}
|
||||||
@@ -1137,7 +1145,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
{service?.code === "GOODS_SALE" && (
|
{service?.code === "GOODS_SALE" && (
|
||||||
<div className="border rounded p-3 space-y-3">
|
<div className="border rounded p-3 space-y-3">
|
||||||
<div className="font-medium text-slate-700">Goods sale</div>
|
<div className="font-medium text-slate-700">Goods sale</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>SKU</Label>
|
<Label>SKU</Label>
|
||||||
<Input value={sku}
|
<Input value={sku}
|
||||||
@@ -1158,7 +1166,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
<Input type="number" step="0.01" value={goodsUnitCostUsd}
|
<Input type="number" step="0.01" value={goodsUnitCostUsd}
|
||||||
onChange={(e) => setGoodsUnitCostUsd(e.target.value)} />
|
onChange={(e) => setGoodsUnitCostUsd(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Label>Serial / IMEI (if any)</Label>
|
<Label>Serial / IMEI (if any)</Label>
|
||||||
<Input value={serialNumber}
|
<Input value={serialNumber}
|
||||||
onChange={(e) => setSerialNumber(e.target.value)} />
|
onChange={(e) => setSerialNumber(e.target.value)} />
|
||||||
@@ -1171,7 +1179,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
{service?.code === "REPAIR" && (
|
{service?.code === "REPAIR" && (
|
||||||
<div className="border rounded p-3 space-y-3">
|
<div className="border rounded p-3 space-y-3">
|
||||||
<div className="font-medium text-slate-700">Repair</div>
|
<div className="font-medium text-slate-700">Repair</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label>Device type</Label>
|
<Label>Device type</Label>
|
||||||
<Input value={deviceType}
|
<Input value={deviceType}
|
||||||
@@ -1183,7 +1191,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
|
|||||||
<Input value={deviceImei}
|
<Input value={deviceImei}
|
||||||
onChange={(e) => setDeviceImei(e.target.value)} />
|
onChange={(e) => setDeviceImei(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Label>Issue summary</Label>
|
<Label>Issue summary</Label>
|
||||||
<Input value={issueSummary}
|
<Input value={issueSummary}
|
||||||
onChange={(e) => setIssueSummary(e.target.value)} />
|
onChange={(e) => setIssueSummary(e.target.value)} />
|
||||||
|
|||||||
+3
-2
@@ -7,8 +7,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function resolveApiBase(): string {
|
function resolveApiBase(): string {
|
||||||
const configuredBase = (import.meta.env.VITE_API_BASE as string | undefined)?.replace(/\/$/, '');
|
const raw = import.meta.env.VITE_API_BASE as string | undefined;
|
||||||
if (configuredBase) return configuredBase;
|
// Explicit empty string means "same origin" (the API serves the SPA in prod).
|
||||||
|
if (raw !== undefined) return raw.replace(/\/$/, '');
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return 'http://localhost:4000';
|
return 'http://localhost:4000';
|
||||||
|
|||||||
Reference in New Issue
Block a user