Fix RBAC, user creation and update components

This commit is contained in:
Krikorios
2026-05-21 20:34:57 +03:00
parent 1f23102050
commit 654b524f1b
18 changed files with 1489 additions and 174 deletions
+1
View File
@@ -30,6 +30,7 @@ create table if not exists auth.users (
email citext unique, email citext unique,
password_hash text not null, password_hash text not null,
full_name text, full_name text,
is_system_admin boolean not null default false,
is_active boolean not null default true, is_active boolean not null default true,
created_at timestamptz not null default now(), created_at timestamptz not null default now(),
last_login_at timestamptz last_login_at timestamptz
+84
View File
@@ -30,3 +30,87 @@ alter table app.employees disable row level security;
alter table app.employee_transactions 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.employees to authenticated;
grant select, insert, update, delete on app.employee_transactions 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;
+3 -2
View File
@@ -24,11 +24,12 @@ declare
v_user_id uuid; v_user_id uuid;
v_shop_id uuid; v_shop_id uuid;
begin begin
insert into auth.users(email, password_hash, full_name, is_active) insert into auth.users(email, password_hash, full_name, is_system_admin, is_active)
values ('${EM}', crypt('${PW}', gen_salt('bf', 10)), '${NM}', true) values ('${EM}', crypt('${PW}', gen_salt('bf', 10)), '${NM}', true, true)
on conflict (email) do update on conflict (email) do update
set password_hash = excluded.password_hash, set password_hash = excluded.password_hash,
full_name = excluded.full_name, full_name = excluded.full_name,
is_system_admin = true,
is_active = true is_active = true
returning id into v_user_id; returning id into v_user_id;
+60 -35
View File
@@ -167,7 +167,9 @@ app.post('/auth/login', async (req, res) => {
if (!email || !password) return res.status(400).json({ error: 'email and password required' }); if (!email || !password) return res.status(400).json({ error: 'email and password required' });
try { try {
const { rows } = await pool.query( 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()], [String(email).trim().toLowerCase()],
); );
const u = rows[0]; 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' }); 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]); await pool.query('UPDATE auth.users SET last_login_at = now() WHERE id = $1', [u.id]);
const token = signToken(u); 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); } } 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.<view> WHERE ... // GET /from/:view?col=val&col2=val2 -> SELECT * FROM app.<view> WHERE ...
const ALLOWED_VIEWS = new Set([ const ALLOWED_VIEWS = new Set([
'v_my_tills', 'v_my_shops', 'v_services_active', 'v_my_recent_transactions', '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): // 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', '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 ----------------------------------------- // ---- admin: user management -----------------------------------------
async function ensureAdmin(req, res) { async function ensureAdmin(req, res) {
// Owner-anywhere == admin in the UI. Compute via app.is_owner_anywhere().
try { try {
const ok = await withUserClient(req, async (client) => { const { rows } = await pool.query(`
const r = await client.query('SELECT app.is_owner_anywhere() AS ok'); SELECT coalesce((to_jsonb(u)->>'is_system_admin')::boolean, false) AS ok
return !!r.rows[0]?.ok; FROM auth.users u
}); WHERE u.id = $1
if (!ok) { res.status(403).json({ error: 'admin only' }); return false; } `, [req.user.id]);
const ok = !!rows[0]?.ok;
if (!ok) {
res.status(403).json({ error: 'admin only' });
return false;
}
return true; return true;
} catch (e) { dbError(res, e); return false; } } 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) => { app.get('/admin/users', authRequired, async (req, res) => {
if (!(await ensureAdmin(req, res))) return; if (!(await ensureAdmin(req, res))) return;
try { try {
const { rows } = await pool.query(` const { rows } = await pool.query(`
SELECT u.id, u.email, u.is_active, u.full_name, SELECT u.id, u.email, u.is_active, u.full_name,
p.full_name AS profile_name, p.full_name AS profile_name,
coalesce((to_jsonb(u)->>'is_system_admin')::boolean, false) AS is_system_admin,
coalesce( coalesce(
(SELECT a.role::text FROM app.user_shop_assignments a (SELECT a.role::text FROM app.user_shop_assignments a
WHERE a.user_id = u.id ORDER BY (a.role = 'owner') DESC LIMIT 1), WHERE a.user_id = u.id ORDER BY (a.role = 'owner') DESC LIMIT 1),
'cashier' 'cashier'
) AS shop_role, ) 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.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 (SELECT e.department FROM app.employees e WHERE e.email = u.email LIMIT 1) AS department
FROM auth.users u FROM auth.users u
@@ -347,23 +386,25 @@ app.get('/admin/users', authRequired, async (req, res) => {
app.post('/admin/users', authRequired, async (req, res) => { app.post('/admin/users', authRequired, async (req, res) => {
if (!(await ensureAdmin(req, res))) return; 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) { if (!email || !password || !name) {
return res.status(400).json({ error: 'email, password, name required' }); return res.status(400).json({ error: 'email, password, name required' });
} }
if (String(password).length < 6) { if (String(password).length < 6) {
return res.status(400).json({ error: 'password must be at least 6 chars' }); 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(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
const hash = await bcrypt.hash(password, 10); const hash = await bcrypt.hash(password, 10);
const u = await client.query( const u = await client.query(
`INSERT INTO auth.users(email, password_hash, full_name, is_active) `INSERT INTO auth.users(email, password_hash, full_name, is_system_admin, is_active)
VALUES ($1, $2, $3, true) RETURNING id, email, full_name`, VALUES ($1, $2, $3, $4, true) RETURNING id, email, full_name`,
[String(email).trim().toLowerCase(), hash, name], [String(email).trim().toLowerCase(), hash, name, isAdmin],
); );
const userId = u.rows[0].id; const userId = u.rows[0].id;
await client.query( 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`, ON CONFLICT (user_id) DO UPDATE SET full_name = EXCLUDED.full_name`,
[userId, name], [userId, name],
); );
// Assign to the first available shop (Default Shop typically) so they have a role. if (!isAdmin) {
const shop = await client.query('SELECT id FROM app.shops ORDER BY created_at LIMIT 1'); await assignShopRole(client, userId, shopRole, req.user.id, shopId);
if (shop.rows[0]) { await upsertEmployee(client, { empId, name, email, department });
await client.query(
`INSERT INTO app.user_shop_assignments(user_id, shop_id, role, assigned_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, shop_id) DO UPDATE SET role = EXCLUDED.role`,
[userId, shop.rows[0].id, isAdmin ? 'owner' : 'cashier', req.user.id],
);
}
if (!isAdmin && empId) {
await client.query(
`INSERT INTO app.employees(emp_id, name, email, department)
VALUES ($1, $2, $3, $4)
ON CONFLICT (emp_id) DO UPDATE
SET name = EXCLUDED.name, email = EXCLUDED.email,
department = EXCLUDED.department`,
[empId, name, email, department || null],
);
} }
await client.query('COMMIT'); await client.query('COMMIT');
res.json({ data: { id: userId, email: u.rows[0].email, full_name: u.rows[0].full_name } }); res.json({ data: { id: userId, email: u.rows[0].email, full_name: u.rows[0].full_name } });
+10 -10
View File
@@ -25,8 +25,8 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
const { user } = useAuth(); const { user } = useAuth();
const { toast } = useToast(); const { toast } = useToast();
const isEmployee = user?.role === 'employee'; const isSelfEntryRole = user?.role === 'employee' || user?.role === 'owner';
const lockedEmployeeId = isEmployee const lockedEmployeeId = isSelfEntryRole
? employees.find(e => e.emp_id === user?.empId || e.email === user?.email)?.id ? employees.find(e => e.emp_id === user?.empId || e.email === user?.email)?.id
: undefined; : undefined;
@@ -38,10 +38,10 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (isEmployee && lockedEmployeeId) { if (isSelfEntryRole && lockedEmployeeId) {
setSelectedEmployeeId(lockedEmployeeId); setSelectedEmployeeId(lockedEmployeeId);
} }
}, [isEmployee, lockedEmployeeId, isOpen]); }, [isSelfEntryRole, lockedEmployeeId, isOpen]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -115,7 +115,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
<DialogContent className="w-[min(96vw,500px)] sm:max-w-[500px] bg-white p-4 sm:p-6"> <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"} {isSelfEntryRole ? "Submit Transaction" : "Insert Employee Data"}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Record collection and deposit amounts with the proper date and currency. Record collection and deposit amounts with the proper date and currency.
@@ -125,18 +125,18 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
<form onSubmit={handleSubmit} className="space-y-6 mt-4"> <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"} {isSelfEntryRole ? "Employee" : "Select Employee"}
</Label> </Label>
<Select <Select
value={selectedEmployeeId} value={selectedEmployeeId}
onValueChange={setSelectedEmployeeId} onValueChange={setSelectedEmployeeId}
disabled={isEmployee} disabled={isSelfEntryRole}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={isEmployee ? "Your account" : "Choose an employee"} /> <SelectValue placeholder={isSelfEntryRole ? "Your account" : "Choose an employee"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(isEmployee && lockedEmployeeId {(isSelfEntryRole && lockedEmployeeId
? employees.filter(e => e.id === lockedEmployeeId) ? employees.filter(e => e.id === lockedEmployeeId)
: employees : employees
).map(employee => ( ).map(employee => (
@@ -146,7 +146,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{isEmployee && !lockedEmployeeId && ( {isSelfEntryRole && !lockedEmployeeId && (
<p className="text-xs text-red-600"> <p className="text-xs text-red-600">
Your account is not linked to an employee record. Ask an admin to set your Employee ID. Your account is not linked to an employee record. Ask an admin to set your Employee ID.
</p> </p>
+143 -2
View File
@@ -19,7 +19,7 @@ type IdDocType = "lebanese_id" | "passport" | "residence_permit" | "driver_licen
const SERVICE_CODES = [ const SERVICE_CODES = [
"OMT_SEND", "OMT_RECEIVE", "WU_SEND", "WU_RECEIVE", "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", "ALFA_RECHARGE", "TOUCH_RECHARGE", "GOODS_SALE", "REPAIR",
]; ];
@@ -70,6 +70,17 @@ interface BankDepositRow {
created_at: string; 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: * ManagerConsole — single screen for managers/owners to:
* - Seed / view fee_schedule rows (per service per currency). * - Seed / view fee_schedule rows (per service per currency).
@@ -122,9 +133,10 @@ export const ManagerConsole: React.FC = () => {
)} )}
<Tabs defaultValue="fees" className="space-y-4"> <Tabs defaultValue="fees" className="space-y-4">
<TabsList className="grid w-full grid-cols-5"> <TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="fees">Fee Schedule</TabsTrigger> <TabsTrigger value="fees">Fee Schedule</TabsTrigger>
<TabsTrigger value="fx">FX Rates</TabsTrigger> <TabsTrigger value="fx">FX Rates</TabsTrigger>
<TabsTrigger value="services">Service Titles</TabsTrigger>
<TabsTrigger value="tills">Tills</TabsTrigger> <TabsTrigger value="tills">Tills</TabsTrigger>
<TabsTrigger value="kyc">Cashier KYC</TabsTrigger> <TabsTrigger value="kyc">Cashier KYC</TabsTrigger>
<TabsTrigger value="safe">Safe / Bank</TabsTrigger> <TabsTrigger value="safe">Safe / Bank</TabsTrigger>
@@ -136,6 +148,9 @@ export const ManagerConsole: React.FC = () => {
<TabsContent value="fx"> <TabsContent value="fx">
<FxRatesTab shopId={shopId} /> <FxRatesTab shopId={shopId} />
</TabsContent> </TabsContent>
<TabsContent value="services">
<ServiceTitlesTab shopId={shopId} />
</TabsContent>
<TabsContent value="tills"> <TabsContent value="tills">
<TillsTab shopId={shopId} /> <TillsTab shopId={shopId} />
</TabsContent> </TabsContent>
@@ -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<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 // Fee schedule tab
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+85 -6
View File
@@ -3,13 +3,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData"; import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
import { Currency, formatCurrency, getUsdToLbpRate } from "@/lib/currency"; import { Currency, convert, formatCurrency, getUsdToLbpRate } from "@/lib/currency";
export const OutstandingReportDashboard = () => { export const OutstandingReportDashboard = () => {
const [displayCurrency, setDisplayCurrency] = useState<Currency>("USD"); const [displayCurrency, setDisplayCurrency] = useState<Currency>("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); const summary = getEmployeeSummary(employee.id);
return { return {
...employee, ...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<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-US'); return new Date(date).toLocaleDateString('en-US');
}; };
@@ -63,7 +124,7 @@ export const OutstandingReportDashboard = () => {
<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 className="flex-1 min-w-0"> <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-xs text-gray-500">USD: <span className="font-medium text-gray-700">{formatCurrency(totalCollectionUSD, "USD")}</span></p> <p className="text-xs text-gray-500">USD: <span className="font-medium text-gray-700">{formatCurrency(totalCollectionUSD, "USD")}</span></p>
<p className="text-xs text-gray-500">LBP: <span className="font-medium text-gray-700">{formatCurrency(totalCollectionLBP, "LBP")}</span></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> <p className="text-lg font-bold text-gray-800 mt-1"> {formatCurrency(totalCollection, displayCurrency)}</p>
@@ -81,7 +142,7 @@ export const OutstandingReportDashboard = () => {
</div> </div>
</div> </div>
<div className="flex-1 min-w-0"> <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-xs text-gray-500">USD: <span className="font-medium text-gray-700">{formatCurrency(totalDepositUSD, "USD")}</span></p> <p className="text-xs text-gray-500">USD: <span className="font-medium text-gray-700">{formatCurrency(totalDepositUSD, "USD")}</span></p>
<p className="text-xs text-gray-500">LBP: <span className="font-medium text-gray-700">{formatCurrency(totalDepositLBP, "LBP")}</span></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> <p className="text-lg font-bold text-gray-800 mt-1"> {formatCurrency(totalDeposit, displayCurrency)}</p>
@@ -117,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>
@@ -131,6 +193,16 @@ export const OutstandingReportDashboard = () => {
<TableCell className="py-4 font-medium"> <TableCell className="py-4 font-medium">
{formatCurrency(employee.totalCollection, displayCurrency)} {formatCurrency(employee.totalCollection, displayCurrency)}
</TableCell> </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 className="py-4 text-gray-600"> <TableCell className="py-4 text-gray-600">
{employee.lastTransactionDate ? formatDate(employee.lastTransactionDate) : '-'} {employee.lastTransactionDate ? formatDate(employee.lastTransactionDate) : '-'}
</TableCell> </TableCell>
@@ -143,6 +215,13 @@ export const OutstandingReportDashboard = () => {
</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>
+399
View File
@@ -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>
);
};
+70 -16
View File
@@ -42,12 +42,28 @@ interface ProductCatalogItem {
unit_face_lbp?: number; unit_face_lbp?: number;
} }
interface ServiceUiSetting {
service_code: string;
display_name: string | null;
icon: string | null;
}
interface OpenShift { interface OpenShift {
shift_id: string; shift_id: string;
till_id: string; till_id: string;
opened_at: string; opened_at: string;
} }
function normalizeActiveShift(raw: unknown): OpenShift | null {
if (!raw || typeof raw !== "object") return null;
const row = raw as Record<string, unknown>;
const shiftId = String(row.shift_id ?? row.shiftId ?? "");
const tillId = String(row.till_id ?? row.tillId ?? "");
const openedAt = String(row.opened_at ?? row.openedAt ?? "");
if (!shiftId || !openedAt) return null;
return { shift_id: shiftId, till_id: tillId, opened_at: openedAt };
}
interface RecentTransactionRow { interface RecentTransactionRow {
id: string; id: string;
reference_no: number; reference_no: number;
@@ -103,6 +119,7 @@ const QUICK_SERVICES: Array<{ code: string; icon: string; accent: string; label:
{ code: "OMT_SEND", icon: "💸", accent: "bg-blue-50 hover:bg-blue-100 border-blue-200 text-blue-700", label: "OMT Send", note: "Take cash, then capture sender and beneficiary." }, { code: "OMT_SEND", icon: "💸", accent: "bg-blue-50 hover:bg-blue-100 border-blue-200 text-blue-700", label: "OMT Send", note: "Take cash, then capture sender and beneficiary." },
{ code: "OMT_RECEIVE", icon: "🏦", accent: "bg-emerald-50 hover:bg-emerald-100 border-emerald-200 text-emerald-700", label: "OMT Receive", note: "Confirm payout code and beneficiary ID first." }, { code: "OMT_RECEIVE", icon: "🏦", accent: "bg-emerald-50 hover:bg-emerald-100 border-emerald-200 text-emerald-700", label: "OMT Receive", note: "Confirm payout code and beneficiary ID first." },
{ code: "WHISH_SEND", icon: "📱", accent: "bg-purple-50 hover:bg-purple-100 border-purple-200 text-purple-700", label: "Whish Transfer", note: "Fast wallet send with sender checks." }, { code: "WHISH_SEND", icon: "📱", accent: "bg-purple-50 hover:bg-purple-100 border-purple-200 text-purple-700", label: "Whish Transfer", note: "Fast wallet send with sender checks." },
{ code: "WHISH_RECEIVE", icon: "📲", accent: "bg-pink-50 hover:bg-pink-100 border-pink-200 text-pink-700", label: "Whish Receive", note: "Confirm payout code and beneficiary ID first." },
{ code: "ALFA_RECHARGE", icon: "📡", accent: "bg-indigo-50 hover:bg-indigo-100 border-indigo-200 text-indigo-700", label: "Alfa Recharge", note: "Use the catalog product for faster entry." }, { code: "ALFA_RECHARGE", icon: "📡", accent: "bg-indigo-50 hover:bg-indigo-100 border-indigo-200 text-indigo-700", label: "Alfa Recharge", note: "Use the catalog product for faster entry." },
{ code: "TOUCH_RECHARGE", icon: "📞", accent: "bg-orange-50 hover:bg-orange-100 border-orange-200 text-orange-700", label: "Touch Recharge", note: "Voucher or e-recharge reference required." }, { code: "TOUCH_RECHARGE", icon: "📞", accent: "bg-orange-50 hover:bg-orange-100 border-orange-200 text-orange-700", label: "Touch Recharge", note: "Voucher or e-recharge reference required." },
{ code: "EDL_BILL", icon: "⚡", accent: "bg-slate-50 hover:bg-slate-100 border-slate-200 text-slate-700", label: "EDL Bill", note: "Reference, biller, and account number required." }, { code: "EDL_BILL", icon: "⚡", accent: "bg-slate-50 hover:bg-slate-100 border-slate-200 text-slate-700", label: "EDL Bill", note: "Reference, biller, and account number required." },
@@ -121,6 +138,10 @@ const SERVICE_GUIDANCE: Record<string, { description: string; required: string[]
description: "Treat this like a money transfer: sender checks, beneficiary details, and provider reference are all mandatory.", description: "Treat this like a money transfer: sender checks, beneficiary details, and provider reference are all mandatory.",
required: ["Amount", "Sender ID", "Sender phone", "Beneficiary", "Reference"], required: ["Amount", "Sender ID", "Sender phone", "Beneficiary", "Reference"],
}, },
WHISH_RECEIVE: {
description: "No cash should leave the drawer until the Whish payout reference and beneficiary ID are confirmed.",
required: ["Payout code", "Beneficiary", "Beneficiary ID", "Amount"],
},
ALFA_RECHARGE: { ALFA_RECHARGE: {
description: "Choose the product from the catalog when possible so the face value drops straight into the amount field.", description: "Choose the product from the catalog when possible so the face value drops straight into the amount field.",
required: ["Operator", "Subscriber number", "Product", "Voucher or e-ref"], required: ["Operator", "Subscriber number", "Product", "Voucher or e-ref"],
@@ -139,6 +160,7 @@ const ACTION_LABELS: Record<string, string> = {
OMT_SEND: "Record OMT send", OMT_SEND: "Record OMT send",
OMT_RECEIVE: "Record OMT receive", OMT_RECEIVE: "Record OMT receive",
WHISH_SEND: "Record Whish send", WHISH_SEND: "Record Whish send",
WHISH_RECEIVE: "Record Whish receive",
ALFA_RECHARGE: "Record Alfa recharge", ALFA_RECHARGE: "Record Alfa recharge",
TOUCH_RECHARGE: "Record Touch recharge", TOUCH_RECHARGE: "Record Touch recharge",
EDL_BILL: "Record EDL bill", EDL_BILL: "Record EDL bill",
@@ -169,6 +191,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
const [tillId, setTillId] = useState<string>(""); const [tillId, setTillId] = useState<string>("");
const [shift, setShift] = useState<OpenShift | null>(null); const [shift, setShift] = useState<OpenShift | null>(null);
const [catalogItems, setCatalogItems] = useState<ProductCatalogItem[]>([]); const [catalogItems, setCatalogItems] = useState<ProductCatalogItem[]>([]);
const [serviceUiSettings, setServiceUiSettings] = useState<Record<string, ServiceUiSetting>>({});
const [serviceCode, setServiceCode] = useState<string>(""); const [serviceCode, setServiceCode] = useState<string>("");
const service: ServiceDef | undefined = useMemo( const service: ServiceDef | undefined = useMemo(
@@ -260,6 +283,25 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [shopId]); }, [shopId]);
useEffect(() => {
if (!shopId) {
setServiceUiSettings({});
return;
}
let cancelled = false;
(async () => {
const { data, error } = await supabase
.from("v_service_ui_settings")
.select("service_code, display_name, icon")
.eq("shop_id", shopId);
if (cancelled) return;
if (!error && data) {
setServiceUiSettings(Object.fromEntries((data as ServiceUiSetting[]).map((row) => [row.service_code, row])));
}
})();
return () => { cancelled = true; };
}, [shopId]);
// Default operator from selected service // Default operator from selected service
useEffect(() => { useEffect(() => {
@@ -299,8 +341,8 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
const { data } = await supabase.rpc("my_active_shift", { p_shop: shopId }); const { data } = await supabase.rpc("my_active_shift", { p_shop: shopId });
if (cancelled) return; if (cancelled) return;
const row = Array.isArray(data) const row = Array.isArray(data)
? (data[0] as OpenShift | undefined) ?? null ? normalizeActiveShift(data[0])
: ((data as OpenShift | null) ?? null); : normalizeActiveShift(data);
setShift(row); setShift(row);
if (row && row.till_id) setTillId(row.till_id); if (row && row.till_id) setTillId(row.till_id);
})(); })();
@@ -356,7 +398,8 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!shopId || !service || !shift || !tillId) { const activeTillId = tillId || shift?.till_id || "";
if (!shopId || !service || !shift || !activeTillId) {
toast({ toast({
title: "Missing context", title: "Missing context",
description: !shift description: !shift
@@ -391,7 +434,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
rpc = { rpc = {
fn: "record_recharge", fn: "record_recharge",
args: { args: {
p_shop: shopId, p_till: tillId, p_service_code: service.code, p_shop: shopId, p_till: activeTillId, p_service_code: service.code,
p_payment_method: paymentMethod, p_payment_method: paymentMethod,
p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp),
p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp),
@@ -417,7 +460,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
rpc = { rpc = {
fn: "record_omt_send", fn: "record_omt_send",
args: { args: {
p_shop: shopId, p_till: tillId, p_shop: shopId, p_till: activeTillId,
p_payment_method: paymentMethod, p_payment_method: paymentMethod,
p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp),
p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp),
@@ -455,7 +498,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
rpc = { rpc = {
fn: "record_whish_send", fn: "record_whish_send",
args: { args: {
p_shop: shopId, p_till: tillId, p_shop: shopId, p_till: activeTillId,
p_payment_method: paymentMethod, p_payment_method: paymentMethod,
p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp),
p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp),
@@ -481,6 +524,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
case "OMT_RECEIVE": case "OMT_RECEIVE":
case "WU_RECEIVE": case "WU_RECEIVE":
case "WHISH_RECEIVE":
if (!payoutCode.trim() || !beneficiaryName.trim() if (!payoutCode.trim() || !beneficiaryName.trim()
|| !recvIdNumber.trim()) { || !recvIdNumber.trim()) {
throw new Error("Payout code, beneficiary name and ID number are required."); throw new Error("Payout code, beneficiary name and ID number are required.");
@@ -488,7 +532,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
rpc = { rpc = {
fn: "record_omt_receive", fn: "record_omt_receive",
args: { args: {
p_shop: shopId, p_till: tillId, p_shop: shopId, p_till: activeTillId,
p_payment_method: paymentMethod, p_payment_method: paymentMethod,
p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp),
p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp),
@@ -518,7 +562,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
rpc = { rpc = {
fn: "record_bill", fn: "record_bill",
args: { args: {
p_shop: shopId, p_till: tillId, p_service_code: service.code, p_shop: shopId, p_till: activeTillId, p_service_code: service.code,
p_payment_method: paymentMethod, p_payment_method: paymentMethod,
p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp),
p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp), p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp),
@@ -541,7 +585,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
rpc = { rpc = {
fn: "record_goods_sale", fn: "record_goods_sale",
args: { args: {
p_shop: shopId, p_till: tillId, p_shop: shopId, p_till: activeTillId,
p_payment_method: paymentMethod, p_payment_method: paymentMethod,
p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp),
p_fx_rate: num(fxRate) || null, p_fx_rate: num(fxRate) || null,
@@ -562,7 +606,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
rpc = { rpc = {
fn: "record_repair", fn: "record_repair",
args: { args: {
p_shop: shopId, p_till: tillId, p_shop: shopId, p_till: activeTillId,
p_payment_method: paymentMethod, p_payment_method: paymentMethod,
p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp), p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp),
p_fx_rate: num(fxRate) || null, p_fx_rate: num(fxRate) || null,
@@ -636,11 +680,21 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
}; };
const cat: ServiceCategory | undefined = service?.category; const cat: ServiceCategory | undefined = service?.category;
const effectiveTillId = tillId || shift?.till_id || "";
// If an open shift is detected, the till is implicit (a shift is on a till). // If an open shift is detected, the till is implicit (a shift is on a till).
// The till dropdown is informational in that case. // The till dropdown is informational in that case.
const canStartTransaction = Boolean(shopId && shift && (tillId || shift.till_id)); const canStartTransaction = Boolean(shopId && shift && effectiveTillId);
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 serviceTitle = (serviceDef: ServiceDef) => serviceUiSettings[serviceDef.code]?.display_name || serviceDef.label;
const serviceIcon = (code: string, fallback: string) => serviceUiSettings[code]?.icon || fallback;
const submitLabel = service
? `Record ${serviceUiSettings[service.code]?.display_name || (ACTION_LABELS[service.code] ?? service.label).replace(/^Record\s+/i, "")}`
: "Record transaction";
const quickServices = QUICK_SERVICES.map((quickService) => ({
...quickService,
icon: serviceIcon(quickService.code, quickService.icon),
label: serviceUiSettings[quickService.code]?.display_name || quickService.label,
}));
if (!isOpen && isClosing) return null; if (!isOpen && isClosing) return null;
@@ -744,7 +798,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
</div> </div>
<div> <div>
<Label>Till</Label> <Label>Till</Label>
<Select value={tillId} onValueChange={setTillId} <Select value={effectiveTillId} onValueChange={setTillId}
disabled={!!shift}> disabled={!!shift}>
<SelectTrigger><SelectValue placeholder="Select till" /></SelectTrigger> <SelectTrigger><SelectValue placeholder="Select till" /></SelectTrigger>
<SelectContent> <SelectContent>
@@ -777,7 +831,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
{!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-1 sm:grid-cols-2 lg: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) => ( {quickServices.map((quickService) => (
<button <button
key={quickService.code} key={quickService.code}
type="button" type="button"
@@ -801,7 +855,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
<SelectGroup key={c}> <SelectGroup key={c}>
<SelectLabel>{CATEGORY_LABEL[c]}</SelectLabel> <SelectLabel>{CATEGORY_LABEL[c]}</SelectLabel>
{SERVICES_BY_CATEGORY[c].map((s) => ( {SERVICES_BY_CATEGORY[c].map((s) => (
<SelectItem key={s.code} value={s.code}>{s.label}</SelectItem> <SelectItem key={s.code} value={s.code}>{serviceTitle(s)}</SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
))} ))}
@@ -987,7 +1041,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
)} )}
{/* Money-transfer RECEIVE fields */} {/* Money-transfer RECEIVE fields */}
{(service?.code === "OMT_RECEIVE" || service?.code === "WU_RECEIVE") && ( {(service?.code === "OMT_RECEIVE" || service?.code === "WU_RECEIVE" || service?.code === "WHISH_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-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+98 -47
View File
@@ -14,38 +14,59 @@ import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
type UserRole = "admin" | "employee"; type UserRole = "admin" | "owner" | "employee";
interface AdminUser { interface AdminUser {
id: string; id: string;
email: string; email: string;
is_active: boolean; is_active: boolean;
full_name: string | null; full_name: string | null;
is_admin: boolean; is_system_admin: boolean;
shop_role: "owner" | "manager" | "cashier" | "auditor";
emp_id: string | null; emp_id: string | null;
department: 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 = () => { export const UserManagement = () => {
const { toast } = useToast(); const { toast } = useToast();
const { employees, refreshData } = useSupabaseEmployeeData(); const { employees, refreshData } = useSupabaseEmployeeData();
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [shops, setShops] = useState<Shop[]>([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [empId, setEmpId] = useState(""); const [empId, setEmpId] = useState("");
const [department, setDepartment] = useState("Collections"); const [department, setDepartment] = useState("Collections");
const [role, setRole] = useState<UserRole>("employee"); const [role, setRole] = useState<UserRole>("employee");
const [shopId, setShopId] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const { data, error } = await api.get<AdminUser[]>("/admin/users"); const { data: userData, error: userError } = await api.get<AdminUser[]>("/admin/users");
if (error) { if (userError) {
toast({ title: "Could not load users", description: error.message, variant: "destructive" }); toast({ title: "Could not load users", description: userError.message, variant: "destructive" });
return; return;
} }
setUsers(data ?? []); setUsers(userData ?? []);
}, [toast]);
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]); useEffect(() => { refresh(); }, [refresh]);
@@ -56,15 +77,18 @@ export const UserManagement = () => {
const finalEmpId = const finalEmpId =
role === "employee" role === "employee"
? empId.trim() || `EMP${String(employees.length + 1).padStart(3, "0")}` ? empId.trim() || `EMP${String(employees.length + 1).padStart(3, "0")}`
: empId.trim() || undefined; : undefined;
const finalDep = role === "employee" ? department : undefined;
const finalShopId = role !== "admin" ? shopId : undefined;
const { error } = await api.post<AdminUser>("/admin/users", { const { error } = await api.post<AdminUser>("/admin/users", {
email, email,
password, password,
name, name,
role, role,
department, department: finalDep,
empId: finalEmpId, empId: finalEmpId,
shopId: finalShopId
}); });
if (error) { if (error) {
toast({ title: "Could not create user", description: error.message, variant: "destructive" }); toast({ title: "Could not create user", description: error.message, variant: "destructive" });
@@ -125,24 +149,46 @@ export const UserManagement = () => {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
/> />
<Input
placeholder="Department"
value={department}
onChange={(e) => setDepartment(e.target.value)}
/>
<Input
placeholder="Employee ID (optional)"
value={empId}
onChange={(e) => setEmpId(e.target.value)}
/>
<select <select
value={role} value={role}
onChange={(e) => setRole(e.target.value as UserRole)} onChange={(e) => setRole(e.target.value as UserRole)}
className="h-10 px-3 border border-gray-300 rounded-md bg-white" className="h-10 px-3 border border-gray-300 rounded-md bg-white"
> >
<option value="employee">Employee</option> <option value="employee">Employee</option>
<option value="owner">Owner</option>
<option value="admin">Admin</option> <option value="admin">Admin</option>
</select> </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 <Button
type="submit" type="submit"
disabled={busy} disabled={busy}
@@ -168,34 +214,39 @@ export const UserManagement = () => {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.map((u) => ( {users.map((u) => {
<TableRow key={u.id}> const r = displayRole(u);
<TableCell className="font-medium">{u.full_name ?? "-"}</TableCell> return (
<TableCell>{u.email}</TableCell> <TableRow key={u.id}>
<TableCell>{u.department ?? "-"}</TableCell> <TableCell className="font-medium">{u.full_name ?? "-"}</TableCell>
<TableCell> <TableCell>{u.email}</TableCell>
<span <TableCell>{u.department ?? "-"}</TableCell>
className={ <TableCell>
u.is_admin <span
? "px-2 py-1 text-xs rounded bg-purple-100 text-purple-700" className={
: "px-2 py-1 text-xs rounded bg-gray-100 text-gray-700" r === "admin"
} ? "px-2 py-1 text-xs rounded bg-purple-100 text-purple-700"
> : r === "owner"
{u.is_admin ? "admin" : "employee"} ? "px-2 py-1 text-xs rounded bg-emerald-100 text-emerald-700"
</span> : "px-2 py-1 text-xs rounded bg-gray-100 text-gray-700"
</TableCell> }
<TableCell className="text-right"> >
<Button {r}
variant="ghost" </span>
size="sm" </TableCell>
onClick={() => handleDelete(u.id)} <TableCell className="text-right">
className="text-red-600 hover:text-red-700" <Button
> variant="ghost"
<Trash2 className="h-4 w-4" /> size="sm"
</Button> onClick={() => handleDelete(u.id)}
</TableCell> className="text-red-600 hover:text-red-700"
</TableRow> >
))} <Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
{users.length === 0 && ( {users.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center text-gray-500"> <TableCell colSpan={5} className="text-center text-gray-500">
+11 -8
View File
@@ -1,13 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, type ApiSession } from "@/lib/api"; import { api, type ApiSession } from "@/lib/api";
/** export type UiRole = "admin" | "owner" | "employee";
* UI-shape consumed by the rest of the app. Mirrors the previous Supabase
* version so existing components that branch on `user.role === "admin"`
* keep working. `admin` here means "owner anywhere"; per-shop business
* roles live in `shops`.
*/
export type UiRole = "admin" | "employee";
export interface UiShopRole { export interface UiShopRole {
shop_id: string; shop_id: string;
@@ -20,7 +14,9 @@ export interface UiUser {
email: string; email: string;
name: string; name: string;
role: UiRole; role: UiRole;
empId?: string | null;
shops: UiShopRole[]; shops: UiShopRole[];
isSystemAdmin: boolean;
isOwnerAnywhere: boolean; isOwnerAnywhere: boolean;
} }
@@ -28,7 +24,9 @@ interface MeRow {
user_id: string; user_id: string;
full_name: string; full_name: string;
is_active: boolean; is_active: boolean;
is_system_admin?: boolean;
is_owner_anywhere: boolean; is_owner_anywhere: boolean;
emp_id?: string | null;
shops: UiShopRole[] | null; shops: UiShopRole[] | null;
} }
@@ -40,19 +38,24 @@ async function loadMe(session: ApiSession): Promise<UiUser> {
email: session.user.email, email: session.user.email,
name: session.user.full_name ?? session.user.email, name: session.user.full_name ?? session.user.email,
role: "employee", role: "employee",
empId: null,
shops: [], shops: [],
isSystemAdmin: false,
isOwnerAnywhere: false, isOwnerAnywhere: false,
}; };
} }
const row = (Array.isArray(data) ? data[0] : data) as MeRow | undefined; const row = (Array.isArray(data) ? data[0] : data) as MeRow | undefined;
const shops = (row?.shops ?? []) as UiShopRole[]; const shops = (row?.shops ?? []) as UiShopRole[];
const isSystemAdmin = !!row?.is_system_admin;
const isOwner = !!row?.is_owner_anywhere; const isOwner = !!row?.is_owner_anywhere;
return { return {
id: session.user.id, id: session.user.id,
email: session.user.email, email: session.user.email,
name: row?.full_name?.trim() || session.user.full_name || session.user.email, name: row?.full_name?.trim() || session.user.full_name || session.user.email,
role: isOwner ? "admin" : "employee", role: isSystemAdmin ? "admin" : isOwner ? "owner" : "employee",
empId: row?.emp_id ?? null,
shops, shops,
isSystemAdmin,
isOwnerAnywhere: isOwner, isOwnerAnywhere: isOwner,
}; };
} }
+61 -1
View File
@@ -38,6 +38,24 @@ export interface EmployeeSummary {
lastTransactionDate: string | null; lastTransactionDate: string | null;
} }
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 { interface DbEmployee {
id: string; id: string;
emp_id: string; emp_id: string;
@@ -56,6 +74,24 @@ interface DbTransaction {
currency: Currency; 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 { function normalizeEmployee(e: DbEmployee): Employee {
return { return {
id: e.id, id: e.id,
@@ -80,19 +116,42 @@ function normalizeTx(t: DbTransaction): Transaction {
}; };
} }
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") => { 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);
const refreshData = useCallback(async () => { const refreshData = useCallback(async () => {
setLoading(true); setLoading(true);
const [eRes, tRes] = await Promise.all([ const [eRes, tRes, bRes] = await Promise.all([
api.get<DbEmployee[]>("/employees"), api.get<DbEmployee[]>("/employees"),
api.get<DbTransaction[]>("/employee_transactions"), api.get<DbTransaction[]>("/employee_transactions"),
api.fromView<DbOutstandingBalance>("v_employee_outstanding_balances"),
]); ]);
setEmployees((eRes.data ?? []).map(normalizeEmployee)); setEmployees((eRes.data ?? []).map(normalizeEmployee));
setTransactions((tRes.data ?? []).map(normalizeTx)); setTransactions((tRes.data ?? []).map(normalizeTx));
setOutstandingBalances((bRes.data ?? []).map(normalizeBalance));
setLoading(false); setLoading(false);
}, []); }, []);
@@ -220,6 +279,7 @@ export const useSupabaseEmployeeData = (displayCurrency: Currency = "USD") => {
return { return {
employees, employees,
transactions, transactions,
outstandingBalances,
loading, loading,
addTransaction, addTransaction,
addEmployee, addEmployee,
+1
View File
@@ -38,6 +38,7 @@ export interface ApiUser {
id: string; id: string;
email: string; email: string;
full_name?: string | null; full_name?: string | null;
is_system_admin?: boolean;
} }
export interface ApiSession { export interface ApiSession {
access_token: string; access_token: string;
+1
View File
@@ -28,6 +28,7 @@ export const SERVICES: ServiceDef[] = [
{ code: "WU_SEND", label: "Western Union — Send", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: true }, { 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: "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_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 // Telecom recharge
{ code: "ALFA_RECHARGE", label: "Alfa recharge", category: "telecom_recharge", requiresExternalRef: false, requiresBeneficiary: false }, { code: "ALFA_RECHARGE", label: "Alfa recharge", category: "telecom_recharge", requiresExternalRef: false, requiresBeneficiary: false },
+90 -47
View File
@@ -11,6 +11,7 @@ import { LoginPage } from "@/components/LoginPage";
import { UserManagement } from "@/components/UserManagement"; import { UserManagement } from "@/components/UserManagement";
import { ManagerConsole } from "@/components/ManagerConsole"; import { ManagerConsole } from "@/components/ManagerConsole";
import { OwnerOverview } from "@/components/OwnerOverview"; 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";
@@ -19,6 +20,15 @@ const Index = () => {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isTxnOpen, setIsTxnOpen] = 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);
@@ -57,19 +67,23 @@ const Index = () => {
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button {canEnterTransactions && (
onClick={() => setIsTxnOpen(true)} <>
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2 rounded-lg font-medium" <Button
> onClick={() => setIsTxnOpen(true)}
New Transaction className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2 rounded-lg font-medium"
</Button> >
<Button New Transaction
onClick={() => setIsModalOpen(true)} </Button>
variant="outline" <Button
className="px-6 py-2 rounded-lg font-medium" onClick={() => setIsModalOpen(true)}
> variant="outline"
{user.role === "admin" ? "Insert Employee Data" : "Submit Collection / Deposit"} className="px-6 py-2 rounded-lg font-medium"
</Button> >
Submit Collection / Deposit
</Button>
</>
)}
<Button <Button
onClick={handleLogout} onClick={handleLogout}
variant="outline" variant="outline"
@@ -83,9 +97,16 @@ const Index = () => {
</div> </div>
<div className="container mx-auto p-6"> <div className="container mx-auto p-6">
<Tabs defaultValue={user.role === "admin" ? "overview" : "shift"} className="space-y-6"> <Tabs
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"> <TabsList className="flex w-full flex-wrap gap-1 h-auto justify-start bg-white rounded-lg shadow-sm p-1.5 border">
{user.role === "admin" && ( {isOwner && (
<TabsTrigger <TabsTrigger
value="overview" 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" 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"
@@ -93,25 +114,35 @@ const Index = () => {
Overview Overview
</TabsTrigger> </TabsTrigger>
)} )}
<TabsTrigger {canEnterTransactions && (
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" <TabsTrigger
> value="shift"
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"
</TabsTrigger> >
<TabsTrigger Shift
value="outstanding" </TabsTrigger>
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" <TabsTrigger
> value="transactions"
Outstanding Report 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"
</TabsTrigger> >
<TabsTrigger Transactions
value="payment" </TabsTrigger>
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" <TabsTrigger
> value="outstanding"
Employee Payment Report 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"
</TabsTrigger> >
{user.role === "admin" && ( Outstanding Report
</TabsTrigger>
<TabsTrigger
value="payment"
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
</TabsTrigger>
</>
)}
{isAdmin && (
<TabsTrigger <TabsTrigger
value="manager" 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" 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"
@@ -119,7 +150,7 @@ const Index = () => {
Manager Console Manager Console
</TabsTrigger> </TabsTrigger>
)} )}
{user.role === "admin" && ( {isAdmin && (
<TabsTrigger <TabsTrigger
value="users" 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" 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"
@@ -129,31 +160,43 @@ const Index = () => {
)} )}
</TabsList> </TabsList>
<TabsContent value="shift"> {canEnterTransactions && (
<ShiftControl /> <TabsContent value="shift">
</TabsContent> <ShiftControl />
</TabsContent>
)}
{user.role === "admin" && ( {canEnterTransactions && (
<TabsContent value="transactions">
<TransactionCenter />
</TabsContent>
)}
{isOwner && (
<TabsContent value="overview"> <TabsContent value="overview">
<OwnerOverview key={refreshTrigger} /> <OwnerOverview key={refreshTrigger} />
</TabsContent> </TabsContent>
)} )}
<TabsContent value="outstanding"> {canEnterTransactions && (
<OutstandingReportDashboard key={refreshTrigger} /> <TabsContent value="outstanding">
</TabsContent> <OutstandingReportDashboard key={refreshTrigger} />
</TabsContent>
)}
<TabsContent value="payment"> {canEnterTransactions && (
<DetailedEmployeePaymentReport key={refreshTrigger} /> <TabsContent value="payment">
</TabsContent> <DetailedEmployeePaymentReport key={refreshTrigger} />
</TabsContent>
)}
{user.role === "admin" && ( {isAdmin && (
<TabsContent value="manager"> <TabsContent value="manager">
<ManagerConsole /> <ManagerConsole />
</TabsContent> </TabsContent>
)} )}
{user.role === "admin" && ( {isAdmin && (
<TabsContent value="users"> <TabsContent value="users">
<UserManagement /> <UserManagement />
</TabsContent> </TabsContent>
+208
View File
@@ -0,0 +1,208 @@
-- =====================================================================
-- Migration 0037 — Whish receive / payout support.
--
-- WHISH_SEND already exists. This adds WHISH_RECEIVE using the same
-- payout detail table and receive accounting as OMT/WU receive:
-- cash leaves the drawer, provider float increases because Whish owes
-- the shop settlement.
-- =====================================================================
set search_path = app, public;
insert into app.services(code, name, category) values
('WHISH_RECEIVE', 'Whish — Receive', 'money_transfer')
on conflict (code) do update
set name = excluded.name,
category = excluded.category;
-- Receive details can back OMT, WU, and Whish payout transactions.
create or replace function app.omt_recv_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 ('OMT_RECEIVE','WU_RECEIVE','WHISH_RECEIVE') then
raise exception 'omt_receive_details only valid for receive services (got %)', svc;
end if;
return new;
end;
$$;
-- Map Whish receive to the Whish float pool.
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
when 'WU_RECEIVE' then 'OMT_CASH'::app.float_provider
when 'WHISH_SEND' then 'WHISH'::app.float_provider
when 'WHISH_RECEIVE' then 'WHISH'::app.float_provider
when 'EDL_BILL' then 'OMT_CASH'::app.float_provider
end;
$$;
-- Re-define receive RPC to include WHISH_RECEIVE.
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'
) 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','WHISH_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'
when 'WHISH_RECEIVE' 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_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
);
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);
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;
-- Detail-required check with REFUND preservation from migration 0008.
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 '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 'WHISH_SEND' then select exists(select 1 from app.omt_send_details where txn_id = new.id) into ok;
when 'WHISH_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 'EDL_BILL' then select exists(select 1 from app.bill_payment_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 'GOODS_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;
$$;
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','WHISH_RECEIVE');
if not is_money_transfer then return null; end if;
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 index if exists app.ux_txn_external_ref_active;
create unique index 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','WHISH_RECEIVE','OMT_BILL','EDL_BILL'
);
@@ -0,0 +1,113 @@
-- =====================================================================
-- Migration 0038 — Shop-scoped service titles and icons.
--
-- Lets owners/managers customize how transaction services appear in the
-- UI without changing canonical service codes used by accounting logic.
-- =====================================================================
set search_path = app, public;
create table if not exists app.service_ui_settings (
shop_id uuid not null references app.shops(id) on delete cascade,
service_code text not null references app.services(code) on delete cascade,
display_name text,
icon text,
updated_at timestamptz not null default now(),
updated_by uuid references auth.users(id),
primary key (shop_id, service_code),
constraint service_ui_display_name_len check (display_name is null or length(display_name) between 1 and 80),
constraint service_ui_icon_len check (icon is null or length(icon) between 1 and 16)
);
alter table app.service_ui_settings enable row level security;
alter table app.service_ui_settings force row level security;
revoke insert, update, delete on app.service_ui_settings from authenticated;
grant select on app.service_ui_settings to authenticated;
drop policy if exists service_ui_select on app.service_ui_settings;
create policy service_ui_select on app.service_ui_settings
for select using (
app.has_any_role_in_shop(shop_id, array['owner','manager','cashier','auditor']::app.business_role[])
);
create or replace view app.v_service_ui_settings as
select
s.id as shop_id,
svc.code as service_code,
svc.name as default_name,
svc.category,
svc.is_active,
ui.display_name,
ui.icon,
ui.updated_at,
ui.updated_by
from app.shops s
join app.services svc on svc.is_active = true
left join app.service_ui_settings ui
on ui.shop_id = s.id and ui.service_code = svc.code
where app.has_any_role_in_shop(s.id, array['owner','manager','cashier','auditor']::app.business_role[]);
grant select on app.v_service_ui_settings to authenticated;
create or replace function app.set_service_ui_setting(
p_shop uuid,
p_service_code text,
p_display_name text,
p_icon text
) returns void
language plpgsql
security definer
set search_path = app, public
as $$
declare
v_display text := nullif(btrim(coalesce(p_display_name, '')), '');
v_icon text := nullif(btrim(coalesce(p_icon, '')), '');
begin
if not app.has_any_role_in_shop(p_shop, array['owner','manager']::app.business_role[]) then
raise exception 'manager or owner role required';
end if;
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;
if v_display is null and v_icon is null then
delete from app.service_ui_settings
where shop_id = p_shop and service_code = p_service_code;
return;
end if;
insert into app.service_ui_settings(shop_id, service_code, display_name, icon, updated_by)
values (p_shop, p_service_code, v_display, v_icon, auth.uid())
on conflict (shop_id, service_code) do update
set display_name = excluded.display_name,
icon = excluded.icon,
updated_at = now(),
updated_by = auth.uid();
end;
$$;
revoke all on function app.set_service_ui_setting(uuid, text, text, text) from public;
grant execute on function app.set_service_ui_setting(uuid, text, text, text) to authenticated;
-- Recent transaction rows should use the shop's display title when set.
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,
coalesce(ui.display_name, 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
left join app.service_ui_settings ui
on ui.shop_id = t.shop_id and ui.service_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;
@@ -0,0 +1,51 @@
alter table auth.users
add column if not exists is_system_admin boolean not null default false;
update auth.users
set is_system_admin = true
where id = (
select u.id
from auth.users u
order by u.created_at
limit 1
)
and not exists (select 1 from auth.users where is_system_admin = true);
drop function if exists app.me();
create or replace function app.me()
returns table (
user_id uuid,
full_name text,
is_active boolean,
is_system_admin boolean,
is_owner_anywhere boolean,
emp_id text,
shops jsonb
) language sql
security definer
set search_path = app, public
stable
as $$
select
auth.uid() as user_id,
coalesce(p.full_name, u.full_name, '') as full_name,
coalesce(p.is_active, u.is_active, true) as is_active,
coalesce(u.is_system_admin, false) as is_system_admin,
app.is_owner_anywhere() as is_owner_anywhere,
e.emp_id as emp_id,
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 auth.users u
left join app.user_profiles p on p.user_id = u.id
left join app.employees e on lower(e.email) = lower(u.email::text)
where u.id = auth.uid()
limit 1;
$$;
revoke all on function app.me() from public;
grant execute on function app.me() to authenticated;