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:
parent
3778801ef5
commit
1f30f00da6
|
|
@ -10,7 +10,7 @@ Session and user preference endpoints for Gishen-B2B demo login and future produ
|
||||||
|
|
||||||
## POST /v1/auth/login
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
"demo_role": "HR_ADMIN",
|
"demo_user_id": "user_hr",
|
||||||
"locale": "en"
|
"locale": "en"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Legacy: `{ "demo_role": "HR_ADMIN", "locale": "en" }`
|
||||||
`demo_role`: `SUPER_USER` \| `HR_ADMIN` \| `FINANCE` \| `MEMBER`
|
`demo_role`: `SUPER_USER` \| `HR_ADMIN` \| `FINANCE` \| `MEMBER`
|
||||||
|
|
||||||
Production (future): `{ "email", "password" }` or OAuth code exchange.
|
Production (future): `{ "email", "password" }` or OAuth code exchange.
|
||||||
|
|
@ -147,7 +148,7 @@ N/A.
|
||||||
|
|
||||||
## GET /v1/auth/demo-profiles
|
## 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": [
|
"data": [
|
||||||
{
|
{
|
||||||
|
"id": "user_super",
|
||||||
|
"email": "super@yaltopia.com",
|
||||||
|
"full_name": "Selam Super",
|
||||||
"demo_role": "SUPER_USER",
|
"demo_role": "SUPER_USER",
|
||||||
|
"roles": ["SUPER_USER", "HR_ADMIN", "FINANCE", "MEMBER"],
|
||||||
"label": "Super User",
|
"label": "Super User",
|
||||||
"description": "Full portal access including clinical detail"
|
"description": "Full portal access including clinical detail"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "user_hr",
|
||||||
"demo_role": "HR_ADMIN",
|
"demo_role": "HR_ADMIN",
|
||||||
"label": "HR Administrator",
|
"label": "HR Administrator",
|
||||||
"description": "Members, departments, packages — no clinical detail"
|
"description": "Members, departments, packages — no clinical detail"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "user_fin",
|
||||||
"demo_role": "FINANCE",
|
"demo_role": "FINANCE",
|
||||||
"label": "Finance Approver",
|
"label": "Finance Approver",
|
||||||
"description": "Spend, statements, approvals — no clinical detail"
|
"description": "Spend, statements, approvals — no clinical detail"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"id": "user_mem",
|
||||||
"demo_role": "MEMBER",
|
"demo_role": "MEMBER",
|
||||||
"label": "Member",
|
"label": "Member",
|
||||||
"description": "Own allowance, orders, prescriptions"
|
"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
|
## UI routes
|
||||||
|
|
||||||
| Route | Purpose |
|
| 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
|
## Clinical withhold notes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ Employee or dependant enrolled under an organisation's corporate medical benefit
|
||||||
| `start_date` | `date` | | Coverage start |
|
| `start_date` | `date` | | Coverage start |
|
||||||
| `end_date` | `date` \| null | | Offboarding |
|
| `end_date` | `date` \| null | | Offboarding |
|
||||||
| `overrides` | `MemberOverrides` | | Per-member entitlement tweaks |
|
| `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 |
|
| `invite_id` | `string` | | If joined via invite |
|
||||||
| `allowance_summary` | `AllowanceSummary` | | Read-only computed |
|
| `allowance_summary` | `AllowanceSummary` | | Read-only computed |
|
||||||
| `prescription_count` | `integer` | | Aggregate only for HR/Finance |
|
| `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 |
|
| Field | Visible |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `full_name`, `email`, `phone`, `employee_id`, `department_id`, `package_id`, `status` | ✓ |
|
| `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) | ✓ |
|
| `allowance_summary` (amounts only) | ✓ |
|
||||||
| `prescription_count` | ✓ (count only) |
|
| `prescription_count` | ✓ (count only) |
|
||||||
| Prescription list / images / medicine lines | ✗ |
|
| Prescription list / images / medicine lines | ✗ |
|
||||||
|
|
@ -117,6 +120,8 @@ SUPER_USER and MEMBER (own record) receive full clinical linkage via prescriptio
|
||||||
"end_date": null,
|
"end_date": null,
|
||||||
"overrides": null,
|
"overrides": null,
|
||||||
"verification_id": "EMP-1042",
|
"verification_id": "EMP-1042",
|
||||||
|
"card_id": "GSH-1042",
|
||||||
|
"avatar_url": "https://api.dicebear.com/9.x/lorelei/svg?seed=mbr_01HMEM001",
|
||||||
"allowance_summary": {
|
"allowance_summary": {
|
||||||
"period_start": "2026-03-01",
|
"period_start": "2026-03-01",
|
||||||
"period_end": "2026-03-31",
|
"period_end": "2026-03-31",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ Feature → pages → entities → endpoints mapping for Gishen-B2B §3 features
|
||||||
| Feature | Pages | Entities | Endpoints | Feature doc |
|
| Feature | Pages | Entities | Endpoints | Feature doc |
|
||||||
|---------|-------|----------|-----------|---------------|
|
|---------|-------|----------|-----------|---------------|
|
||||||
| Auth & roles | `/login` | SessionUser | `auth.md` | [auth-roles.md](auth-roles.md) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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 |
|
| Feature | SUPER_USER | HR_ADMIN | FINANCE | MEMBER |
|
||||||
|---------|:----------:|:--------:|:-------:|:------:|
|
|---------|:----------:|:--------:|:-------:|:------:|
|
||||||
| Auth & roles | ✓ | ✓ | ✓ | ✓ |
|
| Auth & roles | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| Profile | ✓ | ✓ | ✓ | ✓ |
|
||||||
| i18n | ✓ | ✓ | ✓ | ✓ |
|
| i18n | ✓ | ✓ | ✓ | ✓ |
|
||||||
| Org registration | ✓ (public) | — | — | — |
|
| Org registration | ✓ (public) | — | — | — |
|
||||||
| Org & departments | ✓ | ✓ | read | — |
|
| Org & departments | ✓ | ✓ | read | — |
|
||||||
|
|
@ -77,6 +79,7 @@ See [`../coordination/`](../coordination/).
|
||||||
All §3 routes from product brief:
|
All §3 routes from product brief:
|
||||||
|
|
||||||
- [x] `/login`
|
- [x] `/login`
|
||||||
|
- [x] `/profile`
|
||||||
- [x] `/register/organisation`, `/register/request`
|
- [x] `/register/organisation`, `/register/request`
|
||||||
- [x] `/organisation`, `/departments`, `/departments/new`, `/departments/[id]`, `/settings/verification`
|
- [x] `/organisation`, `/departments`, `/departments/new`, `/departments/[id]`, `/settings/verification`
|
||||||
- [x] `/migration`, `/migration/new`, `/migration/[jobId]`
|
- [x] `/migration`, `/migration/new`, `/migration/[jobId]`
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ Demo login with role selector, session management, and role-filtered navigation.
|
||||||
|
|
||||||
| Route | Purpose |
|
| 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
|
## Roles
|
||||||
|
|
||||||
|
|
@ -33,12 +34,13 @@ Demo login with role selector, session management, and role-filtered navigation.
|
||||||
|
|
||||||
## Demo login UX
|
## Demo login UX
|
||||||
|
|
||||||
1. User selects persona: Super User, HR Administrator, Finance Approver, Member.
|
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).
|
2. Optional locale toggle (en/am) on the login panel.
|
||||||
3. "Continue as …" sets mock session and redirects to role-appropriate home.
|
3. Continue sets session via `loginAsUser(id)` and redirects to that user’s home path.
|
||||||
4. Topbar profile menu includes "Switch demo user" → returns to `/login`.
|
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
|
## Route guards
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,16 @@ Allowance remaining: 10,800 ETB
|
||||||
|
|
||||||
See [`open-items.md`](../coordination/open-items.md).
|
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
|
## Platform notes
|
||||||
|
|
||||||
- Web upload mirrors Ecom Rx UX
|
- Web upload mirrors Ecom Rx UX
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ Employee and dependant enrolment — CRUD, quick import, invites, join codes, of
|
||||||
| `/members` | Searchable/filterable member list |
|
| `/members` | Searchable/filterable member list |
|
||||||
| `/members/new` | Invite / create employee (department + package) |
|
| `/members/new` | Invite / create employee (department + package) |
|
||||||
| `/members/import` | Quick Excel/CSV import |
|
| `/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 |
|
| `/join` | Self-join via join code or email domain |
|
||||||
| `/invite/[token]` | Accept invite and set password |
|
| `/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
|
- Name, contact, employee ID, department, package, status
|
||||||
- Allowance summary (amounts)
|
- Allowance summary (amounts)
|
||||||
- `prescription_count` (integer only)
|
- `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.
|
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
|
## UI components
|
||||||
|
|
||||||
- DataTable with toolbar (search, status filter, department filter)
|
- 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
|
- Import dropzone with template download
|
||||||
- Invite link copy + resend
|
- Invite link copy + resend
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
| `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` |
|
| `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 |
|
| 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
|
## 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`.
|
- **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`.
|
- **Header row:** muted background, compact uppercase labels (`text-[11px]`, tracking), `px-4`.
|
||||||
- **Body:** comfortable `py-4` rows, mint-tinted hover, last row border removed.
|
- **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`).
|
- **Mobile:** horizontal scroll via the table container (`overflow-x-auto`).
|
||||||
- **Detail embeds:** prefer `DetailTableSection` (same panel chrome) with flush table primitives.
|
- **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
|
## Tabs
|
||||||
|
|
||||||
|
|
|
||||||
46
docs/features/profile.md
Normal file
46
docs/features/profile.md
Normal 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
10
package-lock.json
generated
|
|
@ -18,6 +18,7 @@
|
||||||
"next": "16.3.0",
|
"next": "16.3.0",
|
||||||
"next-intl": "^4.13.5",
|
"next-intl": "^4.13.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"react-hook-form": "^7.84.0",
|
"react-hook-form": "^7.84.0",
|
||||||
|
|
@ -9132,6 +9133,15 @@
|
||||||
"node": ">=6"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.3",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"next": "16.3.0",
|
"next": "16.3.0",
|
||||||
"next-intl": "^4.13.5",
|
"next-intl": "^4.13.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"react-hook-form": "^7.84.0",
|
"react-hook-form": "^7.84.0",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { PageShell } from "@/components/layout/page-shell";
|
import { PageShell } from "@/components/layout/page-shell";
|
||||||
|
import { PageStatSkeleton } from "@/components/layout/page-loading";
|
||||||
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
||||||
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
|
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
|
||||||
import { TablePanel } from "@/components/table/table-panel";
|
import { TablePanel } from "@/components/table/table-panel";
|
||||||
|
|
@ -64,30 +65,38 @@ export default function DepartmentsPage() {
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StatCardGrid cols={4}>
|
{isLoading ? (
|
||||||
<StatCard label={t("stats.total")} value={stats.total} icon={<Building2 />} />
|
<PageStatSkeleton cols={4} />
|
||||||
<StatCard label={t("stats.sites")} value={stats.sites} icon={<MapPin />} />
|
) : (
|
||||||
<StatCard
|
<StatCardGrid cols={4}>
|
||||||
label={t("stats.totalSpend")}
|
<StatCard label={t("stats.total")} value={stats.total} icon={<Building2 />} />
|
||||||
value={formatEtb(stats.spend)}
|
<StatCard label={t("stats.sites")} value={stats.sites} icon={<MapPin />} />
|
||||||
icon={<Wallet />}
|
<StatCard
|
||||||
/>
|
label={t("stats.totalSpend")}
|
||||||
<StatCard label={t("departments.members")} value={stats.members} icon={<Users />} />
|
value={formatEtb(stats.spend)}
|
||||||
</StatCardGrid>
|
icon={<Wallet />}
|
||||||
|
/>
|
||||||
|
<StatCard label={t("departments.members")} value={stats.members} icon={<Users />} />
|
||||||
|
</StatCardGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
<TablePanel
|
<TablePanel
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
loadingLabel={t("common.loading")}
|
|
||||||
toolbar={
|
toolbar={
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder={t("common.searchPlaceholder")}
|
searchPlaceholder={t("common.searchPlaceholder")}
|
||||||
filterValue={siteFilter}
|
filters={[
|
||||||
onFilterChange={setSiteFilter}
|
{
|
||||||
filterPlaceholder={t("departments.site")}
|
id: "site",
|
||||||
filterAllLabel={t("common.all")}
|
label: t("departments.site"),
|
||||||
filterOptions={sites.map((s) => ({ value: s, label: s }))}
|
value: siteFilter,
|
||||||
|
onChange: setSiteFilter,
|
||||||
|
allLabel: t("common.all"),
|
||||||
|
options: sites.map((s) => ({ value: s, label: s })),
|
||||||
|
},
|
||||||
|
]}
|
||||||
resultCount={filtered.length}
|
resultCount={filtered.length}
|
||||||
resultLabel={t("common.results")}
|
resultLabel={t("common.results")}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { PageShell } from "@/components/layout/page-shell";
|
import { PageShell } from "@/components/layout/page-shell";
|
||||||
|
import { PageStatSkeleton } from "@/components/layout/page-loading";
|
||||||
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
||||||
import { StatusBadge } from "@/components/shared/status-badge";
|
import { StatusBadge } from "@/components/shared/status-badge";
|
||||||
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
|
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
|
||||||
|
|
@ -27,21 +28,28 @@ export default function FinanceApprovalsPage() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
const [deptFilter, setDeptFilter] = useState("all");
|
||||||
|
|
||||||
const { data: approvals, isLoading } = useQuery({
|
const { data: approvals, isLoading } = useQuery({
|
||||||
queryKey: ["approvals"],
|
queryKey: ["approvals"],
|
||||||
queryFn: mockApi.getApprovals,
|
queryFn: mockApi.getApprovals,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const departments = useMemo(() => {
|
||||||
|
const set = new Set((approvals ?? []).map((a) => a.departmentName));
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}, [approvals]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return (approvals ?? []).filter((ap) => {
|
return (approvals ?? []).filter((ap) => {
|
||||||
if (statusFilter !== "all" && ap.status !== statusFilter) return false;
|
if (statusFilter !== "all" && ap.status !== statusFilter) return false;
|
||||||
|
if (deptFilter !== "all" && ap.departmentName !== deptFilter) return false;
|
||||||
return matchesSearch(
|
return matchesSearch(
|
||||||
[ap.memberName, ap.departmentName, ap.reason],
|
[ap.memberName, ap.departmentName, ap.reason],
|
||||||
search,
|
search,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [approvals, search, statusFilter]);
|
}, [approvals, search, statusFilter, deptFilter]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const rows = approvals ?? [];
|
const rows = approvals ?? [];
|
||||||
|
|
@ -76,28 +84,45 @@ export default function FinanceApprovalsPage() {
|
||||||
description={t("finance.approvalsSubtitle")}
|
description={t("finance.approvalsSubtitle")}
|
||||||
breadcrumb
|
breadcrumb
|
||||||
>
|
>
|
||||||
<StatCardGrid cols={3}>
|
{isLoading ? (
|
||||||
<StatCard label={t("common.pending")} value={stats.pending} icon={<Clock />} />
|
<PageStatSkeleton cols={3} />
|
||||||
<StatCard label={t("finance.approve")} value={stats.approved} icon={<CheckCircle2 />} />
|
) : (
|
||||||
<StatCard label={t("finance.flag")} value={stats.flagged} icon={<AlertTriangle />} />
|
<StatCardGrid cols={3}>
|
||||||
</StatCardGrid>
|
<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
|
<TablePanel
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
loadingLabel={t("common.loading")}
|
|
||||||
toolbar={
|
toolbar={
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder={t("common.searchPlaceholder")}
|
searchPlaceholder={t("common.searchPlaceholder")}
|
||||||
filterValue={statusFilter}
|
filters={[
|
||||||
onFilterChange={setStatusFilter}
|
{
|
||||||
filterAllLabel={t("common.all")}
|
id: "status",
|
||||||
filterOptions={[
|
label: t("common.status"),
|
||||||
{ value: "pending", label: t("common.pending") },
|
value: statusFilter,
|
||||||
{ value: "approved", label: t("finance.approve") },
|
onChange: setStatusFilter,
|
||||||
{ value: "flagged", label: t("finance.flag") },
|
allLabel: t("common.all"),
|
||||||
{ value: "rejected", label: "Rejected" },
|
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}
|
resultCount={filtered.length}
|
||||||
resultLabel={t("common.results")}
|
resultLabel={t("common.results")}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { PageShell } from "@/components/layout/page-shell";
|
import { PageShell } from "@/components/layout/page-shell";
|
||||||
|
import { PageStatSkeleton } from "@/components/layout/page-loading";
|
||||||
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
||||||
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
|
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
|
||||||
import { TablePanel } from "@/components/table/table-panel";
|
import { TablePanel } from "@/components/table/table-panel";
|
||||||
|
|
@ -26,6 +27,9 @@ export default function FinancePage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
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 orgQ = useQuery({ queryKey: ["org"], queryFn: mockApi.getOrganisation });
|
||||||
const spendQ = useQuery({ queryKey: ["spend"], queryFn: mockApi.getSpend });
|
const spendQ = useQuery({ queryKey: ["spend"], queryFn: mockApi.getSpend });
|
||||||
|
|
@ -35,15 +39,40 @@ export default function FinancePage() {
|
||||||
return Array.from(set).sort();
|
return Array.from(set).sort();
|
||||||
}, [spendQ.data]);
|
}, [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(() => {
|
const filtered = useMemo(() => {
|
||||||
return (spendQ.data ?? []).filter((row) => {
|
return (spendQ.data ?? []).filter((row) => {
|
||||||
if (categoryFilter !== "all" && row.category !== categoryFilter) return false;
|
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(
|
return matchesSearch(
|
||||||
[row.memberName, row.departmentName, row.category],
|
[row.memberName, row.departmentName, row.category],
|
||||||
search,
|
search,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [spendQ.data, search, categoryFilter]);
|
}, [
|
||||||
|
spendQ.data,
|
||||||
|
search,
|
||||||
|
categoryFilter,
|
||||||
|
deptFilter,
|
||||||
|
coverageFilter,
|
||||||
|
periodFilter,
|
||||||
|
]);
|
||||||
|
|
||||||
function handleExport() {
|
function handleExport() {
|
||||||
toast.success(t("common.export"));
|
toast.success(t("common.export"));
|
||||||
|
|
@ -64,43 +93,78 @@ export default function FinancePage() {
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StatCardGrid cols={3}>
|
{spendQ.isLoading || orgQ.isLoading ? (
|
||||||
<StatCard
|
<PageStatSkeleton cols={3} />
|
||||||
label={t("finance.creditUsage")}
|
) : (
|
||||||
value={`${creditPct}%`}
|
<StatCardGrid cols={3}>
|
||||||
hint={
|
<StatCard
|
||||||
orgQ.data
|
label={t("finance.creditUsage")}
|
||||||
? `${formatEtb(creditUsed)} of ${formatEtb(creditLimit)}`
|
value={`${creditPct}%`}
|
||||||
: undefined
|
hint={
|
||||||
}
|
orgQ.data
|
||||||
/>
|
? `${formatEtb(creditUsed)} of ${formatEtb(creditLimit)}`
|
||||||
<StatCard
|
: undefined
|
||||||
label={t("statements.covered")}
|
}
|
||||||
value={
|
/>
|
||||||
spendQ.data
|
<StatCard
|
||||||
? formatEtb(spendQ.data.reduce((s, r) => s + r.coveredEtb, 0))
|
label={t("statements.covered")}
|
||||||
: "—"
|
value={
|
||||||
}
|
spendQ.data
|
||||||
/>
|
? formatEtb(spendQ.data.reduce((s, r) => s + r.coveredEtb, 0))
|
||||||
<StatCard label={t("finance.transactions")} value={filtered.length} />
|
: "—"
|
||||||
</StatCardGrid>
|
}
|
||||||
|
/>
|
||||||
|
<StatCard label={t("finance.transactions")} value={filtered.length} />
|
||||||
|
</StatCardGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
<TablePanel
|
<TablePanel
|
||||||
loading={spendQ.isLoading}
|
loading={spendQ.isLoading}
|
||||||
loadingLabel={t("common.loading")}
|
|
||||||
toolbar={
|
toolbar={
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder={t("common.searchPlaceholder")}
|
searchPlaceholder={t("common.searchPlaceholder")}
|
||||||
filterValue={categoryFilter}
|
filters={[
|
||||||
onFilterChange={setCategoryFilter}
|
{
|
||||||
filterPlaceholder={t("finance.byCategory")}
|
id: "category",
|
||||||
filterAllLabel={t("common.all")}
|
label: t("finance.byCategory"),
|
||||||
filterOptions={categories.map((c) => ({
|
value: categoryFilter,
|
||||||
value: c,
|
onChange: setCategoryFilter,
|
||||||
label: c.charAt(0).toUpperCase() + c.slice(1),
|
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}
|
resultCount={filtered.length}
|
||||||
resultLabel={t("common.results")}
|
resultLabel={t("common.results")}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -58,20 +58,25 @@ export default function MemberOrdersPage() {
|
||||||
>
|
>
|
||||||
<TablePanel
|
<TablePanel
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
loadingLabel={t("common.loading")}
|
|
||||||
toolbar={
|
toolbar={
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder={t("common.searchPlaceholder")}
|
searchPlaceholder={t("common.searchPlaceholder")}
|
||||||
filterValue={statusFilter}
|
filters={[
|
||||||
onFilterChange={setStatusFilter}
|
{
|
||||||
filterAllLabel={t("common.all")}
|
id: "status",
|
||||||
filterOptions={[
|
label: t("common.status"),
|
||||||
{ value: "placed", label: "Placed" },
|
value: statusFilter,
|
||||||
{ value: "processing", label: "Processing" },
|
onChange: setStatusFilter,
|
||||||
{ value: "delivered", label: "Delivered" },
|
allLabel: t("common.all"),
|
||||||
{ value: "cancelled", label: "Cancelled" },
|
options: [
|
||||||
|
{ value: "placed", label: "Placed" },
|
||||||
|
{ value: "processing", label: "Processing" },
|
||||||
|
{ value: "delivered", label: "Delivered" },
|
||||||
|
{ value: "cancelled", label: "Cancelled" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
resultCount={filtered.length}
|
resultCount={filtered.length}
|
||||||
resultLabel={t("common.results")}
|
resultLabel={t("common.results")}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ import { useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { PageShell } from "@/components/layout/page-shell";
|
import { PageShell } from "@/components/layout/page-shell";
|
||||||
|
import { PageStatSkeleton } from "@/components/layout/page-loading";
|
||||||
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
||||||
import {
|
import {
|
||||||
DETAIL_PANEL,
|
DETAIL_PANEL,
|
||||||
DetailSection,
|
DetailSection,
|
||||||
} from "@/components/layout/detail-layout";
|
} from "@/components/layout/detail-layout";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
import { mockApi } from "@/lib/api/mock-client";
|
import { mockApi } from "@/lib/api/mock-client";
|
||||||
import { formatEtb } from "@/lib/format";
|
import { formatEtb } from "@/lib/format";
|
||||||
|
|
@ -48,33 +50,39 @@ export default function MemberHomePage() {
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
|
const statsLoading = memberQ.isLoading || loyaltyQ.isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title={t("member.homeTitle")} description={t("member.homeSubtitle")}>
|
<PageShell title={t("member.homeTitle")} description={t("member.homeSubtitle")}>
|
||||||
<StatCardGrid cols={3}>
|
{statsLoading ? (
|
||||||
<StatCard
|
<PageStatSkeleton cols={3} />
|
||||||
label={t("member.remaining")}
|
) : (
|
||||||
value={
|
<StatCardGrid cols={3}>
|
||||||
memberQ.data ? formatEtb(memberQ.data.allowanceRemainingEtb) : "—"
|
<StatCard
|
||||||
}
|
label={t("member.remaining")}
|
||||||
hint={
|
value={
|
||||||
memberQ.data
|
memberQ.data ? formatEtb(memberQ.data.allowanceRemainingEtb) : "—"
|
||||||
? `of ${formatEtb(memberQ.data.allowanceTotalEtb)}`
|
}
|
||||||
: undefined
|
hint={
|
||||||
}
|
memberQ.data
|
||||||
icon={<Wallet />}
|
? `of ${formatEtb(memberQ.data.allowanceTotalEtb)}`
|
||||||
/>
|
: undefined
|
||||||
<StatCard
|
}
|
||||||
label={t("member.plan")}
|
icon={<Wallet />}
|
||||||
value={pkgQ.data?.name ?? "—"}
|
/>
|
||||||
icon={<Package />}
|
<StatCard
|
||||||
/>
|
label={t("member.plan")}
|
||||||
<StatCard
|
value={pkgQ.data?.name ?? "—"}
|
||||||
label={t("stats.points")}
|
icon={<Package />}
|
||||||
value={loyaltyQ.data ? `${loyaltyQ.data.points}` : "—"}
|
/>
|
||||||
hint={loyaltyQ.data?.tier}
|
<StatCard
|
||||||
icon={<Gift />}
|
label={t("stats.points")}
|
||||||
/>
|
value={loyaltyQ.data ? `${loyaltyQ.data.points}` : "—"}
|
||||||
</StatCardGrid>
|
hint={loyaltyQ.data?.tier}
|
||||||
|
icon={<Gift />}
|
||||||
|
/>
|
||||||
|
</StatCardGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-3">
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
<DetailSection
|
<DetailSection
|
||||||
|
|
@ -88,7 +96,19 @@ export default function MemberHomePage() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{memberQ.isLoading ? (
|
{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 className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -122,7 +142,11 @@ export default function MemberHomePage() {
|
||||||
{t("member.loyalty")}
|
{t("member.loyalty")}
|
||||||
</p>
|
</p>
|
||||||
{loyaltyQ.isLoading ? (
|
{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 ? (
|
) : loyaltyQ.data ? (
|
||||||
<div className="mt-3 space-y-1.5">
|
<div className="mt-3 space-y-1.5">
|
||||||
<p className="font-display text-2xl font-semibold tabular-nums">
|
<p className="font-display text-2xl font-semibold tabular-nums">
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,13 @@ import { mockApi } from "@/lib/api/mock-client";
|
||||||
import { formatDate, formatEtb } from "@/lib/format";
|
import { formatDate, formatEtb } from "@/lib/format";
|
||||||
import { Link } from "@/i18n/routing";
|
import { Link } from "@/i18n/routing";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Building2,
|
Building2,
|
||||||
|
IdCard,
|
||||||
Mail,
|
Mail,
|
||||||
Percent,
|
Percent,
|
||||||
Phone,
|
Phone,
|
||||||
|
|
@ -38,15 +40,6 @@ import {
|
||||||
Wallet,
|
Wallet,
|
||||||
} from "lucide-react";
|
} 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() {
|
export default function MemberDetailPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const tTabs = useTranslations("tabs");
|
const tTabs = useTranslations("tabs");
|
||||||
|
|
@ -62,12 +55,17 @@ export default function MemberDetailPage() {
|
||||||
});
|
});
|
||||||
const deptsQ = useQuery({ queryKey: ["departments"], queryFn: mockApi.getDepartments });
|
const deptsQ = useQuery({ queryKey: ["departments"], queryFn: mockApi.getDepartments });
|
||||||
const pkgsQ = useQuery({ queryKey: ["packages"], queryFn: mockApi.getPackages });
|
const pkgsQ = useQuery({ queryKey: ["packages"], queryFn: mockApi.getPackages });
|
||||||
|
const orgQ = useQuery({ queryKey: ["organisation"], queryFn: mockApi.getOrganisation });
|
||||||
|
|
||||||
const canManage =
|
const canManage =
|
||||||
user?.activeRole === "SUPER_USER" || user?.activeRole === "HR_ADMIN";
|
user?.activeRole === "SUPER_USER" || user?.activeRole === "HR_ADMIN";
|
||||||
const isClinicalWithheld =
|
const isClinicalWithheld =
|
||||||
user?.activeRole === "HR_ADMIN" || user?.activeRole === "FINANCE";
|
user?.activeRole === "HR_ADMIN" || user?.activeRole === "FINANCE";
|
||||||
const canSeeClinical = user?.activeRole === "SUPER_USER";
|
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(
|
const deptName = useMemo(
|
||||||
() => deptsQ.data?.find((d) => d.id === memberQ.data?.departmentId)?.name,
|
() => deptsQ.data?.find((d) => d.id === memberQ.data?.departmentId)?.name,
|
||||||
|
|
@ -172,11 +170,13 @@ export default function MemberDetailPage() {
|
||||||
aside={
|
aside={
|
||||||
<DetailProfileCard
|
<DetailProfileCard
|
||||||
avatar={
|
avatar={
|
||||||
<Avatar className="size-20 border-4 border-card text-lg shadow-none">
|
<UserAvatar
|
||||||
<AvatarFallback className="bg-brand/20 font-display text-base font-semibold text-foreground">
|
name={member.name}
|
||||||
{initials(member.name)}
|
avatarUrl={member.avatarUrl}
|
||||||
</AvatarFallback>
|
seed={member.id}
|
||||||
</Avatar>
|
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}
|
title={member.name}
|
||||||
subtitle={member.email}
|
subtitle={member.email}
|
||||||
|
|
@ -242,6 +242,9 @@ export default function MemberDetailPage() {
|
||||||
<TabsTrigger value="profile">{tTabs("profile")}</TabsTrigger>
|
<TabsTrigger value="profile">{tTabs("profile")}</TabsTrigger>
|
||||||
<TabsTrigger value="entitlement">{tTabs("entitlement")}</TabsTrigger>
|
<TabsTrigger value="entitlement">{tTabs("entitlement")}</TabsTrigger>
|
||||||
<TabsTrigger value="dependants">{tTabs("dependants")}</TabsTrigger>
|
<TabsTrigger value="dependants">{tTabs("dependants")}</TabsTrigger>
|
||||||
|
{canSeeIdCard ? (
|
||||||
|
<TabsTrigger value="idCard">{tTabs("idCard")}</TabsTrigger>
|
||||||
|
) : null}
|
||||||
{canSeeClinical ? (
|
{canSeeClinical ? (
|
||||||
<TabsTrigger value="clinical">{tTabs("clinical")}</TabsTrigger>
|
<TabsTrigger value="clinical">{tTabs("clinical")}</TabsTrigger>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -330,6 +333,18 @@ export default function MemberDetailPage() {
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
</TabsContent>
|
</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 ? (
|
{canSeeClinical ? (
|
||||||
<TabsContent value="clinical" className="space-y-4 pt-1">
|
<TabsContent value="clinical" className="space-y-4 pt-1">
|
||||||
<DetailSection title={t("details.clinicalSupport")} icon={<Shield />}>
|
<DetailSection title={t("details.clinicalSupport")} icon={<Shield />}>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { PageShell } from "@/components/layout/page-shell";
|
import { PageShell } from "@/components/layout/page-shell";
|
||||||
|
import { PageStatSkeleton } from "@/components/layout/page-loading";
|
||||||
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
||||||
import { StatusBadge } from "@/components/shared/status-badge";
|
import { StatusBadge } from "@/components/shared/status-badge";
|
||||||
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
|
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
|
||||||
|
|
@ -22,12 +23,16 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Upload, UserCheck, UserMinus, UserPlus, Users } from "lucide-react";
|
import { Upload, UserCheck, UserMinus, UserPlus, Users } from "lucide-react";
|
||||||
|
import { UserAvatar } from "@/components/shared/user-avatar";
|
||||||
|
|
||||||
export default function MembersPage() {
|
export default function MembersPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
const [deptFilter, setDeptFilter] = useState("all");
|
||||||
|
const [pkgFilter, setPkgFilter] = useState("all");
|
||||||
|
const [dependantsFilter, setDependantsFilter] = useState("all");
|
||||||
|
|
||||||
const membersQ = useQuery({
|
const membersQ = useQuery({
|
||||||
queryKey: ["members", user?.activeRole],
|
queryKey: ["members", user?.activeRole],
|
||||||
|
|
@ -57,12 +62,25 @@ export default function MembersPage() {
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return (membersQ.data ?? []).filter((m) => {
|
return (membersQ.data ?? []).filter((m) => {
|
||||||
if (statusFilter !== "all" && m.status !== statusFilter) return false;
|
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(
|
return matchesSearch(
|
||||||
[m.name, m.email, m.employeeId, m.phone, deptMap.get(m.departmentId), pkgMap.get(m.packageId)],
|
[m.name, m.email, m.employeeId, m.phone, deptMap.get(m.departmentId), pkgMap.get(m.packageId)],
|
||||||
search,
|
search,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [membersQ.data, search, statusFilter, deptMap, pkgMap]);
|
}, [
|
||||||
|
membersQ.data,
|
||||||
|
search,
|
||||||
|
statusFilter,
|
||||||
|
deptFilter,
|
||||||
|
pkgFilter,
|
||||||
|
dependantsFilter,
|
||||||
|
deptMap,
|
||||||
|
pkgMap,
|
||||||
|
]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const rows = membersQ.data ?? [];
|
const rows = membersQ.data ?? [];
|
||||||
|
|
@ -93,30 +111,71 @@ export default function MembersPage() {
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StatCardGrid cols={4}>
|
{membersQ.isLoading ? (
|
||||||
<StatCard label={t("stats.total")} value={stats.total} icon={<Users />} />
|
<PageStatSkeleton cols={4} />
|
||||||
<StatCard label={t("stats.active")} value={stats.active} icon={<UserCheck />} />
|
) : (
|
||||||
<StatCard label={t("stats.invited")} value={stats.invited} icon={<UserPlus />} />
|
<StatCardGrid cols={4}>
|
||||||
<StatCard label={t("stats.offboarded")} value={stats.offboarded} icon={<UserMinus />} />
|
<StatCard label={t("stats.total")} value={stats.total} icon={<Users />} />
|
||||||
</StatCardGrid>
|
<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
|
<TablePanel
|
||||||
loading={membersQ.isLoading}
|
loading={membersQ.isLoading}
|
||||||
loadingLabel={t("common.loading")}
|
|
||||||
toolbar={
|
toolbar={
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder={t("common.searchPlaceholder")}
|
searchPlaceholder={t("common.searchPlaceholder")}
|
||||||
filterValue={statusFilter}
|
filters={[
|
||||||
onFilterChange={setStatusFilter}
|
{
|
||||||
filterPlaceholder={t("common.status")}
|
id: "status",
|
||||||
filterAllLabel={t("common.all")}
|
label: t("common.status"),
|
||||||
filterOptions={[
|
value: statusFilter,
|
||||||
{ value: "active", label: t("common.active") },
|
onChange: setStatusFilter,
|
||||||
{ value: "invited", label: t("members.statusInvited") },
|
allLabel: t("common.all"),
|
||||||
{ value: "inactive", label: t("members.statusInactive") },
|
options: [
|
||||||
{ value: "offboarded", label: t("members.statusOffboarded") },
|
{ 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}
|
resultCount={filtered.length}
|
||||||
resultLabel={t("common.results")}
|
resultLabel={t("common.results")}
|
||||||
|
|
@ -140,7 +199,18 @@ export default function MembersPage() {
|
||||||
filtered.map((member) => (
|
filtered.map((member) => (
|
||||||
<TableRow key={member.id}>
|
<TableRow key={member.id}>
|
||||||
<TableCell className="font-mono text-xs">{member.employeeId}</TableCell>
|
<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>{deptMap.get(member.departmentId) ?? "—"}</TableCell>
|
||||||
<TableCell>{pkgMap.get(member.packageId) ?? "—"}</TableCell>
|
<TableCell>{pkgMap.get(member.packageId) ?? "—"}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { PageShell } from "@/components/layout/page-shell";
|
import { PageShell } from "@/components/layout/page-shell";
|
||||||
|
import { PageStatSkeleton } from "@/components/layout/page-loading";
|
||||||
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
||||||
import { StatusBadge } from "@/components/shared/status-badge";
|
import { StatusBadge } from "@/components/shared/status-badge";
|
||||||
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
|
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
|
||||||
|
|
@ -26,18 +27,39 @@ export default function MigrationPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
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({
|
const { data: jobs, isLoading } = useQuery({
|
||||||
queryKey: ["migration-jobs"],
|
queryKey: ["migration-jobs"],
|
||||||
queryFn: mockApi.getMigrationJobs,
|
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(() => {
|
const filtered = useMemo(() => {
|
||||||
return (jobs ?? []).filter((job) => {
|
return (jobs ?? []).filter((job) => {
|
||||||
if (statusFilter !== "all" && job.status !== statusFilter) return false;
|
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);
|
return matchesSearch([job.dataset, job.fileName, job.id], search);
|
||||||
});
|
});
|
||||||
}, [jobs, search, statusFilter]);
|
}, [jobs, search, statusFilter, datasetFilter, errorsFilter, createdByFilter]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const rows = jobs ?? [];
|
const rows = jobs ?? [];
|
||||||
|
|
@ -60,33 +82,72 @@ export default function MigrationPage() {
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StatCardGrid cols={4}>
|
{isLoading ? (
|
||||||
<StatCard label={t("stats.jobs")} value={stats.total} icon={<FileSpreadsheet />} />
|
<PageStatSkeleton cols={4} />
|
||||||
<StatCard label={t("stats.completed")} value={stats.completed} icon={<CheckCircle2 />} />
|
) : (
|
||||||
<StatCard label={t("stats.draft")} value={stats.draft} />
|
<StatCardGrid cols={4}>
|
||||||
<StatCard
|
<StatCard label={t("stats.jobs")} value={stats.total} icon={<FileSpreadsheet />} />
|
||||||
label={t("stats.withErrors")}
|
<StatCard label={t("stats.completed")} value={stats.completed} icon={<CheckCircle2 />} />
|
||||||
value={stats.withErrors}
|
<StatCard label={t("stats.draft")} value={stats.draft} />
|
||||||
icon={<AlertTriangle />}
|
<StatCard
|
||||||
/>
|
label={t("stats.withErrors")}
|
||||||
</StatCardGrid>
|
value={stats.withErrors}
|
||||||
|
icon={<AlertTriangle />}
|
||||||
|
/>
|
||||||
|
</StatCardGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
<TablePanel
|
<TablePanel
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
loadingLabel={t("common.loading")}
|
|
||||||
toolbar={
|
toolbar={
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder={t("common.searchPlaceholder")}
|
searchPlaceholder={t("common.searchPlaceholder")}
|
||||||
filterValue={statusFilter}
|
filters={[
|
||||||
onFilterChange={setStatusFilter}
|
{
|
||||||
filterAllLabel={t("common.all")}
|
id: "status",
|
||||||
filterOptions={[
|
label: t("common.status"),
|
||||||
{ value: "draft", label: "Draft" },
|
value: statusFilter,
|
||||||
{ value: "preview", label: "Preview" },
|
onChange: setStatusFilter,
|
||||||
{ value: "completed", label: t("common.active") },
|
allLabel: t("common.all"),
|
||||||
{ value: "failed", label: "Failed" },
|
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}
|
resultCount={filtered.length}
|
||||||
resultLabel={t("common.results")}
|
resultLabel={t("common.results")}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { PageShell } from "@/components/layout/page-shell";
|
import { PageShell } from "@/components/layout/page-shell";
|
||||||
|
import { PageStatSkeleton } from "@/components/layout/page-loading";
|
||||||
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
||||||
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
|
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
|
||||||
import { TablePanel } from "@/components/table/table-panel";
|
import { TablePanel } from "@/components/table/table-panel";
|
||||||
|
|
@ -64,27 +65,36 @@ export default function PackagesPage() {
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<StatCardGrid cols={4}>
|
{isLoading ? (
|
||||||
<StatCard label={t("stats.total")} value={stats.total} icon={<Package />} />
|
<PageStatSkeleton cols={4} />
|
||||||
<StatCard label={t("stats.active")} value={stats.active} />
|
) : (
|
||||||
<StatCard label={t("stats.inactive")} value={stats.inactive} icon={<PowerOff />} />
|
<StatCardGrid cols={4}>
|
||||||
<StatCard label={t("stats.enrolled")} value={stats.enrolled} icon={<Users />} />
|
<StatCard label={t("stats.total")} value={stats.total} icon={<Package />} />
|
||||||
</StatCardGrid>
|
<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
|
<TablePanel
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
loadingLabel={t("common.loading")}
|
|
||||||
toolbar={
|
toolbar={
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder={t("common.searchPlaceholder")}
|
searchPlaceholder={t("common.searchPlaceholder")}
|
||||||
filterValue={statusFilter}
|
filters={[
|
||||||
onFilterChange={setStatusFilter}
|
{
|
||||||
filterAllLabel={t("common.all")}
|
id: "status",
|
||||||
filterOptions={[
|
label: t("common.status"),
|
||||||
{ value: "active", label: t("common.active") },
|
value: statusFilter,
|
||||||
{ value: "inactive", label: t("packages.inactive") },
|
onChange: setStatusFilter,
|
||||||
|
allLabel: t("common.all"),
|
||||||
|
options: [
|
||||||
|
{ value: "active", label: t("common.active") },
|
||||||
|
{ value: "inactive", label: t("packages.inactive") },
|
||||||
|
],
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
resultCount={filtered.length}
|
resultCount={filtered.length}
|
||||||
resultLabel={t("common.results")}
|
resultLabel={t("common.results")}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useEffect, useMemo } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { PageShell } from "@/components/layout/page-shell";
|
import { PageShell } from "@/components/layout/page-shell";
|
||||||
|
import { PageDashboardSkeleton } from "@/components/layout/page-loading";
|
||||||
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
||||||
import { StatusBadge } from "@/components/shared/status-badge";
|
import { StatusBadge } from "@/components/shared/status-badge";
|
||||||
import {
|
import {
|
||||||
|
|
@ -104,6 +105,15 @@ export default function DashboardPage() {
|
||||||
|
|
||||||
if (!user || user.activeRole === "MEMBER") return null;
|
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 activeMembers = membersQ.data?.filter((m) => m.status === "active").length ?? 0;
|
||||||
const pendingApprovals =
|
const pendingApprovals =
|
||||||
approvalsQ.data?.filter((a) => a.status === "pending").length ?? 0;
|
approvalsQ.data?.filter((a) => a.status === "pending").length ?? 0;
|
||||||
|
|
@ -113,6 +123,10 @@ export default function DashboardPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title={t("dashboard.title")} description={t("dashboard.subtitle")}>
|
<PageShell title={t("dashboard.title")} description={t("dashboard.subtitle")}>
|
||||||
|
{dashLoading ? (
|
||||||
|
<PageDashboardSkeleton />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<StatCardGrid cols={4}>
|
<StatCardGrid cols={4}>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("dashboard.members")}
|
label={t("dashboard.members")}
|
||||||
|
|
@ -201,6 +215,8 @@ export default function DashboardPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,21 +70,26 @@ export default function PrescriptionsPage() {
|
||||||
>
|
>
|
||||||
<TablePanel
|
<TablePanel
|
||||||
loading={rxQ.isLoading}
|
loading={rxQ.isLoading}
|
||||||
loadingLabel={t("common.loading")}
|
|
||||||
toolbar={
|
toolbar={
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder={t("common.searchPlaceholder")}
|
searchPlaceholder={t("common.searchPlaceholder")}
|
||||||
filterValue={statusFilter}
|
filters={[
|
||||||
onFilterChange={setStatusFilter}
|
{
|
||||||
filterAllLabel={t("common.all")}
|
id: "status",
|
||||||
filterOptions={[
|
label: t("common.status"),
|
||||||
{ value: "submitted", label: "Submitted" },
|
value: statusFilter,
|
||||||
{ value: "under_review", label: "Under review" },
|
onChange: setStatusFilter,
|
||||||
{ value: "approved", label: "Approved" },
|
allLabel: t("common.all"),
|
||||||
{ value: "queried", label: "Queried" },
|
options: [
|
||||||
{ value: "rejected", label: "Rejected" },
|
{ 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}
|
resultCount={filtered.length}
|
||||||
resultLabel={t("common.results")}
|
resultLabel={t("common.results")}
|
||||||
|
|
|
||||||
187
src/app/[locale]/(portal)/profile/page.tsx
Normal file
187
src/app/[locale]/(portal)/profile/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { CreatePageLayout } from "@/components/layout/create-layout";
|
import { CreatePageLayout } from "@/components/layout/create-layout";
|
||||||
|
import { PageFormSkeleton } from "@/components/layout/page-loading";
|
||||||
import { mockApi } from "@/lib/api/mock-client";
|
import { mockApi } from "@/lib/api/mock-client";
|
||||||
import type { Organisation } from "@/types";
|
import type { Organisation } from "@/types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -53,7 +54,7 @@ export default function VerificationSettingsPage() {
|
||||||
description={t("verification.subtitle")}
|
description={t("verification.subtitle")}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
|
<PageFormSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<Card className="w-full rounded-xl shadow-none">
|
<Card className="w-full rounded-xl shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { PageShell } from "@/components/layout/page-shell";
|
import { PageShell } from "@/components/layout/page-shell";
|
||||||
|
import { PageStatSkeleton } from "@/components/layout/page-loading";
|
||||||
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
|
||||||
import { StatusBadge } from "@/components/shared/status-badge";
|
import { StatusBadge } from "@/components/shared/status-badge";
|
||||||
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
|
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
|
||||||
|
|
@ -51,36 +52,45 @@ export default function StatementsPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title={t("statements.title")} description={t("statements.subtitle")}>
|
<PageShell title={t("statements.title")} description={t("statements.subtitle")}>
|
||||||
<StatCardGrid cols={4}>
|
{isLoading ? (
|
||||||
<StatCard
|
<PageStatSkeleton cols={4} />
|
||||||
label={t("stats.coveredTotal")}
|
) : (
|
||||||
value={formatEtb(stats.covered)}
|
<StatCardGrid cols={4}>
|
||||||
icon={<Wallet />}
|
<StatCard
|
||||||
/>
|
label={t("stats.coveredTotal")}
|
||||||
<StatCard
|
value={formatEtb(stats.covered)}
|
||||||
label={t("stats.creditUsed")}
|
icon={<Wallet />}
|
||||||
value={formatEtb(stats.credit)}
|
/>
|
||||||
icon={<CreditCard />}
|
<StatCard
|
||||||
/>
|
label={t("stats.creditUsed")}
|
||||||
<StatCard label={t("stats.draft")} value={stats.draft} icon={<FileText />} />
|
value={formatEtb(stats.credit)}
|
||||||
<StatCard label={t("stats.issued")} value={stats.issued} />
|
icon={<CreditCard />}
|
||||||
</StatCardGrid>
|
/>
|
||||||
|
<StatCard label={t("stats.draft")} value={stats.draft} icon={<FileText />} />
|
||||||
|
<StatCard label={t("stats.issued")} value={stats.issued} />
|
||||||
|
</StatCardGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
<TablePanel
|
<TablePanel
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
loadingLabel={t("common.loading")}
|
|
||||||
toolbar={
|
toolbar={
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
searchPlaceholder={t("common.searchPlaceholder")}
|
searchPlaceholder={t("common.searchPlaceholder")}
|
||||||
filterValue={statusFilter}
|
filters={[
|
||||||
onFilterChange={setStatusFilter}
|
{
|
||||||
filterAllLabel={t("common.all")}
|
id: "status",
|
||||||
filterOptions={[
|
label: t("common.status"),
|
||||||
{ value: "draft", label: "Draft" },
|
value: statusFilter,
|
||||||
{ value: "issued", label: "Issued" },
|
onChange: setStatusFilter,
|
||||||
{ value: "paid", label: "Paid" },
|
allLabel: t("common.all"),
|
||||||
|
options: [
|
||||||
|
{ value: "draft", label: "Draft" },
|
||||||
|
{ value: "issued", label: "Issued" },
|
||||||
|
{ value: "paid", label: "Paid" },
|
||||||
|
],
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
resultCount={filtered.length}
|
resultCount={filtered.length}
|
||||||
resultLabel={t("common.results")}
|
resultLabel={t("common.results")}
|
||||||
|
|
|
||||||
|
|
@ -1,96 +1,210 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { brand } from "@/data/brand";
|
import { brand } from "@/data/brand";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
import { Link, useRouter } from "@/i18n/routing";
|
import { Link, useRouter } from "@/i18n/routing";
|
||||||
import { ROLE_META } from "@/lib/auth/session";
|
import { ROLE_META, listDemoPersonas } from "@/lib/auth/session";
|
||||||
import type { Role } from "@/types";
|
|
||||||
import { LanguageSwitcher } from "@/components/layout/language-switcher";
|
import { LanguageSwitcher } from "@/components/layout/language-switcher";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Building2, Handshake, KeyRound } from "lucide-react";
|
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() {
|
export default function LoginPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const { loginAs } = useAuth();
|
const { loginAsUser } = useAuth();
|
||||||
const router = useRouter();
|
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 (
|
return (
|
||||||
<div className="relative flex min-h-screen flex-col px-4 py-8 md:px-8">
|
<div className="relative flex min-h-screen overflow-hidden bg-[#f6f7f2]">
|
||||||
<div className="mx-auto flex w-full max-w-5xl items-center justify-between">
|
{/* Atmospheric background */}
|
||||||
<div className="flex items-center gap-3">
|
<div
|
||||||
<Image src={brand.logo} alt={brand.shortName} width={40} height={40} />
|
aria-hidden
|
||||||
<div>
|
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||||
<p className="font-display text-lg font-semibold">{t("common.appName")}</p>
|
>
|
||||||
<p className="text-xs text-muted-foreground">{brand.tagline}</p>
|
<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>
|
<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>
|
<div
|
||||||
<LanguageSwitcher />
|
className="absolute inset-0 opacity-[0.035]"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"radial-gradient(circle at 1px 1px, #141414 1px, transparent 0)",
|
||||||
|
backgroundSize: "22px 22px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto mt-10 grid w-full max-w-5xl gap-8 lg:grid-cols-[1.1fr_0.9fr]">
|
{/* Left brand panel — Yimaru-style split */}
|
||||||
<div className="space-y-4">
|
<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)]">
|
||||||
<h1 className="font-display text-3xl font-semibold tracking-tight md:text-4xl">
|
<div aria-hidden className="absolute inset-0 overflow-hidden">
|
||||||
{t("login.title")}
|
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-white/5" />
|
||||||
</h1>
|
<div className="absolute -bottom-32 -right-16 h-[500px] w-[500px] rounded-full bg-white/5" />
|
||||||
<p className="max-w-xl text-muted-foreground">{t("login.subtitle")}</p>
|
<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>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
{/* Right form panel */}
|
||||||
{ROLES.map((role) => (
|
<div className="relative flex w-full flex-col items-center justify-center px-6 py-12 lg:w-1/2 xl:w-[45%]">
|
||||||
<Card
|
<div className="absolute right-4 top-4 sm:right-6 sm:top-6">
|
||||||
key={role}
|
<LanguageSwitcher className="w-[130px]" />
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
<CardDescription>{t(ROLE_META[role].descriptionKey)}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => {
|
|
||||||
loginAs(role);
|
|
||||||
router.push(ROLE_META[role].homePath);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("login.continueAs", { role: t(ROLE_META[role].labelKey) })}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="h-fit rounded-2xl border-border bg-card shadow-none">
|
<div className="w-full max-w-[420px] animate-in fade-in slide-in-from-bottom-3 duration-500">
|
||||||
<CardHeader>
|
<div className="mb-8 flex justify-center lg:hidden">
|
||||||
<CardTitle>Get started</CardTitle>
|
<Image
|
||||||
<CardDescription>
|
src={brand.logo}
|
||||||
Organisations can self-register or request assisted onboarding.
|
alt={brand.shortName}
|
||||||
</CardDescription>
|
width={64}
|
||||||
</CardHeader>
|
height={64}
|
||||||
<CardContent className="space-y-3">
|
className="rounded-xl bg-white p-1.5 shadow-sm ring-1 ring-border"
|
||||||
<Button variant="outline" className="w-full justify-start gap-2" render={<Link href="/register/organisation" />}>
|
priority
|
||||||
<Building2 className="size-4" />
|
/>
|
||||||
{t("login.registerOrg")}
|
</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="text-sm leading-relaxed text-muted-foreground">
|
||||||
|
{t("login.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{selected ? (
|
||||||
|
<p className="text-xs text-muted-foreground animate-in fade-in duration-300">
|
||||||
|
{t(selected.personaDescKey)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
</Button>
|
||||||
<Button variant="outline" className="w-full justify-start gap-2" render={<Link href="/register/request" />}>
|
</div>
|
||||||
<Handshake className="size-4" />
|
|
||||||
{t("login.requestRegister")}
|
<div className="mt-6 space-y-2">
|
||||||
</Button>
|
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
<Button variant="outline" className="w-full justify-start gap-2" render={<Link href="/join" />}>
|
{t("login.getStarted")}
|
||||||
<KeyRound className="size-4" />
|
</p>
|
||||||
{t("login.joinWithCode")}
|
<div className="flex flex-col gap-2">
|
||||||
</Button>
|
<Button
|
||||||
</CardContent>
|
variant="outline"
|
||||||
</Card>
|
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="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="h-10 w-full justify-start gap-2 rounded-xl"
|
||||||
|
render={<Link href="/join" />}
|
||||||
|
>
|
||||||
|
<KeyRound className="size-4" />
|
||||||
|
{t("login.joinWithCode")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-10 text-center text-xs text-muted-foreground">
|
||||||
|
© {new Date().getFullYear()} {brand.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -112,3 +112,44 @@
|
||||||
font-family: var(--font-sora), var(--font-ethiopic), var(--font-sans), system-ui, sans-serif;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,24 @@ const LINE = "#e2e5d8";
|
||||||
|
|
||||||
const PIE_COLORS = [BRAND, BRAND_DEEP, ACCENT, FOREST, "#96b532", "#c97310"];
|
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 = {
|
type ChartCardProps = {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
@ -35,14 +53,16 @@ type ChartCardProps = {
|
||||||
|
|
||||||
export function ChartCard({ title, description, children, className }: ChartCardProps) {
|
export function ChartCard({ title, description, children, className }: ChartCardProps) {
|
||||||
return (
|
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">
|
<CardHeader className="border-b border-border px-5 py-3.5">
|
||||||
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||||
{description ? (
|
{description ? (
|
||||||
<p className="text-xs text-muted-foreground">{description}</p>
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-5">{children}</CardContent>
|
<CardContent className="overflow-visible p-5">{children}</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +75,7 @@ export function SpendTrendChart({
|
||||||
return (
|
return (
|
||||||
<div className="h-64 w-full">
|
<div className="h-64 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
<AreaChart data={data} margin={CHART_MARGIN}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="gCovered" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="gCovered" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor={BRAND} stopOpacity={0.35} />
|
<stop offset="0%" stopColor={BRAND} stopOpacity={0.35} />
|
||||||
|
|
@ -64,7 +84,7 @@ export function SpendTrendChart({
|
||||||
</defs>
|
</defs>
|
||||||
<CartesianGrid stroke={LINE} strokeDasharray="3 3" vertical={false} />
|
<CartesianGrid stroke={LINE} strokeDasharray="3 3" vertical={false} />
|
||||||
<XAxis dataKey="period" tick={{ fill: MUTED, fontSize: 12 }} axisLine={false} tickLine={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
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
|
|
@ -103,10 +123,10 @@ export function DepartmentSpendChart({
|
||||||
return (
|
return (
|
||||||
<div className="h-64 w-full">
|
<div className="h-64 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<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} />
|
<CartesianGrid stroke={LINE} strokeDasharray="3 3" vertical={false} />
|
||||||
<XAxis dataKey="name" tick={{ fill: MUTED, fontSize: 11 }} axisLine={false} tickLine={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
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import Image from "next/image";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Link, usePathname } from "@/i18n/routing";
|
import { Link, usePathname } from "@/i18n/routing";
|
||||||
import { brand } from "@/data/brand";
|
import { brand } from "@/data/brand";
|
||||||
import { navForRole } from "@/lib/auth/nav";
|
import { navSectionsForRole } from "@/lib/auth/nav";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
@ -24,7 +24,7 @@ export function AppSidebar({
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const items = user ? navForRole(user.activeRole) : [];
|
const sections = user ? navSectionsForRole(user.activeRole) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
|
|
@ -103,47 +103,64 @@ export function AppSidebar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="mt-6 flex-1">
|
<ScrollArea className="mt-6 flex-1">
|
||||||
<nav className="space-y-1 pr-1">
|
<nav className="space-y-5 pr-1">
|
||||||
{items.map((item) => {
|
{sections.map((group, groupIndex) => (
|
||||||
const active =
|
<div key={group.section} className="space-y-1">
|
||||||
item.href === "/"
|
{collapsed ? (
|
||||||
? pathname === "/"
|
groupIndex > 0 ? (
|
||||||
: pathname === item.href || pathname.startsWith(`${item.href}/`);
|
<div
|
||||||
const Icon = item.icon;
|
className="mx-auto mb-2 h-px w-8 bg-sidebar-border"
|
||||||
return (
|
aria-hidden
|
||||||
<Link
|
/>
|
||||||
key={item.href}
|
) : null
|
||||||
href={item.href}
|
) : (
|
||||||
onClick={onNavigate}
|
<p className="px-3 pb-1 text-[10px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
|
||||||
className={cn(
|
{t(`nav.sections.${group.section}`)}
|
||||||
"group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-colors",
|
</p>
|
||||||
"hover:bg-secondary hover:text-brand-deep",
|
)}
|
||||||
active &&
|
{group.items.map((item) => {
|
||||||
"bg-brand/10 text-brand-deep shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand/20",
|
const active =
|
||||||
collapsed && "justify-center px-2",
|
item.href === "/"
|
||||||
)}
|
? pathname === "/"
|
||||||
title={t(item.labelKey)}
|
: pathname === item.href ||
|
||||||
>
|
pathname.startsWith(`${item.href}/`);
|
||||||
<span
|
const Icon = item.icon;
|
||||||
className={cn(
|
return (
|
||||||
"grid size-8 shrink-0 place-items-center rounded-lg bg-secondary text-muted-foreground transition-colors",
|
<Link
|
||||||
"group-hover:bg-brand/20 group-hover:text-brand-deep",
|
key={item.href}
|
||||||
active && "bg-brand text-forest shadow-sm",
|
href={item.href}
|
||||||
)}
|
onClick={onNavigate}
|
||||||
>
|
className={cn(
|
||||||
<Icon className="size-4" />
|
"group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-colors",
|
||||||
</span>
|
"hover:bg-secondary hover:text-brand-deep",
|
||||||
{!collapsed && (
|
active &&
|
||||||
<>
|
"bg-brand/10 text-brand-deep shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand/20",
|
||||||
<span className="truncate">{t(item.labelKey)}</span>
|
collapsed && "justify-center px-2",
|
||||||
{active && (
|
|
||||||
<span className="ml-auto h-6 w-1 shrink-0 rounded-full bg-brand" />
|
|
||||||
)}
|
)}
|
||||||
</>
|
title={t(item.labelKey)}
|
||||||
)}
|
>
|
||||||
</Link>
|
<span
|
||||||
);
|
className={cn(
|
||||||
})}
|
"grid size-8 shrink-0 place-items-center rounded-lg bg-secondary text-muted-foreground transition-colors",
|
||||||
|
"group-hover:bg-brand/20 group-hover:text-brand-deep",
|
||||||
|
active && "bg-brand text-forest shadow-sm",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</span>
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<span className="truncate">{t(item.labelKey)}</span>
|
||||||
|
{active && (
|
||||||
|
<span className="ml-auto h-6 w-1 shrink-0 rounded-full bg-brand" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { ROLE_META } from "@/lib/auth/session";
|
||||||
import { LanguageSwitcher } from "./language-switcher";
|
import { LanguageSwitcher } from "./language-switcher";
|
||||||
import { QuickSearch } from "./quick-search";
|
import { QuickSearch } from "./quick-search";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { UserAvatar } from "@/components/shared/user-avatar";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -52,11 +52,14 @@ export function AppTopbar({ onMenuClick }: { onMenuClick?: () => void }) {
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
render={
|
render={
|
||||||
<Button variant="ghost" className="h-9 gap-2 px-2">
|
<Button variant="ghost" className="h-9 gap-2 px-2">
|
||||||
<Avatar className="size-7">
|
<UserAvatar
|
||||||
<AvatarFallback className="bg-primary/20 text-xs font-semibold">
|
name={user.name}
|
||||||
{user.avatarInitials}
|
avatarUrl={user.avatarUrl}
|
||||||
</AvatarFallback>
|
seed={user.id}
|
||||||
</Avatar>
|
initials={user.avatarInitials}
|
||||||
|
className="size-7"
|
||||||
|
fallbackClassName="bg-primary/20 text-xs font-semibold"
|
||||||
|
/>
|
||||||
<span className="hidden text-left sm:block">
|
<span className="hidden text-left sm:block">
|
||||||
<span className="block text-sm font-medium leading-none">{user.name}</span>
|
<span className="block text-sm font-medium leading-none">{user.name}</span>
|
||||||
<span className="text-[11px] text-muted-foreground">
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
|
@ -75,6 +78,9 @@ export function AppTopbar({ onMenuClick }: { onMenuClick?: () => void }) {
|
||||||
</Badge>
|
</Badge>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => router.push("/profile")}>
|
||||||
|
{t("nav.profile")}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
switchDemoUser();
|
switchDemoUser();
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ const PATH_LABEL_KEYS: Record<string, string> = {
|
||||||
"/me/orders": "myOrders",
|
"/me/orders": "myOrders",
|
||||||
"/prescriptions": "prescriptions",
|
"/prescriptions": "prescriptions",
|
||||||
"/prescriptions/new": "newPrescription",
|
"/prescriptions/new": "newPrescription",
|
||||||
|
"/profile": "profile",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Lone segment fallbacks when full path is unmapped. */
|
/** Lone segment fallbacks when full path is unmapped. */
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { PageShell } from "@/components/layout/page-shell";
|
import { PageShell } from "@/components/layout/page-shell";
|
||||||
|
import { PageDetailSkeleton } from "@/components/layout/page-loading";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Flat MakerSys / Yimaru panel — thin border, no shadow */
|
/** Flat MakerSys / Yimaru panel — thin border, no shadow */
|
||||||
|
|
@ -35,12 +36,17 @@ export function DetailPageLayout({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailLoading({ label }: { label?: string }) {
|
/** Box skeleton for detail routes — keeps layout footprint while queries load. */
|
||||||
return (
|
export function DetailLoading({
|
||||||
<div className="flex min-h-[40vh] items-center justify-center">
|
label: _label,
|
||||||
<p className="text-sm text-muted-foreground">{label ?? "Loading…"}</p>
|
metrics = 3,
|
||||||
</div>
|
}: {
|
||||||
);
|
/** @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 */
|
/** Slim summary strip — left brand accent */
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ export function PageDetailSkeleton({
|
||||||
<div className="space-y-3 border-t border-border pt-4">
|
<div className="space-y-3 border-t border-border pt-4">
|
||||||
<Skeleton className="h-4 w-full" />
|
<Skeleton className="h-4 w-full" />
|
||||||
<Skeleton className="h-4 w-5/6" />
|
<Skeleton className="h-4 w-5/6" />
|
||||||
<Skeleton className="h-4 w-4/6" />
|
<Skeleton className="h-4 w-2/3" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
195
src/components/members/member-id-card.tsx
Normal file
195
src/components/members/member-id-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/components/shared/user-avatar.tsx
Normal file
36
src/components/shared/user-avatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,26 @@
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Flat MakerSys / Yimaru table frame — white panel, thin border, no shadow. */
|
/** Flat MakerSys / Yimaru table frame — white panel, thin border, no shadow. */
|
||||||
export const TABLE_PANEL_CLASS =
|
export const TABLE_PANEL_CLASS =
|
||||||
"overflow-hidden rounded-xl border border-border bg-card shadow-none";
|
"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.
|
* Wraps toolbar + table (or loading) in a single flush panel.
|
||||||
* Toolbar sits inside the card with a hairline bottom; grid scrolls horizontally on mobile.
|
* Toolbar sits inside the card with a hairline bottom; grid scrolls horizontally on mobile.
|
||||||
|
|
@ -14,24 +30,22 @@ export function TablePanel({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
loading,
|
loading,
|
||||||
loadingLabel = "Loading…",
|
loadingLabel: _loadingLabel,
|
||||||
|
skeletonRows = 6,
|
||||||
}: {
|
}: {
|
||||||
toolbar?: ReactNode;
|
toolbar?: ReactNode;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
/** @deprecated Text loading — retained for call-site compatibility */
|
||||||
loadingLabel?: ReactNode;
|
loadingLabel?: ReactNode;
|
||||||
|
skeletonRows?: number;
|
||||||
}) {
|
}) {
|
||||||
|
void _loadingLabel;
|
||||||
return (
|
return (
|
||||||
<div className={cn(TABLE_PANEL_CLASS, className)}>
|
<div className={cn(TABLE_PANEL_CLASS, className)}>
|
||||||
{toolbar}
|
{toolbar}
|
||||||
{loading ? (
|
{loading ? <TableRowsSkeleton rows={skeletonRows} /> : children}
|
||||||
<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
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,19 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { Search } from "lucide-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 { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -17,37 +28,223 @@ export type TableFilterOption = {
|
||||||
label: string;
|
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 = {
|
type TableToolbarProps = {
|
||||||
search: string;
|
search: string;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
searchPlaceholder?: string;
|
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;
|
filterValue?: string;
|
||||||
onFilterChange?: (value: string) => void;
|
onFilterChange?: (value: string) => void;
|
||||||
filterOptions?: TableFilterOption[];
|
filterOptions?: TableFilterOption[];
|
||||||
filterPlaceholder?: string;
|
filterPlaceholder?: string;
|
||||||
filterAllLabel?: string;
|
filterAllLabel?: string;
|
||||||
|
/** Switch to modal when filter count exceeds this. Default 3. */
|
||||||
|
inlineFilterLimit?: number;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
resultCount?: number;
|
resultCount?: number;
|
||||||
resultLabel?: string;
|
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({
|
export function TableToolbar({
|
||||||
search,
|
search,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
searchPlaceholder = "Search…",
|
searchPlaceholder = "Search…",
|
||||||
|
filters: filtersProp,
|
||||||
filterValue,
|
filterValue,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
filterOptions,
|
filterOptions,
|
||||||
filterPlaceholder = "Filter",
|
filterPlaceholder = "Filter",
|
||||||
filterAllLabel = "All",
|
filterAllLabel,
|
||||||
|
inlineFilterLimit = DEFAULT_INLINE_LIMIT,
|
||||||
actions,
|
actions,
|
||||||
className,
|
className,
|
||||||
resultCount,
|
resultCount,
|
||||||
resultLabel,
|
resultLabel,
|
||||||
}: TableToolbarProps) {
|
}: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -67,24 +264,51 @@ export function TableToolbar({
|
||||||
aria-label={searchPlaceholder}
|
aria-label={searchPlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{showFilter ? (
|
|
||||||
<Select
|
{useModal ? (
|
||||||
value={filterValue ?? "all"}
|
<>
|
||||||
onValueChange={(v) => onFilterChange?.(v ?? "all")}
|
<Button
|
||||||
>
|
type="button"
|
||||||
<SelectTrigger className="w-full rounded-xl border-border bg-background shadow-none sm:w-[180px]">
|
variant="outline"
|
||||||
<SelectValue placeholder={filterPlaceholder} />
|
className="gap-2 rounded-xl border-border bg-background shadow-none"
|
||||||
</SelectTrigger>
|
onClick={() => setModalOpen(true)}
|
||||||
<SelectContent>
|
aria-label={
|
||||||
<SelectItem value="all">{filterAllLabel}</SelectItem>
|
activeCount > 0
|
||||||
{filterOptions!.map((opt) => (
|
? `${tFilters("title")} (${tFilters("active", { count: activeCount })})`
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
: tFilters("title")
|
||||||
{opt.label}
|
}
|
||||||
</SelectItem>
|
>
|
||||||
))}
|
<ListFilter className="size-4" />
|
||||||
</SelectContent>
|
{tFilters("title")}
|
||||||
</Select>
|
{activeCount > 0 ? (
|
||||||
) : null}
|
<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" ? (
|
{typeof resultCount === "number" ? (
|
||||||
<p className="text-xs text-muted-foreground whitespace-nowrap tabular-nums">
|
<p className="text-xs text-muted-foreground whitespace-nowrap tabular-nums">
|
||||||
{resultCount}
|
{resultCount}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ import type {
|
||||||
MigrationJobStatus,
|
MigrationJobStatus,
|
||||||
Role,
|
Role,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
import type { LocaleCode, SessionUser } from "@/types";
|
||||||
import {
|
import {
|
||||||
approvals,
|
approvals,
|
||||||
|
demoUsers,
|
||||||
departments,
|
departments,
|
||||||
loyalty,
|
loyalty,
|
||||||
memberOrders,
|
memberOrders,
|
||||||
|
|
@ -18,11 +20,42 @@ import {
|
||||||
spendRows,
|
spendRows,
|
||||||
statements,
|
statements,
|
||||||
} from "@/mocks/data";
|
} 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));
|
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
export const mockApi = {
|
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() {
|
async getOrganisation() {
|
||||||
await delay();
|
await delay();
|
||||||
return { ...organisation };
|
return { ...organisation };
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,13 @@ export const NAV_ITEMS: NavItem[] = [
|
||||||
roles: ["SUPER_USER", "FINANCE"],
|
roles: ["SUPER_USER", "FINANCE"],
|
||||||
section: "finance",
|
section: "finance",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/profile",
|
||||||
|
labelKey: "nav.profile",
|
||||||
|
icon: UserRound,
|
||||||
|
roles: ["SUPER_USER", "HR_ADMIN", "FINANCE", "MEMBER"],
|
||||||
|
section: "account",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/prescriptions",
|
href: "/prescriptions",
|
||||||
labelKey: "nav.prescriptions",
|
labelKey: "nav.prescriptions",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Role, SessionUser } from "@/types";
|
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_COOKIE = "gishen_b2b_session";
|
||||||
export const SESSION_STORAGE_KEY = "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 {
|
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}`);
|
if (!user) throw new Error(`No demo user for role ${role}`);
|
||||||
return { ...user, activeRole: 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 {
|
export function canAccessClinical(role: Role): boolean {
|
||||||
return role === "SUPER_USER" || role === "MEMBER";
|
return role === "SUPER_USER" || role === "MEMBER";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
src/lib/avatar.ts
Normal file
45
src/lib/avatar.ts
Normal 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}`;
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
Settings2,
|
Settings2,
|
||||||
Upload,
|
Upload,
|
||||||
|
UserCircle2,
|
||||||
UserRound,
|
UserRound,
|
||||||
Users,
|
Users,
|
||||||
Wallet,
|
Wallet,
|
||||||
|
|
@ -162,6 +163,15 @@ export const QUICK_DIRECTORY: QuickDirectoryEntry[] = [
|
||||||
keywords: ["orders", "purchases", "history"],
|
keywords: ["orders", "purchases", "history"],
|
||||||
icon: Package,
|
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",
|
id: "verification",
|
||||||
labelKey: "nav.verification",
|
labelKey: "nav.verification",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@
|
||||||
"results": "ውጤቶች",
|
"results": "ውጤቶች",
|
||||||
"requiredFields": "አስፈላጊ መስኮችን ይሙሉ"
|
"requiredFields": "አስፈላጊ መስኮችን ይሙሉ"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"title": "ማጣሪያዎች",
|
||||||
|
"apply": "ተግብር",
|
||||||
|
"reset": "አጽዳ",
|
||||||
|
"active": "{count} ንቁ"
|
||||||
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"superUser": "ሱፐር ተጠቃሚ",
|
"superUser": "ሱፐር ተጠቃሚ",
|
||||||
"superUserDesc": "ሙሉ መዳረሻ — HR, Finance, Packages",
|
"superUserDesc": "ሙሉ መዳረሻ — HR, Finance, Packages",
|
||||||
|
|
@ -62,7 +68,16 @@
|
||||||
"verification": "ማረጋገጫ",
|
"verification": "ማረጋገጫ",
|
||||||
"myBenefits": "የእኔ ጥቅማጥቅሞች",
|
"myBenefits": "የእኔ ጥቅማጥቅሞች",
|
||||||
"myOrders": "የእኔ ትዕዛዎች",
|
"myOrders": "የእኔ ትዕዛዎች",
|
||||||
"prescriptions": "ማዘዛዎች"
|
"prescriptions": "ማዘዛዎች",
|
||||||
|
"profile": "መገለጫ",
|
||||||
|
"sections": {
|
||||||
|
"overview": "አጠቃላይ",
|
||||||
|
"organisation": "ድርጅት",
|
||||||
|
"people": "ሰዎች",
|
||||||
|
"benefits": "ጥቅማጥቅሞች",
|
||||||
|
"finance": "ፋይናንስ",
|
||||||
|
"account": "መለያ"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"bc": {
|
"bc": {
|
||||||
"aria": "Breadcrumb",
|
"aria": "Breadcrumb",
|
||||||
|
|
@ -96,13 +111,15 @@
|
||||||
"prescriptions": "ማዘዛዎች",
|
"prescriptions": "ማዘዛዎች",
|
||||||
"newPrescription": "አዲስ ማዘዣ",
|
"newPrescription": "አዲስ ማዘዣ",
|
||||||
"prescriptionDetail": "የማዘዛ ዝርዝር",
|
"prescriptionDetail": "የማዘዛ ዝርዝር",
|
||||||
"departmentDetail": "የክፍል ዝርዝር"
|
"departmentDetail": "የክፍል ዝርዝር",
|
||||||
|
"profile": "መገለጫ"
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"profile": "መገለጫ",
|
"profile": "መገለጫ",
|
||||||
"entitlement": "መብት",
|
"entitlement": "መብት",
|
||||||
"dependants": "ጥገኞች",
|
"dependants": "ጥገኞች",
|
||||||
"clinical": "ክሊኒክ",
|
"clinical": "ክሊኒክ",
|
||||||
|
"idCard": "መታወቂያ ካርድ",
|
||||||
"rules": "ደንቦች",
|
"rules": "ደንቦች",
|
||||||
"perks": "ጥቅሞች",
|
"perks": "ጥቅሞች",
|
||||||
"summary": "ማጠቃለያ",
|
"summary": "ማጠቃለያ",
|
||||||
|
|
@ -114,6 +131,18 @@
|
||||||
"info": "መረጃ",
|
"info": "መረጃ",
|
||||||
"basics": "መሠረታዊ"
|
"basics": "መሠረታዊ"
|
||||||
},
|
},
|
||||||
|
"idCard": {
|
||||||
|
"title": "የፋርማሲ መታወቂያ ካርድ",
|
||||||
|
"subtitle": "ለፋርማሲ ማረጋገጫ የሚያገለግል ናሙና ካርድ — ያትሙ እና በግዢ ጊዜ ያሳዩ።",
|
||||||
|
"pharmacyId": "የሰራተኛ ፋርማሲ መታወቂያ",
|
||||||
|
"employeeId": "ሰራተኛ",
|
||||||
|
"cardId": "የካርድ መለያ",
|
||||||
|
"package": "እቅድ",
|
||||||
|
"orgCode": "ድርጅት",
|
||||||
|
"scanHint": "ፋርማሲስት፡ ለማረጋገጥ QR ይቃኙ",
|
||||||
|
"print": "መታወቂያ አትም",
|
||||||
|
"printHint": "ህትመት የክሬዲት ካርድ መጠን (85.6 × 54 ሚሜ) ይጠቀማል። ካርዱ ብቻ ይታተማል።"
|
||||||
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"total": "ጠቅላላ",
|
"total": "ጠቅላላ",
|
||||||
"active": "ንቁ",
|
"active": "ንቁ",
|
||||||
|
|
@ -156,12 +185,42 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "የተቋም ፖርታል",
|
"eyebrow": "የተቋም ፖርታል",
|
||||||
"subtitle": "Demo role switcher — pick a persona.",
|
"title": "እንኳን ደህና መጡ",
|
||||||
|
"subtitle": "በሚና የፖርታሉን ለመሞከር የማሳያ ተጠቃሚ ይምረጡ።",
|
||||||
|
"brandBlurb": "ለድርጅቶች የኮርፖሬት የህክምና ጥቅማጥቅሞች — አባላት፣ ፓኬጆች፣ ፋይናንስ እና እንክብካቤ በአንድ ቦታ።",
|
||||||
|
"selectUser": "የሙከራ ተጠቃሚ",
|
||||||
|
"selectUserPlaceholder": "የማሳያ ተጠቃሚ ይምረጡ",
|
||||||
"continueAs": "እንደ {role} ቀጥል",
|
"continueAs": "እንደ {role} ቀጥል",
|
||||||
|
"getStarted": "ጀምር",
|
||||||
"registerOrg": "ድርጅት ይመዝገቡ",
|
"registerOrg": "ድርጅት ይመዝገቡ",
|
||||||
"requestRegister": "ምዝገባ ይጠይቁ",
|
"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": {
|
"dashboard": {
|
||||||
"title": "ዳሽቦርድ",
|
"title": "ዳሽቦርድ",
|
||||||
|
|
@ -241,8 +300,9 @@
|
||||||
"sendInvite": "ግብዣ ላክ",
|
"sendInvite": "ግብዣ ላክ",
|
||||||
"statusInvited": "ተጋብዟል",
|
"statusInvited": "ተጋብዟል",
|
||||||
"statusInactive": "እንቅስቃሴ የለውም",
|
"statusInactive": "እንቅስቃሴ የለውም",
|
||||||
"statusOffboarded": "ተወግዷል"
|
"statusOffboarded": "ተወግዷል",
|
||||||
},
|
"withDependants": "ተመራጮች ያሉት",
|
||||||
|
"withoutDependants": "ተመራጮች የሌሉት" },
|
||||||
"packages": {
|
"packages": {
|
||||||
"title": "ፓኬጆች",
|
"title": "ፓኬጆች",
|
||||||
"subtitle": "Plans, caps, exclusions, perks.",
|
"subtitle": "Plans, caps, exclusions, perks.",
|
||||||
|
|
@ -273,8 +333,10 @@
|
||||||
"stepPreview": "ቅድመ እይታ",
|
"stepPreview": "ቅድመ እይታ",
|
||||||
"stepCommit": "አረጋግጥ",
|
"stepCommit": "አረጋግጥ",
|
||||||
"file": "ፋይል",
|
"file": "ፋይል",
|
||||||
"rows": "ረድፎች"
|
"rows": "ረድፎች",
|
||||||
},
|
"withErrors": "ስህተት ያለው",
|
||||||
|
"noErrors": "ስህተት የሌለው",
|
||||||
|
"createdBy": "ፈጣሪ" },
|
||||||
"finance": {
|
"finance": {
|
||||||
"title": "ፋይናንስ",
|
"title": "ፋይናንስ",
|
||||||
"subtitle": "Spend by dept/member/category — no clinical lines.",
|
"subtitle": "Spend by dept/member/category — no clinical lines.",
|
||||||
|
|
@ -287,7 +349,11 @@
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
"flag": "Flag",
|
"flag": "Flag",
|
||||||
"reason": "ምክንያት",
|
"reason": "ምክንያት",
|
||||||
"transactions": "ግብይቶች"
|
"transactions": "ግብይቶች",
|
||||||
|
"coverage": "ሽፋን",
|
||||||
|
"fullyCovered": "የተሸፈነ",
|
||||||
|
"uncovered": "ያልተሸፈነ",
|
||||||
|
"period": "ጊዜ"
|
||||||
},
|
},
|
||||||
"statements": {
|
"statements": {
|
||||||
"title": "መግለጻዎች",
|
"title": "መግለጻዎች",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@
|
||||||
"results": "results",
|
"results": "results",
|
||||||
"requiredFields": "Please fill required fields"
|
"requiredFields": "Please fill required fields"
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"title": "Filters",
|
||||||
|
"apply": "Apply",
|
||||||
|
"reset": "Reset",
|
||||||
|
"active": "{count} active"
|
||||||
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"superUser": "Super User",
|
"superUser": "Super User",
|
||||||
"superUserDesc": "Full portal access — HR, finance, packages, and support views",
|
"superUserDesc": "Full portal access — HR, finance, packages, and support views",
|
||||||
|
|
@ -62,7 +68,16 @@
|
||||||
"verification": "Verification",
|
"verification": "Verification",
|
||||||
"myBenefits": "My benefits",
|
"myBenefits": "My benefits",
|
||||||
"myOrders": "My orders",
|
"myOrders": "My orders",
|
||||||
"prescriptions": "Prescriptions"
|
"prescriptions": "Prescriptions",
|
||||||
|
"profile": "Profile",
|
||||||
|
"sections": {
|
||||||
|
"overview": "Overview",
|
||||||
|
"organisation": "Organisation",
|
||||||
|
"people": "People",
|
||||||
|
"benefits": "Benefits",
|
||||||
|
"finance": "Finance",
|
||||||
|
"account": "Account"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"bc": {
|
"bc": {
|
||||||
"aria": "Breadcrumb",
|
"aria": "Breadcrumb",
|
||||||
|
|
@ -96,13 +111,15 @@
|
||||||
"prescriptions": "Prescriptions",
|
"prescriptions": "Prescriptions",
|
||||||
"newPrescription": "New prescription",
|
"newPrescription": "New prescription",
|
||||||
"prescriptionDetail": "Prescription detail",
|
"prescriptionDetail": "Prescription detail",
|
||||||
"departmentDetail": "Department detail"
|
"departmentDetail": "Department detail",
|
||||||
|
"profile": "Profile"
|
||||||
},
|
},
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"entitlement": "Entitlement",
|
"entitlement": "Entitlement",
|
||||||
"dependants": "Dependants",
|
"dependants": "Dependants",
|
||||||
"clinical": "Clinical",
|
"clinical": "Clinical",
|
||||||
|
"idCard": "ID card",
|
||||||
"rules": "Rules",
|
"rules": "Rules",
|
||||||
"perks": "Perks",
|
"perks": "Perks",
|
||||||
"summary": "Summary",
|
"summary": "Summary",
|
||||||
|
|
@ -114,6 +131,18 @@
|
||||||
"info": "Info",
|
"info": "Info",
|
||||||
"basics": "Basics"
|
"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": {
|
"stats": {
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
|
@ -156,12 +185,42 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Institutional portal",
|
"eyebrow": "Institutional portal",
|
||||||
"subtitle": "Demo client switcher — pick a persona to explore each experience.",
|
"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}",
|
"continueAs": "Continue as {role}",
|
||||||
|
"getStarted": "Get started",
|
||||||
"registerOrg": "Register organisation",
|
"registerOrg": "Register organisation",
|
||||||
"requestRegister": "Request registration",
|
"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": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
|
|
@ -241,7 +300,9 @@
|
||||||
"importSubtitle": "Quick Excel path — use Migration for full multi-entity onboarding.",
|
"importSubtitle": "Quick Excel path — use Migration for full multi-entity onboarding.",
|
||||||
"statusInvited": "Invited",
|
"statusInvited": "Invited",
|
||||||
"statusInactive": "Inactive",
|
"statusInactive": "Inactive",
|
||||||
"statusOffboarded": "Offboarded"
|
"statusOffboarded": "Offboarded",
|
||||||
|
"withDependants": "With dependants",
|
||||||
|
"withoutDependants": "No dependants"
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"title": "Corporate packages",
|
"title": "Corporate packages",
|
||||||
|
|
@ -273,7 +334,10 @@
|
||||||
"stepMap": "Map columns",
|
"stepMap": "Map columns",
|
||||||
"stepValidate": "Validate",
|
"stepValidate": "Validate",
|
||||||
"stepPreview": "Preview",
|
"stepPreview": "Preview",
|
||||||
"stepCommit": "Commit"
|
"stepCommit": "Commit",
|
||||||
|
"withErrors": "With errors",
|
||||||
|
"noErrors": "No errors",
|
||||||
|
"createdBy": "Created by"
|
||||||
},
|
},
|
||||||
"finance": {
|
"finance": {
|
||||||
"title": "Finance",
|
"title": "Finance",
|
||||||
|
|
@ -287,7 +351,11 @@
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
"flag": "Flag",
|
"flag": "Flag",
|
||||||
"reason": "Reason",
|
"reason": "Reason",
|
||||||
"transactions": "Transactions"
|
"transactions": "Transactions",
|
||||||
|
"coverage": "Coverage",
|
||||||
|
"fullyCovered": "Covered",
|
||||||
|
"uncovered": "Uncovered",
|
||||||
|
"period": "Period"
|
||||||
},
|
},
|
||||||
"statements": {
|
"statements": {
|
||||||
"title": "Statements",
|
"title": "Statements",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
Statement,
|
Statement,
|
||||||
LoyaltySummary,
|
LoyaltySummary,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
import { portraitAvatarUrl } from "@/lib/avatar";
|
||||||
|
|
||||||
export const DEMO_ORG_ID = "org_yaltopia";
|
export const DEMO_ORG_ID = "org_yaltopia";
|
||||||
|
|
||||||
|
|
@ -37,16 +38,19 @@ export const organisation: Organisation = {
|
||||||
createdAt: "2025-11-12T10:00:00Z",
|
createdAt: "2025-11-12T10:00:00Z",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Demo personas for login switcher — distinct users, one per primary role (+ multi-role). */
|
||||||
export const demoUsers: SessionUser[] = [
|
export const demoUsers: SessionUser[] = [
|
||||||
{
|
{
|
||||||
id: "user_super",
|
id: "user_super",
|
||||||
email: "super@yaltopia.com",
|
email: "super@yaltopia.com",
|
||||||
name: "Selam Super",
|
name: "Selam Super",
|
||||||
roles: ["SUPER_USER"],
|
roles: ["SUPER_USER", "HR_ADMIN", "FINANCE", "MEMBER"],
|
||||||
activeRole: "SUPER_USER",
|
activeRole: "SUPER_USER",
|
||||||
orgId: DEMO_ORG_ID,
|
orgId: DEMO_ORG_ID,
|
||||||
|
memberId: "mem_001",
|
||||||
locale: "en",
|
locale: "en",
|
||||||
avatarInitials: "SS",
|
avatarInitials: "SS",
|
||||||
|
avatarUrl: portraitAvatarUrl("user_super"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "user_hr",
|
id: "user_hr",
|
||||||
|
|
@ -57,6 +61,7 @@ export const demoUsers: SessionUser[] = [
|
||||||
orgId: DEMO_ORG_ID,
|
orgId: DEMO_ORG_ID,
|
||||||
locale: "en",
|
locale: "en",
|
||||||
avatarInitials: "HH",
|
avatarInitials: "HH",
|
||||||
|
avatarUrl: portraitAvatarUrl("user_hr"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "user_fin",
|
id: "user_fin",
|
||||||
|
|
@ -65,8 +70,9 @@ export const demoUsers: SessionUser[] = [
|
||||||
roles: ["FINANCE"],
|
roles: ["FINANCE"],
|
||||||
activeRole: "FINANCE",
|
activeRole: "FINANCE",
|
||||||
orgId: DEMO_ORG_ID,
|
orgId: DEMO_ORG_ID,
|
||||||
locale: "en",
|
locale: "am",
|
||||||
avatarInitials: "FF",
|
avatarInitials: "FF",
|
||||||
|
avatarUrl: portraitAvatarUrl("user_fin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "user_mem",
|
id: "user_mem",
|
||||||
|
|
@ -76,11 +82,23 @@ export const demoUsers: SessionUser[] = [
|
||||||
activeRole: "MEMBER",
|
activeRole: "MEMBER",
|
||||||
orgId: DEMO_ORG_ID,
|
orgId: DEMO_ORG_ID,
|
||||||
memberId: "mem_001",
|
memberId: "mem_001",
|
||||||
locale: "en",
|
locale: "am",
|
||||||
avatarInitials: "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[] = [
|
export const departments: Department[] = [
|
||||||
{
|
{
|
||||||
id: "dept_hq",
|
id: "dept_hq",
|
||||||
|
|
@ -185,6 +203,8 @@ export const members: Member[] = [
|
||||||
allowanceRemainingEtb: 2150,
|
allowanceRemainingEtb: 2150,
|
||||||
allowanceTotalEtb: 3500,
|
allowanceTotalEtb: 3500,
|
||||||
verificationId: "VID-1001",
|
verificationId: "VID-1001",
|
||||||
|
cardId: "GSH-1001",
|
||||||
|
avatarUrl: portraitAvatarUrl("mem_001"),
|
||||||
hasActiveRx: true,
|
hasActiveRx: true,
|
||||||
lastRxStatus: "under_review",
|
lastRxStatus: "under_review",
|
||||||
dependants: [
|
dependants: [
|
||||||
|
|
@ -221,6 +241,8 @@ export const members: Member[] = [
|
||||||
allowanceRemainingEtb: 800,
|
allowanceRemainingEtb: 800,
|
||||||
allowanceTotalEtb: 2000,
|
allowanceTotalEtb: 2000,
|
||||||
verificationId: "VID-1044",
|
verificationId: "VID-1044",
|
||||||
|
cardId: "GSH-1044",
|
||||||
|
avatarUrl: portraitAvatarUrl("mem_002"),
|
||||||
hasActiveRx: false,
|
hasActiveRx: false,
|
||||||
dependants: [],
|
dependants: [],
|
||||||
override: {
|
override: {
|
||||||
|
|
@ -243,6 +265,9 @@ export const members: Member[] = [
|
||||||
startDate: "2026-04-01",
|
startDate: "2026-04-01",
|
||||||
allowanceRemainingEtb: 1000,
|
allowanceRemainingEtb: 1000,
|
||||||
allowanceTotalEtb: 1000,
|
allowanceTotalEtb: 1000,
|
||||||
|
verificationId: "VID-1102",
|
||||||
|
cardId: "GSH-1102",
|
||||||
|
avatarUrl: portraitAvatarUrl("mem_003"),
|
||||||
dependants: [],
|
dependants: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -260,6 +285,8 @@ export const members: Member[] = [
|
||||||
allowanceRemainingEtb: 0,
|
allowanceRemainingEtb: 0,
|
||||||
allowanceTotalEtb: 3500,
|
allowanceTotalEtb: 3500,
|
||||||
verificationId: "VID-1088",
|
verificationId: "VID-1088",
|
||||||
|
cardId: "GSH-1088",
|
||||||
|
avatarUrl: portraitAvatarUrl("mem_004"),
|
||||||
dependants: [],
|
dependants: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,17 @@ import {
|
||||||
import type { Role, SessionUser } from "@/types";
|
import type { Role, SessionUser } from "@/types";
|
||||||
import {
|
import {
|
||||||
SESSION_STORAGE_KEY,
|
SESSION_STORAGE_KEY,
|
||||||
|
getDemoUserById,
|
||||||
getDemoUserByRole,
|
getDemoUserByRole,
|
||||||
} from "@/lib/auth/session";
|
} from "@/lib/auth/session";
|
||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
user: SessionUser | null;
|
user: SessionUser | null;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
|
/** @deprecated Prefer loginAsUser — kept for role-only callers */
|
||||||
loginAs: (role: Role) => SessionUser;
|
loginAs: (role: Role) => SessionUser;
|
||||||
|
loginAsUser: (userId: string, activeRole?: Role) => SessionUser;
|
||||||
|
setActiveRole: (role: Role) => SessionUser | null;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
switchDemoUser: () => void;
|
switchDemoUser: () => void;
|
||||||
};
|
};
|
||||||
|
|
@ -59,12 +63,39 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
[persist],
|
[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 logout = useCallback(() => persist(null), [persist]);
|
||||||
const switchDemoUser = useCallback(() => persist(null), [persist]);
|
const switchDemoUser = useCallback(() => persist(null), [persist]);
|
||||||
|
|
||||||
const value = useMemo(
|
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>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ export interface SessionUser {
|
||||||
memberId?: string;
|
memberId?: string;
|
||||||
locale: LocaleCode;
|
locale: LocaleCode;
|
||||||
avatarInitials: string;
|
avatarInitials: string;
|
||||||
|
/** Optional profile photo; demos use deterministic DiceBear URLs */
|
||||||
|
avatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Organisation {
|
export interface Organisation {
|
||||||
|
|
@ -145,6 +147,10 @@ export interface Member {
|
||||||
override?: MemberOverride;
|
override?: MemberOverride;
|
||||||
dependants: Dependant[];
|
dependants: Dependant[];
|
||||||
verificationId?: string;
|
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 */
|
/** Clinical fields — withheld from HR_ADMIN / FINANCE */
|
||||||
hasActiveRx?: boolean;
|
hasActiveRx?: boolean;
|
||||||
lastRxStatus?: PrescriptionStatus;
|
lastRxStatus?: PrescriptionStatus;
|
||||||
|
|
|
||||||
Reference in New Issue
Block a user