diff --git a/docs/backend/endpoints/auth.md b/docs/backend/endpoints/auth.md index 2e396d9..73fd475 100644 --- a/docs/backend/endpoints/auth.md +++ b/docs/backend/endpoints/auth.md @@ -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 diff --git a/docs/backend/entities/member.md b/docs/backend/entities/member.md index c4186d7..ea1ae88 100644 --- a/docs/backend/entities/member.md +++ b/docs/backend/entities/member.md @@ -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", diff --git a/docs/features/INDEX.md b/docs/features/INDEX.md index aa2ad6b..c7579d0 100644 --- a/docs/features/INDEX.md +++ b/docs/features/INDEX.md @@ -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]` diff --git a/docs/features/auth-roles.md b/docs/features/auth-roles.md index 72d18dc..afabba1 100644 --- a/docs/features/auth-roles.md +++ b/docs/features/auth-roles.md @@ -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 diff --git a/docs/features/member-experience.md b/docs/features/member-experience.md index 5620e98..e856a8c 100644 --- a/docs/features/member-experience.md +++ b/docs/features/member-experience.md @@ -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 diff --git a/docs/features/members.md b/docs/features/members.md index 6ce2bb2..224290e 100644 --- a/docs/features/members.md +++ b/docs/features/members.md @@ -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 diff --git a/docs/features/portal-layout.md b/docs/features/portal-layout.md index b334ef4..c51535d 100644 --- a/docs/features/portal-layout.md +++ b/docs/features/portal-layout.md @@ -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 ? : …} +… +``` ## 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 diff --git a/docs/features/profile.md b/docs/features/profile.md new file mode 100644 index 0000000..b243960 --- /dev/null +++ b/docs/features/profile.md @@ -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) diff --git a/package-lock.json b/package-lock.json index c66e337..86e7abf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 66fd86e..78141e4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/[locale]/(portal)/departments/page.tsx b/src/app/[locale]/(portal)/departments/page.tsx index 7b059f7..b8ebc18 100644 --- a/src/app/[locale]/(portal)/departments/page.tsx +++ b/src/app/[locale]/(portal)/departments/page.tsx @@ -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,30 +65,38 @@ export default function DepartmentsPage() { } > - - } /> - } /> - } - /> - } /> - + {isLoading ? ( + + ) : ( + + } /> + } /> + } + /> + } /> + + )} ({ 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")} /> diff --git a/src/app/[locale]/(portal)/finance/approvals/page.tsx b/src/app/[locale]/(portal)/finance/approvals/page.tsx index 387cf85..3885fb1 100644 --- a/src/app/[locale]/(portal)/finance/approvals/page.tsx +++ b/src/app/[locale]/(portal)/finance/approvals/page.tsx @@ -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 ? ( + + ) : ( + + } /> + } /> + } /> + + )} ({ value: d, label: d })), + }, ]} resultCount={filtered.length} resultLabel={t("common.results")} diff --git a/src/app/[locale]/(portal)/finance/page.tsx b/src/app/[locale]/(portal)/finance/page.tsx index 1c52373..07e9280 100644 --- a/src/app/[locale]/(portal)/finance/page.tsx +++ b/src/app/[locale]/(portal)/finance/page.tsx @@ -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,43 +93,78 @@ export default function FinancePage() { } > - - - s + r.coveredEtb, 0)) - : "—" - } - /> - - + {spendQ.isLoading || orgQ.isLoading ? ( + + ) : ( + + + s + r.coveredEtb, 0)) + : "—" + } + /> + + + )} ({ - value: c, - label: c.charAt(0).toUpperCase() + c.slice(1), - }))} + 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")} /> diff --git a/src/app/[locale]/(portal)/me/orders/page.tsx b/src/app/[locale]/(portal)/me/orders/page.tsx index dd70bc5..9ac2789 100644 --- a/src/app/[locale]/(portal)/me/orders/page.tsx +++ b/src/app/[locale]/(portal)/me/orders/page.tsx @@ -58,20 +58,25 @@ export default function MemberOrdersPage() { > - - } - /> - } - /> - } - /> - + {statsLoading ? ( + + ) : ( + + } + /> + } + /> + } + /> + + )}
{memberQ.isLoading ? ( -

{t("common.loading")}

+
+
+ + +
+
+
+ + +
+ +
+
) : (
@@ -122,7 +142,11 @@ export default function MemberHomePage() { {t("member.loyalty")}

{loyaltyQ.isLoading ? ( -

{t("common.loading")}

+
+ + + +
) : loyaltyQ.data ? (

diff --git a/src/app/[locale]/(portal)/members/[id]/page.tsx b/src/app/[locale]/(portal)/members/[id]/page.tsx index 46f3895..2848d50 100644 --- a/src/app/[locale]/(portal)/members/[id]/page.tsx +++ b/src/app/[locale]/(portal)/members/[id]/page.tsx @@ -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={ - - {initials(member.name)} - - + } title={member.name} subtitle={member.email} @@ -242,6 +242,9 @@ export default function MemberDetailPage() { {tTabs("profile")} {tTabs("entitlement")} {tTabs("dependants")} + {canSeeIdCard ? ( + {tTabs("idCard")} + ) : null} {canSeeClinical ? ( {tTabs("clinical")} ) : null} @@ -330,6 +333,18 @@ export default function MemberDetailPage() { + {canSeeIdCard && orgQ.data ? ( + + }> + + + + ) : null} + {canSeeClinical ? ( }> diff --git a/src/app/[locale]/(portal)/members/page.tsx b/src/app/[locale]/(portal)/members/page.tsx index 6fcdb15..b9648b7 100644 --- a/src/app/[locale]/(portal)/members/page.tsx +++ b/src/app/[locale]/(portal)/members/page.tsx @@ -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 ? ( + + ) : ( + + } /> + } /> + } /> + } /> + + )} ({ + 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) => ( {member.employeeId} - {member.name} + +

+ + {member.name} +
+ {deptMap.get(member.departmentId) ?? "—"} {pkgMap.get(member.packageId) ?? "—"} diff --git a/src/app/[locale]/(portal)/migration/page.tsx b/src/app/[locale]/(portal)/migration/page.tsx index 02e1b27..b84733e 100644 --- a/src/app/[locale]/(portal)/migration/page.tsx +++ b/src/app/[locale]/(portal)/migration/page.tsx @@ -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,33 +82,72 @@ export default function MigrationPage() { } > - - } /> - } /> - - } - /> - + {isLoading ? ( + + ) : ( + + } /> + } /> + + } + /> + + )} ({ + 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")} diff --git a/src/app/[locale]/(portal)/packages/page.tsx b/src/app/[locale]/(portal)/packages/page.tsx index 26bf869..de23236 100644 --- a/src/app/[locale]/(portal)/packages/page.tsx +++ b/src/app/[locale]/(portal)/packages/page.tsx @@ -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() { } > - - } /> - - } /> - } /> - + {isLoading ? ( + + ) : ( + + } /> + + } /> + } /> + + )} 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 ( + {dashLoading ? ( + + ) : ( + <>
+ + )} ); } diff --git a/src/app/[locale]/(portal)/prescriptions/page.tsx b/src/app/[locale]/(portal)/prescriptions/page.tsx index b7f9ca7..6ed4075 100644 --- a/src/app/[locale]/(portal)/prescriptions/page.tsx +++ b/src/app/[locale]/(portal)/prescriptions/page.tsx @@ -70,21 +70,26 @@ export default function PrescriptionsPage() { > 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 ( + + + } + title={user.name} + subtitle={user.email} + meta={ + + {t(ROLE_META[user.activeRole].labelKey)} + + } + actions={ + <> + + + + } + > + + + + {orgName} + + + + + + {localeLabel} + + + + } + > + } + > +
+ + {user.name} + + + {user.email} + + + {t(ROLE_META[user.activeRole].labelKey)} + + + {orgName} + + + {localeLabel} + + + {user.id} + +
+
+ + {multiRole ? ( + } + > +
+ + +

+ {t("profile.switchRoleHint")} +

+
+
+ ) : null} + + + + {t("profile.languageHint")}{" "} + + {t("common.language")} + {" "} + {t("profile.languageHintSuffix")} + + +
+
+ ); +} diff --git a/src/app/[locale]/(portal)/settings/verification/page.tsx b/src/app/[locale]/(portal)/settings/verification/page.tsx index 585f5cb..2eb7e26 100644 --- a/src/app/[locale]/(portal)/settings/verification/page.tsx +++ b/src/app/[locale]/(portal)/settings/verification/page.tsx @@ -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 ? ( -

{t("common.loading")}

+ ) : ( diff --git a/src/app/[locale]/(portal)/statements/page.tsx b/src/app/[locale]/(portal)/statements/page.tsx index c6282b4..e8ba143 100644 --- a/src/app/[locale]/(portal)/statements/page.tsx +++ b/src/app/[locale]/(portal)/statements/page.tsx @@ -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,36 +52,45 @@ export default function StatementsPage() { return ( - - } - /> - } - /> - } /> - - + {isLoading ? ( + + ) : ( + + } + /> + } + /> + } /> + + + )} (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 ( -
-
-
- {brand.shortName} -
-

{t("common.appName")}

-

{brand.tagline}

-
-
- +
+ {/* Atmospheric background */} +
+
+
+
-
-
-

- {t("login.title")} -

-

{t("login.subtitle")}

+ {/* Left brand panel — Yimaru-style split */} +
+
+
+
+
+
+
+ {brand.shortName} +

+ {t("common.appName")} +

+

+ {t("login.brandBlurb")} +

+
+
-
- {ROLES.map((role) => ( - - -
- {t(ROLE_META[role].labelKey)} - {role} -
- {t(ROLE_META[role].descriptionKey)} -
- - - -
- ))} -
+ {/* Right form panel */} +
+
+
- - - Get started - - Organisations can self-register or request assisted onboarding. - - - - - - - - +
+ +
+

+ {t("login.getStarted")} +

+
+ + + +
+
+ +

+ © {new Date().getFullYear()} {brand.name} +

+
); diff --git a/src/app/globals.css b/src/app/globals.css index a294b7a..7da6b74 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; + } +} diff --git a/src/components/charts/portal-charts.tsx b/src/components/charts/portal-charts.tsx index 15279c2..8eeff5b 100644 --- a/src/components/charts/portal-charts.tsx +++ b/src/components/charts/portal-charts.tsx @@ -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 ( - + {title} {description ? (

{description}

) : null}
- {children} + {children}
); } @@ -55,7 +75,7 @@ export function SpendTrendChart({ return (
- + @@ -64,7 +84,7 @@ export function SpendTrendChart({ - + - + - + -
diff --git a/src/components/members/member-id-card.tsx b/src/components/members/member-id-card.tsx new file mode 100644 index 0000000..cfaff57 --- /dev/null +++ b/src/components/members/member-id-card.tsx @@ -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; + 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 ( +
+ {!hideActions ? ( +
+
+

{t("title")}

+

{t("subtitle")}

+
+ +
+ ) : null} + + {/* Screen preview + print target */} +
+
+ {/* Accent stripe */} +
+
+
+ +
+ {/* Header */} +
+
+ {brand.shortName} +
+

+ {brand.shortName} +

+

+ {t("pharmacyId")} +

+
+
+ +
+ + {/* Body */} +
+
+ {/* External DiceBear portrait — not next/image remotePatterns */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {member.name} +
+ +
+

+ {member.name} +

+

+ {organisation.tradeName} +

+
+
+
{t("employeeId")}
+
+ {member.employeeId} +
+
+
+
{t("cardId")}
+
{cardId}
+
+ {packageName ? ( +
+
{t("package")}
+
{packageName}
+
+ ) : null} +
+
+ +
+
+ +
+ + {verificationId} + +
+
+ + {/* Footer */} +
+

+ {t("scanHint")} + {organisation.joinCode + ? ` · ${t("orgCode")}: ${organisation.joinCode}` + : null} +

+

+ {cardId} +

+
+
+
+
+ +

+ {t("printHint")} +

+
+ ); +} diff --git a/src/components/shared/user-avatar.tsx b/src/components/shared/user-avatar.tsx new file mode 100644 index 0000000..606111f --- /dev/null +++ b/src/components/shared/user-avatar.tsx @@ -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 ( + + + {label} + + ); +} diff --git a/src/components/table/table-panel.tsx b/src/components/table/table-panel.tsx index 7c838db..b15f419 100644 --- a/src/components/table/table-panel.tsx +++ b/src/components/table/table-panel.tsx @@ -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 ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ + + + +
+ ))} +
+ ); +} + /** * 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 (
{toolbar} - {loading ? ( -
-

{loadingLabel}

-
- ) : ( - children - )} + {loading ? : children}
); } diff --git a/src/components/table/table-toolbar.tsx b/src/components/table/table-toolbar.tsx index ddd1911..d586f57 100644 --- a/src/components/table/table-toolbar.tsx +++ b/src/components/table/table-toolbar.tsx @@ -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 ( + + ); +} + +function countActiveFilters(filters: TableFilterDef[], values: Record) { + 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 { + return Object.fromEntries(filters.map((f) => [f.id, f.value || "all"])); +} + +function allCleared(filters: TableFilterDef[]): Record { + 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>(() => + 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 ( + + + + {t("title")} + {t("title")} + + +
+ {filters.map((filter) => ( +
+ + setDraftValue(filter.id, v)} + className="sm:w-full" + /> +
+ ))} +
+ + + +
+ + +
+
+
+
+ ); +} + +/** 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 (
- {showFilter ? ( - - ) : null} + + {useModal ? ( + <> + + + + ) : ( + filters.map((filter) => ( + + )) + )} + {typeof resultCount === "number" ? (

{resultCount} diff --git a/src/lib/api/mock-client.ts b/src/lib/api/mock-client.ts index fd983f4..999fa5a 100644 --- a/src/lib/api/mock-client.ts +++ b/src/lib/api/mock-client.ts @@ -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>, + ) { + 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 }; diff --git a/src/lib/auth/nav.ts b/src/lib/auth/nav.ts index 9d4a820..4e1e564 100644 --- a/src/lib/auth/nav.ts +++ b/src/lib/auth/nav.ts @@ -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", diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index f3a08ba..7ba5869 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -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"; } diff --git a/src/lib/avatar.ts b/src/lib/avatar.ts new file mode 100644 index 0000000..5458801 --- /dev/null +++ b/src/lib/avatar.ts @@ -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}`; +} diff --git a/src/lib/nav/quick-directory.ts b/src/lib/nav/quick-directory.ts index a855146..8aa042d 100644 --- a/src/lib/nav/quick-directory.ts +++ b/src/lib/nav/quick-directory.ts @@ -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", diff --git a/src/messages/am.json b/src/messages/am.json index 5b2e8c6..e8494fc 100644 --- a/src/messages/am.json +++ b/src/messages/am.json @@ -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": "መግለጻዎች", diff --git a/src/messages/en.json b/src/messages/en.json index d5f3c2f..66dd930 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -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", diff --git a/src/mocks/data.ts b/src/mocks/data.ts index 3651a0f..2620471 100644 --- a/src/mocks/data.ts +++ b/src/mocks/data.ts @@ -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: [], }, ]; diff --git a/src/providers/auth-provider.tsx b/src/providers/auth-provider.tsx index 507ba6f..783d223 100644 --- a/src/providers/auth-provider.tsx +++ b/src/providers/auth-provider.tsx @@ -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 {children}; diff --git a/src/types/index.ts b/src/types/index.ts index 37ab34c..ff64056 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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;