From 654b524f1b084300b39ff95ebf637828c69ccb65 Mon Sep 17 00:00:00 2001 From: Krikorios <99836218+Krikorios@users.noreply.github.com> Date: Thu, 21 May 2026 20:34:57 +0300 Subject: [PATCH] Fix RBAC, user creation and update components --- server/db/init/00_auth_shim.sql | 1 + server/db/init/50_employee_payments.sql | 84 ++++ server/db/init/99_seed_admin.sh | 5 +- server/src/index.js | 95 +++-- src/components/AdminDataEntryModal.tsx | 20 +- src/components/ManagerConsole.tsx | 145 ++++++- src/components/OutstandingReportDashboard.tsx | 91 +++- src/components/TransactionCenter.tsx | 399 ++++++++++++++++++ src/components/TransactionEntry.tsx | 86 +++- src/components/UserManagement.tsx | 145 ++++--- src/hooks/useAuth.tsx | 19 +- src/hooks/useSupabaseEmployeeData.ts | 62 ++- src/lib/api.ts | 1 + src/lib/services.ts | 1 + src/pages/Index.tsx | 137 +++--- supabase/migrations/0037_whish_receive.sql | 208 +++++++++ .../migrations/0038_service_ui_settings.sql | 113 +++++ .../migrations/0039_system_admin_role.sql | 51 +++ 18 files changed, 1489 insertions(+), 174 deletions(-) create mode 100644 src/components/TransactionCenter.tsx create mode 100644 supabase/migrations/0037_whish_receive.sql create mode 100644 supabase/migrations/0038_service_ui_settings.sql create mode 100644 supabase/migrations/0039_system_admin_role.sql diff --git a/server/db/init/00_auth_shim.sql b/server/db/init/00_auth_shim.sql index 82450bd..1a8ee03 100644 --- a/server/db/init/00_auth_shim.sql +++ b/server/db/init/00_auth_shim.sql @@ -30,6 +30,7 @@ create table if not exists auth.users ( 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 diff --git a/server/db/init/50_employee_payments.sql b/server/db/init/50_employee_payments.sql index d7ce3a0..96bfa02 100644 --- a/server/db/init/50_employee_payments.sql +++ b/server/db/init/50_employee_payments.sql @@ -30,3 +30,87 @@ 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; diff --git a/server/db/init/99_seed_admin.sh b/server/db/init/99_seed_admin.sh index b098b47..3443a6e 100755 --- a/server/db/init/99_seed_admin.sh +++ b/server/db/init/99_seed_admin.sh @@ -24,11 +24,12 @@ declare v_user_id uuid; v_shop_id uuid; begin - insert into auth.users(email, password_hash, full_name, is_active) - values ('${EM}', crypt('${PW}', gen_salt('bf', 10)), '${NM}', true) + 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; diff --git a/server/src/index.js b/server/src/index.js index d9e49b2..c971a55 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -167,7 +167,9 @@ app.post('/auth/login', async (req, res) => { if (!email || !password) return res.status(400).json({ error: 'email and password required' }); try { const { rows } = await pool.query( - 'SELECT id, email, password_hash, full_name, is_active FROM auth.users WHERE email = $1 LIMIT 1', + `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]; @@ -176,7 +178,7 @@ app.post('/auth/login', async (req, res) => { 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 } }); + 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); } }); @@ -221,7 +223,8 @@ app.post('/rpc/:fn', authRequired, async (req, res) => { // GET /from/:view?col=val&col2=val2 -> SELECT * FROM app. WHERE ... const ALLOWED_VIEWS = new Set([ 'v_my_tills', 'v_my_shops', 'v_services_active', 'v_my_recent_transactions', - 'v_manage_tills', + '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', ]); @@ -311,30 +314,66 @@ app.post('/employee_transactions', authRequired, async (req, res) => { // ---- admin: user management ----------------------------------------- async function ensureAdmin(req, res) { - // Owner-anywhere == admin in the UI. Compute via app.is_owner_anywhere(). try { - const ok = await withUserClient(req, async (client) => { - const r = await client.query('SELECT app.is_owner_anywhere() AS ok'); - return !!r.rows[0]?.ok; - }); - if (!ok) { res.status(403).json({ error: 'admin only' }); return false; } + 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, - EXISTS (SELECT 1 FROM app.user_shop_assignments a - WHERE a.user_id = u.id AND a.role = 'owner') AS is_admin, (SELECT e.emp_id FROM app.employees e WHERE e.email = u.email LIMIT 1) AS emp_id, (SELECT e.department FROM app.employees e WHERE e.email = u.email LIMIT 1) AS department FROM auth.users u @@ -347,23 +386,25 @@ app.get('/admin/users', authRequired, async (req, res) => { app.post('/admin/users', authRequired, async (req, res) => { if (!(await ensureAdmin(req, res))) return; - const { email, password, name, role, department, empId } = req.body || {}; + 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 isAdmin = role === 'admin'; + 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_active) - VALUES ($1, $2, $3, true) RETURNING id, email, full_name`, - [String(email).trim().toLowerCase(), hash, name], + `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( @@ -372,25 +413,9 @@ app.post('/admin/users', authRequired, async (req, res) => { ON CONFLICT (user_id) DO UPDATE SET full_name = EXCLUDED.full_name`, [userId, name], ); - // Assign to the first available shop (Default Shop typically) so they have a role. - const shop = await client.query('SELECT id FROM app.shops ORDER BY created_at LIMIT 1'); - if (shop.rows[0]) { - await client.query( - `INSERT INTO app.user_shop_assignments(user_id, shop_id, role, assigned_by) - VALUES ($1, $2, $3, $4) - ON CONFLICT (user_id, shop_id) DO UPDATE SET role = EXCLUDED.role`, - [userId, shop.rows[0].id, isAdmin ? 'owner' : 'cashier', req.user.id], - ); - } - if (!isAdmin && empId) { - await client.query( - `INSERT INTO app.employees(emp_id, name, email, department) - VALUES ($1, $2, $3, $4) - ON CONFLICT (emp_id) DO UPDATE - SET name = EXCLUDED.name, email = EXCLUDED.email, - department = EXCLUDED.department`, - [empId, name, email, department || null], - ); + 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 } }); diff --git a/src/components/AdminDataEntryModal.tsx b/src/components/AdminDataEntryModal.tsx index ddd0f01..a58b29d 100644 --- a/src/components/AdminDataEntryModal.tsx +++ b/src/components/AdminDataEntryModal.tsx @@ -25,8 +25,8 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData const { user } = useAuth(); const { toast } = useToast(); - const isEmployee = user?.role === 'employee'; - const lockedEmployeeId = isEmployee + 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; @@ -38,10 +38,10 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData const [loading, setLoading] = useState(false); useEffect(() => { - if (isEmployee && lockedEmployeeId) { + if (isSelfEntryRole && lockedEmployeeId) { setSelectedEmployeeId(lockedEmployeeId); } - }, [isEmployee, lockedEmployeeId, isOpen]); + }, [isSelfEntryRole, lockedEmployeeId, isOpen]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -115,7 +115,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData - {isEmployee ? "Submit Transaction" : "Insert Employee Data"} + {isSelfEntryRole ? "Submit Transaction" : "Insert Employee Data"} Record collection and deposit amounts with the proper date and currency. @@ -125,18 +125,18 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
- {isEmployee && !lockedEmployeeId && ( + {isSelfEntryRole && !lockedEmployeeId && (

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

diff --git a/src/components/ManagerConsole.tsx b/src/components/ManagerConsole.tsx index 119b4f1..6b6b4d5 100644 --- a/src/components/ManagerConsole.tsx +++ b/src/components/ManagerConsole.tsx @@ -19,7 +19,7 @@ type IdDocType = "lebanese_id" | "passport" | "residence_permit" | "driver_licen const SERVICE_CODES = [ "OMT_SEND", "OMT_RECEIVE", "WU_SEND", "WU_RECEIVE", - "WHISH_SEND", "OMT_BILL", "EDL_BILL", + "WHISH_SEND", "WHISH_RECEIVE", "OMT_BILL", "EDL_BILL", "ALFA_RECHARGE", "TOUCH_RECHARGE", "GOODS_SALE", "REPAIR", ]; @@ -70,6 +70,17 @@ interface BankDepositRow { 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). @@ -122,9 +133,10 @@ export const ManagerConsole: React.FC = () => { )} - + Fee Schedule FX Rates + Service Titles Tills Cashier KYC Safe / Bank @@ -136,6 +148,9 @@ export const ManagerConsole: React.FC = () => { + + + @@ -150,6 +165,132 @@ export const ManagerConsole: React.FC = () => { ); }; +// --------------------------------------------------------------------------- +// Service title/icon tab +// --------------------------------------------------------------------------- +const ServiceTitlesTab: React.FC<{ shopId: string }> = ({ shopId }) => { + const { toast } = useToast(); + const [rows, setRows] = useState([]); + const [drafts, setDrafts] = useState>({}); + const [busyCode, setBusyCode] = useState(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 ( + + Service Titles & Icons + +
+ Customize how transaction buttons and receipt lists appear for this shop. Service codes stay unchanged for accounting. +
+ + + + Service + Default title + Display title + Icon + Action + + + + {rows.map((row) => { + const draft = drafts[row.service_code] ?? { display_name: "", icon: "" }; + return ( + + +
{row.service_code}
+
{row.category}
+
+ {row.default_name} + + updateDraft(row.service_code, { display_name: event.target.value })} + /> + + + updateDraft(row.service_code, { icon: event.target.value })} + /> + + + + +
+ ); + })} + {!rows.length && ( + + + No services available. + + + )} +
+
+
+
+ ); +}; + // --------------------------------------------------------------------------- // Fee schedule tab // --------------------------------------------------------------------------- diff --git a/src/components/OutstandingReportDashboard.tsx b/src/components/OutstandingReportDashboard.tsx index a612e67..43f0cff 100644 --- a/src/components/OutstandingReportDashboard.tsx +++ b/src/components/OutstandingReportDashboard.tsx @@ -3,13 +3,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Card, CardContent } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData"; -import { Currency, formatCurrency, getUsdToLbpRate } from "@/lib/currency"; +import { Currency, convert, formatCurrency, getUsdToLbpRate } from "@/lib/currency"; export const OutstandingReportDashboard = () => { const [displayCurrency, setDisplayCurrency] = useState("USD"); - const { employees, getEmployeeSummary } = useSupabaseEmployeeData(displayCurrency); + const { employees, getEmployeeSummary, outstandingBalances } = useSupabaseEmployeeData(displayCurrency); - const employeeSummaries = employees.map(employee => { + const legacyEmployeeSummaries = employees.map(employee => { const summary = getEmployeeSummary(employee.id); return { ...employee, @@ -17,6 +17,67 @@ export const OutstandingReportDashboard = () => { }; }); + const balancedSummaries = Array.from( + outstandingBalances.reduce((map, row) => { + const current = map.get(row.employee_id) ?? { + id: row.employee_id, + 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()).values() + ); + + const employeeSummaries = outstandingBalances.length ? balancedSummaries : legacyEmployeeSummaries; + const formatDate = (date: string) => { return new Date(date).toLocaleDateString('en-US'); }; @@ -63,7 +124,7 @@ export const OutstandingReportDashboard = () => {
-

Total Collection (MM)

+

Total Collection / Shortage

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

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

≈ {formatCurrency(totalCollection, displayCurrency)}

@@ -81,7 +142,7 @@ export const OutstandingReportDashboard = () => {
-

Total Deposit Amount

+

Total Deposit / Overage

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

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

≈ {formatCurrency(totalDeposit, displayCurrency)}

@@ -117,7 +178,8 @@ export const OutstandingReportDashboard = () => { Location Emp. ID Emp. Name - Collections (MM) + Collections / Shortage + Shift Variance Date Difference @@ -131,6 +193,16 @@ export const OutstandingReportDashboard = () => { {formatCurrency(employee.totalCollection, displayCurrency)} + + {'shiftShortageUSD' in employee ? ( +
+
Short USD: {formatCurrency(employee.shiftShortageUSD, "USD")}
+
Short LBP: {formatCurrency(employee.shiftShortageLBP, "LBP")}
+
Over USD: {formatCurrency(employee.shiftOverageUSD, "USD")}
+
Over LBP: {formatCurrency(employee.shiftOverageLBP, "LBP")}
+
+ ) : "Manual only"} +
{employee.lastTransactionDate ? formatDate(employee.lastTransactionDate) : '-'} @@ -143,6 +215,13 @@ export const OutstandingReportDashboard = () => { ))} + {employeeSummaries.length === 0 && ( + + + No outstanding balances yet. + + + )}
diff --git a/src/components/TransactionCenter.tsx b/src/components/TransactionCenter.tsx new file mode 100644 index 0000000..43d871a --- /dev/null +++ b/src/components/TransactionCenter.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [query, setQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState<"all" | TxnStatus>("all"); + const [selected, setSelected] = useState(null); + const [receipt, setReceipt] = useState(null); + const [voidTarget, setVoidTarget] = useState(null); + const [voidReason, setVoidReason] = useState(""); + const [managerPin, setManagerPin] = useState(""); + const [refundTarget, setRefundTarget] = useState(null); + const [refundUsd, setRefundUsd] = useState(""); + const [refundLbp, setRefundLbp] = useState(""); + const [refundReason, setRefundReason] = useState(""); + const [refundManagerPin, setRefundManagerPin] = useState(""); + const [busyTxnId, setBusyTxnId] = useState(null); + + const refresh = async () => { + setLoading(true); + const { data, error } = await api.fromView("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("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("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("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 ( +
+ + + Transactions + + +
+
+ + setQuery(event.target.value)} + placeholder="Search receipt, service, reference, beneficiary" + className="pl-9" + /> +
+ + +
+ +
+ + + + Receipt + Service + Amount + External Ref + Customer + Status + Date + Actions + + + + {filteredRows.map((row) => ( + + #{row.reference_no} + +
{row.service_name}
+
{row.payment_method}
+
+ +
USD {fmtUsd(row.gross_usd)}
+
LBP {fmtLbp(row.gross_lbp)}
+
+ {row.external_ref || "-"} + +
{row.beneficiary_name || "-"}
+
{row.beneficiary_phone || ""}
+
+ + + {row.status} + + + {new Date(row.occurred_at).toLocaleString()} + +
+ + + + +
+
+
+ ))} + {!filteredRows.length && ( + + + {loading ? "Loading transactions..." : "No matching transactions."} + + + )} +
+
+
+
+
+ + !open && setSelected(null)}> + + + Receipt #{selected?.reference_no} + {selected?.service_name} transaction details and verification data. + + {selected && ( +
+
+
Transaction ID
{selected.id}
+
Status
{selected.status}
+
External reference
{selected.external_ref || "Not captured"}
+
Occurred
{new Date(selected.occurred_at).toLocaleString()}
+
USD
{fmtUsd(selected.gross_usd)}
+
LBP
{fmtLbp(selected.gross_lbp)}
+
+ {receipt && ( +
+
Reprint logged
+
Receipt log ID: {receipt.receipt_id}
+
QR token: {receipt.qr_token}
+
PDF: {receipt.pdf_url ?? "Not generated yet"}
+
+ )} +
+ )} + + {selected && } + + +
+
+ + !open && setVoidTarget(null)}> + + + Void receipt #{voidTarget?.reference_no} + + Voiding reverses cash, float, stock, and voucher movements. Manager PIN is required after the self-void window or for another cashier's transaction. + + +
+
+ +