Finalize portal polish: profile, avatars, ID cards, and list UX.

Ship leftover Yimaru login/personas, sidebar sections, filter modal, chart/stat clip fixes, and matching feature docs so the polish work is fully committed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kirukib 2026-08-06 21:57:07 +03:00
parent 3778801ef5
commit 1f30f00da6
46 changed files with 2127 additions and 398 deletions

View File

@ -10,7 +10,7 @@ Session and user preference endpoints for Gishen-B2B demo login and future produ
## POST /v1/auth/login
Demo role selector login (mock phase).
Demo persona login (mock phase). Prefer `demo_user_id`; `demo_role` remains for compatibility.
| | |
|---|---|
@ -21,11 +21,12 @@ Demo role selector login (mock phase).
```json
{
"demo_role": "HR_ADMIN",
"demo_user_id": "user_hr",
"locale": "en"
}
```
Legacy: `{ "demo_role": "HR_ADMIN", "locale": "en" }`
`demo_role`: `SUPER_USER` \| `HR_ADMIN` \| `FINANCE` \| `MEMBER`
Production (future): `{ "email", "password" }` or OAuth code exchange.
@ -147,7 +148,7 @@ N/A.
## GET /v1/auth/demo-profiles
List pre-seeded demo personas for `/login` selector.
List pre-seeded demo personas for `/login` test-user dropdown.
| | |
|---|---|
@ -160,21 +161,28 @@ List pre-seeded demo personas for `/login` selector.
{
"data": [
{
"id": "user_super",
"email": "super@yaltopia.com",
"full_name": "Selam Super",
"demo_role": "SUPER_USER",
"roles": ["SUPER_USER", "HR_ADMIN", "FINANCE", "MEMBER"],
"label": "Super User",
"description": "Full portal access including clinical detail"
},
{
"id": "user_hr",
"demo_role": "HR_ADMIN",
"label": "HR Administrator",
"description": "Members, departments, packages — no clinical detail"
},
{
"id": "user_fin",
"demo_role": "FINANCE",
"label": "Finance Approver",
"description": "Spend, statements, approvals — no clinical detail"
},
{
"id": "user_mem",
"demo_role": "MEMBER",
"label": "Member",
"description": "Own allowance, orders, prescriptions"
@ -183,13 +191,66 @@ List pre-seeded demo personas for `/login` selector.
}
```
Login request may use `{ "demo_user_id": "user_hr" }` (preferred) or legacy `{ "demo_role": "HR_ADMIN" }`.
---
## GET /v1/me/profile
Current user’s profile (account page).
| | |
|---|---|
| **Auth** | Required |
| **Roles** | Any |
### Response 200
```json
{
"data": {
"id": "user_hr",
"email": "hr@yaltopia.com",
"full_name": "Hanna HR",
"org_id": "org_yaltopia",
"org_name": "Yaltopia",
"roles": ["HR_ADMIN"],
"active_role": "HR_ADMIN",
"locale": "en",
"avatar_initials": "HH"
}
}
```
---
## PATCH /v1/me/profile
Update display preferences (locale / name stub).
| | |
|---|---|
| **Auth** | Required |
| **Roles** | Any |
### Request
```json
{ "locale": "am" }
```
### Response 200
Updated profile object.
---
## UI routes
| Route | Purpose |
|-------|---------|
| `/login` | Demo role selector + locale switcher |
| `/login` | Demo test-user dropdown + locale switcher |
| `/profile` | Own profile — role switch, sign out |
## Clinical withhold notes

View File

@ -24,7 +24,9 @@ Employee or dependant enrolled under an organisation's corporate medical benefit
| `start_date` | `date` | | Coverage start |
| `end_date` | `date` \| null | | Offboarding |
| `overrides` | `MemberOverrides` | | Per-member entitlement tweaks |
| `verification_id` | `string` | | Employee ID / QR payload |
| `verification_id` | `string` | | Employee verification token used in pharmacy QR payload |
| `card_id` | `string` | | Printed pharmacy card number (e.g. `GSH-1001`) |
| `avatar_url` | `string` | | Profile / ID-card photo URL |
| `invite_id` | `string` | | If joined via invite |
| `allowance_summary` | `AllowanceSummary` | | Read-only computed |
| `prescription_count` | `integer` | | Aggregate only for HR/Finance |
@ -89,6 +91,7 @@ When `SessionUser.roles` includes HR_ADMIN or FINANCE **without** SUPER_USER:
| Field | Visible |
|-------|---------|
| `full_name`, `email`, `phone`, `employee_id`, `department_id`, `package_id`, `status` | ✓ |
| `verification_id`, `card_id`, `avatar_url` | ✓ (pharmacy verification / ID card) |
| `allowance_summary` (amounts only) | ✓ |
| `prescription_count` | ✓ (count only) |
| Prescription list / images / medicine lines | ✗ |
@ -117,6 +120,8 @@ SUPER_USER and MEMBER (own record) receive full clinical linkage via prescriptio
"end_date": null,
"overrides": null,
"verification_id": "EMP-1042",
"card_id": "GSH-1042",
"avatar_url": "https://api.dicebear.com/9.x/lorelei/svg?seed=mbr_01HMEM001",
"allowance_summary": {
"period_start": "2026-03-01",
"period_end": "2026-03-31",

View File

@ -11,6 +11,7 @@ Feature → pages → entities → endpoints mapping for Gishen-B2B §3 features
| Feature | Pages | Entities | Endpoints | Feature doc |
|---------|-------|----------|-----------|---------------|
| Auth & roles | `/login` | SessionUser | `auth.md` | [auth-roles.md](auth-roles.md) |
| Profile | `/profile` | SessionUser | `auth.md` (`/v1/me/profile`) | [profile.md](profile.md) |
| Quick search | Portal topbar (Cmd/Ctrl+K) | — | — | [quick-search.md](quick-search.md) |
| Portal layout | All portal (PageShell, DetailPageLayout, Bc, StatCard, Tabs) | — | — | [portal-layout.md](portal-layout.md) |
| i18n | `/login`, all portal | SessionUser (`locale`) | `PATCH /v1/auth/me/locale` | [i18n.md](i18n.md) |
@ -39,6 +40,7 @@ Feature → pages → entities → endpoints mapping for Gishen-B2B §3 features
| Feature | SUPER_USER | HR_ADMIN | FINANCE | MEMBER |
|---------|:----------:|:--------:|:-------:|:------:|
| Auth & roles | ✓ | ✓ | ✓ | ✓ |
| Profile | ✓ | ✓ | ✓ | ✓ |
| i18n | ✓ | ✓ | ✓ | ✓ |
| Org registration | ✓ (public) | — | — | — |
| Org & departments | ✓ | ✓ | read | — |
@ -77,6 +79,7 @@ See [`../coordination/`](../coordination/).
All §3 routes from product brief:
- [x] `/login`
- [x] `/profile`
- [x] `/register/organisation`, `/register/request`
- [x] `/organisation`, `/departments`, `/departments/new`, `/departments/[id]`, `/settings/verification`
- [x] `/migration`, `/migration/new`, `/migration/[jobId]`

View File

@ -8,7 +8,8 @@ Demo login with role selector, session management, and role-filtered navigation.
| Route | Purpose |
|-------|---------|
| `/login` | Demo role selector + locale switcher; "Register organisation" / "Request registration" CTAs |
| `/login` | Yimaru-style split login; **test-user dropdown** (demo personas) + locale switcher; register/join CTAs |
| `/profile` | Own account — see [profile.md](profile.md) |
## Roles
@ -33,12 +34,13 @@ Demo login with role selector, session management, and role-filtered navigation.
## Demo login UX
1. User selects persona: Super User, HR Administrator, Finance Approver, Member.
2. Optional locale toggle (en/am).
3. "Continue as …" sets mock session and redirects to role-appropriate home.
4. Topbar profile menu includes "Switch demo user" → returns to `/login`.
1. User picks a **test user** from the dropdown (not only a role): Selam Super, Hanna HR, Fikru Finance, Abebe Member.
2. Optional locale toggle (en/am) on the login panel.
3. Continue sets session via `loginAsUser(id)` and redirects to that user’s home path.
4. Topbar menu: Profile → `/profile`; "Switch demo user" → `/login`.
5. Multi-role demo (`user_super`) can switch active role on `/profile`.
Pre-seeded demo data: single mock org (`org_01DEMO`) with realistic members, packages, spend.
Pre-seeded demo data: single mock org (`org_yaltopia` / Yaltopia) with realistic members, packages, spend.
## Route guards

View File

@ -95,6 +95,16 @@ Allowance remaining: 10,800 ETB
See [`open-items.md`](../coordination/open-items.md).
## Pharmacy ID card
Members can view/print their pharmacy verification badge from `/members/[id]` (own record) when linked. QR payload:
```
gishen://member/{memberId}?v={verificationId}
```
HR/SUPER_USER print cards for employees from the member detail **ID card** tab. See [members.md](members.md).
## Platform notes
- Web upload mirrors Ecom Rx UX

View File

@ -11,7 +11,7 @@ Employee and dependant enrolment — CRUD, quick import, invites, join codes, of
| `/members` | Searchable/filterable member list |
| `/members/new` | Invite / create employee (department + package) |
| `/members/import` | Quick Excel/CSV import |
| `/members/[id]` | Member detail — profile, package, dependants, offboard |
| `/members/[id]` | Member detail — profile, package, dependants, pharmacy ID card, offboard |
| `/join` | Self-join via join code or email domain |
| `/invite/[token]` | Accept invite and set password |
@ -84,13 +84,40 @@ Member list/detail for HR/Finance shows:
- Name, contact, employee ID, department, package, status
- Allowance summary (amounts)
- `prescription_count` (integer only)
- Pharmacy verification ID / printable ID card (employee verification — not clinical)
No prescription list, images, or medicine detail. SUPER_USER sees full member record including Rx summary link.
## Pharmacy ID card
Printable employee badge on `/members/[id]` → **ID card** tab (SUPER_USER, HR_ADMIN; MEMBER for own record).
| Field | Source |
|-------|--------|
| Photo | `avatar_url` (demo: deterministic DiceBear) |
| Full name | Member name |
| Employee ID | `employee_id` |
| Card ID | `card_id` (e.g. `GSH-1001`) |
| Verification ID | `verification_id` |
| Package / status | Assigned plan + member status |
| Org | Trade name + join code hint |
**QR scan payload** (pharmacist):
```
gishen://member/{memberId}?v={verificationId}
```
Print uses `window.print()` with `@media print` CSS that isolates `#member-id-card-print` at credit-card size (85.6 × 54 mm).
## Profile pictures
Optional `avatar_url` on Member and SessionUser. List, detail rail, `/profile`, topbar chip, and login demo personas show avatars with initials fallback.
## UI components
- DataTable with toolbar (search, status filter, department filter)
- Detail layout: profile card, package assignment, dependants tab, offboard dialog
- Detail layout: profile card, package assignment, dependants tab, ID card tab, offboard dialog
- Import dropzone with template download
- Invite link copy + resend

View File

@ -16,6 +16,39 @@ Shared shell patterns for list, detail, and create pages.
| `TableToolbar` | `src/components/table/table-toolbar.tsx` | In-panel search / filter strip (hairline bottom) |
| `DataTable` | `src/components/table/data-table.tsx` | Column-driven table inside `TablePanel` |
| Table primitives | `src/components/ui/table.tsx` | Yimaru / Fortune density: muted uppercase headers, `px-4` cells, mint hover |
| Page loaders | `src/components/layout/page-loading.tsx` | Flat MakerSys skeletons: `PageStatSkeleton`, `PageTableSkeleton`, `PageListSkeleton`, `PageDetailSkeleton`, `PageFormSkeleton`, `PageDashboardSkeleton` |
| Sidebar | `src/components/layout/app-sidebar.tsx` + `src/lib/auth/nav.ts` | Role-filtered nav grouped into sections (`nav.sections.*`) |
## Sidebar sections
Nav items carry a `section` key. `navSectionsForRole(role)` returns only non-empty groups for that role.
| Section | Routes |
|---------|--------|
| Overview | Dashboard |
| Organisation | Organisation, Departments, Verification |
| People | Members, Migration |
| Benefits | Packages |
| Finance | Finance, Approvals, Statements |
| Account | Profile (all roles), Prescriptions, My benefits, My orders |
Expanded sidebar shows muted uppercase micro labels; collapsed uses thin dividers between groups (labels hidden). Role filtering via `roles` / `navForRole` is unchanged.
## Page loading states
Prefer box skeletons over “Loading…” text so list/detail footprints stay flash-free:
- **List pages:** `PageStatSkeleton` while stats query loads; `TablePanel loading` renders row skeletons (toolbar stays).
- **Detail pages:** `DetailLoading` → `PageDetailSkeleton` (metrics + profile/content panels).
- **Dashboard:** `PageDashboardSkeleton`.
- **Forms / settings:** `PageFormSkeleton`.
```tsx
import { PageStatSkeleton, PageDetailSkeleton } from "@/components/layout/page-loading";
{isLoading ? <PageStatSkeleton cols={4} /> : <StatCardGrid>…</StatCardGrid>}
<TablePanel loading={isLoading}>…</TablePanel>
```
## Breadcrumbs API
@ -78,9 +111,15 @@ Conventions:
- **Panel:** `rounded-xl`, `border-border`, white `bg-card`, **no shadow** — do not wrap tables in padded `Card` / `CardContent`.
- **Header row:** muted background, compact uppercase labels (`text-[11px]`, tracking), `px-4`.
- **Body:** comfortable `py-4` rows, mint-tinted hover, last row border removed.
- **Empty / loading:** centered `h-28` empty cell or `TablePanel` `loading` state.
- **Empty / loading:** empty state cell when idle; `TablePanel loading` shows row skeletons (not text).
- **Mobile:** horizontal scroll via the table container (`overflow-x-auto`).
- **Detail embeds:** prefer `DetailTableSection` (same panel chrome) with flush table primitives.
- **Filters:** prefer `filters: TableFilterDef[]`. When count **> 3** (`inlineFilterLimit`), toolbar shows a Filters button + modal (draft values, Apply / Reset). ≤3 stay inline. Strings under `filters.*` in i18n. Members, finance spend, and migration use the modal path today.
## Charts & metrics
- **Stat cards:** `overflow-visible` + `break-words` / `tabular-nums` so large ETB figures are not clipped.
- **Charts:** Y-axis uses compact tick formatting (`800K`) with wider left margin; `ChartCard` allows overflow so axis labels are not cut off.
## Tabs

46
docs/features/profile.md Normal file
View File

@ -0,0 +1,46 @@
# Feature: Profile
Authenticated user’s own account page — demo session details, role switch (multi-role), sign out.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Pages
| Route | Purpose |
|-------|---------|
| `/profile` | Own profile — name, email, active role, org, locale, avatar initials |
## Roles
All authenticated roles: `SUPER_USER`, `HR_ADMIN`, `FINANCE`, `MEMBER`.
## UI
- Account section in sidebar (`nav.profile`)
- Quick directory entry (`profile`)
- Topbar avatar menu → Profile
- `DetailPageLayout` + `DetailProfileCard` / `DetailSection` (Yimaru-style)
## Demo personas (login)
| Id | Name | Primary role | Notes |
|----|------|--------------|-------|
| `user_super` | Selam Super | SUPER_USER | Multi-role: all four; role switcher on profile |
| `user_hr` | Hanna HR | HR_ADMIN | |
| `user_fin` | Fikru Finance | FINANCE | Locale `am` |
| `user_mem` | Abebe Member | MEMBER | Locale `am` |
Login uses a **test-user dropdown** (`listDemoPersonas` / `loginAsUser`), not role-only cards.
## Endpoints (stubs)
- `GET /v1/me/profile` — current user profile
- `PATCH /v1/me/profile` — locale / display name (mock)
- Related: `GET /v1/auth/demo-profiles`, `GET /v1/auth/me`
See [`../backend/endpoints/auth.md`](../backend/endpoints/auth.md).
## Related
- [auth-roles.md](auth-roles.md)
- [i18n.md](i18n.md)

10
package-lock.json generated
View File

@ -18,6 +18,7 @@
"next": "16.3.0",
"next-intl": "^4.13.5",
"next-themes": "^0.4.6",
"qrcode.react": "^4.2.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-hook-form": "^7.84.0",
@ -9132,6 +9133,15 @@
"node": ">=6"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",

View File

@ -19,6 +19,7 @@
"next": "16.3.0",
"next-intl": "^4.13.5",
"next-themes": "^0.4.6",
"qrcode.react": "^4.2.0",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-hook-form": "^7.84.0",

View File

@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { PageStatSkeleton } from "@/components/layout/page-loading";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
import { TablePanel } from "@/components/table/table-panel";
@ -64,6 +65,9 @@ export default function DepartmentsPage() {
</Button>
}
>
{isLoading ? (
<PageStatSkeleton cols={4} />
) : (
<StatCardGrid cols={4}>
<StatCard label={t("stats.total")} value={stats.total} icon={<Building2 />} />
<StatCard label={t("stats.sites")} value={stats.sites} icon={<MapPin />} />
@ -74,20 +78,25 @@ export default function DepartmentsPage() {
/>
<StatCard label={t("departments.members")} value={stats.members} icon={<Users />} />
</StatCardGrid>
)}
<TablePanel
loading={isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={siteFilter}
onFilterChange={setSiteFilter}
filterPlaceholder={t("departments.site")}
filterAllLabel={t("common.all")}
filterOptions={sites.map((s) => ({ value: s, label: s }))}
filters={[
{
id: "site",
label: t("departments.site"),
value: siteFilter,
onChange: setSiteFilter,
allLabel: t("common.all"),
options: sites.map((s) => ({ value: s, label: s })),
},
]}
resultCount={filtered.length}
resultLabel={t("common.results")}
/>

View File

@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { PageShell } from "@/components/layout/page-shell";
import { PageStatSkeleton } from "@/components/layout/page-loading";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { StatusBadge } from "@/components/shared/status-badge";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
@ -27,21 +28,28 @@ export default function FinanceApprovalsPage() {
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [deptFilter, setDeptFilter] = useState("all");
const { data: approvals, isLoading } = useQuery({
queryKey: ["approvals"],
queryFn: mockApi.getApprovals,
});
const departments = useMemo(() => {
const set = new Set((approvals ?? []).map((a) => a.departmentName));
return Array.from(set).sort();
}, [approvals]);
const filtered = useMemo(() => {
return (approvals ?? []).filter((ap) => {
if (statusFilter !== "all" && ap.status !== statusFilter) return false;
if (deptFilter !== "all" && ap.departmentName !== deptFilter) return false;
return matchesSearch(
[ap.memberName, ap.departmentName, ap.reason],
search,
);
});
}, [approvals, search, statusFilter]);
}, [approvals, search, statusFilter, deptFilter]);
const stats = useMemo(() => {
const rows = approvals ?? [];
@ -76,28 +84,45 @@ export default function FinanceApprovalsPage() {
description={t("finance.approvalsSubtitle")}
breadcrumb
>
{isLoading ? (
<PageStatSkeleton cols={3} />
) : (
<StatCardGrid cols={3}>
<StatCard label={t("common.pending")} value={stats.pending} icon={<Clock />} />
<StatCard label={t("finance.approve")} value={stats.approved} icon={<CheckCircle2 />} />
<StatCard label={t("finance.flag")} value={stats.flagged} icon={<AlertTriangle />} />
</StatCardGrid>
)}
<TablePanel
loading={isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterAllLabel={t("common.all")}
filterOptions={[
filters={[
{
id: "status",
label: t("common.status"),
value: statusFilter,
onChange: setStatusFilter,
allLabel: t("common.all"),
options: [
{ value: "pending", label: t("common.pending") },
{ value: "approved", label: t("finance.approve") },
{ value: "flagged", label: t("finance.flag") },
{ value: "rejected", label: "Rejected" },
],
},
{
id: "department",
label: t("finance.byDept"),
value: deptFilter,
onChange: setDeptFilter,
allLabel: t("common.all"),
options: departments.map((d) => ({ value: d, label: d })),
},
]}
resultCount={filtered.length}
resultLabel={t("common.results")}

View File

@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { PageShell } from "@/components/layout/page-shell";
import { PageStatSkeleton } from "@/components/layout/page-loading";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
import { TablePanel } from "@/components/table/table-panel";
@ -26,6 +27,9 @@ export default function FinancePage() {
const t = useTranslations();
const [search, setSearch] = useState("");
const [categoryFilter, setCategoryFilter] = useState("all");
const [deptFilter, setDeptFilter] = useState("all");
const [coverageFilter, setCoverageFilter] = useState("all");
const [periodFilter, setPeriodFilter] = useState("all");
const orgQ = useQuery({ queryKey: ["org"], queryFn: mockApi.getOrganisation });
const spendQ = useQuery({ queryKey: ["spend"], queryFn: mockApi.getSpend });
@ -35,15 +39,40 @@ export default function FinancePage() {
return Array.from(set).sort();
}, [spendQ.data]);
const departments = useMemo(() => {
const set = new Set((spendQ.data ?? []).map((r) => r.departmentName));
return Array.from(set).sort();
}, [spendQ.data]);
const periods = useMemo(() => {
const set = new Set(
(spendQ.data ?? []).map((r) => r.occurredAt.slice(0, 7)),
);
return Array.from(set).sort().reverse();
}, [spendQ.data]);
const filtered = useMemo(() => {
return (spendQ.data ?? []).filter((row) => {
if (categoryFilter !== "all" && row.category !== categoryFilter) return false;
if (deptFilter !== "all" && row.departmentName !== deptFilter) return false;
if (coverageFilter === "covered" && row.coveredEtb <= 0) return false;
if (coverageFilter === "uncovered" && row.coveredEtb > 0) return false;
if (periodFilter !== "all" && !row.occurredAt.startsWith(periodFilter)) {
return false;
}
return matchesSearch(
[row.memberName, row.departmentName, row.category],
search,
);
});
}, [spendQ.data, search, categoryFilter]);
}, [
spendQ.data,
search,
categoryFilter,
deptFilter,
coverageFilter,
periodFilter,
]);
function handleExport() {
toast.success(t("common.export"));
@ -64,6 +93,9 @@ export default function FinancePage() {
</Button>
}
>
{spendQ.isLoading || orgQ.isLoading ? (
<PageStatSkeleton cols={3} />
) : (
<StatCardGrid cols={3}>
<StatCard
label={t("finance.creditUsage")}
@ -84,23 +116,55 @@ export default function FinancePage() {
/>
<StatCard label={t("finance.transactions")} value={filtered.length} />
</StatCardGrid>
)}
<TablePanel
loading={spendQ.isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={categoryFilter}
onFilterChange={setCategoryFilter}
filterPlaceholder={t("finance.byCategory")}
filterAllLabel={t("common.all")}
filterOptions={categories.map((c) => ({
filters={[
{
id: "category",
label: t("finance.byCategory"),
value: categoryFilter,
onChange: setCategoryFilter,
allLabel: t("common.all"),
options: categories.map((c) => ({
value: c,
label: c.charAt(0).toUpperCase() + c.slice(1),
}))}
})),
},
{
id: "department",
label: t("finance.byDept"),
value: deptFilter,
onChange: setDeptFilter,
allLabel: t("common.all"),
options: departments.map((d) => ({ value: d, label: d })),
},
{
id: "coverage",
label: t("finance.coverage"),
value: coverageFilter,
onChange: setCoverageFilter,
allLabel: t("common.all"),
options: [
{ value: "covered", label: t("finance.fullyCovered") },
{ value: "uncovered", label: t("finance.uncovered") },
],
},
{
id: "period",
label: t("finance.period"),
value: periodFilter,
onChange: setPeriodFilter,
allLabel: t("common.all"),
options: periods.map((p) => ({ value: p, label: p })),
},
]}
resultCount={filtered.length}
resultLabel={t("common.results")}
/>

View File

@ -58,20 +58,25 @@ export default function MemberOrdersPage() {
>
<TablePanel
loading={isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterAllLabel={t("common.all")}
filterOptions={[
filters={[
{
id: "status",
label: t("common.status"),
value: statusFilter,
onChange: setStatusFilter,
allLabel: t("common.all"),
options: [
{ value: "placed", label: "Placed" },
{ value: "processing", label: "Processing" },
{ value: "delivered", label: "Delivered" },
{ value: "cancelled", label: "Cancelled" },
],
},
]}
resultCount={filtered.length}
resultLabel={t("common.results")}

View File

@ -4,11 +4,13 @@ import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { PageStatSkeleton } from "@/components/layout/page-loading";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import {
DETAIL_PANEL,
DetailSection,
} from "@/components/layout/detail-layout";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/providers/auth-provider";
import { mockApi } from "@/lib/api/mock-client";
import { formatEtb } from "@/lib/format";
@ -48,8 +50,13 @@ export default function MemberHomePage() {
if (!user) return null;
const statsLoading = memberQ.isLoading || loyaltyQ.isLoading;
return (
<PageShell title={t("member.homeTitle")} description={t("member.homeSubtitle")}>
{statsLoading ? (
<PageStatSkeleton cols={3} />
) : (
<StatCardGrid cols={3}>
<StatCard
label={t("member.remaining")}
@ -75,6 +82,7 @@ export default function MemberHomePage() {
icon={<Gift />}
/>
</StatCardGrid>
)}
<div className="grid gap-4 lg:grid-cols-3">
<DetailSection
@ -88,7 +96,19 @@ export default function MemberHomePage() {
}
>
{memberQ.isLoading ? (
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-28" />
</div>
<div className="space-y-2">
<div className="flex justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-2 w-full rounded-full" />
</div>
</div>
) : (
<div className="space-y-4">
<div>
@ -122,7 +142,11 @@ export default function MemberHomePage() {
{t("member.loyalty")}
</p>
{loyaltyQ.isLoading ? (
<p className="mt-3 text-sm text-muted-foreground">{t("common.loading")}</p>
<div className="mt-3 space-y-2">
<Skeleton className="h-8 w-24" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-full" />
</div>
) : loyaltyQ.data ? (
<div className="mt-3 space-y-1.5">
<p className="font-display text-2xl font-semibold tabular-nums">

View File

@ -23,11 +23,13 @@ import { mockApi } from "@/lib/api/mock-client";
import { formatDate, formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { UserAvatar } from "@/components/shared/user-avatar";
import { MemberIdCard } from "@/components/members/member-id-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ArrowLeft,
Building2,
IdCard,
Mail,
Percent,
Phone,
@ -38,15 +40,6 @@ import {
Wallet,
} from "lucide-react";
function initials(name: string) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("");
}
export default function MemberDetailPage() {
const t = useTranslations();
const tTabs = useTranslations("tabs");
@ -62,12 +55,17 @@ export default function MemberDetailPage() {
});
const deptsQ = useQuery({ queryKey: ["departments"], queryFn: mockApi.getDepartments });
const pkgsQ = useQuery({ queryKey: ["packages"], queryFn: mockApi.getPackages });
const orgQ = useQuery({ queryKey: ["organisation"], queryFn: mockApi.getOrganisation });
const canManage =
user?.activeRole === "SUPER_USER" || user?.activeRole === "HR_ADMIN";
const isClinicalWithheld =
user?.activeRole === "HR_ADMIN" || user?.activeRole === "FINANCE";
const canSeeClinical = user?.activeRole === "SUPER_USER";
const canSeeIdCard =
user?.activeRole === "SUPER_USER" ||
user?.activeRole === "HR_ADMIN" ||
(user?.activeRole === "MEMBER" && user.memberId === memberId);
const deptName = useMemo(
() => deptsQ.data?.find((d) => d.id === memberQ.data?.departmentId)?.name,
@ -172,11 +170,13 @@ export default function MemberDetailPage() {
aside={
<DetailProfileCard
avatar={
<Avatar className="size-20 border-4 border-card text-lg shadow-none">
<AvatarFallback className="bg-brand/20 font-display text-base font-semibold text-foreground">
{initials(member.name)}
</AvatarFallback>
</Avatar>
<UserAvatar
name={member.name}
avatarUrl={member.avatarUrl}
seed={member.id}
className="size-20 border-4 border-card text-lg shadow-none"
fallbackClassName="bg-brand/20 font-display text-base font-semibold text-foreground"
/>
}
title={member.name}
subtitle={member.email}
@ -242,6 +242,9 @@ export default function MemberDetailPage() {
<TabsTrigger value="profile">{tTabs("profile")}</TabsTrigger>
<TabsTrigger value="entitlement">{tTabs("entitlement")}</TabsTrigger>
<TabsTrigger value="dependants">{tTabs("dependants")}</TabsTrigger>
{canSeeIdCard ? (
<TabsTrigger value="idCard">{tTabs("idCard")}</TabsTrigger>
) : null}
{canSeeClinical ? (
<TabsTrigger value="clinical">{tTabs("clinical")}</TabsTrigger>
) : null}
@ -330,6 +333,18 @@ export default function MemberDetailPage() {
</DetailSection>
</TabsContent>
{canSeeIdCard && orgQ.data ? (
<TabsContent value="idCard" className="space-y-4 pt-1">
<DetailSection title={t("idCard.title")} icon={<IdCard />}>
<MemberIdCard
member={member}
organisation={orgQ.data}
packageName={pkgName}
/>
</DetailSection>
</TabsContent>
) : null}
{canSeeClinical ? (
<TabsContent value="clinical" className="space-y-4 pt-1">
<DetailSection title={t("details.clinicalSupport")} icon={<Shield />}>

View File

@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { PageStatSkeleton } from "@/components/layout/page-loading";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { StatusBadge } from "@/components/shared/status-badge";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
@ -22,12 +23,16 @@ import {
TableRow,
} from "@/components/ui/table";
import { Upload, UserCheck, UserMinus, UserPlus, Users } from "lucide-react";
import { UserAvatar } from "@/components/shared/user-avatar";
export default function MembersPage() {
const t = useTranslations();
const { user } = useAuth();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [deptFilter, setDeptFilter] = useState("all");
const [pkgFilter, setPkgFilter] = useState("all");
const [dependantsFilter, setDependantsFilter] = useState("all");
const membersQ = useQuery({
queryKey: ["members", user?.activeRole],
@ -57,12 +62,25 @@ export default function MembersPage() {
const filtered = useMemo(() => {
return (membersQ.data ?? []).filter((m) => {
if (statusFilter !== "all" && m.status !== statusFilter) return false;
if (deptFilter !== "all" && m.departmentId !== deptFilter) return false;
if (pkgFilter !== "all" && m.packageId !== pkgFilter) return false;
if (dependantsFilter === "with" && m.dependants.length === 0) return false;
if (dependantsFilter === "without" && m.dependants.length > 0) return false;
return matchesSearch(
[m.name, m.email, m.employeeId, m.phone, deptMap.get(m.departmentId), pkgMap.get(m.packageId)],
search,
);
});
}, [membersQ.data, search, statusFilter, deptMap, pkgMap]);
}, [
membersQ.data,
search,
statusFilter,
deptFilter,
pkgFilter,
dependantsFilter,
deptMap,
pkgMap,
]);
const stats = useMemo(() => {
const rows = membersQ.data ?? [];
@ -93,30 +111,71 @@ export default function MembersPage() {
</>
}
>
{membersQ.isLoading ? (
<PageStatSkeleton cols={4} />
) : (
<StatCardGrid cols={4}>
<StatCard label={t("stats.total")} value={stats.total} icon={<Users />} />
<StatCard label={t("stats.active")} value={stats.active} icon={<UserCheck />} />
<StatCard label={t("stats.invited")} value={stats.invited} icon={<UserPlus />} />
<StatCard label={t("stats.offboarded")} value={stats.offboarded} icon={<UserMinus />} />
</StatCardGrid>
)}
<TablePanel
loading={membersQ.isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterPlaceholder={t("common.status")}
filterAllLabel={t("common.all")}
filterOptions={[
filters={[
{
id: "status",
label: t("common.status"),
value: statusFilter,
onChange: setStatusFilter,
allLabel: t("common.all"),
options: [
{ value: "active", label: t("common.active") },
{ value: "invited", label: t("members.statusInvited") },
{ value: "inactive", label: t("members.statusInactive") },
{ value: "offboarded", label: t("members.statusOffboarded") },
],
},
{
id: "department",
label: t("members.department"),
value: deptFilter,
onChange: setDeptFilter,
allLabel: t("common.all"),
options: (deptsQ.data ?? []).map((d) => ({
value: d.id,
label: d.name,
})),
},
{
id: "package",
label: t("members.package"),
value: pkgFilter,
onChange: setPkgFilter,
allLabel: t("common.all"),
options: (pkgsQ.data ?? []).map((p) => ({
value: p.id,
label: p.name,
})),
},
{
id: "dependants",
label: t("members.dependants"),
value: dependantsFilter,
onChange: setDependantsFilter,
allLabel: t("common.all"),
options: [
{ value: "with", label: t("members.withDependants") },
{ value: "without", label: t("members.withoutDependants") },
],
},
]}
resultCount={filtered.length}
resultLabel={t("common.results")}
@ -140,7 +199,18 @@ export default function MembersPage() {
filtered.map((member) => (
<TableRow key={member.id}>
<TableCell className="font-mono text-xs">{member.employeeId}</TableCell>
<TableCell className="font-medium">{member.name}</TableCell>
<TableCell>
<div className="flex items-center gap-2.5">
<UserAvatar
name={member.name}
avatarUrl={member.avatarUrl}
seed={member.id}
size="sm"
fallbackClassName="bg-brand/20 text-[10px] font-semibold text-foreground"
/>
<span className="font-medium">{member.name}</span>
</div>
</TableCell>
<TableCell>{deptMap.get(member.departmentId) ?? "—"}</TableCell>
<TableCell>{pkgMap.get(member.packageId) ?? "—"}</TableCell>
<TableCell className="text-right">

View File

@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { PageStatSkeleton } from "@/components/layout/page-loading";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { StatusBadge } from "@/components/shared/status-badge";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
@ -26,18 +27,39 @@ export default function MigrationPage() {
const t = useTranslations();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [datasetFilter, setDatasetFilter] = useState("all");
const [errorsFilter, setErrorsFilter] = useState("all");
const [createdByFilter, setCreatedByFilter] = useState("all");
const { data: jobs, isLoading } = useQuery({
queryKey: ["migration-jobs"],
queryFn: mockApi.getMigrationJobs,
});
const datasets = useMemo(() => {
const set = new Set((jobs ?? []).map((j) => j.dataset));
return Array.from(set).sort();
}, [jobs]);
const creators = useMemo(() => {
const set = new Set((jobs ?? []).map((j) => j.createdBy));
return Array.from(set).sort();
}, [jobs]);
const filtered = useMemo(() => {
return (jobs ?? []).filter((job) => {
if (statusFilter !== "all" && job.status !== statusFilter) return false;
if (datasetFilter !== "all" && job.dataset !== datasetFilter) return false;
if (errorsFilter === "with" && job.errorCount === 0 && job.status !== "failed") {
return false;
}
if (errorsFilter === "without" && (job.errorCount > 0 || job.status === "failed")) {
return false;
}
if (createdByFilter !== "all" && job.createdBy !== createdByFilter) return false;
return matchesSearch([job.dataset, job.fileName, job.id], search);
});
}, [jobs, search, statusFilter]);
}, [jobs, search, statusFilter, datasetFilter, errorsFilter, createdByFilter]);
const stats = useMemo(() => {
const rows = jobs ?? [];
@ -60,6 +82,9 @@ export default function MigrationPage() {
</Button>
}
>
{isLoading ? (
<PageStatSkeleton cols={4} />
) : (
<StatCardGrid cols={4}>
<StatCard label={t("stats.jobs")} value={stats.total} icon={<FileSpreadsheet />} />
<StatCard label={t("stats.completed")} value={stats.completed} icon={<CheckCircle2 />} />
@ -70,23 +95,59 @@ export default function MigrationPage() {
icon={<AlertTriangle />}
/>
</StatCardGrid>
)}
<TablePanel
loading={isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterAllLabel={t("common.all")}
filterOptions={[
filters={[
{
id: "status",
label: t("common.status"),
value: statusFilter,
onChange: setStatusFilter,
allLabel: t("common.all"),
options: [
{ value: "draft", label: "Draft" },
{ value: "preview", label: "Preview" },
{ value: "completed", label: t("common.active") },
{ value: "failed", label: "Failed" },
],
},
{
id: "dataset",
label: t("migration.dataset"),
value: datasetFilter,
onChange: setDatasetFilter,
allLabel: t("common.all"),
options: datasets.map((d) => ({
value: d,
label: d.replaceAll("_", " "),
})),
},
{
id: "errors",
label: t("stats.withErrors"),
value: errorsFilter,
onChange: setErrorsFilter,
allLabel: t("common.all"),
options: [
{ value: "with", label: t("migration.withErrors") },
{ value: "without", label: t("migration.noErrors") },
],
},
{
id: "createdBy",
label: t("migration.createdBy"),
value: createdByFilter,
onChange: setCreatedByFilter,
allLabel: t("common.all"),
options: creators.map((c) => ({ value: c, label: c })),
},
]}
resultCount={filtered.length}
resultLabel={t("common.results")}

View File

@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { PageStatSkeleton } from "@/components/layout/page-loading";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
import { TablePanel } from "@/components/table/table-panel";
@ -64,27 +65,36 @@ export default function PackagesPage() {
</Button>
}
>
{isLoading ? (
<PageStatSkeleton cols={4} />
) : (
<StatCardGrid cols={4}>
<StatCard label={t("stats.total")} value={stats.total} icon={<Package />} />
<StatCard label={t("stats.active")} value={stats.active} />
<StatCard label={t("stats.inactive")} value={stats.inactive} icon={<PowerOff />} />
<StatCard label={t("stats.enrolled")} value={stats.enrolled} icon={<Users />} />
</StatCardGrid>
)}
<TablePanel
loading={isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterAllLabel={t("common.all")}
filterOptions={[
filters={[
{
id: "status",
label: t("common.status"),
value: statusFilter,
onChange: setStatusFilter,
allLabel: t("common.all"),
options: [
{ value: "active", label: t("common.active") },
{ value: "inactive", label: t("packages.inactive") },
],
},
]}
resultCount={filtered.length}
resultLabel={t("common.results")}

View File

@ -4,6 +4,7 @@ import { useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { PageDashboardSkeleton } from "@/components/layout/page-loading";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { StatusBadge } from "@/components/shared/status-badge";
import {
@ -104,6 +105,15 @@ export default function DashboardPage() {
if (!user || user.activeRole === "MEMBER") return null;
const dashLoading =
orgQ.isLoading ||
membersQ.isLoading ||
approvalsQ.isLoading ||
deptsQ.isLoading ||
statementsQ.isLoading ||
spendQ.isLoading ||
packagesQ.isLoading;
const activeMembers = membersQ.data?.filter((m) => m.status === "active").length ?? 0;
const pendingApprovals =
approvalsQ.data?.filter((a) => a.status === "pending").length ?? 0;
@ -113,6 +123,10 @@ export default function DashboardPage() {
return (
<PageShell title={t("dashboard.title")} description={t("dashboard.subtitle")}>
{dashLoading ? (
<PageDashboardSkeleton />
) : (
<>
<StatCardGrid cols={4}>
<StatCard
label={t("dashboard.members")}
@ -201,6 +215,8 @@ export default function DashboardPage() {
</CardContent>
</Card>
</div>
</>
)}
</PageShell>
);
}

View File

@ -70,21 +70,26 @@ export default function PrescriptionsPage() {
>
<TablePanel
loading={rxQ.isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterAllLabel={t("common.all")}
filterOptions={[
filters={[
{
id: "status",
label: t("common.status"),
value: statusFilter,
onChange: setStatusFilter,
allLabel: t("common.all"),
options: [
{ value: "submitted", label: "Submitted" },
{ value: "under_review", label: "Under review" },
{ value: "approved", label: "Approved" },
{ value: "queried", label: "Queried" },
{ value: "rejected", label: "Rejected" },
],
},
]}
resultCount={filtered.length}
resultLabel={t("common.results")}

View File

@ -0,0 +1,187 @@
"use client";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import {
DetailCallout,
DetailPageColumns,
DetailPageLayout,
DetailProfileCard,
DetailReadOnlyBox,
DetailSection,
} from "@/components/layout/detail-layout";
import { useAuth } from "@/providers/auth-provider";
import {
ROLE_META,
getDemoOrgName,
} from "@/lib/auth/session";
import { useRouter } from "@/i18n/routing";
import { LANGUAGE_REGISTRY } from "@/i18n/registry";
import type { Role } from "@/types";
import { UserAvatar } from "@/components/shared/user-avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Building2, Globe2, LogOut, Shield, UserRound } from "lucide-react";
export default function ProfilePage() {
const t = useTranslations();
const { user, setActiveRole, logout, switchDemoUser } = useAuth();
const router = useRouter();
if (!user) return null;
const orgName = getDemoOrgName();
const localeLabel =
LANGUAGE_REGISTRY.find((l) => l.code === user.locale)?.nativeName ??
user.locale;
const multiRole = user.roles.length > 1;
function handleRoleChange(role: string | null) {
if (!role) return;
const next = setActiveRole(role as Role);
if (next) {
toast.success(t("profile.roleSwitched", { role: t(ROLE_META[next.activeRole].labelKey) }));
router.push(ROLE_META[next.activeRole].homePath);
}
}
return (
<DetailPageLayout
title={t("profile.title")}
description={t("profile.subtitle")}
>
<DetailPageColumns
aside={
<DetailProfileCard
avatar={
<UserAvatar
name={user.name}
avatarUrl={user.avatarUrl}
seed={user.id}
initials={user.avatarInitials}
className="size-20 ring-4 ring-card"
fallbackClassName="bg-brand/20 text-lg font-semibold text-brand-deep"
/>
}
title={user.name}
subtitle={user.email}
meta={
<Badge variant="secondary">
{t(ROLE_META[user.activeRole].labelKey)}
</Badge>
}
actions={
<>
<Button
variant="outline"
className="w-full justify-start gap-2"
onClick={() => {
switchDemoUser();
router.push("/login");
}}
>
<UserRound className="size-4" />
{t("common.switchDemo")}
</Button>
<Button
variant="outline"
className="w-full justify-start gap-2"
onClick={() => {
logout();
router.push("/login");
}}
>
<LogOut className="size-4" />
{t("common.signOut")}
</Button>
</>
}
>
<DetailReadOnlyBox label={t("profile.organisation")}>
<span className="inline-flex items-center gap-2">
<Building2 className="size-3.5 text-muted-foreground" />
{orgName}
</span>
</DetailReadOnlyBox>
<DetailReadOnlyBox label={t("profile.localePreference")}>
<span className="inline-flex items-center gap-2">
<Globe2 className="size-3.5 text-muted-foreground" />
{localeLabel}
</span>
</DetailReadOnlyBox>
</DetailProfileCard>
}
>
<DetailSection
title={t("profile.accountDetails")}
icon={<Shield className="size-4" />}
>
<div className="grid gap-4 sm:grid-cols-2">
<DetailReadOnlyBox label={t("common.name")}>
{user.name}
</DetailReadOnlyBox>
<DetailReadOnlyBox label={t("common.email")}>
{user.email}
</DetailReadOnlyBox>
<DetailReadOnlyBox label={t("profile.activeRole")}>
{t(ROLE_META[user.activeRole].labelKey)}
</DetailReadOnlyBox>
<DetailReadOnlyBox label={t("profile.organisation")}>
{orgName}
</DetailReadOnlyBox>
<DetailReadOnlyBox label={t("profile.localePreference")}>
{localeLabel}
</DetailReadOnlyBox>
<DetailReadOnlyBox label={t("profile.userId")}>
<span className="font-mono text-xs">{user.id}</span>
</DetailReadOnlyBox>
</div>
</DetailSection>
{multiRole ? (
<DetailSection
title={t("profile.switchRole")}
icon={<UserRound className="size-4" />}
>
<div className="max-w-sm space-y-2">
<Label htmlFor="active-role">{t("profile.activeRole")}</Label>
<Select value={user.activeRole} onValueChange={handleRoleChange}>
<SelectTrigger id="active-role" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{user.roles.map((role) => (
<SelectItem key={role} value={role}>
{t(ROLE_META[role].labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("profile.switchRoleHint")}
</p>
</div>
</DetailSection>
) : null}
<DetailSection title={t("profile.language")}>
<DetailCallout tone="brand">
{t("profile.languageHint")}{" "}
<span className="font-medium text-foreground">
{t("common.language")}
</span>{" "}
{t("profile.languageHintSuffix")}
</DetailCallout>
</DetailSection>
</DetailPageColumns>
</DetailPageLayout>
);
}

View File

@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { CreatePageLayout } from "@/components/layout/create-layout";
import { PageFormSkeleton } from "@/components/layout/page-loading";
import { mockApi } from "@/lib/api/mock-client";
import type { Organisation } from "@/types";
import { Button } from "@/components/ui/button";
@ -53,7 +54,7 @@ export default function VerificationSettingsPage() {
description={t("verification.subtitle")}
>
{isLoading ? (
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
<PageFormSkeleton />
) : (
<Card className="w-full rounded-xl shadow-none">
<CardHeader>

View File

@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { PageStatSkeleton } from "@/components/layout/page-loading";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { StatusBadge } from "@/components/shared/status-badge";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
@ -51,6 +52,9 @@ export default function StatementsPage() {
return (
<PageShell title={t("statements.title")} description={t("statements.subtitle")}>
{isLoading ? (
<PageStatSkeleton cols={4} />
) : (
<StatCardGrid cols={4}>
<StatCard
label={t("stats.coveredTotal")}
@ -65,22 +69,28 @@ export default function StatementsPage() {
<StatCard label={t("stats.draft")} value={stats.draft} icon={<FileText />} />
<StatCard label={t("stats.issued")} value={stats.issued} />
</StatCardGrid>
)}
<TablePanel
loading={isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterAllLabel={t("common.all")}
filterOptions={[
filters={[
{
id: "status",
label: t("common.status"),
value: statusFilter,
onChange: setStatusFilter,
allLabel: t("common.all"),
options: [
{ value: "draft", label: "Draft" },
{ value: "issued", label: "Issued" },
{ value: "paid", label: "Paid" },
],
},
]}
resultCount={filtered.length}
resultLabel={t("common.results")}

View File

@ -1,96 +1,210 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { brand } from "@/data/brand";
import { useAuth } from "@/providers/auth-provider";
import { Link, useRouter } from "@/i18n/routing";
import { ROLE_META } from "@/lib/auth/session";
import type { Role } from "@/types";
import { ROLE_META, listDemoPersonas } from "@/lib/auth/session";
import { LanguageSwitcher } from "@/components/layout/language-switcher";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Building2, Handshake, KeyRound } from "lucide-react";
import { UserAvatar } from "@/components/shared/user-avatar";
const ROLES: Role[] = ["SUPER_USER", "HR_ADMIN", "FINANCE", "MEMBER"];
const PERSONAS = listDemoPersonas();
export default function LoginPage() {
const t = useTranslations();
const { loginAs } = useAuth();
const { loginAsUser } = useAuth();
const router = useRouter();
const [personaId, setPersonaId] = useState<string>(PERSONAS[0]?.id ?? "user_super");
const [submitting, setSubmitting] = useState(false);
const selected = PERSONAS.find((p) => p.id === personaId) ?? PERSONAS[0];
function handleContinue() {
if (!selected) return;
setSubmitting(true);
const next = loginAsUser(selected.id);
router.push(ROLE_META[next.activeRole].homePath);
}
return (
<div className="relative flex min-h-screen flex-col px-4 py-8 md:px-8">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between">
<div className="flex items-center gap-3">
<Image src={brand.logo} alt={brand.shortName} width={40} height={40} />
<div>
<p className="font-display text-lg font-semibold">{t("common.appName")}</p>
<p className="text-xs text-muted-foreground">{brand.tagline}</p>
</div>
</div>
<LanguageSwitcher />
<div className="relative flex min-h-screen overflow-hidden bg-[#f6f7f2]">
{/* Atmospheric background */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<div className="absolute -left-24 -top-28 h-[28rem] w-[28rem] rounded-full bg-[color-mix(in_srgb,var(--brand)_22%,transparent)] blur-3xl" />
<div className="absolute -bottom-32 -right-20 h-[32rem] w-[32rem] rounded-full bg-[color-mix(in_srgb,var(--brand-deep)_14%,transparent)] blur-3xl" />
<div
className="absolute inset-0 opacity-[0.035]"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, #141414 1px, transparent 0)",
backgroundSize: "22px 22px",
}}
/>
</div>
<div className="mx-auto mt-10 grid w-full max-w-5xl gap-8 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-4">
<h1 className="font-display text-3xl font-semibold tracking-tight md:text-4xl">
{/* Left brand panel — Yimaru-style split */}
<div className="relative hidden lg:flex lg:w-1/2 xl:w-[55%] items-center justify-center overflow-hidden bg-gradient-to-br from-[#1a2610] via-[#2a3d18] to-[color-mix(in_srgb,var(--brand)_55%,#1a2610)]">
<div aria-hidden className="absolute inset-0 overflow-hidden">
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-white/5" />
<div className="absolute -bottom-32 -right-16 h-[500px] w-[500px] rounded-full bg-white/5" />
<div className="absolute left-1/3 top-1/4 h-64 w-64 rounded-full bg-[color-mix(in_srgb,var(--brand)_25%,transparent)]" />
</div>
<div className="relative z-10 max-w-md px-12 text-center animate-in fade-in slide-in-from-left-4 duration-700">
<Image
src={brand.logo}
alt={brand.shortName}
width={88}
height={88}
className="mx-auto mb-8 rounded-2xl bg-white/95 p-2 shadow-lg"
priority
/>
<p className="font-display text-3xl font-semibold tracking-tight text-white">
{t("common.appName")}
</p>
<p className="mt-3 text-base leading-relaxed text-white/70">
{t("login.brandBlurb")}
</p>
</div>
</div>
{/* Right form panel */}
<div className="relative flex w-full flex-col items-center justify-center px-6 py-12 lg:w-1/2 xl:w-[45%]">
<div className="absolute right-4 top-4 sm:right-6 sm:top-6">
<LanguageSwitcher className="w-[130px]" />
</div>
<div className="w-full max-w-[420px] animate-in fade-in slide-in-from-bottom-3 duration-500">
<div className="mb-8 flex justify-center lg:hidden">
<Image
src={brand.logo}
alt={brand.shortName}
width={64}
height={64}
className="rounded-xl bg-white p-1.5 shadow-sm ring-1 ring-border"
priority
/>
</div>
<div className="mb-8">
<p className="mb-1.5 text-sm font-medium uppercase tracking-widest text-brand-deep">
{t("login.eyebrow")}
</p>
<h1 className="mb-2 font-display text-3xl font-semibold tracking-tight text-foreground">
{t("login.title")}
</h1>
<p className="max-w-xl text-muted-foreground">{t("login.subtitle")}</p>
<div className="grid gap-3 sm:grid-cols-2">
{ROLES.map((role) => (
<Card
key={role}
className="rounded-2xl border-border bg-card/90 shadow-none transition-colors hover:border-primary"
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base">{t(ROLE_META[role].labelKey)}</CardTitle>
<Badge variant="secondary">{role}</Badge>
<p className="text-sm leading-relaxed text-muted-foreground">
{t("login.subtitle")}
</p>
</div>
<CardDescription>{t(ROLE_META[role].descriptionKey)}</CardDescription>
</CardHeader>
<CardContent>
<Button
className="w-full"
onClick={() => {
loginAs(role);
router.push(ROLE_META[role].homePath);
<div className="space-y-5 rounded-2xl border border-border bg-card/95 p-6 shadow-none backdrop-blur-sm">
<div className="space-y-2">
<Label htmlFor="demo-user">{t("login.selectUser")}</Label>
<Select
value={personaId}
onValueChange={(v) => {
if (v) setPersonaId(v);
}}
>
{t("login.continueAs", { role: t(ROLE_META[role].labelKey) })}
</Button>
</CardContent>
</Card>
<SelectTrigger id="demo-user" className="h-11 w-full rounded-xl">
<SelectValue placeholder={t("login.selectUserPlaceholder")} />
</SelectTrigger>
<SelectContent>
{PERSONAS.map((p) => (
<SelectItem key={p.id} value={p.id}>
<span className="flex items-center gap-2.5 py-0.5 text-left">
<UserAvatar
name={p.name}
avatarUrl={p.avatarUrl}
seed={p.id}
initials={p.avatarInitials}
size="sm"
fallbackClassName="bg-brand/20 text-[10px] font-semibold"
/>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="font-medium">
{t(p.personaLabelKey)}
</span>
<span className="truncate text-xs text-muted-foreground">
{p.name} · {p.email}
</span>
</span>
</span>
</SelectItem>
))}
</div>
</SelectContent>
</Select>
{selected ? (
<p className="text-xs text-muted-foreground animate-in fade-in duration-300">
{t(selected.personaDescKey)}
</p>
) : null}
</div>
<Card className="h-fit rounded-2xl border-border bg-card shadow-none">
<CardHeader>
<CardTitle>Get started</CardTitle>
<CardDescription>
Organisations can self-register or request assisted onboarding.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button variant="outline" className="w-full justify-start gap-2" render={<Link href="/register/organisation" />}>
<Button
className="h-11 w-full rounded-xl text-sm font-semibold"
disabled={submitting || !selected}
onClick={handleContinue}
>
{selected
? t("login.continueAs", {
role: t(ROLE_META[selected.activeRole].labelKey),
})
: t("common.continue")}
</Button>
</div>
<div className="mt-6 space-y-2">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t("login.getStarted")}
</p>
<div className="flex flex-col gap-2">
<Button
variant="outline"
className="h-10 w-full justify-start gap-2 rounded-xl"
render={<Link href="/register/organisation" />}
>
<Building2 className="size-4" />
{t("login.registerOrg")}
</Button>
<Button variant="outline" className="w-full justify-start gap-2" render={<Link href="/register/request" />}>
<Button
variant="outline"
className="h-10 w-full justify-start gap-2 rounded-xl"
render={<Link href="/register/request" />}
>
<Handshake className="size-4" />
{t("login.requestRegister")}
</Button>
<Button variant="outline" className="w-full justify-start gap-2" render={<Link href="/join" />}>
<Button
variant="outline"
className="h-10 w-full justify-start gap-2 rounded-xl"
render={<Link href="/join" />}
>
<KeyRound className="size-4" />
{t("login.joinWithCode")}
</Button>
</CardContent>
</Card>
</div>
</div>
<p className="mt-10 text-center text-xs text-muted-foreground">
© {new Date().getFullYear()} {brand.name}
</p>
</div>
</div>
</div>
);

View File

@ -112,3 +112,44 @@
font-family: var(--font-sora), var(--font-ethiopic), var(--font-sans), system-ui, sans-serif;
}
}
/* Print: pharmacy member ID card only (hide shell/nav) */
@media print {
@page {
size: 85.6mm 54mm;
margin: 0;
}
html,
body {
background: white !important;
background-image: none !important;
}
body * {
visibility: hidden !important;
}
#member-id-card-print,
#member-id-card-print * {
visibility: visible !important;
}
#member-id-card-print {
position: absolute !important;
left: 0 !important;
top: 0 !important;
width: 85.6mm !important;
height: 54mm !important;
max-width: none !important;
margin: 0 !important;
box-shadow: none !important;
border-radius: 0 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.print\:hidden {
display: none !important;
}
}

View File

@ -26,6 +26,24 @@ const LINE = "#e2e5d8";
const PIE_COLORS = [BRAND, BRAND_DEEP, ACCENT, FOREST, "#96b532", "#c97310"];
/** Compact ETB-scale ticks so Y labels fit (e.g. 800000 → 800K). */
const formatAxisValue = (value: number) =>
new Intl.NumberFormat("en", {
notation: "compact",
compactDisplay: "short",
maximumFractionDigits: 1,
}).format(value);
const VALUE_Y_AXIS = {
tick: { fill: MUTED, fontSize: 11 },
axisLine: false as const,
tickLine: false as const,
width: 56,
tickFormatter: formatAxisValue,
};
const CHART_MARGIN = { top: 8, right: 8, left: 8, bottom: 0 };
type ChartCardProps = {
title: string;
description?: string;
@ -35,14 +53,16 @@ type ChartCardProps = {
export function ChartCard({ title, description, children, className }: ChartCardProps) {
return (
<Card className={`h-full rounded-xl border-border shadow-none ${className ?? ""}`}>
<Card
className={`h-full overflow-visible rounded-xl border-border shadow-none ${className ?? ""}`}
>
<CardHeader className="border-b border-border px-5 py-3.5">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</CardHeader>
<CardContent className="p-5">{children}</CardContent>
<CardContent className="overflow-visible p-5">{children}</CardContent>
</Card>
);
}
@ -55,7 +75,7 @@ export function SpendTrendChart({
return (
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<AreaChart data={data} margin={CHART_MARGIN}>
<defs>
<linearGradient id="gCovered" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={BRAND} stopOpacity={0.35} />
@ -64,7 +84,7 @@ export function SpendTrendChart({
</defs>
<CartesianGrid stroke={LINE} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="period" tick={{ fill: MUTED, fontSize: 12 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: MUTED, fontSize: 12 }} axisLine={false} tickLine={false} width={48} />
<YAxis {...VALUE_Y_AXIS} />
<Tooltip
contentStyle={{
borderRadius: 12,
@ -103,10 +123,10 @@ export function DepartmentSpendChart({
return (
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<BarChart data={data} margin={CHART_MARGIN}>
<CartesianGrid stroke={LINE} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="name" tick={{ fill: MUTED, fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: MUTED, fontSize: 12 }} axisLine={false} tickLine={false} width={48} />
<YAxis {...VALUE_Y_AXIS} />
<Tooltip
contentStyle={{
borderRadius: 12,

View File

@ -4,7 +4,7 @@ import Image from "next/image";
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/routing";
import { brand } from "@/data/brand";
import { navForRole } from "@/lib/auth/nav";
import { navSectionsForRole } from "@/lib/auth/nav";
import { useAuth } from "@/providers/auth-provider";
import { cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
@ -24,7 +24,7 @@ export function AppSidebar({
const t = useTranslations();
const pathname = usePathname();
const { user } = useAuth();
const items = user ? navForRole(user.activeRole) : [];
const sections = user ? navSectionsForRole(user.activeRole) : [];
return (
<aside
@ -103,12 +103,27 @@ export function AppSidebar({
</div>
<ScrollArea className="mt-6 flex-1">
<nav className="space-y-1 pr-1">
{items.map((item) => {
<nav className="space-y-5 pr-1">
{sections.map((group, groupIndex) => (
<div key={group.section} className="space-y-1">
{collapsed ? (
groupIndex > 0 ? (
<div
className="mx-auto mb-2 h-px w-8 bg-sidebar-border"
aria-hidden
/>
) : null
) : (
<p className="px-3 pb-1 text-[10px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
{t(`nav.sections.${group.section}`)}
</p>
)}
{group.items.map((item) => {
const active =
item.href === "/"
? pathname === "/"
: pathname === item.href || pathname.startsWith(`${item.href}/`);
: pathname === item.href ||
pathname.startsWith(`${item.href}/`);
const Icon = item.icon;
return (
<Link
@ -144,6 +159,8 @@ export function AppSidebar({
</Link>
);
})}
</div>
))}
</nav>
</ScrollArea>
</aside>

View File

@ -8,7 +8,7 @@ import { ROLE_META } from "@/lib/auth/session";
import { LanguageSwitcher } from "./language-switcher";
import { QuickSearch } from "./quick-search";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { UserAvatar } from "@/components/shared/user-avatar";
import {
DropdownMenu,
DropdownMenuContent,
@ -52,11 +52,14 @@ export function AppTopbar({ onMenuClick }: { onMenuClick?: () => void }) {
<DropdownMenuTrigger
render={
<Button variant="ghost" className="h-9 gap-2 px-2">
<Avatar className="size-7">
<AvatarFallback className="bg-primary/20 text-xs font-semibold">
{user.avatarInitials}
</AvatarFallback>
</Avatar>
<UserAvatar
name={user.name}
avatarUrl={user.avatarUrl}
seed={user.id}
initials={user.avatarInitials}
className="size-7"
fallbackClassName="bg-primary/20 text-xs font-semibold"
/>
<span className="hidden text-left sm:block">
<span className="block text-sm font-medium leading-none">{user.name}</span>
<span className="text-[11px] text-muted-foreground">
@ -75,6 +78,9 @@ export function AppTopbar({ onMenuClick }: { onMenuClick?: () => void }) {
</Badge>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push("/profile")}>
{t("nav.profile")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
switchDemoUser();

View File

@ -28,6 +28,7 @@ const PATH_LABEL_KEYS: Record<string, string> = {
"/me/orders": "myOrders",
"/prescriptions": "prescriptions",
"/prescriptions/new": "newPrescription",
"/profile": "profile",
};
/** Lone segment fallbacks when full path is unmapped. */

View File

@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { PageShell } from "@/components/layout/page-shell";
import { PageDetailSkeleton } from "@/components/layout/page-loading";
import { cn } from "@/lib/utils";
/** Flat MakerSys / Yimaru panel — thin border, no shadow */
@ -35,12 +36,17 @@ export function DetailPageLayout({
);
}
export function DetailLoading({ label }: { label?: string }) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-sm text-muted-foreground">{label ?? "Loading…"}</p>
</div>
);
/** Box skeleton for detail routes — keeps layout footprint while queries load. */
export function DetailLoading({
label: _label,
metrics = 3,
}: {
/** @deprecated Text loading — retained for call-site compatibility */
label?: string;
metrics?: 2 | 3 | 4;
}) {
void _label;
return <PageDetailSkeleton metrics={metrics} />;
}
/** Slim summary strip — left brand accent */

View File

@ -121,7 +121,7 @@ export function PageDetailSkeleton({
<div className="space-y-3 border-t border-border pt-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
<div className="space-y-4">

View File

@ -0,0 +1,195 @@
"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { QRCodeSVG } from "qrcode.react";
import { Printer } from "lucide-react";
import { brand } from "@/data/brand";
import {
memberScanPayload,
resolveAvatarUrl,
resolveCardId,
} from "@/lib/avatar";
import type { Member, Organisation } from "@/types";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/shared/status-badge";
import { cn } from "@/lib/utils";
export type MemberIdCardProps = {
member: Member;
organisation: Pick<Organisation, "tradeName" | "legalName" | "joinCode">;
packageName?: string;
className?: string;
/** Hide the print toolbar (e.g. when embedding) */
hideActions?: boolean;
};
export function MemberIdCard({
member,
organisation,
packageName,
className,
hideActions = false,
}: MemberIdCardProps) {
const t = useTranslations("idCard");
const verificationId = member.verificationId ?? member.employeeId;
const cardId = resolveCardId({
cardId: member.cardId,
verificationId: member.verificationId,
employeeId: member.employeeId,
memberId: member.id,
});
const scanPayload = memberScanPayload(member.id, verificationId);
const photoUrl = resolveAvatarUrl(member.avatarUrl, member.id);
function handlePrint() {
window.print();
}
return (
<div className={cn("space-y-4", className)}>
{!hideActions ? (
<div className="flex flex-wrap items-center justify-between gap-3 print:hidden">
<div className="min-w-0">
<p className="text-sm font-medium">{t("title")}</p>
<p className="text-xs text-muted-foreground">{t("subtitle")}</p>
</div>
<Button
type="button"
size="sm"
className="gap-2"
onClick={handlePrint}
disabled={member.status === "offboarded"}
>
<Printer className="size-4" />
{t("print")}
</Button>
</div>
) : null}
{/* Screen preview + print target */}
<div className="flex justify-center print:block">
<div
id="member-id-card-print"
className={cn(
"member-id-card relative w-full max-w-[340px] overflow-hidden rounded-xl",
"border border-[#c5d97a] bg-white text-[#141414] shadow-sm",
"aspect-[85.6/54]",
"print:max-w-none print:rounded-none print:border print:shadow-none",
)}
>
{/* Accent stripe */}
<div
aria-hidden
className="absolute inset-x-0 top-0 h-1.5 bg-[#a8c73a]"
/>
<div
aria-hidden
className="pointer-events-none absolute -right-8 -top-10 size-28 rounded-full bg-[#a8c73a]/20"
/>
<div
aria-hidden
className="pointer-events-none absolute -bottom-10 -left-6 size-24 rounded-full bg-[#141414]/05"
/>
<div className="relative flex h-full flex-col p-3.5 sm:p-4">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Image
src={brand.logo}
alt={brand.shortName}
width={28}
height={28}
className="size-7 shrink-0 rounded-md bg-white object-contain ring-1 ring-[#e2e5d8]"
/>
<div className="min-w-0">
<p className="truncate font-display text-[11px] font-semibold leading-tight tracking-tight">
{brand.shortName}
</p>
<p className="truncate text-[9px] leading-tight text-[#5c5f56]">
{t("pharmacyId")}
</p>
</div>
</div>
<StatusBadge status={member.status} />
</div>
{/* Body */}
<div className="mt-2.5 flex min-h-0 flex-1 gap-3">
<div className="relative size-[72px] shrink-0 overflow-hidden rounded-lg bg-[#eef1e3] ring-1 ring-[#e2e5d8] sm:size-[80px]">
{/* External DiceBear portrait — not next/image remotePatterns */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={photoUrl}
alt={member.name}
className="size-full object-cover"
/>
</div>
<div className="min-w-0 flex-1 space-y-1">
<p className="truncate font-display text-sm font-semibold leading-tight tracking-tight sm:text-base">
{member.name}
</p>
<p className="truncate text-[10px] text-[#5c5f56]">
{organisation.tradeName}
</p>
<dl className="mt-1.5 grid gap-0.5 text-[9px] leading-snug sm:text-[10px]">
<div className="flex gap-1.5">
<dt className="shrink-0 text-[#5c5f56]">{t("employeeId")}</dt>
<dd className="truncate font-mono font-medium">
{member.employeeId}
</dd>
</div>
<div className="flex gap-1.5">
<dt className="shrink-0 text-[#5c5f56]">{t("cardId")}</dt>
<dd className="truncate font-mono font-medium">{cardId}</dd>
</div>
{packageName ? (
<div className="flex gap-1.5">
<dt className="shrink-0 text-[#5c5f56]">{t("package")}</dt>
<dd className="truncate font-medium">{packageName}</dd>
</div>
) : null}
</dl>
</div>
<div className="flex shrink-0 flex-col items-center justify-center gap-0.5">
<div className="rounded-md bg-white p-0.5 ring-1 ring-[#e2e5d8]">
<QRCodeSVG
value={scanPayload}
size={64}
level="M"
bgColor="#ffffff"
fgColor="#141414"
marginSize={0}
/>
</div>
<span className="max-w-[68px] truncate text-center font-mono text-[7px] text-[#5c5f56]">
{verificationId}
</span>
</div>
</div>
{/* Footer */}
<div className="mt-2 flex items-end justify-between gap-2 border-t border-[#e2e5d8] pt-1.5">
<p className="text-[8px] leading-tight text-[#5c5f56] sm:text-[9px]">
{t("scanHint")}
{organisation.joinCode
? ` · ${t("orgCode")}: ${organisation.joinCode}`
: null}
</p>
<p className="shrink-0 font-mono text-[8px] text-[#5c5f56]">
{cardId}
</p>
</div>
</div>
</div>
</div>
<p className="text-center text-xs text-muted-foreground print:hidden">
{t("printHint")}
</p>
</div>
);
}

View File

@ -0,0 +1,36 @@
"use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { getInitials, resolveAvatarUrl } from "@/lib/avatar";
import { cn } from "@/lib/utils";
type AvatarSize = "default" | "sm" | "lg";
export function UserAvatar({
name,
avatarUrl,
seed,
initials,
size = "default",
className,
fallbackClassName,
}: {
name: string;
avatarUrl?: string;
/** Seed for deterministic portrait when avatarUrl is missing */
seed?: string;
initials?: string;
size?: AvatarSize;
className?: string;
fallbackClassName?: string;
}) {
const src = resolveAvatarUrl(avatarUrl, seed ?? name);
const label = initials ?? getInitials(name);
return (
<Avatar size={size} className={cn(className)}>
<AvatarImage src={src} alt={name} />
<AvatarFallback className={fallbackClassName}>{label}</AvatarFallback>
</Avatar>
);
}

View File

@ -1,10 +1,26 @@
import type { ReactNode } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
/** Flat MakerSys / Yimaru table frame — white panel, thin border, no shadow. */
export const TABLE_PANEL_CLASS =
"overflow-hidden rounded-xl border border-border bg-card shadow-none";
function TableRowsSkeleton({ rows = 6 }: { rows?: number }) {
return (
<div className="divide-y divide-border" aria-busy="true" aria-live="polite">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex items-center gap-4 px-4 py-4">
<Skeleton className="h-4 w-32 shrink-0" />
<Skeleton className="hidden h-4 flex-1 sm:block" />
<Skeleton className="h-4 w-20" />
<Skeleton className="hidden h-6 w-16 rounded-full md:block" />
</div>
))}
</div>
);
}
/**
* Wraps toolbar + table (or loading) in a single flush panel.
* Toolbar sits inside the card with a hairline bottom; grid scrolls horizontally on mobile.
@ -14,24 +30,22 @@ export function TablePanel({
children,
className,
loading,
loadingLabel = "Loading…",
loadingLabel: _loadingLabel,
skeletonRows = 6,
}: {
toolbar?: ReactNode;
children?: ReactNode;
className?: string;
loading?: boolean;
/** @deprecated Text loading — retained for call-site compatibility */
loadingLabel?: ReactNode;
skeletonRows?: number;
}) {
void _loadingLabel;
return (
<div className={cn(TABLE_PANEL_CLASS, className)}>
{toolbar}
{loading ? (
<div className="flex min-h-28 items-center justify-center px-5 py-10">
<p className="text-sm text-muted-foreground">{loadingLabel}</p>
</div>
) : (
children
)}
{loading ? <TableRowsSkeleton rows={skeletonRows} /> : children}
</div>
);
}

View File

@ -1,8 +1,19 @@
"use client";
import type { ReactNode } from "react";
import { Search } from "lucide-react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { ListFilter, Search } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
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,
@ -17,37 +28,223 @@ export type TableFilterOption = {
label: string;
};
/** One select filter in the toolbar (or filter modal when there are many). */
export type TableFilterDef = {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
options: TableFilterOption[];
/** Shown as the “all / any” option. Defaults to translated common.all. */
allLabel?: string;
placeholder?: string;
};
type TableToolbarProps = {
search: string;
onSearchChange: (value: string) => void;
searchPlaceholder?: string;
/**
* Preferred multi-filter API. When `filters.length > inlineFilterLimit` (default 3),
* filters open in a modal with draft state + Apply / Reset.
*/
filters?: TableFilterDef[];
/** Legacy single-filter API — still supported (treated as one filter). */
filterValue?: string;
onFilterChange?: (value: string) => void;
filterOptions?: TableFilterOption[];
filterPlaceholder?: string;
filterAllLabel?: string;
/** Switch to modal when filter count exceeds this. Default 3. */
inlineFilterLimit?: number;
actions?: ReactNode;
className?: string;
resultCount?: number;
resultLabel?: string;
};
/** In-panel search + optional filter (Fortune AppTable toolbar / Yimaru list header). */
const DEFAULT_INLINE_LIMIT = 3;
function FilterSelect({
filter,
value,
onValueChange,
className,
}: {
filter: TableFilterDef;
value: string;
onValueChange: (value: string) => void;
className?: string;
}) {
return (
<Select value={value || "all"} onValueChange={(v) => onValueChange(v ?? "all")}>
<SelectTrigger
className={cn(
"w-full rounded-xl border-border bg-background shadow-none sm:w-[180px]",
className,
)}
>
<SelectValue placeholder={filter.placeholder ?? filter.label} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{filter.allLabel ?? "All"}</SelectItem>
{filter.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function countActiveFilters(filters: TableFilterDef[], values: Record<string, string>) {
return filters.reduce((n, f) => {
const v = values[f.id] ?? f.value;
return n + (v && v !== "all" ? 1 : 0);
}, 0);
}
function valuesFromFilters(filters: TableFilterDef[]): Record<string, string> {
return Object.fromEntries(filters.map((f) => [f.id, f.value || "all"]));
}
function allCleared(filters: TableFilterDef[]): Record<string, string> {
return Object.fromEntries(filters.map((f) => [f.id, "all"]));
}
function FilterModal({
open,
onOpenChange,
filters,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
filters: TableFilterDef[];
}) {
const t = useTranslations("filters");
const tCommon = useTranslations("common");
const [draft, setDraft] = useState<Record<string, string>>(() =>
valuesFromFilters(filters),
);
// Sync draft from applied values only when the modal opens (not on every parent re-render).
useEffect(() => {
if (open) setDraft(valuesFromFilters(filters));
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional open-only sync
}, [open]);
function setDraftValue(id: string, value: string) {
setDraft((prev) => ({ ...prev, [id]: value }));
}
function handleApply() {
for (const f of filters) {
const next = draft[f.id] ?? "all";
if (next !== f.value) f.onChange(next);
}
onOpenChange(false);
}
function handleReset() {
setDraft(allCleared(filters));
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="gap-0 p-0 sm:max-w-md" showCloseButton>
<DialogHeader className="border-b border-border px-4 py-3.5">
<DialogTitle>{t("title")}</DialogTitle>
<DialogDescription className="sr-only">{t("title")}</DialogDescription>
</DialogHeader>
<div className="flex max-h-[min(60vh,420px)] flex-col gap-4 overflow-y-auto px-4 py-4">
{filters.map((filter) => (
<div key={filter.id} className="flex flex-col gap-1.5">
<Label htmlFor={`filter-${filter.id}`} className="text-xs font-medium">
{filter.label}
</Label>
<FilterSelect
filter={{
...filter,
allLabel: filter.allLabel ?? tCommon("all"),
}}
value={draft[filter.id] ?? "all"}
onValueChange={(v) => setDraftValue(filter.id, v)}
className="sm:w-full"
/>
</div>
))}
</div>
<DialogFooter className="sm:justify-between">
<Button type="button" variant="ghost" onClick={handleReset}>
{t("reset")}
</Button>
<div className="flex flex-col-reverse gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{tCommon("cancel")}
</Button>
<Button type="button" onClick={handleApply}>
{t("apply")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** In-panel search + optional filters (inline ≤3, modal when more). */
export function TableToolbar({
search,
onSearchChange,
searchPlaceholder = "Search…",
filters: filtersProp,
filterValue,
onFilterChange,
filterOptions,
filterPlaceholder = "Filter",
filterAllLabel = "All",
filterAllLabel,
inlineFilterLimit = DEFAULT_INLINE_LIMIT,
actions,
className,
resultCount,
resultLabel,
}: TableToolbarProps) {
const showFilter = !!filterOptions?.length && !!onFilterChange;
const tCommon = useTranslations("common");
const tFilters = useTranslations("filters");
const [modalOpen, setModalOpen] = useState(false);
const filters = useMemo((): TableFilterDef[] => {
if (filtersProp?.length) return filtersProp;
if (filterOptions?.length && onFilterChange) {
return [
{
id: "legacy",
label: filterPlaceholder,
value: filterValue ?? "all",
onChange: onFilterChange,
options: filterOptions,
allLabel: filterAllLabel ?? tCommon("all"),
placeholder: filterPlaceholder,
},
];
}
return [];
}, [
filtersProp,
filterOptions,
onFilterChange,
filterValue,
filterPlaceholder,
filterAllLabel,
tCommon,
]);
const useModal = filters.length > inlineFilterLimit;
const appliedValues = useMemo(() => valuesFromFilters(filters), [filters]);
const activeCount = countActiveFilters(filters, appliedValues);
return (
<div
@ -67,24 +264,51 @@ export function TableToolbar({
aria-label={searchPlaceholder}
/>
</div>
{showFilter ? (
<Select
value={filterValue ?? "all"}
onValueChange={(v) => onFilterChange?.(v ?? "all")}
{useModal ? (
<>
<Button
type="button"
variant="outline"
className="gap-2 rounded-xl border-border bg-background shadow-none"
onClick={() => setModalOpen(true)}
aria-label={
activeCount > 0
? `${tFilters("title")} (${tFilters("active", { count: activeCount })})`
: tFilters("title")
}
>
<SelectTrigger className="w-full rounded-xl border-border bg-background shadow-none sm:w-[180px]">
<SelectValue placeholder={filterPlaceholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{filterAllLabel}</SelectItem>
{filterOptions!.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<ListFilter className="size-4" />
{tFilters("title")}
{activeCount > 0 ? (
<span
className="inline-flex min-w-5 items-center justify-center rounded-md bg-foreground/10 px-1.5 py-0.5 text-[11px] font-semibold tabular-nums text-foreground"
title={tFilters("active", { count: activeCount })}
>
{activeCount}
</span>
) : null}
</Button>
<FilterModal
open={modalOpen}
onOpenChange={setModalOpen}
filters={filters}
/>
</>
) : (
filters.map((filter) => (
<FilterSelect
key={filter.id}
filter={{
...filter,
allLabel: filter.allLabel ?? tCommon("all"),
}}
value={filter.value}
onValueChange={filter.onChange}
/>
))
)}
{typeof resultCount === "number" ? (
<p className="text-xs text-muted-foreground whitespace-nowrap tabular-nums">
{resultCount}

View File

@ -4,8 +4,10 @@ import type {
MigrationJobStatus,
Role,
} from "@/types";
import type { LocaleCode, SessionUser } from "@/types";
import {
approvals,
demoUsers,
departments,
loyalty,
memberOrders,
@ -18,11 +20,42 @@ import {
spendRows,
statements,
} from "@/mocks/data";
import { canAccessClinical } from "@/lib/auth/session";
import { canAccessClinical, listDemoPersonas } from "@/lib/auth/session";
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
export const mockApi = {
/** GET /v1/me/profile — current session profile stub */
async getProfile(userId: string) {
await delay();
const user = demoUsers.find((u) => u.id === userId);
if (!user) return null;
return {
...user,
orgName: organisation.tradeName,
orgLegalName: organisation.legalName,
};
},
/** PATCH /v1/me/profile — demo locale / display updates */
async updateProfile(
userId: string,
patch: Partial<Pick<SessionUser, "locale" | "name">>,
) {
await delay(180);
const user = demoUsers.find((u) => u.id === userId);
if (!user) throw new Error("NOT_FOUND");
if (patch.locale) user.locale = patch.locale as LocaleCode;
if (patch.name) user.name = patch.name;
return { ...user };
},
/** GET /v1/auth/demo-profiles */
async getDemoProfiles() {
await delay(80);
return listDemoPersonas();
},
async getOrganisation() {
await delay();
return { ...organisation };

View File

@ -115,6 +115,13 @@ export const NAV_ITEMS: NavItem[] = [
roles: ["SUPER_USER", "FINANCE"],
section: "finance",
},
{
href: "/profile",
labelKey: "nav.profile",
icon: UserRound,
roles: ["SUPER_USER", "HR_ADMIN", "FINANCE", "MEMBER"],
section: "account",
},
{
href: "/prescriptions",
labelKey: "nav.prescriptions",

View File

@ -1,5 +1,5 @@
import type { Role, SessionUser } from "@/types";
import { demoUsers } from "@/mocks/data";
import { DEMO_PERSONA_IDS, demoUsers, organisation } from "@/mocks/data";
export const SESSION_COOKIE = "gishen_b2b_session";
export const SESSION_STORAGE_KEY = "gishen_b2b_session";
@ -30,12 +30,68 @@ export const ROLE_META: Record<
},
};
export type DemoPersona = SessionUser & {
personaLabelKey: string;
personaDescKey: string;
};
const PERSONA_META: Record<
string,
{ personaLabelKey: string; personaDescKey: string }
> = {
user_super: {
personaLabelKey: "login.personas.superUser",
personaDescKey: "login.personas.superUserDesc",
},
user_hr: {
personaLabelKey: "login.personas.hrAdmin",
personaDescKey: "login.personas.hrAdminDesc",
},
user_fin: {
personaLabelKey: "login.personas.finance",
personaDescKey: "login.personas.financeDesc",
},
user_mem: {
personaLabelKey: "login.personas.member",
personaDescKey: "login.personas.memberDesc",
},
};
export function getDemoUserById(
id: string,
activeRole?: Role,
): SessionUser {
const user = demoUsers.find((u) => u.id === id);
if (!user) throw new Error(`No demo user for id ${id}`);
const role =
activeRole && user.roles.includes(activeRole)
? activeRole
: user.activeRole;
return { ...user, activeRole: role };
}
export function getDemoUserByRole(role: Role): SessionUser {
const user = demoUsers.find((u) => u.activeRole === role);
const user = demoUsers.find(
(u) => u.activeRole === role || u.roles.includes(role),
);
if (!user) throw new Error(`No demo user for role ${role}`);
return { ...user, activeRole: role };
}
/** Personas for the login test-user dropdown. */
export function listDemoPersonas(): DemoPersona[] {
return DEMO_PERSONA_IDS.map((id) => {
const user = demoUsers.find((u) => u.id === id);
if (!user) throw new Error(`Missing demo persona ${id}`);
const meta = PERSONA_META[id]!;
return { ...user, ...meta };
});
}
export function getDemoOrgName(): string {
return organisation.tradeName;
}
export function canAccessClinical(role: Role): boolean {
return role === "SUPER_USER" || role === "MEMBER";
}

45
src/lib/avatar.ts Normal file
View File

@ -0,0 +1,45 @@
/** Deterministic portrait URL for demo personas / members (DiceBear). */
export function portraitAvatarUrl(seed: string): string {
const safe = encodeURIComponent(seed.trim() || "gishen");
return `https://api.dicebear.com/9.x/lorelei/svg?seed=${safe}&backgroundColor=eef1e3,d4e8a8,c5d97a`;
}
export function getInitials(name: string, fallback = "?"): string {
const parts = name.split(/\s+/).filter(Boolean);
if (parts.length === 0) return fallback;
return parts
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("");
}
/** Resolve avatar URL: explicit value, else deterministic portrait from seed. */
export function resolveAvatarUrl(
avatarUrl: string | undefined,
seed: string,
): string {
return avatarUrl?.trim() || portraitAvatarUrl(seed);
}
/** Pharmacy ID card QR / barcode payload for pharmacist scan. */
export function memberScanPayload(
memberId: string,
verificationId: string,
): string {
return `gishen://member/${memberId}?v=${encodeURIComponent(verificationId)}`;
}
/** Display card ID — prefer cardId, else GSH- from verification / employee. */
export function resolveCardId(opts: {
cardId?: string;
verificationId?: string;
employeeId?: string;
memberId: string;
}): string {
if (opts.cardId) return opts.cardId;
const raw =
opts.verificationId?.replace(/^VID-?/i, "") ||
opts.employeeId?.replace(/^[A-Z]+-?/i, "") ||
opts.memberId.replace(/^mem_?/i, "");
return `GSH-${raw}`;
}

View File

@ -11,6 +11,7 @@ import {
Plus,
Settings2,
Upload,
UserCircle2,
UserRound,
Users,
Wallet,
@ -162,6 +163,15 @@ export const QUICK_DIRECTORY: QuickDirectoryEntry[] = [
keywords: ["orders", "purchases", "history"],
icon: Package,
},
{
id: "profile",
labelKey: "nav.profile",
href: "/profile",
group: "settings",
roles: ["SUPER_USER", "HR_ADMIN", "FINANCE", "MEMBER"],
keywords: ["account", "me", "user", "settings", "avatar"],
icon: UserCircle2,
},
{
id: "verification",
labelKey: "nav.verification",

View File

@ -39,6 +39,12 @@
"results": "ውጤቶች",
"requiredFields": "አስፈላጊ መስኮችን ይሙሉ"
},
"filters": {
"title": "ማጣሪያዎች",
"apply": "ተግብር",
"reset": "አጽዳ",
"active": "{count} ንቁ"
},
"roles": {
"superUser": "ሱፐር ተጠቃሚ",
"superUserDesc": "ሙሉ መዳረሻ — HR, Finance, Packages",
@ -62,7 +68,16 @@
"verification": "ማረጋገጫ",
"myBenefits": "የእኔ ጥቅማጥቅሞች",
"myOrders": "የእኔ ትዕዛዎች",
"prescriptions": "ማዘዛዎች"
"prescriptions": "ማዘዛዎች",
"profile": "መገለጫ",
"sections": {
"overview": "አጠቃላይ",
"organisation": "ድርጅት",
"people": "ሰዎች",
"benefits": "ጥቅማጥቅሞች",
"finance": "ፋይናንስ",
"account": "መለያ"
}
},
"bc": {
"aria": "Breadcrumb",
@ -96,13 +111,15 @@
"prescriptions": "ማዘዛዎች",
"newPrescription": "አዲስ ማዘዣ",
"prescriptionDetail": "የማዘዛ ዝርዝር",
"departmentDetail": "የክፍል ዝርዝር"
"departmentDetail": "የክፍል ዝርዝር",
"profile": "መገለጫ"
},
"tabs": {
"profile": "መገለጫ",
"entitlement": "መብት",
"dependants": "ጥገኞች",
"clinical": "ክሊኒክ",
"idCard": "መታወቂያ ካርድ",
"rules": "ደንቦች",
"perks": "ጥቅሞች",
"summary": "ማጠቃለያ",
@ -114,6 +131,18 @@
"info": "መረጃ",
"basics": "መሠረታዊ"
},
"idCard": {
"title": "የፋርማሲ መታወቂያ ካርድ",
"subtitle": "ለፋርማሲ ማረጋገጫ የሚያገለግል ናሙና ካርድ — ያትሙ እና በግዢ ጊዜ ያሳዩ።",
"pharmacyId": "የሰራተኛ ፋርማሲ መታወቂያ",
"employeeId": "ሰራተኛ",
"cardId": "የካርድ መለያ",
"package": "እቅድ",
"orgCode": "ድርጅት",
"scanHint": "ፋርማሲስት፡ ለማረጋገጥ QR ይቃኙ",
"print": "መታወቂያ አትም",
"printHint": "ህትመት የክሬዲት ካርድ መጠን (85.6 × 54 ሚሜ) ይጠቀማል። ካርዱ ብቻ ይታተማል።"
},
"stats": {
"total": "ጠቅላላ",
"active": "ንቁ",
@ -156,12 +185,42 @@
}
},
"login": {
"title": "የተቋም ፖርታል",
"subtitle": "Demo role switcher — pick a persona.",
"eyebrow": "የተቋም ፖርታል",
"title": "እንኳን ደህና መጡ",
"subtitle": "በሚና የፖርታሉን ለመሞከር የማሳያ ተጠቃሚ ይምረጡ።",
"brandBlurb": "ለድርጅቶች የኮርፖሬት የህክምና ጥቅማጥቅሞች — አባላት፣ ፓኬጆች፣ ፋይናንስ እና እንክብካቤ በአንድ ቦታ።",
"selectUser": "የሙከራ ተጠቃሚ",
"selectUserPlaceholder": "የማሳያ ተጠቃሚ ይምረጡ",
"continueAs": "እንደ {role} ቀጥል",
"getStarted": "ጀምር",
"registerOrg": "ድርጅት ይመዝገቡ",
"requestRegister": "ምዝገባ ይጠይቁ",
"joinWithCode": "በኮድ ይቀላቀሉ"
"joinWithCode": "በኮድ ይቀላቀሉ",
"personas": {
"superUser": "ሱፐር ተጠቃሚ",
"superUserDesc": "ሙሉ መዳረሻ — HR፣ ፋይናንስ፣ ፓኬጆች እና ክሊኒክ። ብዙ ሚና መቀያየር ይቻላል።",
"hrAdmin": "HR አስተዳዳሪ",
"hrAdminDesc": "አባላት፣ ክፍሎች፣ ፓኬጆች እና ማስተላለፍ — ክሊኒክ ተደብቋል።",
"finance": "ፋይናንስ አጽዳቂ",
"financeDesc": "ወጢ፣ መግለጻዎች፣ ክሬዲት እና ማጽደቂያዎች — ክሊኒክ ተደብቋል።",
"member": "አባል",
"memberDesc": "የራስ ጥቅማጥቅሞች፣ ትእዛዎች እና ማዘዛዎች ብቻ።"
}
},
"profile": {
"title": "መገለጫ",
"subtitle": "የማሳያ መለያዎ፣ ንቁ ሚና እና ምርጫዎች።",
"accountDetails": "የመለያ ዝርዝሮች",
"organisation": "ድርጅት",
"activeRole": "ንቁ ሚና",
"localePreference": "የቋንቋ ምርጫ",
"userId": "የተጠቃሚ መለያ",
"switchRole": "ንቁ ሚና ቀይር",
"switchRoleHint": "የሚታዩ የፖርታል ክፍሎችን ይቀይራል። ለብዙ-ሚና የማሳያ ተጠቃሚዎች።",
"roleSwitched": "ወደ {role} ተቀይሯል",
"language": "ቋንቋ",
"languageHint": "ቋንቋ ለመቀየር ከላይ ባለው አሞሌ ላይ ያለውን",
"languageHintSuffix": "ይጠቀሙ።"
},
"dashboard": {
"title": "ዳሽቦርድ",
@ -241,8 +300,9 @@
"sendInvite": "ግብዣ ላክ",
"statusInvited": "ተጋብዟል",
"statusInactive": "እንቅስቃሴ የለውም",
"statusOffboarded": "ተወግዷል"
},
"statusOffboarded": "ተወግዷል",
"withDependants": "ተመራጮች ያሉት",
"withoutDependants": "ተመራጮች የሌሉት" },
"packages": {
"title": "ፓኬጆች",
"subtitle": "Plans, caps, exclusions, perks.",
@ -273,8 +333,10 @@
"stepPreview": "ቅድመ እይታ",
"stepCommit": "አረጋግጥ",
"file": "ፋይል",
"rows": "ረድፎች"
},
"rows": "ረድፎች",
"withErrors": "ስህተት ያለው",
"noErrors": "ስህተት የሌለው",
"createdBy": "ፈጣሪ" },
"finance": {
"title": "ፋይናንስ",
"subtitle": "Spend by dept/member/category — no clinical lines.",
@ -287,7 +349,11 @@
"approve": "Approve",
"flag": "Flag",
"reason": "ምክንያት",
"transactions": "ግብይቶች"
"transactions": "ግብይቶች",
"coverage": "ሽፋን",
"fullyCovered": "የተሸፈነ",
"uncovered": "ያልተሸፈነ",
"period": "ጊዜ"
},
"statements": {
"title": "መግለጻዎች",

View File

@ -39,6 +39,12 @@
"results": "results",
"requiredFields": "Please fill required fields"
},
"filters": {
"title": "Filters",
"apply": "Apply",
"reset": "Reset",
"active": "{count} active"
},
"roles": {
"superUser": "Super User",
"superUserDesc": "Full portal access — HR, finance, packages, and support views",
@ -62,7 +68,16 @@
"verification": "Verification",
"myBenefits": "My benefits",
"myOrders": "My orders",
"prescriptions": "Prescriptions"
"prescriptions": "Prescriptions",
"profile": "Profile",
"sections": {
"overview": "Overview",
"organisation": "Organisation",
"people": "People",
"benefits": "Benefits",
"finance": "Finance",
"account": "Account"
}
},
"bc": {
"aria": "Breadcrumb",
@ -96,13 +111,15 @@
"prescriptions": "Prescriptions",
"newPrescription": "New prescription",
"prescriptionDetail": "Prescription detail",
"departmentDetail": "Department detail"
"departmentDetail": "Department detail",
"profile": "Profile"
},
"tabs": {
"profile": "Profile",
"entitlement": "Entitlement",
"dependants": "Dependants",
"clinical": "Clinical",
"idCard": "ID card",
"rules": "Rules",
"perks": "Perks",
"summary": "Summary",
@ -114,6 +131,18 @@
"info": "Info",
"basics": "Basics"
},
"idCard": {
"title": "Pharmacy ID card",
"subtitle": "Sample badge for pharmacy verification — print and present at checkout.",
"pharmacyId": "Employee pharmacy ID",
"employeeId": "Employee",
"cardId": "Card ID",
"package": "Plan",
"orgCode": "Org",
"scanHint": "Pharmacist: scan QR to verify coverage",
"print": "Print ID card",
"printHint": "Print uses credit-card size (85.6 × 54 mm). Only the card is printed."
},
"stats": {
"total": "Total",
"active": "Active",
@ -156,12 +185,42 @@
}
},
"login": {
"title": "Institutional portal",
"subtitle": "Demo client switcher — pick a persona to explore each experience.",
"eyebrow": "Institutional portal",
"title": "Welcome back",
"subtitle": "Choose a demo persona to explore the portal by role.",
"brandBlurb": "Corporate medical benefits for organisations — members, packages, finance, and care in one place.",
"selectUser": "Test user",
"selectUserPlaceholder": "Select a demo user",
"continueAs": "Continue as {role}",
"getStarted": "Get started",
"registerOrg": "Register organisation",
"requestRegister": "Request registration",
"joinWithCode": "Join with code"
"joinWithCode": "Join with code",
"personas": {
"superUser": "Super User",
"superUserDesc": "Full access across HR, finance, packages, and clinical support views. Multi-role switcher enabled.",
"hrAdmin": "HR Administrator",
"hrAdminDesc": "Members, departments, packages, and migration — clinical detail withheld.",
"finance": "Finance Approver",
"financeDesc": "Spend, statements, credit, and approvals — clinical detail withheld.",
"member": "Member",
"memberDesc": "Own benefits, orders, and prescriptions only."
}
},
"profile": {
"title": "Profile",
"subtitle": "Your demo account, active role, and preferences.",
"accountDetails": "Account details",
"organisation": "Organisation",
"activeRole": "Active role",
"localePreference": "Locale preference",
"userId": "User ID",
"switchRole": "Switch active role",
"switchRoleHint": "Changes which portal areas you see. Available on multi-role demo users.",
"roleSwitched": "Switched to {role}",
"language": "Language",
"languageHint": "Use the language control in the top bar to change",
"languageHintSuffix": "for this session."
},
"dashboard": {
"title": "Dashboard",
@ -241,7 +300,9 @@
"importSubtitle": "Quick Excel path — use Migration for full multi-entity onboarding.",
"statusInvited": "Invited",
"statusInactive": "Inactive",
"statusOffboarded": "Offboarded"
"statusOffboarded": "Offboarded",
"withDependants": "With dependants",
"withoutDependants": "No dependants"
},
"packages": {
"title": "Corporate packages",
@ -273,7 +334,10 @@
"stepMap": "Map columns",
"stepValidate": "Validate",
"stepPreview": "Preview",
"stepCommit": "Commit"
"stepCommit": "Commit",
"withErrors": "With errors",
"noErrors": "No errors",
"createdBy": "Created by"
},
"finance": {
"title": "Finance",
@ -287,7 +351,11 @@
"approve": "Approve",
"flag": "Flag",
"reason": "Reason",
"transactions": "Transactions"
"transactions": "Transactions",
"coverage": "Coverage",
"fullyCovered": "Covered",
"uncovered": "Uncovered",
"period": "Period"
},
"statements": {
"title": "Statements",

View File

@ -12,6 +12,7 @@ import type {
Statement,
LoyaltySummary,
} from "@/types";
import { portraitAvatarUrl } from "@/lib/avatar";
export const DEMO_ORG_ID = "org_yaltopia";
@ -37,16 +38,19 @@ export const organisation: Organisation = {
createdAt: "2025-11-12T10:00:00Z",
};
/** Demo personas for login switcher — distinct users, one per primary role (+ multi-role). */
export const demoUsers: SessionUser[] = [
{
id: "user_super",
email: "super@yaltopia.com",
name: "Selam Super",
roles: ["SUPER_USER"],
roles: ["SUPER_USER", "HR_ADMIN", "FINANCE", "MEMBER"],
activeRole: "SUPER_USER",
orgId: DEMO_ORG_ID,
memberId: "mem_001",
locale: "en",
avatarInitials: "SS",
avatarUrl: portraitAvatarUrl("user_super"),
},
{
id: "user_hr",
@ -57,6 +61,7 @@ export const demoUsers: SessionUser[] = [
orgId: DEMO_ORG_ID,
locale: "en",
avatarInitials: "HH",
avatarUrl: portraitAvatarUrl("user_hr"),
},
{
id: "user_fin",
@ -65,8 +70,9 @@ export const demoUsers: SessionUser[] = [
roles: ["FINANCE"],
activeRole: "FINANCE",
orgId: DEMO_ORG_ID,
locale: "en",
locale: "am",
avatarInitials: "FF",
avatarUrl: portraitAvatarUrl("user_fin"),
},
{
id: "user_mem",
@ -76,11 +82,23 @@ export const demoUsers: SessionUser[] = [
activeRole: "MEMBER",
orgId: DEMO_ORG_ID,
memberId: "mem_001",
locale: "en",
locale: "am",
avatarInitials: "AM",
avatarUrl: portraitAvatarUrl("mem_001"),
},
];
/** Stable order for the login test-user dropdown. */
export const DEMO_PERSONA_IDS = [
"user_super",
"user_hr",
"user_fin",
"user_mem",
] as const;
/** @deprecated Use DEMO_PERSONA_IDS */
export const DEMO_LOGIN_USER_IDS = DEMO_PERSONA_IDS;
export const departments: Department[] = [
{
id: "dept_hq",
@ -185,6 +203,8 @@ export const members: Member[] = [
allowanceRemainingEtb: 2150,
allowanceTotalEtb: 3500,
verificationId: "VID-1001",
cardId: "GSH-1001",
avatarUrl: portraitAvatarUrl("mem_001"),
hasActiveRx: true,
lastRxStatus: "under_review",
dependants: [
@ -221,6 +241,8 @@ export const members: Member[] = [
allowanceRemainingEtb: 800,
allowanceTotalEtb: 2000,
verificationId: "VID-1044",
cardId: "GSH-1044",
avatarUrl: portraitAvatarUrl("mem_002"),
hasActiveRx: false,
dependants: [],
override: {
@ -243,6 +265,9 @@ export const members: Member[] = [
startDate: "2026-04-01",
allowanceRemainingEtb: 1000,
allowanceTotalEtb: 1000,
verificationId: "VID-1102",
cardId: "GSH-1102",
avatarUrl: portraitAvatarUrl("mem_003"),
dependants: [],
},
{
@ -260,6 +285,8 @@ export const members: Member[] = [
allowanceRemainingEtb: 0,
allowanceTotalEtb: 3500,
verificationId: "VID-1088",
cardId: "GSH-1088",
avatarUrl: portraitAvatarUrl("mem_004"),
dependants: [],
},
];

View File

@ -12,13 +12,17 @@ import {
import type { Role, SessionUser } from "@/types";
import {
SESSION_STORAGE_KEY,
getDemoUserById,
getDemoUserByRole,
} from "@/lib/auth/session";
type AuthContextValue = {
user: SessionUser | null;
ready: boolean;
/** @deprecated Prefer loginAsUser — kept for role-only callers */
loginAs: (role: Role) => SessionUser;
loginAsUser: (userId: string, activeRole?: Role) => SessionUser;
setActiveRole: (role: Role) => SessionUser | null;
logout: () => void;
switchDemoUser: () => void;
};
@ -59,12 +63,39 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[persist],
);
const loginAsUser = useCallback(
(userId: string, activeRole?: Role) => {
const next = getDemoUserById(userId, activeRole);
persist(next);
return next;
},
[persist],
);
const setActiveRole = useCallback(
(role: Role) => {
if (!user || !user.roles.includes(role)) return null;
const next = { ...user, activeRole: role };
persist(next);
return next;
},
[persist, user],
);
const logout = useCallback(() => persist(null), [persist]);
const switchDemoUser = useCallback(() => persist(null), [persist]);
const value = useMemo(
() => ({ user, ready, loginAs, logout, switchDemoUser }),
[user, ready, loginAs, logout, switchDemoUser],
() => ({
user,
ready,
loginAs,
loginAsUser,
setActiveRole,
logout,
switchDemoUser,
}),
[user, ready, loginAs, loginAsUser, setActiveRole, logout, switchDemoUser],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@ -49,6 +49,8 @@ export interface SessionUser {
memberId?: string;
locale: LocaleCode;
avatarInitials: string;
/** Optional profile photo; demos use deterministic DiceBear URLs */
avatarUrl?: string;
}
export interface Organisation {
@ -145,6 +147,10 @@ export interface Member {
override?: MemberOverride;
dependants: Dependant[];
verificationId?: string;
/** Pharmacy ID card number shown on printable badge (e.g. GSH-1001) */
cardId?: string;
/** Optional profile photo; demos use deterministic DiceBear URLs */
avatarUrl?: string;
/** Clinical fields — withheld from HR_ADMIN / FINANCE */
hasActiveRx?: boolean;
lastRxStatus?: PrescriptionStatus;