Fix RBAC, user creation and update components
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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 } });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user