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,
password_hash text not null,
full_name text,
is_system_admin boolean not null default false,
is_active boolean not null default true,
created_at timestamptz not null default now(),
last_login_at timestamptz
+84
View File
@@ -30,3 +30,87 @@ alter table app.employees disable row level security;
alter table app.employee_transactions disable row level security;
grant select, insert, update, delete on app.employees to authenticated;
grant select, insert, update, delete on app.employee_transactions to authenticated;
-- Report-ready bridge from the modern POS/shift ledger into the legacy
-- employee payment report shape. A negative closed-shift variance means the
-- cashier is short, so it increases outstanding collection. A positive
-- variance means the drawer is over, so it is treated as a deposit/credit.
create or replace view app.v_employee_outstanding_balances as
with manual as (
select
e.id as employee_id,
e.emp_id,
e.name,
e.email,
e.department,
e.location,
et.currency,
sum(et.collection_amount) as manual_collection,
sum(et.deposit_amount) as manual_deposit,
0::numeric as shift_shortage,
0::numeric as shift_overage,
max(et.transaction_date)::timestamptz as last_activity_at
from app.employees e
join app.employee_transactions et on et.employee_id = e.id
group by e.id, e.emp_id, e.name, e.email, e.department, e.location, et.currency
), shift_variance as (
select
e.id as employee_id,
coalesce(e.emp_id, 'AUTH-' || left(u.id::text, 8)) as emp_id,
coalesce(e.name, p.full_name, u.full_name, u.email) as name,
u.email,
e.department,
e.location,
currency_rows.currency,
0::numeric as manual_collection,
0::numeric as manual_deposit,
sum(greatest(-currency_rows.variance_amount, 0)) as shift_shortage,
sum(greatest(currency_rows.variance_amount, 0)) as shift_overage,
max(sh.closed_at) as last_activity_at
from app.shifts sh
join auth.users u on u.id = sh.user_id
left join app.user_profiles p on p.user_id = u.id
left join app.employees e on lower(e.email) = lower(u.email)
cross join lateral (values
('USD'::text, coalesce(sh.variance_usd, 0)::numeric),
('LBP'::text, coalesce(sh.variance_lbp, 0)::numeric)
) as currency_rows(currency, variance_amount)
where sh.status = 'closed'
and currency_rows.variance_amount <> 0
group by e.id, coalesce(e.emp_id, 'AUTH-' || left(u.id::text, 8)),
coalesce(e.name, p.full_name, u.full_name, u.email), u.email,
e.department, e.location, currency_rows.currency
), combined as (
select * from manual
union all
select * from shift_variance
)
select
coalesce(
employee_id,
(
substr(md5(coalesce(email, emp_id)), 1, 8) || '-' ||
substr(md5(coalesce(email, emp_id)), 9, 4) || '-' ||
substr(md5(coalesce(email, emp_id)), 13, 4) || '-' ||
substr(md5(coalesce(email, emp_id)), 17, 4) || '-' ||
substr(md5(coalesce(email, emp_id)), 21, 12)
)::uuid
) as employee_id,
emp_id,
name,
email,
department,
location,
currency,
sum(manual_collection) as manual_collection,
sum(manual_deposit) as manual_deposit,
sum(shift_shortage) as shift_shortage,
sum(shift_overage) as shift_overage,
sum(manual_collection + shift_shortage) as total_collection,
sum(manual_deposit + shift_overage) as total_deposit,
sum(manual_collection + shift_shortage - manual_deposit - shift_overage) as outstanding_amount,
max(last_activity_at) as last_activity_at
from combined
group by employee_id, emp_id, name, email, department, location, currency;
grant select on app.v_employee_outstanding_balances to authenticated;
+3 -2
View File
@@ -24,11 +24,12 @@ declare
v_user_id uuid;
v_shop_id uuid;
begin
insert into auth.users(email, password_hash, full_name, is_active)
values ('${EM}', crypt('${PW}', gen_salt('bf', 10)), '${NM}', true)
insert into auth.users(email, password_hash, full_name, is_system_admin, is_active)
values ('${EM}', crypt('${PW}', gen_salt('bf', 10)), '${NM}', true, true)
on conflict (email) do update
set password_hash = excluded.password_hash,
full_name = excluded.full_name,
is_system_admin = true,
is_active = true
returning id into v_user_id;
+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' });
try {
const { rows } = await pool.query(
'SELECT id, email, password_hash, full_name, is_active FROM auth.users WHERE email = $1 LIMIT 1',
`SELECT id, email, password_hash, full_name, is_active,
coalesce((to_jsonb(u)->>'is_system_admin')::boolean, false) AS is_system_admin
FROM auth.users u WHERE email = $1 LIMIT 1`,
[String(email).trim().toLowerCase()],
);
const u = rows[0];
@@ -176,7 +178,7 @@ app.post('/auth/login', async (req, res) => {
if (!ok) return res.status(401).json({ error: 'invalid credentials' });
await pool.query('UPDATE auth.users SET last_login_at = now() WHERE id = $1', [u.id]);
const token = signToken(u);
res.json({ token, user: { id: u.id, email: u.email, full_name: u.full_name } });
res.json({ token, user: { id: u.id, email: u.email, full_name: u.full_name, is_system_admin: u.is_system_admin } });
} catch (e) { dbError(res, e); }
});
@@ -221,7 +223,8 @@ app.post('/rpc/:fn', authRequired, async (req, res) => {
// GET /from/:view?col=val&col2=val2 -> SELECT * FROM app.<view> WHERE ...
const ALLOWED_VIEWS = new Set([
'v_my_tills', 'v_my_shops', 'v_services_active', 'v_my_recent_transactions',
'v_manage_tills',
'v_manage_tills', 'v_service_ui_settings',
'v_employee_outstanding_balances',
// Owner / manager dashboards (RLS still restricts rows to allowed shops):
'v_owner_dashboard', 'v_z_report', 'v_employee_scorecard_30d', 'alerts', 'v_end_of_day_reports',
]);
@@ -311,30 +314,66 @@ app.post('/employee_transactions', authRequired, async (req, res) => {
// ---- admin: user management -----------------------------------------
async function ensureAdmin(req, res) {
// Owner-anywhere == admin in the UI. Compute via app.is_owner_anywhere().
try {
const ok = await withUserClient(req, async (client) => {
const r = await client.query('SELECT app.is_owner_anywhere() AS ok');
return !!r.rows[0]?.ok;
});
if (!ok) { res.status(403).json({ error: 'admin only' }); return false; }
const { rows } = await pool.query(`
SELECT coalesce((to_jsonb(u)->>'is_system_admin')::boolean, false) AS ok
FROM auth.users u
WHERE u.id = $1
`, [req.user.id]);
const ok = !!rows[0]?.ok;
if (!ok) {
res.status(403).json({ error: 'admin only' });
return false;
}
return true;
} catch (e) { dbError(res, e); return false; }
}
function normalizeUserRole(role) {
if (role === 'admin' || role === 'owner' || role === 'employee') return role;
return 'employee';
}
async function firstShopId(client) {
const shop = await client.query('SELECT id FROM app.shops ORDER BY created_at LIMIT 1');
return shop.rows[0]?.id ?? null;
}
async function assignShopRole(client, userId, role, assignedBy, shopIdParam) {
const shopId = shopIdParam || await firstShopId(client);
if (!shopId) return;
await client.query(
`INSERT INTO app.user_shop_assignments(user_id, shop_id, role, assigned_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, shop_id) DO UPDATE SET role = EXCLUDED.role`,
[userId, shopId, role, assignedBy],
);
}
async function upsertEmployee(client, { empId, name, email, department }) {
if (!empId) return;
await client.query(
`INSERT INTO app.employees(emp_id, name, email, department)
VALUES ($1, $2, $3, $4)
ON CONFLICT (emp_id) DO UPDATE
SET name = EXCLUDED.name, email = EXCLUDED.email,
department = EXCLUDED.department`,
[empId, name, email, department || null],
);
}
app.get('/admin/users', authRequired, async (req, res) => {
if (!(await ensureAdmin(req, res))) return;
try {
const { rows } = await pool.query(`
SELECT u.id, u.email, u.is_active, u.full_name,
p.full_name AS profile_name,
coalesce((to_jsonb(u)->>'is_system_admin')::boolean, false) AS is_system_admin,
coalesce(
(SELECT a.role::text FROM app.user_shop_assignments a
WHERE a.user_id = u.id ORDER BY (a.role = 'owner') DESC LIMIT 1),
'cashier'
) AS shop_role,
EXISTS (SELECT 1 FROM app.user_shop_assignments a
WHERE a.user_id = u.id AND a.role = 'owner') AS is_admin,
(SELECT e.emp_id FROM app.employees e WHERE e.email = u.email LIMIT 1) AS emp_id,
(SELECT e.department FROM app.employees e WHERE e.email = u.email LIMIT 1) AS department
FROM auth.users u
@@ -347,23 +386,25 @@ app.get('/admin/users', authRequired, async (req, res) => {
app.post('/admin/users', authRequired, async (req, res) => {
if (!(await ensureAdmin(req, res))) return;
const { email, password, name, role, department, empId } = req.body || {};
const { email, password, name, role, department, empId, shopId } = req.body || {};
if (!email || !password || !name) {
return res.status(400).json({ error: 'email, password, name required' });
}
if (String(password).length < 6) {
return res.status(400).json({ error: 'password must be at least 6 chars' });
}
const isAdmin = role === 'admin';
const userRole = normalizeUserRole(role);
const isAdmin = userRole === 'admin';
const shopRole = userRole === 'employee' ? 'cashier' : 'owner';
const client = await pool.connect();
try {
await client.query('BEGIN');
const hash = await bcrypt.hash(password, 10);
const u = await client.query(
`INSERT INTO auth.users(email, password_hash, full_name, is_active)
VALUES ($1, $2, $3, true) RETURNING id, email, full_name`,
[String(email).trim().toLowerCase(), hash, name],
`INSERT INTO auth.users(email, password_hash, full_name, is_system_admin, is_active)
VALUES ($1, $2, $3, $4, true) RETURNING id, email, full_name`,
[String(email).trim().toLowerCase(), hash, name, isAdmin],
);
const userId = u.rows[0].id;
await client.query(
@@ -372,25 +413,9 @@ app.post('/admin/users', authRequired, async (req, res) => {
ON CONFLICT (user_id) DO UPDATE SET full_name = EXCLUDED.full_name`,
[userId, name],
);
// Assign to the first available shop (Default Shop typically) so they have a role.
const shop = await client.query('SELECT id FROM app.shops ORDER BY created_at LIMIT 1');
if (shop.rows[0]) {
await client.query(
`INSERT INTO app.user_shop_assignments(user_id, shop_id, role, assigned_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, shop_id) DO UPDATE SET role = EXCLUDED.role`,
[userId, shop.rows[0].id, isAdmin ? 'owner' : 'cashier', req.user.id],
);
}
if (!isAdmin && empId) {
await client.query(
`INSERT INTO app.employees(emp_id, name, email, department)
VALUES ($1, $2, $3, $4)
ON CONFLICT (emp_id) DO UPDATE
SET name = EXCLUDED.name, email = EXCLUDED.email,
department = EXCLUDED.department`,
[empId, name, email, department || null],
);
if (!isAdmin) {
await assignShopRole(client, userId, shopRole, req.user.id, shopId);
await upsertEmployee(client, { empId, name, email, department });
}
await client.query('COMMIT');
res.json({ data: { id: userId, email: u.rows[0].email, full_name: u.rows[0].full_name } });
+10 -10
View File
@@ -25,8 +25,8 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
const { user } = useAuth();
const { toast } = useToast();
const isEmployee = user?.role === 'employee';
const lockedEmployeeId = isEmployee
const isSelfEntryRole = user?.role === 'employee' || user?.role === 'owner';
const lockedEmployeeId = isSelfEntryRole
? employees.find(e => e.emp_id === user?.empId || e.email === user?.email)?.id
: undefined;
@@ -38,10 +38,10 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isEmployee && lockedEmployeeId) {
if (isSelfEntryRole && lockedEmployeeId) {
setSelectedEmployeeId(lockedEmployeeId);
}
}, [isEmployee, lockedEmployeeId, isOpen]);
}, [isSelfEntryRole, lockedEmployeeId, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -115,7 +115,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
<DialogContent className="w-[min(96vw,500px)] sm:max-w-[500px] bg-white p-4 sm:p-6">
<DialogHeader>
<DialogTitle className="text-2xl font-bold text-slate-800">
{isEmployee ? "Submit Transaction" : "Insert Employee Data"}
{isSelfEntryRole ? "Submit Transaction" : "Insert Employee Data"}
</DialogTitle>
<DialogDescription>
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">
<div className="space-y-2">
<Label htmlFor="employee" className="text-sm font-medium text-slate-700">
{isEmployee ? "Employee" : "Select Employee"}
{isSelfEntryRole ? "Employee" : "Select Employee"}
</Label>
<Select
value={selectedEmployeeId}
onValueChange={setSelectedEmployeeId}
disabled={isEmployee}
disabled={isSelfEntryRole}
>
<SelectTrigger>
<SelectValue placeholder={isEmployee ? "Your account" : "Choose an employee"} />
<SelectValue placeholder={isSelfEntryRole ? "Your account" : "Choose an employee"} />
</SelectTrigger>
<SelectContent>
{(isEmployee && lockedEmployeeId
{(isSelfEntryRole && lockedEmployeeId
? employees.filter(e => e.id === lockedEmployeeId)
: employees
).map(employee => (
@@ -146,7 +146,7 @@ export const AdminDataEntryModal = ({ isOpen, onClose, onDataUpdate }: AdminData
))}
</SelectContent>
</Select>
{isEmployee && !lockedEmployeeId && (
{isSelfEntryRole && !lockedEmployeeId && (
<p className="text-xs text-red-600">
Your account is not linked to an employee record. Ask an admin to set your Employee ID.
</p>
+143 -2
View File
@@ -19,7 +19,7 @@ type IdDocType = "lebanese_id" | "passport" | "residence_permit" | "driver_licen
const SERVICE_CODES = [
"OMT_SEND", "OMT_RECEIVE", "WU_SEND", "WU_RECEIVE",
"WHISH_SEND", "OMT_BILL", "EDL_BILL",
"WHISH_SEND", "WHISH_RECEIVE", "OMT_BILL", "EDL_BILL",
"ALFA_RECHARGE", "TOUCH_RECHARGE", "GOODS_SALE", "REPAIR",
];
@@ -70,6 +70,17 @@ interface BankDepositRow {
created_at: string;
}
interface ServiceUiRow {
shop_id: string;
service_code: string;
default_name: string;
category: string;
is_active: boolean;
display_name: string | null;
icon: string | null;
updated_at: string | null;
}
/**
* ManagerConsole — single screen for managers/owners to:
* - Seed / view fee_schedule rows (per service per currency).
@@ -122,9 +133,10 @@ export const ManagerConsole: React.FC = () => {
)}
<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="fx">FX Rates</TabsTrigger>
<TabsTrigger value="services">Service Titles</TabsTrigger>
<TabsTrigger value="tills">Tills</TabsTrigger>
<TabsTrigger value="kyc">Cashier KYC</TabsTrigger>
<TabsTrigger value="safe">Safe / Bank</TabsTrigger>
@@ -136,6 +148,9 @@ export const ManagerConsole: React.FC = () => {
<TabsContent value="fx">
<FxRatesTab shopId={shopId} />
</TabsContent>
<TabsContent value="services">
<ServiceTitlesTab shopId={shopId} />
</TabsContent>
<TabsContent value="tills">
<TillsTab shopId={shopId} />
</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
// ---------------------------------------------------------------------------
+85 -6
View File
@@ -3,13 +3,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Card, CardContent } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
import { Currency, formatCurrency, getUsdToLbpRate } from "@/lib/currency";
import { Currency, convert, formatCurrency, getUsdToLbpRate } from "@/lib/currency";
export const OutstandingReportDashboard = () => {
const [displayCurrency, setDisplayCurrency] = useState<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);
return {
...employee,
@@ -17,6 +17,67 @@ export const OutstandingReportDashboard = () => {
};
});
const balancedSummaries = Array.from(
outstandingBalances.reduce((map, row) => {
const current = map.get(row.employee_id) ?? {
id: row.employee_id,
emp_id: row.emp_id,
name: row.name,
location: row.location || "",
totalCollectionUSD: 0,
totalCollectionLBP: 0,
totalDepositUSD: 0,
totalDepositLBP: 0,
shiftShortageUSD: 0,
shiftShortageLBP: 0,
shiftOverageUSD: 0,
shiftOverageLBP: 0,
totalCollection: 0,
totalDeposit: 0,
outstandingAmount: 0,
lastTransactionDate: null as string | null,
};
if (row.currency === "USD") {
current.totalCollectionUSD += row.total_collection;
current.totalDepositUSD += row.total_deposit;
current.shiftShortageUSD += row.shift_shortage;
current.shiftOverageUSD += row.shift_overage;
} else {
current.totalCollectionLBP += row.total_collection;
current.totalDepositLBP += row.total_deposit;
current.shiftShortageLBP += row.shift_shortage;
current.shiftOverageLBP += row.shift_overage;
}
current.totalCollection += convert(row.total_collection, row.currency, displayCurrency);
current.totalDeposit += convert(row.total_deposit, row.currency, displayCurrency);
current.outstandingAmount += convert(row.outstanding_amount, row.currency, displayCurrency);
if (row.last_activity_at && (!current.lastTransactionDate || row.last_activity_at > current.lastTransactionDate)) {
current.lastTransactionDate = row.last_activity_at;
}
map.set(row.employee_id, current);
return map;
}, new Map<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) => {
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>
<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">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>
@@ -81,7 +142,7 @@ export const OutstandingReportDashboard = () => {
</div>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-500 mb-1">Total Deposit Amount</p>
<p className="text-sm text-gray-500 mb-1">Total Deposit / Overage</p>
<p className="text-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-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">Emp. ID</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">Difference</TableHead>
</TableRow>
@@ -131,6 +193,16 @@ export const OutstandingReportDashboard = () => {
<TableCell className="py-4 font-medium">
{formatCurrency(employee.totalCollection, displayCurrency)}
</TableCell>
<TableCell className="py-4 text-xs text-gray-600">
{'shiftShortageUSD' in employee ? (
<div className="space-y-1">
<div>Short USD: <span className="font-medium text-red-600">{formatCurrency(employee.shiftShortageUSD, "USD")}</span></div>
<div>Short LBP: <span className="font-medium text-red-600">{formatCurrency(employee.shiftShortageLBP, "LBP")}</span></div>
<div>Over USD: <span className="font-medium text-green-600">{formatCurrency(employee.shiftOverageUSD, "USD")}</span></div>
<div>Over LBP: <span className="font-medium text-green-600">{formatCurrency(employee.shiftOverageLBP, "LBP")}</span></div>
</div>
) : "Manual only"}
</TableCell>
<TableCell className="py-4 text-gray-600">
{employee.lastTransactionDate ? formatDate(employee.lastTransactionDate) : '-'}
</TableCell>
@@ -143,6 +215,13 @@ export const OutstandingReportDashboard = () => {
</TableCell>
</TableRow>
))}
{employeeSummaries.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-gray-500 py-6">
No outstanding balances yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</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;
}
interface ServiceUiSetting {
service_code: string;
display_name: string | null;
icon: string | null;
}
interface OpenShift {
shift_id: string;
till_id: 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 {
id: string;
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_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_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: "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." },
@@ -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.",
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: {
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"],
@@ -139,6 +160,7 @@ const ACTION_LABELS: Record<string, string> = {
OMT_SEND: "Record OMT send",
OMT_RECEIVE: "Record OMT receive",
WHISH_SEND: "Record Whish send",
WHISH_RECEIVE: "Record Whish receive",
ALFA_RECHARGE: "Record Alfa recharge",
TOUCH_RECHARGE: "Record Touch recharge",
EDL_BILL: "Record EDL bill",
@@ -169,6 +191,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
const [tillId, setTillId] = useState<string>("");
const [shift, setShift] = useState<OpenShift | null>(null);
const [catalogItems, setCatalogItems] = useState<ProductCatalogItem[]>([]);
const [serviceUiSettings, setServiceUiSettings] = useState<Record<string, ServiceUiSetting>>({});
const [serviceCode, setServiceCode] = useState<string>("");
const service: ServiceDef | undefined = useMemo(
@@ -260,6 +283,25 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
return () => { cancelled = true; };
}, [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
useEffect(() => {
@@ -299,8 +341,8 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
const { data } = await supabase.rpc("my_active_shift", { p_shop: shopId });
if (cancelled) return;
const row = Array.isArray(data)
? (data[0] as OpenShift | undefined) ?? null
: ((data as OpenShift | null) ?? null);
? normalizeActiveShift(data[0])
: normalizeActiveShift(data);
setShift(row);
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) => {
e.preventDefault();
if (!shopId || !service || !shift || !tillId) {
const activeTillId = tillId || shift?.till_id || "";
if (!shopId || !service || !shift || !activeTillId) {
toast({
title: "Missing context",
description: !shift
@@ -391,7 +434,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
rpc = {
fn: "record_recharge",
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_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp),
p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp),
@@ -417,7 +460,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
rpc = {
fn: "record_omt_send",
args: {
p_shop: shopId, p_till: tillId,
p_shop: shopId, p_till: activeTillId,
p_payment_method: paymentMethod,
p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp),
p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp),
@@ -455,7 +498,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
rpc = {
fn: "record_whish_send",
args: {
p_shop: shopId, p_till: tillId,
p_shop: shopId, p_till: activeTillId,
p_payment_method: paymentMethod,
p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp),
p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp),
@@ -481,6 +524,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
case "OMT_RECEIVE":
case "WU_RECEIVE":
case "WHISH_RECEIVE":
if (!payoutCode.trim() || !beneficiaryName.trim()
|| !recvIdNumber.trim()) {
throw new Error("Payout code, beneficiary name and ID number are required.");
@@ -488,7 +532,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
rpc = {
fn: "record_omt_receive",
args: {
p_shop: shopId, p_till: tillId,
p_shop: shopId, p_till: activeTillId,
p_payment_method: paymentMethod,
p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp),
p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp),
@@ -518,7 +562,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
rpc = {
fn: "record_bill",
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_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp),
p_fee_usd: num(feeUsd), p_fee_lbp: num(feeLbp),
@@ -541,7 +585,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
rpc = {
fn: "record_goods_sale",
args: {
p_shop: shopId, p_till: tillId,
p_shop: shopId, p_till: activeTillId,
p_payment_method: paymentMethod,
p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp),
p_fx_rate: num(fxRate) || null,
@@ -562,7 +606,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
rpc = {
fn: "record_repair",
args: {
p_shop: shopId, p_till: tillId,
p_shop: shopId, p_till: activeTillId,
p_payment_method: paymentMethod,
p_gross_usd: num(grossUsd), p_gross_lbp: num(grossLbp),
p_fx_rate: num(fxRate) || null,
@@ -636,11 +680,21 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
};
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).
// 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 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;
@@ -744,7 +798,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
</div>
<div>
<Label>Till</Label>
<Select value={tillId} onValueChange={setTillId}
<Select value={effectiveTillId} onValueChange={setTillId}
disabled={!!shift}>
<SelectTrigger><SelectValue placeholder="Select till" /></SelectTrigger>
<SelectContent>
@@ -777,7 +831,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
{!serviceCode ? (
<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">
{QUICK_SERVICES.map((quickService) => (
{quickServices.map((quickService) => (
<button
key={quickService.code}
type="button"
@@ -801,7 +855,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
<SelectGroup key={c}>
<SelectLabel>{CATEGORY_LABEL[c]}</SelectLabel>
{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>
))}
@@ -987,7 +1041,7 @@ export const TransactionEntry: React.FC<TransactionEntryProps> = ({
)}
{/* 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="font-medium text-slate-700">Beneficiary payout</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
+72 -21
View File
@@ -14,38 +14,59 @@ import { useSupabaseEmployeeData } from "@/hooks/useSupabaseEmployeeData";
import { api } from "@/lib/api";
import { Trash2 } from "lucide-react";
type UserRole = "admin" | "employee";
type UserRole = "admin" | "owner" | "employee";
interface AdminUser {
id: string;
email: string;
is_active: boolean;
full_name: string | null;
is_admin: boolean;
is_system_admin: boolean;
shop_role: "owner" | "manager" | "cashier" | "auditor";
emp_id: string | null;
department: string | null;
}
interface Shop {
shop_id: string;
name: string;
}
function displayRole(user: AdminUser): UserRole {
// ensure we handle cases where it comes in as string "true" somehow, though API should send boolean
if (user.is_system_admin === true || String(user.is_system_admin) === "true") return "admin";
if (user.shop_role === "owner") return "owner";
return "employee";
}
export const UserManagement = () => {
const { toast } = useToast();
const { employees, refreshData } = useSupabaseEmployeeData();
const [users, setUsers] = useState<AdminUser[]>([]);
const [shops, setShops] = useState<Shop[]>([]);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [empId, setEmpId] = useState("");
const [department, setDepartment] = useState("Collections");
const [role, setRole] = useState<UserRole>("employee");
const [shopId, setShopId] = useState("");
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
const { data, error } = await api.get<AdminUser[]>("/admin/users");
if (error) {
toast({ title: "Could not load users", description: error.message, variant: "destructive" });
const { data: userData, error: userError } = await api.get<AdminUser[]>("/admin/users");
if (userError) {
toast({ title: "Could not load users", description: userError.message, variant: "destructive" });
return;
}
setUsers(data ?? []);
}, [toast]);
setUsers(userData ?? []);
const { data: shopsData } = await api.fromView<Shop>("v_my_shops");
setShops(shopsData ?? []);
if (shopsData && shopsData.length > 0 && !shopId) {
setShopId(shopsData[0].shop_id);
}
}, [toast, shopId]);
useEffect(() => { refresh(); }, [refresh]);
@@ -56,15 +77,18 @@ export const UserManagement = () => {
const finalEmpId =
role === "employee"
? 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", {
email,
password,
name,
role,
department,
department: finalDep,
empId: finalEmpId,
shopId: finalShopId
});
if (error) {
toast({ title: "Could not create user", description: error.message, variant: "destructive" });
@@ -125,6 +149,33 @@ export const UserManagement = () => {
onChange={(e) => setPassword(e.target.value)}
required
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
className="h-10 px-3 border border-gray-300 rounded-md bg-white"
>
<option value="employee">Employee</option>
<option value="owner">Owner</option>
<option value="admin">Admin</option>
</select>
{role !== "admin" && shops.length > 0 && (
<select
value={shopId}
onChange={(e) => setShopId(e.target.value)}
className="h-10 px-3 border border-gray-300 rounded-md bg-white"
required
>
<option value="" disabled>Select Shop</option>
{shops.map(s => (
<option key={s.shop_id} value={s.shop_id}>{s.name}</option>
))}
</select>
)}
{role === "employee" && (
<>
<Input
placeholder="Department"
value={department}
@@ -135,14 +186,9 @@ export const UserManagement = () => {
value={empId}
onChange={(e) => setEmpId(e.target.value)}
/>
<select
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
className="h-10 px-3 border border-gray-300 rounded-md bg-white"
>
<option value="employee">Employee</option>
<option value="admin">Admin</option>
</select>
</>
)}
<Button
type="submit"
disabled={busy}
@@ -168,7 +214,9 @@ export const UserManagement = () => {
</TableRow>
</TableHeader>
<TableBody>
{users.map((u) => (
{users.map((u) => {
const r = displayRole(u);
return (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.full_name ?? "-"}</TableCell>
<TableCell>{u.email}</TableCell>
@@ -176,12 +224,14 @@ export const UserManagement = () => {
<TableCell>
<span
className={
u.is_admin
r === "admin"
? "px-2 py-1 text-xs rounded bg-purple-100 text-purple-700"
: r === "owner"
? "px-2 py-1 text-xs rounded bg-emerald-100 text-emerald-700"
: "px-2 py-1 text-xs rounded bg-gray-100 text-gray-700"
}
>
{u.is_admin ? "admin" : "employee"}
{r}
</span>
</TableCell>
<TableCell className="text-right">
@@ -195,7 +245,8 @@ export const UserManagement = () => {
</Button>
</TableCell>
</TableRow>
))}
);
})}
{users.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-gray-500">
+11 -8
View File
@@ -1,13 +1,7 @@
import { useEffect, useState } from "react";
import { api, type ApiSession } from "@/lib/api";
/**
* 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 type UiRole = "admin" | "owner" | "employee";
export interface UiShopRole {
shop_id: string;
@@ -20,7 +14,9 @@ export interface UiUser {
email: string;
name: string;
role: UiRole;
empId?: string | null;
shops: UiShopRole[];
isSystemAdmin: boolean;
isOwnerAnywhere: boolean;
}
@@ -28,7 +24,9 @@ interface MeRow {
user_id: string;
full_name: string;
is_active: boolean;
is_system_admin?: boolean;
is_owner_anywhere: boolean;
emp_id?: string | null;
shops: UiShopRole[] | null;
}
@@ -40,19 +38,24 @@ async function loadMe(session: ApiSession): Promise<UiUser> {
email: session.user.email,
name: session.user.full_name ?? session.user.email,
role: "employee",
empId: null,
shops: [],
isSystemAdmin: false,
isOwnerAnywhere: false,
};
}
const row = (Array.isArray(data) ? data[0] : data) as MeRow | undefined;
const shops = (row?.shops ?? []) as UiShopRole[];
const isSystemAdmin = !!row?.is_system_admin;
const isOwner = !!row?.is_owner_anywhere;
return {
id: session.user.id,
email: session.user.email,
name: row?.full_name?.trim() || session.user.full_name || session.user.email,
role: isOwner ? "admin" : "employee",
role: isSystemAdmin ? "admin" : isOwner ? "owner" : "employee",
empId: row?.emp_id ?? null,
shops,
isSystemAdmin,
isOwnerAnywhere: isOwner,
};
}
+61 -1
View File
@@ -38,6 +38,24 @@ export interface EmployeeSummary {
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 {
id: string;
emp_id: string;
@@ -56,6 +74,24 @@ interface DbTransaction {
currency: Currency;
}
interface DbOutstandingBalance {
employee_id: string;
emp_id: string;
name: string;
email: string | null;
department: string | null;
location: string | null;
currency: Currency;
manual_collection: string | number;
manual_deposit: string | number;
shift_shortage: string | number;
shift_overage: string | number;
total_collection: string | number;
total_deposit: string | number;
outstanding_amount: string | number;
last_activity_at: string | null;
}
function normalizeEmployee(e: DbEmployee): Employee {
return {
id: e.id,
@@ -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") => {
const [employees, setEmployees] = useState<Employee[]>([]);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [outstandingBalances, setOutstandingBalances] = useState<EmployeeOutstandingBalance[]>([]);
const [loading, setLoading] = useState(true);
const refreshData = useCallback(async () => {
setLoading(true);
const [eRes, tRes] = await Promise.all([
const [eRes, tRes, bRes] = await Promise.all([
api.get<DbEmployee[]>("/employees"),
api.get<DbTransaction[]>("/employee_transactions"),
api.fromView<DbOutstandingBalance>("v_employee_outstanding_balances"),
]);
setEmployees((eRes.data ?? []).map(normalizeEmployee));
setTransactions((tRes.data ?? []).map(normalizeTx));
setOutstandingBalances((bRes.data ?? []).map(normalizeBalance));
setLoading(false);
}, []);
@@ -220,6 +279,7 @@ export const useSupabaseEmployeeData = (displayCurrency: Currency = "USD") => {
return {
employees,
transactions,
outstandingBalances,
loading,
addTransaction,
addEmployee,
+1
View File
@@ -38,6 +38,7 @@ export interface ApiUser {
id: string;
email: string;
full_name?: string | null;
is_system_admin?: boolean;
}
export interface ApiSession {
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_RECEIVE", label: "Western Union — Recv", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: false },
{ code: "WHISH_SEND", label: "Whish — Send", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: true },
{ code: "WHISH_RECEIVE",label: "Whish — Receive", category: "money_transfer", requiresExternalRef: true, requiresBeneficiary: false },
// Telecom recharge
{ code: "ALFA_RECHARGE", label: "Alfa recharge", category: "telecom_recharge", requiresExternalRef: false, requiresBeneficiary: false },
+51 -8
View File
@@ -11,6 +11,7 @@ import { LoginPage } from "@/components/LoginPage";
import { UserManagement } from "@/components/UserManagement";
import { ManagerConsole } from "@/components/ManagerConsole";
import { OwnerOverview } from "@/components/OwnerOverview";
import { TransactionCenter } from "@/components/TransactionCenter";
import { useAuth } from "@/hooks/useAuth";
import { LogOut } from "lucide-react";
@@ -19,6 +20,15 @@ const Index = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isTxnOpen, setIsTxnOpen] = useState(false);
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 = () => {
setRefreshTrigger(prev => prev + 1);
@@ -57,6 +67,8 @@ const Index = () => {
</p>
</div>
<div className="flex items-center gap-4">
{canEnterTransactions && (
<>
<Button
onClick={() => setIsTxnOpen(true)}
className="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2 rounded-lg font-medium"
@@ -68,8 +80,10 @@ const Index = () => {
variant="outline"
className="px-6 py-2 rounded-lg font-medium"
>
{user.role === "admin" ? "Insert Employee Data" : "Submit Collection / Deposit"}
Submit Collection / Deposit
</Button>
</>
)}
<Button
onClick={handleLogout}
variant="outline"
@@ -83,9 +97,16 @@ const Index = () => {
</div>
<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">
{user.role === "admin" && (
{isOwner && (
<TabsTrigger
value="overview"
className="flex-1 min-w-[120px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
@@ -93,12 +114,20 @@ const Index = () => {
Overview
</TabsTrigger>
)}
{canEnterTransactions && (
<>
<TabsTrigger
value="shift"
className="flex-1 min-w-[120px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
>
Shift
</TabsTrigger>
<TabsTrigger
value="transactions"
className="flex-1 min-w-[140px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
>
Transactions
</TabsTrigger>
<TabsTrigger
value="outstanding"
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"
@@ -111,7 +140,9 @@ const Index = () => {
>
Employee Payment Report
</TabsTrigger>
{user.role === "admin" && (
</>
)}
{isAdmin && (
<TabsTrigger
value="manager"
className="flex-1 min-w-[140px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
@@ -119,7 +150,7 @@ const Index = () => {
Manager Console
</TabsTrigger>
)}
{user.role === "admin" && (
{isAdmin && (
<TabsTrigger
value="users"
className="flex-1 min-w-[140px] data-[state=active]:bg-purple-600 data-[state=active]:text-white data-[state=active]:shadow-sm rounded-md px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-all duration-200"
@@ -129,31 +160,43 @@ const Index = () => {
)}
</TabsList>
{canEnterTransactions && (
<TabsContent value="shift">
<ShiftControl />
</TabsContent>
)}
{user.role === "admin" && (
{canEnterTransactions && (
<TabsContent value="transactions">
<TransactionCenter />
</TabsContent>
)}
{isOwner && (
<TabsContent value="overview">
<OwnerOverview key={refreshTrigger} />
</TabsContent>
)}
{canEnterTransactions && (
<TabsContent value="outstanding">
<OutstandingReportDashboard key={refreshTrigger} />
</TabsContent>
)}
{canEnterTransactions && (
<TabsContent value="payment">
<DetailedEmployeePaymentReport key={refreshTrigger} />
</TabsContent>
)}
{user.role === "admin" && (
{isAdmin && (
<TabsContent value="manager">
<ManagerConsole />
</TabsContent>
)}
{user.role === "admin" && (
{isAdmin && (
<TabsContent value="users">
<UserManagement />
</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;