Ship Gishen B2B institutional portal with polished layout and mock-backed flows.

Deliver role-aware shell (sidebar, breadcrumbs, quick search, tables, detail/create layouts), locale-ready pages, shared backend/feature docs, and Vercel project config so HR, finance, and members can demo against typed mocks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kirukib 2026-08-06 21:18:23 +03:00
parent 695c172aba
commit 3778801ef5
150 changed files with 27992 additions and 0 deletions

9
.env.example Normal file
View File

@ -0,0 +1,9 @@
# Public app URL (Vercel preview/production)
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Shared backend (not used while mocks are active)
NEXT_PUBLIC_API_BASE_URL=
NEXT_PUBLIC_USE_MOCKS=true
# Optional analytics
NEXT_PUBLIC_VERCEL_ENV=

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

9
AGENTS.md Normal file
View File

@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

1
CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

View File

@ -0,0 +1,98 @@
# Gishen B2B
Institutional portal for Gishen corporate medical benefits clients (Feature Requirements §3).
Organisations hold the contract and credit limit. HR enrols people and configures packages. Finance sees covered spend and statements (no clinical detail). Members use their allowance, orders, and prescriptions.
## Stack
- Next.js 16 (App Router) + React 19 + TypeScript
- Tailwind CSS 4 + shadcn/ui
- next-intl (`en` / `am`)
- TanStack Query + typed mock API adapters
- Hosting: Vercel
## Run locally
```bash
npm install
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) — you will be redirected to `/en/login`.
### Demo login
On `/login`, pick a persona:
| Role | Access |
|------|--------|
| **SUPER_USER** | Full portal (including clinical/Rx for support) |
| **HR_ADMIN** | Org, departments, members, packages, migration — no clinical |
| **FINANCE** | Spend, statements, approvals — no clinical |
| **MEMBER** | Own benefits, orders, prescriptions |
Use **Switch demo user** in the topbar profile menu to return to the role selector.
Language switcher is on login and in the topbar (English / አማርኛ).
## Routes
| Area | Paths |
|------|--------|
| Auth / signup | `/login`, `/register/organisation`, `/register/request`, `/join`, `/invite/[token]` |
| Org | `/organisation`, `/departments`, `/settings/verification` |
| People | `/members`, `/members/import`, `/members/[id]` |
| Packages | `/packages`, `/packages/[id]` |
| Migration | `/migration`, `/migration/new`, `/migration/[jobId]` |
| Finance | `/finance`, `/finance/approvals`, `/statements` |
| Member | `/me`, `/me/orders`, `/prescriptions`, `/prescriptions/new` |
All app routes are locale-prefixed (`/en/...`, `/am/...`).
## Backend specs (standing rule)
This frontend ships against mocks typed to a living shared-backend spec:
- `docs/README.md` — how to extend specs
- `docs/backend/` — entities, endpoints, events, OVERVIEW
- `docs/features/INDEX.md` — feature → pages → entities → endpoints
- `docs/coordination/` — Admin / Ecom / open items
**Whenever UI changes, update `docs/backend/` and `docs/features/INDEX.md` in the same change.**
## Environment
Copy `.env.example` to `.env.local`:
```bash
cp .env.example .env.local
```
| Variable | Purpose |
|----------|---------|
| `NEXT_PUBLIC_APP_URL` | Public URL |
| `NEXT_PUBLIC_API_BASE_URL` | Shared API (unused while mocks are on) |
| `NEXT_PUBLIC_USE_MOCKS` | Keep `true` until shared backend exists |
## Deploy (Vercel)
1. Install and authenticate CLI: `npm i -g vercel && vercel login`
2. From this repo: `vercel link` (project name e.g. `gishen-b2b`)
3. `vercel env pull` (optional)
4. Preview: `vercel` · Production: `vercel --prod`
Config: `vercel.json`. Connect the Gitea remote via Vercel Git integration when available.
## Scripts
```bash
npm run dev # development
npm run build # production build
npm run start # serve production build
npm run lint # eslint
```
## Brand
Gishen mint / black palette and logo from Gishen-Ecom (`public/brand/gishen-logo.png`, `src/data/brand.ts`). Layout patterns follow Fortune Admin `ui` (PageShell, collapsible sidebar, topbar) built with shadcn only.

25
components.json Normal file
View File

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

74
docs/README.md Normal file
View File

@ -0,0 +1,74 @@
# Gishen-B2B documentation
Living documentation for the **Gishen-B2B institutional portal** — the corporate medical-benefits client for HR, Finance, and Members.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Standing rule (non-negotiable)
**Whenever UI changes, update `docs/backend/` and `docs/features/INDEX.md` in the same change.**
Do not ship UI-only work with a “spec later” note. Every page, route, field, enum, role gate, or cross-repo contract must be reflected in the backend spec before the work is considered done.
Applies to:
- Scaffold and baseline entities
- New or changed routes/pages
- New UI capabilities (import, invite, package rules, Rx upload, finance export, approvals, etc.)
- Field/enum/status changes
- Role/permission changes
- Cross-repo contracts (Admin activation, Ecom checkout split, events)
**Done checklist for any PR/task:** UI/mock works **and** `docs/backend/` + `docs/features/INDEX.md` reflect it.
## How to extend specs
1. **Product scope** — start at [`product/b2b-features.md`](product/b2b-features.md) for the §3 feature checklist.
2. **Feature map** — update [`features/INDEX.md`](features/INDEX.md) when adding pages; link to per-feature docs under `features/`.
3. **Backend contract** — add or edit entities in [`backend/entities/`](backend/entities/), endpoints in [`backend/endpoints/`](backend/endpoints/), events in [`backend/events/`](backend/events/README.md).
4. **Cross-repo** — record Admin/Ecom/Mobile coordination in [`coordination/`](coordination/).
5. **Clinical withhold** — every entity/endpoint that touches member health data must document what HR_ADMIN and FINANCE must **not** receive; note SUPER_USER bypass where applicable.
## Directory layout
```
docs/
README.md # this file — standing rule + navigation
product/
b2b-features.md # §3 feature checklist (build brief)
backend/ # living spec sheet for shared platform API
OVERVIEW.md # auth, tenancy, errors, pagination, RBAC
entities/ # data model
endpoints/ # REST (or RPC) contract
events/ # domain events / webhooks
coordination/
gishen-admin.md # Admin writes commercial terms; Rx queue
gishen-ecom.md # shared identity, checkout entitlement split
open-items.md # cross-repo flags and decisions
features/
INDEX.md # feature → pages → entities → endpoints
*.md # per-feature notes
```
## Shared backend model
There is **one shared backend** (not built in this repo). This frontend implements §3 UI against mocks typed to the living spec sheet. Backend consolidation happens later after Ecom, Mobile, B2B, and Admin spec sheets stabilize.
## Brand & i18n (reference)
| Token | Value |
|-------|-------|
| Mint (primary) | `#a8c73a` |
| Forest (ink) | `#141414` |
| Accent (credit warnings) | `#e28a1a` |
Launch locales: **English (`en`)** + **Amharic (`am`)**. User preference stored as `user.locale`.
## Related repos
| Repo | Role |
|------|------|
| **Gishen-B2B** (this) | Institutional portal — HR, Finance, Member |
| **Gishen-Admin** | Org commercial activation, pharmacist Rx review, credit/invoice |
| **Gishen-Ecom** | Retail catalog, cart, checkout (entitlement split UI) |
| **Gishen-Mob** | Mobile-only: camera edge detection, biometrics, push |

191
docs/backend/OVERVIEW.md Normal file
View File

@ -0,0 +1,191 @@
# Backend overview
Living spec for the **shared platform API** consumed by Gishen-B2B. This repo implements mocks typed to this contract; the backend service is consolidated later from B2B, Ecom, Mobile, and Admin spec sheets.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Base URL (future)
```
https://api.gishenpharmacy.org/v1
```
Mock adapters in this repo live under `src/lib/api/` and `src/mocks/`.
## Tenancy
- Every authenticated request (except public registration) is scoped to an **organisation** via `orgId` on the session or explicit path prefix.
- Org lifecycle: `pending_activation` → `active` → `suspended` | `closed`.
- Pending orgs: SUPER_USER may read status and submitted registration; **cannot** create members, packages, or migration commits until Admin activates.
```
X-Org-Id: org_01H...
```
For single-org sessions, `orgId` is implicit from JWT/session. Multi-org users (rare in B2B) pass `X-Org-Id`.
## Authentication
| Mode (this phase) | Description |
|-------------------|-------------|
| Mock demo | `/login` role selector sets session cookie with role + org + user |
| Production (future) | OAuth2 / magic link / SSO — TBD with platform auth |
### Session shape
See [`entities/session-user.md`](entities/session-user.md).
### JWT claims (future)
```json
{
"sub": "usr_01H...",
"org_id": "org_01H...",
"roles": ["HR_ADMIN", "FINANCE"],
"locale": "am",
"iat": 1710000000,
"exp": 1710003600
}
```
## RBAC matrix
| Resource / action | SUPER_USER | HR_ADMIN | FINANCE | MEMBER |
|-------------------|:----------:|:--------:|:-------:|:------:|
| Org profile (commercial read) | ✓ | ✓ (active only) | ✓ (active only) | — |
| Org registration submit | ✓ (public path) | — | — | — |
| Departments CRUD | ✓ | ✓ | read | — |
| Members CRUD / import / invite | ✓ | ✓ | read metadata | own row |
| Packages CRUD | ✓ | ✓ | read | own assignment |
| Migration jobs | ✓ | ✓ | read history | — |
| Finance spend / statements | ✓ | read aggregates | ✓ | — |
| Finance approvals | ✓ | — | ✓ | — |
| Member allowance / orders | ✓ | — | — | ✓ |
| Prescriptions (clinical) | ✓ full | **withheld** | **withheld** | own only |
| Settings / verification | ✓ | ✓ | — | — |
**Clinical withhold:** HR_ADMIN and FINANCE never receive prescription images, medicine line items, dosages, prescriber notes, or diagnosis-related fields. Aggregates (e.g. “Rx order — ETB 450, category: chronic”) may appear in finance spend. SUPER_USER bypasses withhold for support/disputes.
## Error model
All errors return:
```json
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Human-readable summary",
"details": [
{ "field": "email", "code": "INVALID_FORMAT", "message": "..." }
],
"request_id": "req_01H..."
}
}
```
### Standard HTTP status codes
| Status | Usage |
|--------|-------|
| 400 | Validation failed, malformed JSON |
| 401 | Missing or expired session |
| 403 | Authenticated but role/org forbids action; clinical withhold |
| 404 | Resource not found or not visible in tenant |
| 409 | Conflict (duplicate member, job already committed) |
| 422 | Business rule violation (org pending, credit exceeded) |
| 429 | Rate limit |
| 500 | Internal error |
### Error codes (catalog)
| Code | HTTP | Description |
|------|------|-------------|
| `UNAUTHENTICATED` | 401 | No valid session |
| `FORBIDDEN` | 403 | Role cannot perform action |
| `CLINICAL_WITHHELD` | 403 | Clinical field requested by HR/Finance |
| `ORG_PENDING` | 422 | Org not yet activated by Admin |
| `ORG_SUSPENDED` | 422 | Org suspended |
| `VALIDATION_FAILED` | 400 | Field-level validation |
| `DUPLICATE_MEMBER` | 409 | Phone/email/employee_id conflict |
| `CREDIT_LIMIT_EXCEEDED` | 422 | Would exceed org credit |
| `MIGRATION_JOB_LOCKED` | 409 | Job already committed or rolled back |
| `INVITE_EXPIRED` | 422 | Invite token expired |
| `NOT_FOUND` | 404 | Generic not found |
## Pagination
List endpoints accept:
```
?page=1&page_size=25&sort=-created_at&q=search
```
Response envelope:
```json
{
"data": [ ... ],
"pagination": {
"page": 1,
"page_size": 25,
"total_items": 142,
"total_pages": 6
}
}
```
Default `page_size`: 25. Max: 100.
## Filtering & date ranges
Finance and spend endpoints accept:
```
?from=2026-01-01&to=2026-01-31&department_id=dept_01H...
```
ISO 8601 dates in org timezone (default `Africa/Addis_Ababa`).
## File uploads
Multipart for Rx images and migration files:
```
POST /v1/...
Content-Type: multipart/form-data
```
Max file size (Rx page): 10 MB. Accepted: `image/jpeg`, `image/png`, `application/pdf`.
Migration uploads: 50 MB `.xlsx`, `.xls`, `.csv`.
## Idempotency
Mutating endpoints that create billing or migration commits accept:
```
Idempotency-Key: <uuid>
```
Duplicate keys within 24h return the original response.
## Webhooks / events
Domain events published to platform bus; see [`events/README.md`](events/README.md). B2B emits and consumes events for org registration, migration, and prescriptions.
## Environment (Vercel frontend)
| Variable | Purpose |
|----------|---------|
| `NEXT_PUBLIC_API_BASE_URL` | Platform API base (mock or real) |
| `NEXT_PUBLIC_MOCK_AUTH` | `true` for demo role selector |
| `NEXT_PUBLIC_DEFAULT_LOCALE` | `en` |
Document production URL in this file when first Vercel deploy is finalized.
## Related docs
- [Entities index](entities/README.md)
- [Endpoints index](endpoints/README.md)
- [Events](events/README.md)
- [Features INDEX](../features/INDEX.md)

View File

@ -0,0 +1,67 @@
# Endpoints index
REST-style API contract for Gishen-B2B. All paths prefixed with `/v1` unless noted.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Domain groups
| Group | Doc | Description |
|-------|-----|-------------|
| Auth | [auth.md](auth.md) | Login, session, locale |
| Organisation | [org.md](org.md) | Org profile, registration, departments, verification |
| Members | [members.md](members.md) | CRUD, import, invites, join |
| Packages | [packages.md](packages.md) | Benefit plans |
| Migration | [migration.md](migration.md) | Bulk import jobs |
| Finance | [finance.md](finance.md) | Spend, statements, approvals |
| Prescriptions | [prescriptions.md](prescriptions.md) | Member Rx upload/status |
## Conventions
### Auth header
```
Authorization: Bearer <token>
```
Mock phase: session cookie `gishen_b2b_session`.
### Org scope
Implicit from session `org_id`. Public routes (registration) omit org scope.
### Response envelope
Single resource:
```json
{ "data": { ... } }
```
List (see [OVERVIEW.md](../OVERVIEW.md#pagination)):
```json
{ "data": [ ... ], "pagination": { ... } }
```
### Clinical withhold
Endpoints returning prescription or order clinical detail check session roles. HR_ADMIN / FINANCE without SUPER_USER receive redacted payloads or `403 CLINICAL_WITHHELD`.
## Public vs authenticated
| Path pattern | Auth |
|--------------|------|
| `/v1/auth/login` | Public |
| `/v1/org/register*` | Public |
| `/v1/invite/:token` (GET) | Public |
| `/v1/join` (POST) | Public or member session |
| All other `/v1/*` | Required |
## Error reference
See [OVERVIEW.md](../OVERVIEW.md#error-model).
## Mock implementation
Frontend mocks in `src/mocks/` and adapters in `src/lib/api/` must match these contracts exactly.

View File

@ -0,0 +1,196 @@
# Endpoints: Auth
Session and user preference endpoints for Gishen-B2B demo login and future production auth.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
**Entity:** [`../entities/session-user.md`](../entities/session-user.md)
---
## POST /v1/auth/login
Demo role selector login (mock phase).
| | |
|---|---|
| **Auth** | Public |
| **Roles** | — |
### Request
```json
{
"demo_role": "HR_ADMIN",
"locale": "en"
}
```
`demo_role`: `SUPER_USER` \| `HR_ADMIN` \| `FINANCE` \| `MEMBER`
Production (future): `{ "email", "password" }` or OAuth code exchange.
### Response 200
```json
{
"data": {
"token": "mock_jwt_...",
"user": {
"id": "usr_01HHR",
"email": "hr.demo@acme.et",
"full_name": "Demo HR Admin",
"org_id": "org_01DEMO",
"roles": ["HR_ADMIN"],
"locale": "en",
"org_status": "active",
"permissions": ["org:read", "org:write_hr", "members:write", "packages:write", "migration:write", "finance:read"]
}
}
}
```
### Errors
| Code | HTTP | When |
|------|------|------|
| `VALIDATION_FAILED` | 400 | Invalid `demo_role` |
| `UNAUTHENTICATED` | 401 | Production bad credentials |
### Clinical withhold
N/A — session only.
---
## POST /v1/auth/logout
| | |
|---|---|
| **Auth** | Required |
| **Roles** | Any |
### Response 204
No body.
---
## GET /v1/auth/me
Current session user.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | Any |
### Response 200
```json
{
"data": {
"id": "usr_01HMEM",
"email": "member.demo@acme.et",
"full_name": "Demo Member",
"org_id": "org_01DEMO",
"member_id": "mbr_01DEMO",
"customer_id": "cus_01DEMO",
"roles": ["MEMBER"],
"locale": "am",
"org_status": "active",
"allowance_summary": {
"allowance_remaining": { "amount": "10800.00", "currency": "ETB" }
}
}
}
```
### Errors
| Code | HTTP |
|------|------|
| `UNAUTHENTICATED` | 401 |
---
## PATCH /v1/auth/me/locale
Update user locale preference.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | Any |
### Request
```json
{ "locale": "am" }
```
### Response 200
Returns updated session user with `locale: "am"`.
### Validation
| Rule | Code |
|------|------|
| `locale` in `en`, `am` | `VALIDATION_FAILED` |
### Clinical withhold
N/A.
---
## GET /v1/auth/demo-profiles
List pre-seeded demo personas for `/login` selector.
| | |
|---|---|
| **Auth** | Public |
| **Roles** | — |
### Response 200
```json
{
"data": [
{
"demo_role": "SUPER_USER",
"label": "Super User",
"description": "Full portal access including clinical detail"
},
{
"demo_role": "HR_ADMIN",
"label": "HR Administrator",
"description": "Members, departments, packages — no clinical detail"
},
{
"demo_role": "FINANCE",
"label": "Finance Approver",
"description": "Spend, statements, approvals — no clinical detail"
},
{
"demo_role": "MEMBER",
"label": "Member",
"description": "Own allowance, orders, prescriptions"
}
]
}
```
---
## UI routes
| Route | Purpose |
|-------|---------|
| `/login` | Demo role selector + locale switcher |
## Clinical withhold notes
Auth endpoints do not expose clinical data. Role list on session determines withhold behavior on downstream endpoints.

View File

@ -0,0 +1,255 @@
# Endpoints: Finance
Spend analytics, statements, credit usage, and approval workflow.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
**Entity:** [`../entities/finance.md`](../entities/finance.md)
---
## GET /v1/finance/spend/summary
Aggregated spend.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN (read), FINANCE |
### Query
`?from=2026-03-01&to=2026-03-31&group_by=department|category|member`
### Response 200
[`SpendSummary`](../entities/finance.md) — no medicine line detail.
### Clinical withhold
`by_category` uses category codes only. Member breakdown excludes Rx content.
---
## GET /v1/finance/spend/lines
Paginated spend lines.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, FINANCE |
### Query
`?page=1&member_id=mbr_01H&department_id=dept_01H&approval_status=flagged`
### Response 200
Redacted [`SpendLine`](../entities/finance.md) list for FINANCE.
SUPER_USER may pass `?include_detail=true` for support (still no Rx images in finance API).
### Clinical withhold
HR_ADMIN: **403 FORBIDDEN** on this endpoint (use summary only).
FINANCE: redacted lines — no SKU/medicine names.
---
## GET /v1/finance/spend/export
Excel export.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, FINANCE |
### Query
Same filters as summary + `format=xlsx`
### Response 200
Binary XLSX file.
---
## GET /v1/finance/credit
Current credit usage vs limit.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN (read), FINANCE |
### Response 200
```json
{
"data": {
"credit_limit": { "amount": "5000000.00", "currency": "ETB" },
"credit_used": { "amount": "1245000.00", "currency": "ETB" },
"credit_available": { "amount": "3755000.00", "currency": "ETB" },
"utilization_percent": 24.9,
"period_end": "2026-03-31"
}
}
```
### Errors
| Code | HTTP | When |
|------|------|------|
| `ORG_PENDING` | 422 | No commercial terms |
---
## GET /v1/finance/statements
List monthly statements.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, FINANCE |
### Query
`?page=1&year=2026`
### Response 200
Paginated [`Statement`](../entities/finance.md) list.
---
## GET /v1/finance/statements/:id
Statement detail + PDF URL.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, FINANCE |
---
## GET /v1/finance/statements/:id/download
Download PDF.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, FINANCE |
---
## GET /v1/finance/approvals
List approval flags.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, FINANCE |
### Query
`?status=flagged&page=1`
### Response 200
Paginated [`ApprovalFlag`](../entities/finance.md).
---
## POST /v1/finance/approvals/:id/decide
Approve or reject flagged spend.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, FINANCE |
### Request
```json
{
"decision": "approved",
"notes": "Verified with department head"
}
```
`decision`: `approved` \| `rejected`
### Response 200
Updated approval flag + spend line status.
### Errors
| Code | HTTP |
|------|------|
| `NOT_FOUND` | 404 |
| `FORBIDDEN` | 403 — HR_ADMIN |
---
## GET /v1/finance/spend/lines/:id
Single spend line detail (mock: `/finance/spend/[id]`).
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, FINANCE |
### Clinical withhold
FINANCE: redacted description only. SUPER_USER: optional support detail without Rx images.
---
## POST /v1/finance/statements/:id/issue
Mark draft statement as issued (mock action).
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, FINANCE |
---
## POST /v1/finance/statements/:id/mark-paid
Mark issued statement as paid (mock action).
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, FINANCE |
---
## UI routes
| Route | Endpoint(s) |
|-------|-------------|
| `/finance` | Spend summary + credit + charts |
| `/finance/spend/[id]` | GET spend line detail |
| `/finance/approvals` | Approvals list + decide |
| `/statements` | Statements list |
| `/statements/[id]` | Statement detail + issue/pay + download |
## Clinical withhold
Finance APIs never return prescription images, medicine names, dosages, or diagnosis. Aggregates and redacted descriptions only.
HR_ADMIN has read access to **summary** endpoints only, not spend lines or approvals mutation.

View File

@ -0,0 +1,309 @@
# Endpoints: Members
Member CRUD, quick import, invites, join flow, and dependants.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
**Entity:** [`../entities/member.md`](../entities/member.md)
---
## GET /v1/members
List members.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN, FINANCE (read metadata) |
### Query
`?page=1&status=active&department_id=dept_01H&q=abel&member_type=primary`
### Response 200
Paginated members. HR/Finance receive withhold-safe fields (no Rx detail).
### Clinical withhold
Response omits prescription arrays; includes `prescription_count` only.
---
## POST /v1/members
Create member.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Request
```json
{
"full_name": "Sara Hailu",
"phone": "+251933445566",
"email": "sara.h@acme.et",
"employee_id": "EMP-2001",
"department_id": "dept_01HABC",
"package_id": "pkg_01HGOLD",
"member_type": "primary",
"start_date": "2026-04-01",
"send_invite": true
}
```
### Response 201
Created [`Member`](../entities/member.md).
### Errors
| Code | HTTP | When |
|------|------|------|
| `ORG_PENDING` | 422 | Org not active |
| `DUPLICATE_MEMBER` | 409 | Phone/employee_id conflict |
| `NOT_FOUND` | 404 | Invalid department/package |
| `DEPENDANT_LIMIT_EXCEEDED` | 422 | For dependant create |
---
## GET /v1/members/:id
Member detail.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN, FINANCE (metadata); MEMBER (own only) |
### Errors
| Code | HTTP |
|------|------|
| `FORBIDDEN` | 403 — MEMBER accessing other member |
| `NOT_FOUND` | 404 |
### Clinical withhold
HR/Finance: no linked prescriptions. SUPER_USER: may include `prescriptions_summary`. MEMBER own: full allowance, order pointers.
---
## PATCH /v1/members/:id
Update member.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Request (partial)
```json
{
"department_id": "dept_01HNEW",
"package_id": "pkg_01HSILVER",
"overrides": { "copay_percent": 5 }
}
```
---
## POST /v1/members/:id/offboard
Offboard member.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Request
```json
{
"end_date": "2026-03-31",
"reason": "resignation"
}
```
### Response 200
`status: "offboarded"`. Prescriptions remain on `customer_id`.
---
## POST /v1/members/import
Quick Excel/CSV import (simplified migration).
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Request
`multipart/form-data`: `file`, optional `send_invites=true`
### Response 202
```json
{
"data": {
"job_id": "mig_01HQUICK",
"status": "validating"
}
}
```
Delegates to migration engine — see [`migration.md`](migration.md).
---
## POST /v1/members/:id/invite
Resend or create invite.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Response 200
```json
{
"data": {
"invite_url": "https://b2b.gishen.../invite/tok_abc123",
"expires_at": "2026-03-20T00:00:00Z"
}
}
```
---
## GET /v1/invite/:token
Public invite preview.
| | |
|---|---|
| **Auth** | Public |
### Response 200
```json
{
"data": {
"org_name": "Acme Bank",
"member_name": "Sara Hailu",
"expires_at": "2026-03-20T00:00:00Z",
"valid": true
}
}
```
### Errors
| Code | HTTP |
|------|------|
| `INVITE_EXPIRED` | 422 |
| `NOT_FOUND` | 404 |
---
## POST /v1/invite/:token/accept
Accept invite and activate member account.
| | |
|---|---|
| **Auth** | Public (creates session) |
### Request
```json
{
"password": "SecurePass1",
"locale": "en"
}
```
---
## POST /v1/join
Self-join via join code or email domain.
| | |
|---|---|
| **Auth** | Public |
### Request
```json
{
"join_code": "ACME-2026",
"full_name": "New Employee",
"email": "new@acme.et",
"phone": "+251944556677"
}
```
Or domain-verified email flow.
### Response 201
Pending or active member + session depending on org verification settings.
### Errors
| Code | HTTP |
|------|------|
| `VALIDATION_FAILED` | 400 — bad code/domain |
| `ORG_PENDING` | 422 |
---
## POST /v1/members/:id/dependants
Add dependant.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Request
```json
{
"full_name": "Kidus Hailu",
"phone": "+251955667788",
"relationship": "child",
"start_date": "2026-04-01"
}
```
---
## UI routes
| Route | Endpoint(s) |
|-------|-------------|
| `/members` | GET `/v1/members` |
| `/members/new` | POST `/v1/members` |
| `/members/import` | POST `/v1/members/import` |
| `/members/[id]` | GET/PATCH + invite/offboard/status mock actions |
| `/invite/[token]` | GET/POST invite |
| `/join` | POST `/v1/join` |
## Clinical withhold
Member endpoints never return prescription images or medicine lines to HR_ADMIN/FINANCE. Use prescription endpoints with role checks for SUPER_USER/MEMBER clinical access.

View File

@ -0,0 +1,253 @@
# Endpoints: Migration
Bulk data migration wizard — upload, map, validate, preview, commit, rollback.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
**Entity:** [`../entities/migration-job.md`](../entities/migration-job.md)
---
## GET /v1/migration/jobs
List migration jobs.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN, FINANCE (read history) |
### Query
`?page=1&status=completed&dataset_type=members`
### Response 200
Paginated [`MigrationJob`](../entities/migration-job.md) summaries.
---
## POST /v1/migration/jobs
Create job + upload file.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Request
`multipart/form-data`:
| Field | Type | Required |
|-------|------|----------|
| `dataset_type` | string | ✓ |
| `file` | file | ✓ |
### Response 201
```json
{
"data": {
"id": "mig_01HNEW",
"status": "uploaded",
"file_name": "employees.xlsx"
}
}
```
---
## GET /v1/migration/jobs/:id
Job detail with validation/preview state.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN, FINANCE (read) |
---
## GET /v1/migration/templates/:dataset_type
Download Excel template.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Response 200
`Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
Filename: `gishen_migration_{dataset_type}_template.xlsx`
---
## PUT /v1/migration/jobs/:id/mapping
Save column mapping.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Request
```json
{
"column_mapping": {
"Full Name": "full_name",
"Mobile": "phone",
"Emp ID": "employee_id"
},
"save_profile": true,
"profile_name": "Default employee import"
}
```
### Response 200
Job `status: "mapping"` → triggers `validating`.
---
## POST /v1/migration/jobs/:id/validate
Re-run validation after mapping changes.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Response 200
Job with `validation` report. Status → `validated` or `validated` with errors.
---
## POST /v1/migration/jobs/:id/preview
Generate preview diff.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Response 200
Job `status: "preview_ready"` with `preview` summary.
### Errors
| Code | HTTP | When |
|------|------|------|
| `VALIDATION_FAILED` | 400 | Unresolved errors |
---
## POST /v1/migration/jobs/:id/commit
Commit import.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Headers
`Idempotency-Key: <uuid>` (recommended)
### Response 202
```json
{
"data": {
"id": "mig_01HNEW",
"status": "committing"
}
}
```
Poll GET until `completed` or `failed`.
### Errors
| Code | HTTP | When |
|------|------|------|
| `ORG_PENDING` | 422 | Org not active |
| `MIGRATION_JOB_LOCKED` | 409 | Wrong status |
| `VALIDATION_FAILED` | 400 | Errors remain |
### Events
- `migration.job_started`
- `migration.job_completed` or `migration.job_failed`
---
## POST /v1/migration/jobs/:id/rollback
Soft rollback within window.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER |
### Response 200
`status: "rolled_back"` — deactivates members created in job where safe.
### Errors
| Code | HTTP |
|------|------|
| `ROLLBACK_EXPIRED` | 422 |
---
## GET /v1/migration/jobs/:id/error-report
Download error CSV.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
---
## GET /v1/migration/mapping-profiles
Saved column mapping profiles for org.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
---
## UI routes
| Route | Endpoint(s) |
|-------|-------------|
| `/migration` | GET `/v1/migration/jobs` |
| `/migration/new` | POST job wizard |
| `/migration/[jobId]` | Job detail + wizard steps |
## Clinical withhold
Migration datasets exclude Rx/clinical history. No withhold on responses.
## Shared engine
`/members/import` uses same validation/commit pipeline with `dataset_type=members`.

View File

@ -0,0 +1,291 @@
# Endpoints: Organisation
Org profile, registration, departments, and verification settings.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
**Entities:** [`organisation`](../entities/organisation.md), [`org-registration`](../entities/org-registration.md), [`department`](../entities/department.md)
---
## POST /v1/org/register
Self-register organisation + first SUPER_USER.
| | |
|---|---|
| **Auth** | Public |
| **Roles** | — |
### Request
See sample in [`org-registration.md`](../entities/org-registration.md).
### Response 201
```json
{
"data": {
"registration_id": "oreg_01HABC",
"org_id": "org_01HNEW",
"status": "submitted",
"org_status": "pending_activation",
"session": { "user_id": "usr_01HNEW", "roles": ["SUPER_USER"], "org_id": "org_01HNEW" }
}
}
```
### Validation / errors
| Code | HTTP | When |
|------|------|------|
| `DUPLICATE_ORG` | 409 | TIN exists |
| `DUPLICATE_USER` | 409 | Email taken |
| `WEAK_PASSWORD` | 400 | Password policy |
| `VALIDATION_FAILED` | 400 | Field errors |
### Event
`org.registration_submitted`
### Clinical withhold
N/A.
---
## POST /v1/org/register/request
Request-to-register (email or phone lead).
| | |
|---|---|
| **Auth** | Public |
### Request / Response
See [`org-registration.md`](../entities/org-registration.md).
### Event
`org.registration_requested`
---
## GET /v1/org
Current organisation profile.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN, FINANCE |
### Response 200
Full [`Organisation`](../entities/organisation.md). `commercial` null if pending.
### Errors
| Code | HTTP | When |
|------|------|------|
| `FORBIDDEN` | 403 | MEMBER role |
| `UNAUTHENTICATED` | 401 | |
### Clinical withhold
No clinical fields.
---
## PATCH /v1/org
Update HR-managed org fields (not commercial).
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Request (partial)
```json
{
"display_name": "Acme Bank",
"dependant_limit": 4,
"join_rules": {
"join_code": "ACME-2026",
"allowed_email_domains": ["acme.et"]
}
}
```
### Response 200
Updated organisation.
### Errors
| Code | HTTP | When |
|------|------|------|
| `ORG_PENDING` | 422 | Some actions blocked — read still allowed |
| `FORBIDDEN` | 403 | Attempt to mutate `commercial` |
---
## GET /v1/org/registration-status
Pending org activation status for SUPER_USER.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER |
### Response 200
```json
{
"data": {
"org_status": "pending_activation",
"submitted_at": "2026-03-01T09:00:00Z",
"message": "Gishen is reviewing your application. You will be notified when activated."
}
}
```
---
## PATCH /v1/org/verification
Verification method settings.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Request
```json
{
"method": "employee_id",
"require_at_checkout": true
}
```
### Response 200
Updated `verification` object.
### Errors
| Code | HTTP | When |
|------|------|------|
| `ORG_PENDING` | 422 | Org not active |
---
## GET /v1/org/departments
List departments.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN, FINANCE (read) |
### Query
`?page=1&page_size=25&q=addis&parent_id=dept_root`
### Response 200
Paginated [`Department`](../entities/department.md) list.
---
## POST /v1/org/departments
Create department.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Request
```json
{
"name": "Hawassa Branch",
"code": "HAW",
"parent_id": null,
"sub_limit": { "amount": "100000.00", "currency": "ETB" }
}
```
### Response 201
Created department.
### Errors
| Code | HTTP |
|------|------|
| `DUPLICATE_DEPARTMENT` | 409 |
| `ORG_PENDING` | 422 |
| `VALIDATION_FAILED` | 400 |
---
## GET /v1/org/departments/:id
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN, FINANCE |
---
## PATCH /v1/org/departments/:id
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
---
## DELETE /v1/org/departments/:id
Soft-deactivate (`is_active: false`).
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Errors
| Code | HTTP |
|------|------|
| `DEPARTMENT_IN_USE` | 422 |
---
## UI routes
| Route | Endpoint(s) |
|-------|-------------|
| `/organisation` | GET/PATCH `/v1/org` + spend charts |
| `/departments` | Department list |
| `/departments/new` | POST `/v1/org/departments` |
| `/departments/[id]` | GET/PATCH department detail |
| `/settings/verification` | PATCH `/v1/org/verification` |
| `/register/organisation` | POST `/v1/org/register` |
| `/register/request` | POST `/v1/org/register/request` |
## Clinical withhold
Organisation and department endpoints expose no clinical data.

View File

@ -0,0 +1,185 @@
# Endpoints: Packages
Corporate benefit plan CRUD and member assignment helpers.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
**Entity:** [`../entities/package.md`](../entities/package.md)
---
## GET /v1/packages
List packages.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN, FINANCE (read), MEMBER (own assignment via `/v1/me/package`) |
### Query
`?page=1&status=active&q=gold`
### Response 200
Paginated [`Package`](../entities/package.md) list.
---
## POST /v1/packages
Create package.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Request
```json
{
"name": "Silver Standard",
"code": "SILVER",
"description": "Standard employee coverage",
"allowance": {
"amount": { "amount": "8000.00", "currency": "ETB" },
"period": "monthly",
"rollover": false
},
"copay_percent": 15,
"categories": [
{ "category_code": "chronic", "coverage_percent": 90, "max_per_order": null }
],
"caps": {
"max_order_amount": { "amount": "5000.00", "currency": "ETB" },
"max_orders_per_month": 8,
"max_rx_fills_per_month": 3
},
"exclusions": [],
"perks": [],
"status": "draft"
}
```
### Response 201
Created package.
### Errors
| Code | HTTP | When |
|------|------|------|
| `DUPLICATE_PACKAGE` | 409 | Code exists |
| `ORG_PENDING` | 422 | Cannot activate until org active |
| `VALIDATION_FAILED` | 400 | |
---
## GET /v1/packages/:id
Package detail.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN, FINANCE; MEMBER if assigned |
### Response 200
Full package including assigned `member_count`.
---
## PATCH /v1/packages/:id
Update package.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Notes
- Setting `status: "active"` requires org `active`.
- Archiving blocked if members assigned without reassignment.
### Errors
| Code | HTTP |
|------|------|
| `PACKAGE_IN_USE` | 422 |
---
## DELETE /v1/packages/:id
Archive package (`status: archived`).
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
---
## GET /v1/packages/:id/members
Members assigned to package.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER, HR_ADMIN |
### Response 200
Paginated member summary (id, name, employee_id, status).
---
## GET /v1/me/package
Member's assigned package + allowance (member portal).
| | |
|---|---|
| **Auth** | Required |
| **Roles** | MEMBER, SUPER_USER (when viewing as member) |
### Response 200
```json
{
"data": {
"package": { "id": "pkg_01HGOLD", "name": "Gold Executive", "code": "GOLD", "...": "..." },
"allowance_summary": {
"allowance_total": { "amount": "15000.00", "currency": "ETB" },
"allowance_used": { "amount": "4200.00", "currency": "ETB" },
"allowance_remaining": { "amount": "10800.00", "currency": "ETB" }
},
"overrides": null
}
}
```
---
## UI routes
| Route | Endpoint(s) |
|-------|-------------|
| `/packages` | GET `/v1/packages` |
| `/packages/new` | POST `/v1/packages` |
| `/packages/[id]` | GET/PATCH + activate/deactivate mock |
| `/me` | GET `/v1/me/package` |
## Clinical withhold
Packages contain coverage rules only — safe for all roles.
## Checkout note
Remaining allowance displayed in B2B member UI; **entitlement split at checkout** is implemented in Gishen-Ecom — see [`../../coordination/gishen-ecom.md`](../../coordination/gishen-ecom.md).

View File

@ -0,0 +1,228 @@
# Endpoints: Prescriptions
Member prescription upload, status tracking, and refill reminders.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
**Entity:** [`../entities/prescription.md`](../entities/prescription.md)
---
## GET /v1/prescriptions
List prescriptions for current member.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | MEMBER (own), SUPER_USER (any member via `?member_id=`) |
### Query
`?page=1&status=submitted`
### Response 200
Full [`Prescription`](../entities/prescription.md) for MEMBER/SUPER_USER.
### Clinical withhold
HR_ADMIN / FINANCE: **403 FORBIDDEN**
---
## POST /v1/prescriptions
Create draft prescription.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | MEMBER, SUPER_USER |
### Request
```json
{
"notes": "Renewal for chronic medication"
}
```
### Response 201
```json
{
"data": {
"id": "rx_01HNEW",
"status": "draft",
"pages": []
}
}
```
---
## GET /v1/prescriptions/:id
Prescription detail.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | MEMBER (own), SUPER_USER |
### Clinical withhold
HR/Finance: 403 or redacted stub (id, status, dates only) if explicitly granted read metadata — default deny.
---
## POST /v1/prescriptions/:id/pages
Upload page (multipart).
| | |
|---|---|
| **Auth** | Required |
| **Roles** | MEMBER (own), SUPER_USER |
### Request
`multipart/form-data`: `file` (jpeg/png/pdf), `page_number`
### Response 201
Updated prescription with new page.
### Validation
| Rule | Code |
|------|------|
| Max 10 pages | `VALIDATION_FAILED` |
| Max 10 MB | `VALIDATION_FAILED` |
| Allowed mime types | `VALIDATION_FAILED` |
---
## DELETE /v1/prescriptions/:id/pages/:pageId
Remove page from draft.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | MEMBER (own draft), SUPER_USER |
---
## POST /v1/prescriptions/:id/submit
Submit for pharmacist review.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | MEMBER (own), SUPER_USER |
### Response 200
`status: "submitted"`
### Errors
| Code | HTTP | When |
|------|------|------|
| `VALIDATION_FAILED` | 400 | No pages |
| `MEMBER_INACTIVE` | 422 | |
### Event
`prescription.submitted`
---
## POST /v1/prescriptions/:id/respond
Member response to query (upload additional pages / message).
| | |
|---|---|
| **Auth** | Required |
| **Roles** | MEMBER (own) |
### Request
```json
{
"message": "Added clearer photo of page 2"
}
```
Sets status → `under_review`.
---
## GET /v1/prescriptions/refill-reminders
Upcoming refills for member.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | MEMBER |
### Response 200
```json
{
"data": [
{
"prescription_id": "rx_01H001",
"refill_due_at": "2026-03-25",
"days_remaining": 5,
"status": "approved"
}
]
}
```
No medicine names in reminder list (optional generic label only).
---
## GET /v1/members/:id/prescriptions (SUPER_USER only)
Support view of member prescriptions.
| | |
|---|---|
| **Auth** | Required |
| **Roles** | SUPER_USER |
Full clinical payload.
### Clinical withhold
Explicitly SUPER_USER only. HR/Finance: 403.
---
## UI routes
| Route | Endpoint(s) |
|-------|-------------|
| `/prescriptions` | GET `/v1/prescriptions` |
| `/prescriptions/new` | POST + page upload + submit |
| `/prescriptions/[id]` | GET detail + respond / mock pharmacist actions (SUPER_USER) |
| `/me` | Refill reminders widget |
| `/me/orders/[id]` | Order detail (Ecom pointer; mock) |
## Admin integration
Pharmacist review queue is in Gishen-Admin. B2B consumes `prescription.review_updated` events to refresh status.
## Platform notes
- Web: multi-page file upload
- Mobile: edge-detection camera (Gishen-Mob)
- No handwritten OCR

View File

@ -0,0 +1,91 @@
# Entities index
Data model entities required by Gishen-B2B. Types below are canonical for mocks in `src/types/` and the future shared API.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Entity catalog
| Entity | Doc | Primary consumers |
|--------|-----|-------------------|
| Organisation | [organisation.md](organisation.md) | All roles (scoped) |
| Org registration | [org-registration.md](org-registration.md) | Public signup, Admin |
| Department | [department.md](department.md) | HR, Finance (aggregates) |
| Member | [member.md](member.md) | HR, Member; Finance (metadata only) |
| Package | [package.md](package.md) | HR, Member |
| Migration job | [migration-job.md](migration-job.md) | HR, SUPER_USER |
| Finance | [finance.md](finance.md) | Finance, HR (read), SUPER_USER |
| Prescription | [prescription.md](prescription.md) | Member, SUPER_USER; Admin review |
| Session user | [session-user.md](session-user.md) | Auth layer |
## ID conventions
```
org_01HXXXXXXXXXXXXXX
dept_01HXXXXXXXXXXXXXX
mbr_01HXXXXXXXXXXXXXX
pkg_01HXXXXXXXXXXXXXX
mig_01HXXXXXXXXXXXXXX
rx_01HXXXXXXXXXXXXXX
usr_01HXXXXXXXXXXXXXX
```
ULID-style prefixes for human readability in logs.
## Common field types
| Type | Format |
|------|--------|
| `Money` | `{ "amount": "1250.00", "currency": "ETB" }` — decimal string |
| `Timestamp` | ISO 8601 UTC |
| `Phone` | E.164, e.g. `+251911234567` |
| `Locale` | `en` \| `am` |
## Cross-cutting enums
### OrgStatus
```
pending_activation | active | suspended | closed
```
### MemberStatus
```
invited | active | inactive | offboarded
```
### PrescriptionStatus
```
draft | submitted | under_review | approved | queried | rejected
```
## Clinical withhold (entity-level)
When serializing for HR_ADMIN or FINANCE:
| Entity | Withheld fields |
|--------|-----------------|
| Prescription | `pages[]`, `medicine_lines[]`, `prescriber_notes`, `diagnosis_codes` |
| Member (finance view) | Link to full Rx list — only `prescription_count` aggregate |
| Order line (if clinical) | Medicine SKU detail → category + amount only |
SUPER_USER receives full payloads. MEMBER receives own clinical data only.
## Relationships (ER sketch)
```
Organisation 1──* Department
Organisation 1──* Member
Organisation 1──* Package
Member *──1 Package (assignment)
Member 1──* Prescription
Member 0──1 Member (primary_member for dependants)
Organisation 1──* MigrationJob
Organisation 1──* Statement (finance)
```
## Versioning
Each entity includes `created_at`, `updated_at`. Optimistic concurrency via `version` (integer) on mutable resources.

View File

@ -0,0 +1,71 @@
# Entity: Department
Organisational unit or site within an org, optionally with spend sub-limits for finance reporting and package routing.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Fields
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | `string` | ✓ | `dept_*` |
| `org_id` | `string` | ✓ | Tenant scope |
| `name` | `string` | ✓ | e.g. "Addis HQ", "Hawassa Branch" |
| `code` | `string` | | Short code for imports |
| `parent_id` | `string` \| null | | Site hierarchy |
| `sub_limit` | `Money` \| null | | Optional monthly cap for department |
| `sub_limit_used` | `Money` | | Read-only; current period |
| `member_count` | `integer` | | Denormalized count |
| `is_active` | `boolean` | ✓ | Default true |
| `created_at` | `Timestamp` | ✓ | |
| `updated_at` | `Timestamp` | ✓ | |
| `version` | `integer` | ✓ | |
## Validation rules
| Rule | Error code |
|------|------------|
| `name` unique per org (case-insensitive) | `DUPLICATE_DEPARTMENT` |
| `code` unique per org if provided | `DUPLICATE_DEPARTMENT` |
| `parent_id` must belong to same org | `VALIDATION_FAILED` |
| No circular parent chain | `VALIDATION_FAILED` |
| `sub_limit.amount` ≥ 0 | `VALIDATION_FAILED` |
| Cannot delete dept with active members | `DEPARTMENT_IN_USE` |
## Clinical withhold
Departments have no clinical fields. Finance may see spend **aggregated by department** without medicine detail.
## Sample payload
```json
{
"id": "dept_01HABC123",
"org_id": "org_01HQXYZ",
"name": "Addis HQ — Finance Division",
"code": "ADD-FIN",
"parent_id": "dept_01HROOT",
"sub_limit": { "amount": "250000.00", "currency": "ETB" },
"sub_limit_used": { "amount": "87250.00", "currency": "ETB" },
"member_count": 42,
"is_active": true,
"created_at": "2026-01-10T08:00:00Z",
"updated_at": "2026-02-15T11:00:00Z",
"version": 3
}
```
## Migration mapping
Canonical import columns:
| Column | Field |
|--------|-------|
| `department_name` | `name` |
| `department_code` | `code` |
| `parent_code` | resolve → `parent_id` |
| `sub_limit_etb` | `sub_limit.amount` |
## Related endpoints
- [`../endpoints/org.md`](../endpoints/org.md) — department CRUD

View File

@ -0,0 +1,169 @@
# Entity: Finance
Spend aggregates, statements, credit usage, and approval flags — **no clinical line detail** for HR/Finance roles.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Sub-entities
| Entity | Purpose |
|--------|---------|
| `SpendSummary` | Aggregated spend by dimension |
| `SpendLine` | Individual covered transaction (redacted for HR/Finance) |
| `Statement` | Monthly org invoice summary |
| `ApprovalFlag` | Threshold-triggered item for Finance |
| `CreditUsage` | Real-time credit vs limit |
## SpendSummary
| Field | Type | Notes |
|-------|------|-------|
| `org_id` | `string` | |
| `period_start` | `date` | |
| `period_end` | `date` | |
| `total_spend` | `Money` | |
| `by_department` | `DeptSpend[]` | |
| `by_category` | `CategorySpend[]` | |
| `by_member` | `MemberSpend[]` | Optional; paginated separately |
### DeptSpend / CategorySpend
| Field | Type |
|-------|------|
| `id` / `code` | `string` |
| `label` | `string` |
| `amount` | `Money` |
| `order_count` | `integer` |
### MemberSpend (HR/Finance view)
| Field | Type | Notes |
|-------|------|-------|
| `member_id` | `string` | |
| `full_name` | `string` | |
| `employee_id` | `string` | |
| `department_name` | `string` | |
| `amount` | `Money` | |
| `order_count` | `integer` | |
| `rx_order_count` | `integer` | Count only — no Rx detail |
## SpendLine
| Field | Type | Notes |
|-------|------|-------|
| `id` | `string` | `spd_*` |
| `org_id` | `string` | |
| `member_id` | `string` | |
| `order_id` | `string` | Ecom order ref |
| `occurred_at` | `Timestamp` | |
| `amount_total` | `Money` | |
| `amount_org_covered` | `Money` | |
| `amount_member_paid` | `Money` | Co-pay / OOP |
| `category_code` | `string` | **Not** SKU/medicine name for HR/Finance |
| `department_id` | `string` | |
| `description` | `string` | Redacted: "Chronic medication order" |
| `approval_status` | `ApprovalStatus` | |
### ApprovalStatus
```
none | flagged | approved | rejected
```
## Statement
| Field | Type | Notes |
|-------|------|-------|
| `id` | `string` | `stmt_*` |
| `org_id` | `string` | |
| `period` | `string` | e.g. `2026-02` |
| `opening_balance` | `Money` | |
| `charges` | `Money` | |
| `payments` | `Money` | |
| `closing_balance` | `Money` | |
| `credit_limit` | `Money` | Snapshot |
| `pdf_url` | `string` | Download |
| `issued_at` | `Timestamp` | |
## ApprovalFlag
| Field | Type | Notes |
|-------|------|-------|
| `id` | `string` | `appr_*` |
| `org_id` | `string` | |
| `spend_line_id` | `string` | |
| `member_id` | `string` | |
| `reason` | `FlagReason` | |
| `threshold_rule` | `string` | e.g. `single_order > 5000 ETB` |
| `amount` | `Money` | |
| `status` | `ApprovalStatus` | |
| `reviewed_by` | `string` | |
| `reviewed_at` | `Timestamp` | |
| `notes` | `string` | Finance notes |
### FlagReason
```
threshold_exceeded | unusual_category | first_time_high_value | manual_flag
```
## CreditUsage
| Field | Type |
|-------|------|
| `credit_limit` | `Money` |
| `credit_used` | `Money` |
| `credit_available` | `Money` |
| `utilization_percent` | `number` |
| `period_end` | `date` |
## Clinical withhold
For HR_ADMIN and FINANCE:
| Withheld on SpendLine | Allowed substitute |
|-----------------------|-------------------|
| Medicine names, SKUs, dosages | `category_code` + redacted `description` |
| Prescription image refs | `rx_order_count` on member aggregate |
| Prescriber / diagnosis | Never exposed |
SUPER_USER may request full order detail via Admin/Ecom cross-ref (out of B2B finance API default).
## Sample: SpendSummary
```json
{
"org_id": "org_01HQXYZ",
"period_start": "2026-03-01",
"period_end": "2026-03-31",
"total_spend": { "amount": "1245000.00", "currency": "ETB" },
"by_department": [
{ "id": "dept_01HABC", "label": "Addis HQ", "amount": { "amount": "800000.00", "currency": "ETB" }, "order_count": 420 }
],
"by_category": [
{ "code": "chronic", "label": "Chronic", "amount": { "amount": "900000.00", "currency": "ETB" }, "order_count": 310 }
]
}
```
## Sample: ApprovalFlag
```json
{
"id": "appr_01H001",
"org_id": "org_01HQXYZ",
"spend_line_id": "spd_01H999",
"member_id": "mbr_01HMEM001",
"reason": "threshold_exceeded",
"threshold_rule": "single_order > 5000 ETB",
"amount": { "amount": "7200.00", "currency": "ETB" },
"status": "flagged",
"reviewed_by": null,
"reviewed_at": null,
"notes": null
}
```
## Related endpoints
- [`../endpoints/finance.md`](../endpoints/finance.md)

View File

@ -0,0 +1,145 @@
# Entity: Member
Employee or dependant enrolled under an organisation's corporate medical benefit.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Fields
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | `string` | ✓ | `mbr_*` |
| `org_id` | `string` | ✓ | |
| `customer_id` | `string` | | Shared Gishen customer identity (Ecom/Mob) |
| `employee_id` | `string` | | Org HR identifier |
| `full_name` | `string` | ✓ | |
| `email` | `string` | | |
| `phone` | `Phone` | ✓ | Primary contact |
| `department_id` | `string` | | FK → Department |
| `package_id` | `string` | | Assigned benefit plan |
| `role` | `MemberPortalRole` | ✓ | Portal access |
| `member_type` | `MemberType` | ✓ | |
| `primary_member_id` | `string` \| null | | For dependants |
| `status` | `MemberStatus` | ✓ | |
| `start_date` | `date` | | Coverage start |
| `end_date` | `date` \| null | | Offboarding |
| `overrides` | `MemberOverrides` | | Per-member entitlement tweaks |
| `verification_id` | `string` | | Employee ID / QR payload |
| `invite_id` | `string` | | If joined via invite |
| `allowance_summary` | `AllowanceSummary` | | Read-only computed |
| `prescription_count` | `integer` | | Aggregate only for HR/Finance |
| `created_at` | `Timestamp` | ✓ | |
| `updated_at` | `Timestamp` | ✓ | |
| `version` | `integer` | ✓ | |
### MemberPortalRole
```
MEMBER | HR_ADMIN | FINANCE | SUPER_USER
```
A user may hold multiple portal roles; stored on linked `User`, reflected here for display.
### MemberType
```
primary | dependant
```
### MemberStatus
```
invited | active | inactive | offboarded
```
### MemberOverrides
| Field | Type | Notes |
|-------|------|-------|
| `allowance_cap` | `Money` \| null | Overrides package cap |
| `copay_percent` | `number` \| null | 0–100 |
| `excluded_categories` | `string[]` | |
| `included_perks` | `string[]` | Extra perk codes |
### AllowanceSummary
| Field | Type | Notes |
|-------|------|-------|
| `period_start` | `date` | |
| `period_end` | `date` | |
| `allowance_total` | `Money` | |
| `allowance_used` | `Money` | |
| `allowance_remaining` | `Money` | Shown to member pre-checkout |
## Validation rules
| Rule | Error code |
|------|------------|
| Org must be `active` for create (except migration draft) | `ORG_PENDING` |
| Unique `phone` per org (active members) | `DUPLICATE_MEMBER` |
| Unique `employee_id` per org if set | `DUPLICATE_MEMBER` |
| Dependant requires `primary_member_id` | `VALIDATION_FAILED` |
| Dependant count ≤ org `dependant_limit` | `DEPENDANT_LIMIT_EXCEEDED` |
| `package_id` must exist and be active | `NOT_FOUND` |
## Clinical withhold
When `SessionUser.roles` includes HR_ADMIN or FINANCE **without** SUPER_USER:
| Field | Visible |
|-------|---------|
| `full_name`, `email`, `phone`, `employee_id`, `department_id`, `package_id`, `status` | ✓ |
| `allowance_summary` (amounts only) | ✓ |
| `prescription_count` | ✓ (count only) |
| Prescription list / images / medicine lines | ✗ |
| `customer_id` order line medicine detail | ✗ (category + amount in finance spend only) |
SUPER_USER and MEMBER (own record) receive full clinical linkage via prescription endpoints.
## Sample payload (HR view)
```json
{
"id": "mbr_01HMEM001",
"org_id": "org_01HQXYZ",
"customer_id": "cus_01HSHARED",
"employee_id": "EMP-1042",
"full_name": "Abel Mekonnen",
"email": "abel.m@acme.et",
"phone": "+251911111111",
"department_id": "dept_01HABC",
"package_id": "pkg_01HGOLD",
"role": "MEMBER",
"member_type": "primary",
"primary_member_id": null,
"status": "active",
"start_date": "2026-01-01",
"end_date": null,
"overrides": null,
"verification_id": "EMP-1042",
"allowance_summary": {
"period_start": "2026-03-01",
"period_end": "2026-03-31",
"allowance_total": { "amount": "15000.00", "currency": "ETB" },
"allowance_used": { "amount": "4200.00", "currency": "ETB" },
"allowance_remaining": { "amount": "10800.00", "currency": "ETB" }
},
"prescription_count": 2,
"created_at": "2026-01-05T10:00:00Z",
"updated_at": "2026-03-10T14:00:00Z",
"version": 8
}
```
## Invite token (related)
| Field | Type |
|-------|------|
| `token` | `string` |
| `member_id` | `string` |
| `expires_at` | `Timestamp` |
| `accepted_at` | `Timestamp` \| null |
## Related endpoints
- [`../endpoints/members.md`](../endpoints/members.md)

View File

@ -0,0 +1,166 @@
# Entity: Migration job
Batch import job for onboarding org data from Excel/CSV — departments, packages, members, overrides, dependants, verification IDs.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Fields
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | `string` | ✓ | `mig_*` |
| `org_id` | `string` | ✓ | |
| `created_by` | `string` | ✓ | User id |
| `dataset_type` | `MigrationDatasetType` | ✓ | |
| `status` | `MigrationJobStatus` | ✓ | |
| `file_name` | `string` | ✓ | Original upload |
| `file_storage_key` | `string` | | Server-side blob ref |
| `column_mapping` | `ColumnMapping` | | Source → canonical |
| `mapping_profile_id` | `string` | | Saved profile for reuse |
| `validation` | `ValidationReport` | | After parse |
| `preview` | `PreviewSummary` | | Before commit |
| `commit_result` | `CommitResult` | | After commit |
| `error_report_url` | `string` | | Downloadable CSV of failures |
| `rollback_until` | `Timestamp` | | Soft rollback window |
| `started_at` | `Timestamp` | | |
| `completed_at` | `Timestamp` | | |
| `created_at` | `Timestamp` | ✓ | |
| `updated_at` | `Timestamp` | ✓ | |
### MigrationDatasetType
```
departments | packages | members | overrides | dependants | verification_ids | full_onboarding_pack
```
### MigrationJobStatus
```
uploaded | mapping | validating | validated | preview_ready | committing | completed | failed | rolled_back
```
### ColumnMapping
```typescript
Record<string, string> // sourceColumn → canonicalField
// e.g. { "Dept Name": "department_name", "Emp ID": "employee_id" }
```
### ValidationReport
| Field | Type |
|-------|------|
| `total_rows` | `integer` |
| `error_count` | `integer` |
| `warning_count` | `integer` |
| `rows` | `ValidationRow[]` |
### ValidationRow
| Field | Type |
|-------|------|
| `row_number` | `integer` |
| `severity` | `error` \| `warning` |
| `code` | `string` |
| `message` | `string` |
| `field` | `string` |
Common validation codes: `MISSING_REQUIRED`, `DUPLICATE_IN_FILE`, `DUPLICATE_MEMBER`, `INVALID_PHONE`, `UNKNOWN_PACKAGE_CODE`, `UNKNOWN_DEPARTMENT`.
### PreviewSummary
| Field | Type |
|-------|------|
| `create_count` | `integer` |
| `update_count` | `integer` |
| `skip_count` | `integer` |
| `sample_creates` | `object[]` | First N rows |
| `sample_updates` | `object[]` | |
### CommitResult
| Field | Type |
|-------|------|
| `created` | `integer` |
| `updated` | `integer` |
| `skipped` | `integer` |
| `failed` | `integer` |
| `created_member_ids` | `string[]` | For rollback scope |
## Validation rules (job lifecycle)
| Transition | Rule | Error code |
|------------|------|------------|
| → `committing` | Org must be `active` | `ORG_PENDING` |
| → `committing` | No unresolved errors | `VALIDATION_FAILED` |
| → `committing` | Status must be `preview_ready` | `MIGRATION_JOB_LOCKED` |
| Rollback | Before `rollback_until` | `ROLLBACK_EXPIRED` |
## Clinical withhold
Migration datasets exclude clinical/Rx history. No withhold concerns on entity itself.
## Out of scope
- Clinical/Rx history
- Retail orders
- Admin credit/price lists
## Sample payload (completed)
```json
{
"id": "mig_01HJOB001",
"org_id": "org_01HQXYZ",
"created_by": "usr_01HSUPER",
"dataset_type": "members",
"status": "completed",
"file_name": "employees_march_2026.xlsx",
"column_mapping": {
"Full Name": "full_name",
"Mobile": "phone",
"Emp ID": "employee_id",
"Dept": "department_code",
"Plan": "package_code"
},
"validation": {
"total_rows": 500,
"error_count": 3,
"warning_count": 12,
"rows": []
},
"preview": {
"create_count": 487,
"update_count": 10,
"skip_count": 3
},
"commit_result": {
"created": 487,
"updated": 10,
"skipped": 3,
"failed": 0,
"created_member_ids": ["mbr_...", "..."]
},
"rollback_until": "2026-03-12T10:00:00Z",
"started_at": "2026-03-11T09:00:00Z",
"completed_at": "2026-03-11T09:45:00Z",
"created_at": "2026-03-11T08:30:00Z",
"updated_at": "2026-03-11T09:45:00Z"
}
```
## Events
| Event | When |
|-------|------|
| `migration.job_started` | Commit begins |
| `migration.job_completed` | Success |
| `migration.job_failed` | Fatal error |
## Related endpoints
- [`../endpoints/migration.md`](../endpoints/migration.md)
## Relation to `/members/import`
Quick member import shares the same validation engine; creates a simplified `MigrationJob` with `dataset_type=members` or a lightweight `ImportJob` alias.

View File

@ -0,0 +1,162 @@
# Entity: Org registration
Captures self-serve signup and assisted “request to register” flows before Admin activates commercial terms.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Sub-entities
| Entity | Purpose |
|--------|---------|
| `OrgRegistration` | Full self-register submission + first SUPER_USER |
| `OrgRegistrationRequest` | Lightweight lead capture (email or phone) |
## OrgRegistration
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | `string` | ✓ | `oreg_*` |
| `org_id` | `string` | ✓ | Created org in `pending_activation` |
| `submitted_at` | `Timestamp` | ✓ | |
| `company` | `RegistrationCompany` | ✓ | Mirrors org fields at submit |
| `super_user` | `RegistrationUser` | ✓ | First admin account |
| `status` | `RegistrationStatus` | ✓ | |
| `admin_notes` | `string` | | Admin-only (not in B2B API) |
### RegistrationCompany
| Field | Type |
|-------|------|
| `legal_name` | `string` |
| `tin` | `string` |
| `billing_contact` | `Contact` |
| `approx_headcount` | `integer` |
### RegistrationUser
| Field | Type |
|-------|------|
| `full_name` | `string` |
| `email` | `string` |
| `phone` | `Phone` |
| `password_hash` | `string` | Server-side only; never returned |
### RegistrationStatus
```
submitted | under_review | activated | rejected
```
## OrgRegistrationRequest
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | `string` | ✓ | `orreq_*` |
| `contact_name` | `string` | ✓ | |
| `company_name` | `string` | | Optional |
| `channel` | `RequestChannel` | ✓ | Preferred contact method |
| `email` | `string` | conditional | Required if `channel=email` |
| `phone` | `Phone` | conditional | Required if `channel=phone` |
| `message` | `string` | | Free text |
| `status` | `RequestStatus` | ✓ | |
| `created_at` | `Timestamp` | ✓ | |
### RequestChannel
```
email | phone
```
### RequestStatus
```
new | contacted | converted | closed
```
## Validation rules
| Rule | Error code |
|------|------------|
| Unique `tin` on self-register | `DUPLICATE_ORG` |
| Password min 8 chars, 1 upper, 1 digit | `WEAK_PASSWORD` |
| Email unique for super_user | `DUPLICATE_USER` |
| Request must have email OR phone per channel | `VALIDATION_FAILED` |
## Clinical withhold
No clinical data in registration entities.
## Sample: self-register submit
**Request:**
```json
{
"company": {
"legal_name": "Gamma Industries PLC",
"tin": "0011223344",
"billing_contact": {
"name": "Hanna T.",
"email": "hanna@gamma.et",
"phone": "+251922334455"
},
"approx_headcount": 200
},
"super_user": {
"full_name": "Hanna Tadesse",
"email": "hanna@gamma.et",
"phone": "+251922334455",
"password": "SecurePass1"
}
}
```
**Response (201):**
```json
{
"registration_id": "oreg_01HABC",
"org_id": "org_01HNEW",
"status": "submitted",
"org_status": "pending_activation",
"session": {
"user_id": "usr_01HNEW",
"roles": ["SUPER_USER"],
"org_id": "org_01HNEW"
}
}
```
## Sample: request to register
```json
{
"contact_name": "Daniel Kebede",
"company_name": "Delta Embassy",
"channel": "phone",
"phone": "+251911998877",
"message": "We need coverage for ~40 staff"
}
```
**Response (201):**
```json
{
"request_id": "orreq_01HXYZ",
"status": "new",
"message": "A Gishen representative will contact you."
}
```
## Events
| Event | When |
|-------|------|
| `org.registration_submitted` | Self-register completes |
| `org.registration_requested` | Request form submitted |
| `org.activated` | Admin activates org (see Organisation) |
## Related endpoints
- [`../endpoints/org.md`](../endpoints/org.md) — registration routes

View File

@ -0,0 +1,155 @@
# Entity: Organisation
Corporate account holding contract, credit limit, and monthly invoice for covered medical/pharmacy benefits.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Fields
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | `string` | ✓ | `org_*` |
| `legal_name` | `string` | ✓ | Registered company name |
| `display_name` | `string` | | Trading name |
| `tin` | `string` | ✓ | Tax identification number |
| `status` | `OrgStatus` | ✓ | See enum below |
| `billing_contact` | `Contact` | ✓ | Name, email, phone |
| `approx_headcount` | `integer` | | Submitted at registration |
| `commercial` | `CommercialTerms` | | **Admin-written**; null until active |
| `verification` | `VerificationSettings` | | HR-configured once active |
| `dependant_limit` | `integer` | | Max dependants per primary member |
| `join_rules` | `JoinRules` | | Domain allowlist, join code |
| `locale_default` | `Locale` | | Org default; users may override |
| `created_at` | `Timestamp` | ✓ | |
| `updated_at` | `Timestamp` | ✓ | |
| `version` | `integer` | ✓ | Optimistic lock |
### OrgStatus
```
pending_activation | active | suspended | closed
```
### Contact
```typescript
{
name: string;
email: string;
phone: Phone;
}
```
### CommercialTerms (read-only in B2B; Admin writes)
| Field | Type | Notes |
|-------|------|-------|
| `credit_limit` | `Money` | Monthly or rolling — per contract |
| `credit_used` | `Money` | Current period usage |
| `payment_terms_days` | `integer` | e.g. 30 |
| `price_list_id` | `string` | Admin price list reference |
| `contract_start` | `date` | ISO date |
| `contract_end` | `date` | ISO date |
| `activated_at` | `Timestamp` | When Admin activated |
| `activated_by` | `string` | Admin user id |
### VerificationSettings
| Field | Type | Notes |
|-------|------|-------|
| `method` | `VerificationMethod` | See enum |
| `require_at_checkout` | `boolean` | Member must verify before Ecom checkout |
```
VerificationMethod = employee_id | qr_code | domain_email | manual_hr
```
### JoinRules
| Field | Type |
|-------|------|
| `join_code` | `string` \| null |
| `allowed_email_domains` | `string[]` |
| `join_code_expires_at` | `Timestamp` \| null |
## Validation rules
| Rule | Error code |
|------|------------|
| `legal_name` min 2 chars | `VALIDATION_FAILED` |
| `tin` unique platform-wide | `DUPLICATE_ORG` |
| `billing_contact.email` valid format | `INVALID_FORMAT` |
| `billing_contact.phone` E.164 | `INVALID_PHONE` |
| Commercial fields immutable from B2B | `FORBIDDEN` |
## Clinical withhold
Organisation entity has **no clinical fields**. HR and Finance may read `commercial.credit_*` and aggregates only.
## Sample payload (active org, HR view)
```json
{
"id": "org_01HQXYZ123456789",
"legal_name": "Acme Bank Ethiopia",
"display_name": "Acme Bank",
"tin": "0001234567",
"status": "active",
"billing_contact": {
"name": "Selam Bekele",
"email": "selam@acme.et",
"phone": "+251911234567"
},
"approx_headcount": 850,
"commercial": {
"credit_limit": { "amount": "5000000.00", "currency": "ETB" },
"credit_used": { "amount": "1245000.00", "currency": "ETB" },
"payment_terms_days": 30,
"price_list_id": "pl_corporate_2026",
"contract_start": "2026-01-01",
"contract_end": "2026-12-31",
"activated_at": "2025-12-15T10:00:00Z",
"activated_by": "adm_01H..."
},
"verification": {
"method": "employee_id",
"require_at_checkout": true
},
"dependant_limit": 4,
"join_rules": {
"join_code": "ACME-2026",
"allowed_email_domains": ["acme.et"],
"join_code_expires_at": null
},
"locale_default": "en",
"created_at": "2025-12-01T08:00:00Z",
"updated_at": "2026-02-01T12:00:00Z",
"version": 12
}
```
## Sample payload (pending org)
```json
{
"id": "org_01HPENDING123456",
"legal_name": "Beta NGO",
"tin": "0009876543",
"status": "pending_activation",
"billing_contact": { "name": "...", "email": "...", "phone": "+251..." },
"approx_headcount": 120,
"commercial": null,
"created_at": "2026-03-01T09:00:00Z",
"updated_at": "2026-03-01T09:00:00Z",
"version": 1
}
```
## Related endpoints
- [`../endpoints/org.md`](../endpoints/org.md)
## Events
- `org.registration_submitted`
- `org.activated` (Admin)

View File

@ -0,0 +1,135 @@
# Entity: Package
Corporate benefit plan defining allowance, co-pay, covered categories, perks, caps, and exclusions.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Fields
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | `string` | ✓ | `pkg_*` |
| `org_id` | `string` | ✓ | |
| `name` | `string` | ✓ | e.g. "Gold Executive" |
| `code` | `string` | ✓ | Unique per org; used in imports |
| `description` | `string` | | HR-facing |
| `status` | `PackageStatus` | ✓ | |
| `allowance` | `PackageAllowance` | ✓ | |
| `copay_percent` | `number` | ✓ | 0–100; member pays remainder |
| `categories` | `CategoryRule[]` | ✓ | Covered product categories |
| `perks` | `Perk[]` | | Custom perks |
| `caps` | `PackageCaps` | | |
| `exclusions` | `Exclusion[]` | | SKU/category blocks |
| `member_count` | `integer` | | Assigned members |
| `created_at` | `Timestamp` | ✓ | |
| `updated_at` | `Timestamp` | ✓ | |
| `version` | `integer` | ✓ | |
### PackageStatus
```
draft | active | archived
```
Pending orgs: packages may be saved as `draft` but cannot be `active` until org activation (configurable; default: block).
### PackageAllowance
| Field | Type | Notes |
|-------|------|-------|
| `amount` | `Money` | Monthly allowance per member |
| `period` | `AllowancePeriod` | |
| `rollover` | `boolean` | Unused rolls to next period |
```
AllowancePeriod = monthly | quarterly | annual
```
### CategoryRule
| Field | Type |
|-------|------|
| `category_code` | `string` |
| `coverage_percent` | `number` | 0–100 |
| `max_per_order` | `Money` \| null |
### Perk
| Field | Type |
|-------|------|
| `code` | `string` |
| `label` | `string` |
| `description` | `string` |
Example perks: `free_delivery`, `priority_dispense`, `annual_checkup_voucher`.
### PackageCaps
| Field | Type |
|-------|------|
| `max_order_amount` | `Money` |
| `max_orders_per_month` | `integer` |
| `max_rx_fills_per_month` | `integer` |
### Exclusion
| Field | Type |
|-------|------|
| `type` | `sku` \| `category` |
| `ref` | `string` |
| `reason` | `string` |
## Validation rules
| Rule | Error code |
|------|------------|
| `code` unique per org | `DUPLICATE_PACKAGE` |
| `copay_percent` 0–100 | `VALIDATION_FAILED` |
| Cannot archive package with active members without reassignment | `PACKAGE_IN_USE` |
| At least one category rule | `VALIDATION_FAILED` |
## Clinical withhold
Packages define **coverage rules**, not patient clinical data. Safe for all roles.
## Sample payload
```json
{
"id": "pkg_01HGOLD",
"org_id": "org_01HQXYZ",
"name": "Gold Executive",
"code": "GOLD",
"description": "Full chronic + acute coverage with 10% co-pay",
"status": "active",
"allowance": {
"amount": { "amount": "15000.00", "currency": "ETB" },
"period": "monthly",
"rollover": false
},
"copay_percent": 10,
"categories": [
{ "category_code": "chronic", "coverage_percent": 100, "max_per_order": null },
{ "category_code": "otc", "coverage_percent": 80, "max_per_order": { "amount": "500.00", "currency": "ETB" } }
],
"perks": [
{ "code": "free_delivery", "label": "Free delivery", "description": "On all orders" }
],
"caps": {
"max_order_amount": { "amount": "10000.00", "currency": "ETB" },
"max_orders_per_month": 10,
"max_rx_fills_per_month": 4
},
"exclusions": [
{ "type": "category", "ref": "cosmetics", "reason": "Not covered" }
],
"member_count": 128,
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-02-01T00:00:00Z",
"version": 5
}
```
## Related endpoints
- [`../endpoints/packages.md`](../endpoints/packages.md)

View File

@ -0,0 +1,140 @@
# Entity: Prescription
Member-submitted prescription for pharmacist review and covered medicine fulfillment.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Fields
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | `string` | ✓ | `rx_*` |
| `member_id` | `string` | ✓ | |
| `org_id` | `string` | ✓ | |
| `customer_id` | `string` | ✓ | Shared identity |
| `status` | `PrescriptionStatus` | ✓ | |
| `pages` | `PrescriptionPage[]` | ✓ | Images/files — **clinical withhold** |
| `medicine_lines` | `MedicineLine[]` | | Pharmacist-entered after review |
| `prescriber_notes` | `string` | | Clinical — withheld from HR/Finance |
| `diagnosis_codes` | `string[]` | | Withheld |
| `days_supply` | `integer` | | For refill reminders |
| `refill_due_at` | `date` | | Computed |
| `submitted_at` | `Timestamp` | | |
| `reviewed_at` | `Timestamp` | | Admin pharmacist |
| `reviewer_id` | `string` | | Admin user |
| `query_message` | `string` | | If status=queried |
| `rejection_reason` | `string` | | If status=rejected |
| `created_at` | `Timestamp` | ✓ | |
| `updated_at` | `Timestamp` | ✓ | |
### PrescriptionStatus
```
draft | submitted | under_review | approved | queried | rejected
```
### PrescriptionPage
| Field | Type | Notes |
|-------|------|-------|
| `id` | `string` | |
| `page_number` | `integer` | |
| `url` | `string` | Signed URL |
| `mime_type` | `string` | |
| `uploaded_at` | `Timestamp` | |
### MedicineLine
| Field | Type | Notes |
|-------|------|-------|
| `name` | `string` | **Clinical** |
| `dosage` | `string` | |
| `quantity` | `integer` | |
| `sku` | `string` | |
| `instructions` | `string` | |
## Status flow
```
draft → submitted → under_review → approved
↘ queried → (member resubmits) → under_review
↘ rejected
```
Review/dispensing queue lives in **Gishen-Admin**. B2B emits `prescription.submitted` and consumes review status updates.
## Validation rules
| Rule | Error code |
|------|------------|
| Member must be `active` | `MEMBER_INACTIVE` |
| At least one page on submit | `VALIDATION_FAILED` |
| Max 10 pages | `VALIDATION_FAILED` |
| Only MEMBER (own) or SUPER_USER may upload | `FORBIDDEN` |
## Clinical withhold
| Role | `pages` | `medicine_lines` | `prescriber_notes` | `diagnosis_codes` | Status/metadata |
|------|---------|------------------|--------------------|--------------------|-----------------|
| MEMBER (own) | ✓ | ✓ when approved | ✓ | ✓ | ✓ |
| SUPER_USER | ✓ | ✓ | ✓ | ✓ | ✓ |
| HR_ADMIN | ✗ | ✗ | ✗ | ✗ | ✓ status + dates only |
| FINANCE | ✗ | ✗ | ✗ | ✗ | ✗ (use spend aggregates) |
HR/Finance listing endpoint returns **403** or redacted stub if clinical fields requested.
## Sample payload (member view)
```json
{
"id": "rx_01H001",
"member_id": "mbr_01HMEM001",
"org_id": "org_01HQXYZ",
"customer_id": "cus_01HSHARED",
"status": "under_review",
"pages": [
{
"id": "rxp_01",
"page_number": 1,
"url": "https://storage.gishen.../rx_01H001_p1.jpg",
"mime_type": "image/jpeg",
"uploaded_at": "2026-03-10T08:00:00Z"
}
],
"medicine_lines": [],
"days_supply": null,
"refill_due_at": null,
"submitted_at": "2026-03-10T08:00:00Z",
"reviewed_at": null,
"created_at": "2026-03-10T07:55:00Z",
"updated_at": "2026-03-10T08:00:00Z"
}
```
## Sample payload (HR redacted)
```json
{
"id": "rx_01H001",
"member_id": "mbr_01HMEM001",
"status": "under_review",
"submitted_at": "2026-03-10T08:00:00Z",
"clinical_withheld": true
}
```
## Events
| Event | When |
|-------|------|
| `prescription.submitted` | Member submits |
| `prescription.review_updated` | Admin review (consumed) |
## Related endpoints
- [`../endpoints/prescriptions.md`](../endpoints/prescriptions.md)
## Platform notes
- Multi-page photo/file upload on web; edge-detection camera is Mobile-only.
- No handwritten OCR.

View File

@ -0,0 +1,101 @@
# Entity: Session user
Authenticated user context for B2B portal — demo mock now, JWT/session later.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Fields
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `id` | `string` | ✓ | `usr_*` |
| `email` | `string` | ✓ | |
| `full_name` | `string` | ✓ | |
| `phone` | `Phone` | | |
| `org_id` | `string` | ✓ | Active tenant |
| `member_id` | `string` | | Link to Member row if enrolled |
| `customer_id` | `string` | | Shared Gishen customer (Ecom/Mob) |
| `roles` | `PortalRole[]` | ✓ | One or more |
| `locale` | `Locale` | ✓ | `en` \| `am` |
| `avatar_url` | `string` | | |
| `org_status` | `OrgStatus` | ✓ | Denormalized for gating |
| `permissions` | `string[]` | | Computed from roles |
| `demo_profile` | `DemoProfile` | | Mock only |
### PortalRole
```
SUPER_USER | HR_ADMIN | FINANCE | MEMBER
```
### DemoProfile (mock `/login` only)
| Field | Type |
|-------|------|
| `label` | `string` |
| `description` | `string` |
Pre-seeded demo profiles per role against a shared mock org.
## Computed permissions
| Permission | Roles |
|------------|-------|
| `org:read` | SUPER_USER, HR_ADMIN, FINANCE |
| `org:write_hr` | SUPER_USER, HR_ADMIN |
| `members:write` | SUPER_USER, HR_ADMIN |
| `packages:write` | SUPER_USER, HR_ADMIN |
| `migration:write` | SUPER_USER, HR_ADMIN |
| `finance:read` | SUPER_USER, HR_ADMIN, FINANCE |
| `finance:approve` | SUPER_USER, FINANCE |
| `clinical:read` | SUPER_USER, MEMBER (own) |
| `prescriptions:write` | MEMBER (own), SUPER_USER |
## Locale preference
`locale` is persisted on the user record and returned in session. Updated via `PATCH /v1/auth/me/locale`. Synced to client cookie/localStorage for next-intl.
## Clinical withhold
Session drives RBAC. If `roles` includes HR_ADMIN or FINANCE and **not** SUPER_USER, all prescription and clinical serializers apply withhold filters automatically.
## Sample session (mock demo — HR)
```json
{
"id": "usr_01HHR",
"email": "hr.demo@acme.et",
"full_name": "Demo HR Admin",
"phone": "+251900000001",
"org_id": "org_01DEMO",
"member_id": null,
"customer_id": null,
"roles": ["HR_ADMIN"],
"locale": "en",
"org_status": "active",
"permissions": ["org:read", "org:write_hr", "members:write", "packages:write", "migration:write", "finance:read"],
"demo_profile": {
"label": "HR Administrator",
"description": "Manage members, departments, and packages"
}
}
```
## Sample session (SUPER_USER)
```json
{
"id": "usr_01HSUPER",
"roles": ["SUPER_USER"],
"locale": "am",
"permissions": ["*"],
"demo_profile": {
"label": "Super User",
"description": "Full portal access including clinical detail"
}
}
```
## Related endpoints
- [`../endpoints/auth.md`](../endpoints/auth.md)

View File

@ -0,0 +1,167 @@
# Domain events
Events published to the platform event bus for cross-system coordination. B2B **emits** and **consumes** as noted.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Event envelope
```json
{
"id": "evt_01H...",
"type": "org.registration_submitted",
"occurred_at": "2026-03-01T09:00:00Z",
"org_id": "org_01HNEW",
"actor_id": "usr_01HNEW",
"payload": { ... },
"schema_version": "1"
}
```
## Catalog
### Organisation
| Event | Emitter | Consumers | Payload |
|-------|---------|-----------|---------|
| `org.registration_submitted` | B2B API | Admin, CRM | `registration_id`, `org_id`, `company`, `super_user` (no password) |
| `org.registration_requested` | B2B API | Admin, CRM | `request_id`, `contact_name`, `channel`, `email?`, `phone?`, `message?` |
| `org.activated` | Admin API | B2B, Ecom | `org_id`, `commercial`, `activated_by`, `activated_at` |
#### Sample: org.registration_submitted
```json
{
"type": "org.registration_submitted",
"org_id": "org_01HNEW",
"payload": {
"registration_id": "oreg_01HABC",
"company": {
"legal_name": "Gamma Industries PLC",
"tin": "0011223344",
"approx_headcount": 200
},
"super_user_email": "hanna@gamma.et"
}
}
```
#### Sample: org.activated
```json
{
"type": "org.activated",
"org_id": "org_01HNEW",
"payload": {
"status": "active",
"commercial": {
"credit_limit": { "amount": "2000000.00", "currency": "ETB" },
"price_list_id": "pl_corporate_2026",
"contract_start": "2026-04-01",
"contract_end": "2027-03-31"
}
}
}
```
---
### Migration
| Event | Emitter | Consumers | Payload |
|-------|---------|-----------|---------|
| `migration.job_started` | B2B API | Admin (audit), internal | `job_id`, `dataset_type`, `org_id` |
| `migration.job_completed` | B2B API | Admin (audit), internal | `job_id`, `commit_result` |
| `migration.job_failed` | B2B API | Admin (audit), internal | `job_id`, `error_code`, `message` |
#### Sample: migration.job_completed
```json
{
"type": "migration.job_completed",
"org_id": "org_01HQXYZ",
"payload": {
"job_id": "mig_01HJOB001",
"dataset_type": "members",
"commit_result": {
"created": 487,
"updated": 10,
"skipped": 3,
"failed": 0
}
}
}
```
---
### Prescriptions
| Event | Emitter | Consumers | Payload |
|-------|---------|-----------|---------|
| `prescription.submitted` | B2B API | Admin (pharmacist queue) | `prescription_id`, `member_id`, `customer_id`, `page_count` |
| `prescription.review_updated` | Admin API | B2B, Ecom, Mob | `prescription_id`, `status`, `query_message?`, `medicine_lines?` |
#### Sample: prescription.submitted
```json
{
"type": "prescription.submitted",
"org_id": "org_01HQXYZ",
"payload": {
"prescription_id": "rx_01H001",
"member_id": "mbr_01HMEM001",
"customer_id": "cus_01HSHARED",
"page_count": 2,
"submitted_at": "2026-03-10T08:00:00Z"
}
}
```
**Clinical withhold:** `prescription.submitted` event must **not** include image URLs or clinical content — Admin fetches via secure internal API.
#### Sample: prescription.review_updated (consumed by B2B)
```json
{
"type": "prescription.review_updated",
"payload": {
"prescription_id": "rx_01H001",
"status": "approved",
"reviewed_at": "2026-03-11T10:00:00Z",
"days_supply": 30,
"refill_due_at": "2026-04-10"
}
}
```
Medicine lines included in consumer payload for MEMBER/SUPER_USER views only — filtered at API layer for HR/Finance.
---
## Webhook delivery (future)
```
POST https://client.example/webhooks/gishen
X-Gishen-Signature: sha256=...
```
Retry: exponential backoff, 3 days max.
## B2B responsibilities
| Action | Event |
|--------|-------|
| Self-register completes | Emit `org.registration_submitted` |
| Request form submitted | Emit `org.registration_requested` |
| Migration commit starts/ends | Emit `migration.job_*` |
| Member submits Rx | Emit `prescription.submitted` |
| Admin activates org | **Consume** `org.activated` → unlock entitlements |
| Pharmacist reviews Rx | **Consume** `prescription.review_updated` → update member UI |
## Related docs
- [Organisation entity](../entities/organisation.md)
- [Migration job entity](../entities/migration-job.md)
- [Prescription entity](../entities/prescription.md)
- [Gishen Admin coordination](../../coordination/gishen-admin.md)

View File

@ -0,0 +1,70 @@
# Coordination: Gishen-Admin
How Gishen-B2B interacts with **Gishen-Admin** (pharmacy ops, commercial activation, Rx review).
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Admin responsibilities (Admin repo owns UI)
| Domain | Admin action | B2B impact |
|--------|--------------|------------|
| Commercial activation | Set credit limit, payment terms, price list, contract dates | Org moves `pending_activation` → `active`; B2B unlocks members/packages/migration commit |
| Org creation (legacy) | Offline sales creates org + invites SUPER_USER | B2B receives invite link |
| Registration queue | Review `org.registration_submitted` / `org.registration_requested` | Admin contacts client, completes setup, activates |
| Pharmacist Rx queue | Review submitted prescriptions | B2B member sees status updates via `prescription.review_updated` |
| Credit / invoice oversight | Monthly billing, suspensions | B2B Finance sees statements; `org.suspended` blocks new orders |
| Price lists | Corporate pricing | Referenced by `organisation.commercial.price_list_id` — read-only in B2B |
## B2B responsibilities
| Action | Event emitted |
|--------|---------------|
| Self-register | `org.registration_submitted` |
| Request to register | `org.registration_requested` |
| Member submits Rx | `prescription.submitted` |
| Migration commit | `migration.job_*` (audit) |
## Events: Admin → B2B
| Event | B2B handler |
|-------|-------------|
| `org.activated` | Update org status; show commercial fields; unlock HR workflows |
| `org.suspended` | Show banner; block mutations |
| `prescription.review_updated` | Refresh prescription status in member portal |
## Events: B2B → Admin
| Event | Admin handler |
|-------|---------------|
| `org.registration_submitted` | Registration review queue |
| `org.registration_requested` | CRM / sales follow-up |
| `prescription.submitted` | Pharmacist review queue |
| `migration.job_completed` | Optional audit log |
## Data ownership
| Field / entity | Writer | Reader (B2B) |
|----------------|--------|--------------|
| `Organisation.commercial` | Admin | HR, Finance, SUPER_USER (read) |
| `Organisation.status` | Admin | All authenticated |
| `Prescription` review fields | Admin | Member, SUPER_USER |
| `Member`, `Department`, `Package` | B2B (HR) | B2B |
| `Statement` | Admin/billing engine | B2B Finance |
## Clinical withhold alignment
Admin pharmacists see full Rx detail. B2B HR/Finance never receive clinical payloads — enforced in B2B API layer and documented in entity/endpoint specs.
SUPER_USER in B2B may view member Rx for support; does not replace Admin review workflow.
## Integration checklist
- [ ] Admin registration queue consumes `org.registration_*`
- [ ] Admin activation emits `org.activated` with commercial payload shape matching [`../backend/entities/organisation.md`](../backend/entities/organisation.md)
- [ ] Pharmacist queue consumes `prescription.submitted`
- [ ] Admin review emits `prescription.review_updated`
- [ ] Statement PDF generation exposes URLs consumed by B2B `/statements`
## Contact / escalation
Cross-repo contract changes must update both Admin spec sheet and this repo's `docs/backend/` in the same release cycle.

View File

@ -0,0 +1,96 @@
# Coordination: Gishen-Ecom
How Gishen-B2B shares **customer identity**, **allowance visibility**, and **checkout entitlement split** with Gishen-Ecom.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Shared customer identity
| Concept | Notes |
|---------|-------|
| `customer_id` | Single Gishen customer across Ecom, Mob, B2B member |
| `member_id` | B2B enrolment link; optional until member joins |
| Loyalty | Lives on customer identity — B2B shows summary pointer; Ecom/Mob own full loyalty UI |
When HR enrols a member with existing retail account (phone match), backend links `member.customer_id` without merging clinical history incorrectly.
## Allowance visibility
| Surface | Owner | Data |
|---------|-------|------|
| B2B `/me` | B2B | `allowance_remaining`, package summary |
| Ecom/Mob checkout | Ecom | Same allowance fetched from platform API at checkout time |
| B2B Finance | B2B | Spend aggregates — no basket detail |
B2B displays remaining allowance so members know coverage before shopping. **Actual payment split UI lives in Ecom checkout.**
## Checkout entitlement split (shared concern)
When a covered member checks out in Ecom or Mobile:
1. Platform calculates org-covered amount vs member co-pay / out-of-pocket.
2. Org credit applied up to allowance and category rules.
3. Member pays remainder via payment gateway (Chapa, etc.).
```
Basket total: 1,200 ETB
Org covered: 900 ETB (package rules)
Member pays: 300 ETB (co-pay + exclusions)
```
### Ownership (recommended)
| Component | Recommended owner |
|-----------|-------------------|
| Entitlement calculation API | Shared backend |
| Split UI in checkout | **Gishen-Ecom** (+ Mob reuses) |
| Allowance display pre-checkout | B2B `/me` + Ecom header widget |
| Finance reconciliation | B2B Finance + Admin billing |
See [`open-items.md`](open-items.md) for decision status.
## B2B does NOT build
- Product catalog browse (Ecom)
- Cart and payment UI (Ecom/Mob)
- Retail-only promotions
## B2B member paths to Ecom
Member portal should deep-link to Ecom storefront with session/SSO (future):
```
https://shop.gishenpharmacy.org/?customer_id=cus_01H...
```
Mock phase: static link + shared demo customer.
## Events (cross-repo)
| Event | Relevance |
|-------|-----------|
| `order.completed` | Updates allowance_used; feeds B2B finance spend |
| `prescription.review_updated` | Approved Rx may create Ecom refill cart |
| `org.activated` | Enables entitlement at checkout |
## API dependencies (future shared backend)
| Endpoint | Consumer |
|----------|----------|
| `GET /v1/customers/:id/allowance` | Ecom checkout, B2B `/me` |
| `POST /v1/checkout/entitlement-preview` | Ecom cart |
| `GET /v1/customers/:id/orders` | B2B `/me/orders` (member view) |
Document exact shapes in Ecom spec sheet; B2B references via this coordination doc.
## Telegram Mini App
Telegram Mini App rides **Ecom's backend spec**, not B2B. No B2B action required.
## Integration checklist
- [ ] Shared `customer_id` linking on member enrolment
- [ ] Allowance API consumed by both B2B and Ecom
- [ ] Checkout split UI implemented once in Ecom
- [ ] Order events feed B2B finance aggregates (redacted)
- [ ] Confirm SSO/deep-link from B2B member portal to Ecom

View File

@ -0,0 +1,79 @@
# Open items (cross-repo)
Tracked decisions and flags that span Gishen-B2B, Admin, Ecom, and Mobile.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Active
### 1. Checkout entitlement split ownership
| Field | Value |
|-------|-------|
| **Status** | Open — default recommendation documented |
| **Description** | Basket split (org credit vs member gateway payment) must appear in Ecom Web / Mobile checkout, not only B2B |
| **Default recommendation** | Single shared implementation owned with **Gishen-Ecom**; shared backend calculates split |
| **B2B scope** | Show `allowance_remaining` on `/me`; link to Ecom for checkout |
| **Decision needed** | Confirm shared package vs per-repo duplicate UI |
| **References** | [`gishen-ecom.md`](gishen-ecom.md), [`../backend/endpoints/packages.md`](../backend/endpoints/packages.md) |
**Action:** Product/engineering leads confirm owner before Ecom checkout sprint.
---
### 2. SSO / deep link B2B member → Ecom storefront
| Field | Value |
|-------|-------|
| **Status** | Open |
| **Description** | How member session propagates from B2B portal to Ecom cart |
| **Options** | (a) Shared auth token, (b) customer_id query + magic link, (c) full SSO provider |
| **B2B impact** | `/me` CTA "Shop with coverage" |
---
### 3. Pending org draft packages
| Field | Value |
|-------|-------|
| **Status** | Decided — default block |
| **Description** | Can SUPER_USER draft packages while `pending_activation`? |
| **Decision** | **Default: block entitlements until active.** Packages may be saved as `draft` only. |
| **Spec** | [`../backend/entities/package.md`](../backend/entities/package.md) |
---
## Watch list (informational)
### Telegram Mini App
Rides Ecom backend spec — no B2B action.
### Procurement vs Stock Manager
Admin-only overlap — ignore in B2B.
### Production auth provider
Mock demo login ships first. OAuth/SSO provider TBD — track in shared platform auth spec.
### Custom domain
`b2b.gishenpharmacy.org` optional after first Vercel deploy.
---
## Resolved
_None yet — move items here when decided._
---
## How to update
When an open item is resolved:
1. Move to **Resolved** with decision date and owner.
2. Update affected `docs/backend/` entities/endpoints.
3. Update [`../features/INDEX.md`](../features/INDEX.md) if routes or ownership change.
4. Notify sibling repo spec sheets (Admin/Ecom).

108
docs/features/INDEX.md Normal file
View File

@ -0,0 +1,108 @@
# Features index
Feature → pages → entities → endpoints mapping for Gishen-B2B §3 features.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
**Standing rule:** Update this file whenever UI routes or capabilities change. See [`../README.md`](../README.md).
## Summary table
| Feature | Pages | Entities | Endpoints | Feature doc |
|---------|-------|----------|-----------|---------------|
| Auth & roles | `/login` | SessionUser | `auth.md` | [auth-roles.md](auth-roles.md) |
| Quick search | Portal topbar (Cmd/Ctrl+K) | — | — | [quick-search.md](quick-search.md) |
| Portal layout | All portal (PageShell, DetailPageLayout, Bc, StatCard, Tabs) | — | — | [portal-layout.md](portal-layout.md) |
| i18n | `/login`, all portal | SessionUser (`locale`) | `PATCH /v1/auth/me/locale` | [i18n.md](i18n.md) |
| Org registration | `/register/organisation`, `/register/request`, `/login` | OrgRegistration, OrgRegistrationRequest, Organisation | `org.md` (register*) | [org-registration.md](org-registration.md) |
| Org & departments | `/organisation`, `/departments`, `/departments/new`, `/departments/[id]`, `/settings/verification` | Organisation, Department | `org.md` | [org-departments.md](org-departments.md) |
| Data migration | `/migration`, `/migration/new`, `/migration/[jobId]` | MigrationJob | `migration.md` | [migration.md](migration.md) |
| Members & onboarding | `/members`, `/members/new`, `/members/import`, `/members/[id]`, `/join`, `/invite/[token]` | Member | `members.md` | [members.md](members.md) |
| Packages & entitlements | `/packages`, `/packages/new`, `/packages/[id]`, `/me` | Package, Member (`overrides`) | `packages.md` | [packages.md](packages.md) |
| Finance | `/finance`, `/finance/spend/[id]`, `/finance/approvals`, `/statements`, `/statements/[id]` | Finance (SpendSummary, Statement, ApprovalFlag) | `finance.md` | [finance.md](finance.md) |
| Member experience | `/me`, `/me/orders`, `/me/orders/[id]` | Member, Package | `packages.md` (`/me/package`), Ecom orders* | [member-experience.md](member-experience.md) |
| Prescriptions | `/prescriptions`, `/prescriptions/new`, `/prescriptions/[id]` | Prescription | `prescriptions.md` | — (see member-experience) |
\* `/me/orders` consumes shared customer order API (Ecom spec); allowance from B2B packages API.
## Events by feature
| Feature | Events emitted | Events consumed |
|---------|----------------|-----------------|
| Org registration | `org.registration_submitted`, `org.registration_requested` | `org.activated` |
| Data migration | `migration.job_started`, `migration.job_completed`, `migration.job_failed` | — |
| Prescriptions | `prescription.submitted` | `prescription.review_updated` |
| Finance | — | `order.completed` (future) |
## Role × feature matrix
| Feature | SUPER_USER | HR_ADMIN | FINANCE | MEMBER |
|---------|:----------:|:--------:|:-------:|:------:|
| Auth & roles | ✓ | ✓ | ✓ | ✓ |
| i18n | ✓ | ✓ | ✓ | ✓ |
| Org registration | ✓ (public) | — | — | — |
| Org & departments | ✓ | ✓ | read | — |
| Data migration | ✓ | ✓ | read | — |
| Members | ✓ | ✓ | read | own |
| Packages | ✓ | ✓ | read | own |
| Finance | ✓ | summary | ✓ | — |
| Member experience | ✓ | — | — | ✓ |
| Prescriptions | ✓ full | **withheld** | **withheld** | own |
## Clinical withhold by feature
| Feature | HR_ADMIN / FINANCE restriction |
|---------|-------------------------------|
| Members | Metadata only; `prescription_count` not detail |
| Finance | Category + amount aggregates; no medicine names |
| Prescriptions | 403 / redacted |
| Member experience | N/A for HR/Finance routes |
SUPER_USER bypasses all withhold rules.
## Cross-repo dependencies
| Feature | Sibling repo |
|---------|--------------|
| Org registration activation | Admin |
| Prescription review | Admin |
| Checkout entitlement split | Ecom |
| `/me/orders` | Ecom (shared orders API) |
| Statements PDF | Admin / billing |
See [`../coordination/`](../coordination/).
## Route completeness check
All §3 routes from product brief:
- [x] `/login`
- [x] `/register/organisation`, `/register/request`
- [x] `/organisation`, `/departments`, `/departments/new`, `/departments/[id]`, `/settings/verification`
- [x] `/migration`, `/migration/new`, `/migration/[jobId]`
- [x] `/members`, `/members/new`, `/members/import`, `/members/[id]`, `/join`, `/invite/[token]`
- [x] `/packages`, `/packages/new`, `/packages/[id]`
- [x] `/me`, `/me/orders`, `/me/orders/[id]`
- [x] `/prescriptions`, `/prescriptions/new`, `/prescriptions/[id]`
- [x] `/finance`, `/finance/spend/[id]`, `/finance/approvals`, `/statements`, `/statements/[id]`
## Quick directory (standing rule)
Portal top-bar command search is driven by `src/lib/nav/quick-directory.ts`. **Update that module (and `quickSearch.*` i18n keys) whenever you add portal routes or primary create actions.** See [quick-search.md](quick-search.md).
## List UX
All primary tables include in-card **search + filter** toolbar (`TableToolbar`): members, packages, departments, migration, prescriptions, finance spend, approvals, statements, member orders. List query params for backend: `q`, `status` / category / site (see endpoint docs).
## Detail UX
Yimaru-style `DetailHero` + `DetailSection` layouts with demo mutations (invite/offboard, package activate, migration commit/rollback, statement issue/pay, approval approve/flag). Dashboard uses Recharts placeholders from statements/spend/departments/packages.
## Spec file paths
```
docs/backend/entities/ → data model
docs/backend/endpoints/ → API contract
docs/backend/events/ → domain events
docs/features/*.md → feature notes (this directory)
docs/coordination/ → Admin/Ecom alignment
```

View File

@ -0,0 +1,79 @@
# Feature: Auth & roles
Demo login with role selector, session management, and role-filtered navigation.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Pages
| Route | Purpose |
|-------|---------|
| `/login` | Demo role selector + locale switcher; "Register organisation" / "Request registration" CTAs |
## Roles
| Role | Nav sections |
|------|--------------|
| `SUPER_USER` | All — Organisation, Departments, Migration, Members, Packages, Finance, Settings |
| `HR_ADMIN` | Organisation, Departments, Migration, Members, Packages, Settings |
| `FINANCE` | Finance, Statements (read org summary) |
| `MEMBER` | Me, Orders, Prescriptions |
## Entities
- [`SessionUser`](../backend/entities/session-user.md)
## Endpoints
- [`auth.md`](../backend/endpoints/auth.md)
- `POST /v1/auth/login`
- `POST /v1/auth/logout`
- `GET /v1/auth/me`
- `GET /v1/auth/demo-profiles`
## Demo login UX
1. User selects persona: Super User, HR Administrator, Finance Approver, Member.
2. Optional locale toggle (en/am).
3. "Continue as …" sets mock session and redirects to role-appropriate home.
4. Topbar profile menu includes "Switch demo user" → returns to `/login`.
Pre-seeded demo data: single mock org (`org_01DEMO`) with realistic members, packages, spend.
## Route guards
Middleware or layout checks:
| Check | Behavior |
|-------|----------|
| Unauthenticated | Redirect to `/login` |
| MEMBER on `/finance` | 403 → member home |
| HR on `/prescriptions/:id` clinical | Withhold / redirect |
| Pending org | Block `/members`, `/packages` (active), migration commit |
## Clinical withhold
Enforced at API mock layer and UI:
- HR_ADMIN / FINANCE menus exclude Prescriptions clinical views.
- SUPER_USER sees all nav items including member Rx support paths.
## Validation / errors
| Scenario | Code |
|----------|------|
| Invalid demo role | `VALIDATION_FAILED` |
| Expired session | `UNAUTHENTICATED` |
## Mock implementation notes
```
src/mocks/auth.ts — demo profiles + session
src/lib/api/auth.ts — adapter
src/types/session.ts — SessionUser type
```
## Related
- [i18n.md](i18n.md) — locale on login
- [org-registration.md](org-registration.md) — public CTAs from login

115
docs/features/finance.md Normal file
View File

@ -0,0 +1,115 @@
# Feature: Finance
Spend analytics, credit usage, monthly statements, Excel export, and threshold approvals.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Pages
| Route | Purpose |
|-------|---------|
| `/finance` | Spend dashboard — by dept/member/category, credit usage |
| `/finance/approvals` | Flagged spend queue — approve/reject |
| `/statements` | Monthly statement list + PDF download |
## Entities
- [`Finance`](../backend/entities/finance.md) — SpendSummary, SpendLine, Statement, ApprovalFlag, CreditUsage
## Endpoints
- [`finance.md`](../backend/endpoints/finance.md)
## Role access
| Capability | SUPER_USER | HR_ADMIN | FINANCE |
|------------|:----------:|:--------:|:-------:|
| Spend summary | ✓ | ✓ (aggregates) | ✓ |
| Spend lines | ✓ | ✗ | ✓ (redacted) |
| Excel export | ✓ | ✗ | ✓ |
| Credit usage | ✓ | ✓ read | ✓ |
| Statements | ✓ | ✗ | ✓ |
| Approvals | ✓ | ✗ | ✓ |
## Spend dimensions
| Group by | Use case |
|----------|----------|
| Department | Branch/division budget tracking |
| Category | Chronic vs OTC vs other |
| Member | Individual spend (metadata + amounts) |
Date range filter default: current calendar month.
## Approval workflow
Threshold rules (configured by Admin or org policy) flag spend lines:
| Reason | Example |
|--------|---------|
| `threshold_exceeded` | Single order > 5,000 ETB |
| `unusual_category` | High cosmetics spend |
| `first_time_high_value` | New member large order |
Finance reviews on `/finance/approvals` → approve or reject with notes.
## Statements
Monthly PDF statements from billing engine:
- Opening/closing balance
- Charges and payments
- Credit limit snapshot
- Issued date
## Credit usage widget
Shown on `/finance` and optionally `/organisation`:
```
Credit used: 1,245,000 / 5,000,000 ETB (24.9%)
```
Warning styling (accent `#e28a1a`) above 80% utilization.
## Clinical withhold
**Critical:** Finance views never show:
- Prescription images
- Medicine names, SKUs, dosages
- Prescriber or diagnosis data
Spend lines use:
- `category_code` (e.g. `chronic`)
- Redacted `description` (e.g. "Chronic medication order")
- Amounts: total, org-covered, member-paid
HR_ADMIN sees **summary aggregates only** — not line detail or approvals.
## Validation / errors
| Scenario | Code |
|----------|------|
| Org pending (no commercial) | `ORG_PENDING` |
| HR accessing spend lines | `FORBIDDEN` |
| HR mutating approval | `FORBIDDEN` |
## UI components
- Spend charts (by dept/category) — shadcn cards
- DataTable for spend lines and approvals
- Date range picker
- Export button → XLSX download
- Statement list with PDF link
## Coordination
- Statements generated by Admin/billing — [`gishen-admin.md`](../coordination/gishen-admin.md)
- Order events from Ecom feed spend — [`gishen-ecom.md`](../coordination/gishen-ecom.md)
## Related
- [org-departments.md](org-departments.md) — department sub-limits
- [packages.md](packages.md) — category coverage drives org-covered amounts

113
docs/features/i18n.md Normal file
View File

@ -0,0 +1,113 @@
# Feature: i18n (internationalization)
English and Amharic at launch with user locale preference persisted across sessions.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Surfaces
| Location | Control |
|----------|---------|
| `/login` | Language switcher beside role selector |
| Portal topbar / profile menu | Language switcher |
| All portal pages | Translated UI chrome |
Entered data (names, addresses, free text) stays as typed — not machine-translated.
## Locales
| Code | Language | Launch |
|------|----------|--------|
| `en` | English | ✓ |
| `am` | Amharic | ✓ |
Registry extensible for future locales (e.g. `om`, `ti`).
## Implementation (frontend)
Mirror Fortune Admin i18n pattern:
```
src/i18n/registry.ts — locale metadata
src/i18n/LocaleContext.tsx — client provider
src/messages/en.json — English strings
src/messages/am.json — Amharic strings
```
Library: **next-intl** with App Router integration.
Adding a locale = registry row + messages file + optional font adjustments.
## User preference
| Field | Storage |
|-------|---------|
| `user.locale` | Shared backend user record |
| Cookie / localStorage | Client cache for next-intl |
### Endpoint
- `PATCH /v1/auth/me/locale` — see [`auth.md`](../backend/endpoints/auth.md)
### Request
```json
{ "locale": "am" }
```
### Session response includes
```json
{ "locale": "am" }
```
## Entity
- [`SessionUser.locale`](../backend/entities/session-user.md)
## Coverage requirements
All UI strings translated:
- Navigation labels
- Page titles and descriptions
- Buttons, form labels, placeholders
- Table headers and empty states
- Error and toast messages
- Demo role selector labels
- Date/number formatting per locale
## Validation
| Rule | Code |
|------|------|
| `locale` must be registered (`en`, `am`) | `VALIDATION_FAILED` |
## Default locale
- Org default: `organisation.locale_default`
- User override: `user.locale`
- Fallback chain: user → org → `en`
## Clinical withhold
N/A — i18n affects presentation only.
## UI component
Compact language switcher in topbar:
```
[ EN | አማ ]
```
Persist on change via `PATCH /v1/auth/me/locale` + client cookie update.
## Related
- [auth-roles.md](auth-roles.md) — login surface
- [org-departments.md](org-departments.md) — org default locale
## Backend note (shared API)
When production auth ships, `user.locale` must sync across B2B, Ecom, and Mob for consistent customer experience.

View File

@ -0,0 +1,124 @@
# Feature: Member experience
Member portal — allowance summary, order history, prescription upload/status, loyalty pointer.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Pages
| Route | Purpose |
|-------|---------|
| `/me` | Home — plan/allowance, refill reminders, shop CTA |
| `/me/orders` | Covered order history |
| `/prescriptions` | Rx list with status |
| `/prescriptions/new` | Multi-page upload wizard |
## Entities
- [`Member`](../backend/entities/member.md)
- [`Package`](../backend/entities/package.md)
- [`Prescription`](../backend/entities/prescription.md)
## Endpoints
| Endpoint | Purpose |
|----------|---------|
| `GET /v1/auth/me` | Session + allowance snippet |
| `GET /v1/me/package` | Package + allowance detail |
| `GET /v1/customers/:id/orders`* | Order history |
| `GET/POST /v1/prescriptions*` | Rx CRUD + submit |
| `GET /v1/prescriptions/refill-reminders` | Upcoming refills |
\* Shared customer orders API — spec owned with Ecom; mocked in B2B phase.
## `/me` home sections
| Section | Content |
|---------|---------|
| Allowance card | Total, used, remaining (mint progress bar) |
| Package summary | Plan name, co-pay %, key perks |
| Refill reminders | Prescriptions due within N days |
| Shop CTA | Deep link to Ecom with coverage |
| Loyalty teaser | Points balance pointer — full UI in Ecom/Mob |
## Order history (`/me/orders`)
Member sees own orders with:
- Date, order ID, total
- Org-covered vs member-paid split
- Status (processing, delivered, etc.)
- Line items with product names (member owns their data)
## Prescriptions
### Upload flow (`/prescriptions/new`)
1. Create draft
2. Upload pages (jpeg/png/pdf) — multi-page
3. Review thumbnails
4. Submit → `prescription.submitted` event
5. Track status on `/prescriptions`
### Status display
| Status | Member UX |
|--------|-----------|
| `submitted` | "Waiting for review" |
| `under_review` | "Pharmacist reviewing" |
| `approved` | "Approved — ready to order" |
| `queried` | Show query message + resubmit |
| `rejected` | Show reason + contact support |
Review happens in Admin — B2B consumes `prescription.review_updated`.
### Refill reminders
When `days_supply` set and `refill_due_at` approaching, show banner on `/me`.
## Clinical access
Members see **full own** prescription data including images and approved medicine lines.
HR/Finance **cannot** access member prescription routes — 403.
SUPER_USER may view any member's Rx for support via `/members/[id]` + prescription endpoints.
## Checkout entitlement
B2B shows remaining allowance. Basket split UI at checkout lives in **Gishen-Ecom**:
```
Allowance remaining: 10,800 ETB
[ Shop with coverage → Ecom ]
```
See [`open-items.md`](../coordination/open-items.md).
## Platform notes
- Web upload mirrors Ecom Rx UX
- Edge-detection camera: Mobile-only (Gishen-Mob)
- No handwritten OCR
## Validation / errors
| Scenario | Code |
|----------|------|
| Submit Rx without pages | `VALIDATION_FAILED` |
| Inactive member | `MEMBER_INACTIVE` |
| Non-member on member routes | `FORBIDDEN` |
## UI components
- Allowance hero card (Gishen mint/forest)
- Order list with split amounts
- Rx upload dropzone + page gallery
- Status timeline component
- Refill reminder alert
## Related
- [packages.md](packages.md) — entitlement rules
- [auth-roles.md](auth-roles.md) — MEMBER role
- [gishen-ecom.md](../coordination/gishen-ecom.md) — checkout split

100
docs/features/members.md Normal file
View File

@ -0,0 +1,100 @@
# Feature: Members & onboarding
Employee and dependant enrolment — CRUD, quick import, invites, join codes, offboarding.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Pages
| Route | Purpose |
|-------|---------|
| `/members` | Searchable/filterable member list |
| `/members/new` | Invite / create employee (department + package) |
| `/members/import` | Quick Excel/CSV import |
| `/members/[id]` | Member detail — profile, package, dependants, offboard |
| `/join` | Self-join via join code or email domain |
| `/invite/[token]` | Accept invite and set password |
## Entities
- [`Member`](../backend/entities/member.md)
## Endpoints
- [`members.md`](../backend/endpoints/members.md)
## Capabilities
| Action | Roles |
|--------|-------|
| List / search | SUPER_USER, HR_ADMIN, FINANCE (metadata) |
| Create / edit | SUPER_USER, HR_ADMIN |
| Quick import | SUPER_USER, HR_ADMIN |
| Send/resend invite | SUPER_USER, HR_ADMIN |
| Add dependant | SUPER_USER, HR_ADMIN |
| Offboard | SUPER_USER, HR_ADMIN |
| Self-join | Public (join code / domain) |
| Accept invite | Public |
## Member lifecycle
```
invited → active → inactive | offboarded
```
Offboarding sets `end_date`; prescriptions remain on `customer_id` (portable).
## Dependants
- Linked via `primary_member_id`
- Subject to org `dependant_limit`
- Share primary's package by default; overrides optional
## Join rules (org-level)
Configured on `/organisation`:
- Join code (e.g. `ACME-2026`)
- Allowed email domains
- Optional expiry
## Quick import vs migration
| Path | Use case |
|------|----------|
| `/members/import` | Small ad-hoc batch |
| `/migration` | Full onboarding with mapping profiles |
Both use shared validation engine — see [migration.md](migration.md).
## Validation / errors
| Scenario | Code |
|----------|------|
| Duplicate phone in org | `DUPLICATE_MEMBER` |
| Duplicate employee_id | `DUPLICATE_MEMBER` |
| Dependant limit exceeded | `DEPENDANT_LIMIT_EXCEEDED` |
| Org pending | `ORG_PENDING` |
| Invite expired | `INVITE_EXPIRED` |
## Clinical withhold
Member list/detail for HR/Finance shows:
- Name, contact, employee ID, department, package, status
- Allowance summary (amounts)
- `prescription_count` (integer only)
No prescription list, images, or medicine detail. SUPER_USER sees full member record including Rx summary link.
## UI components
- DataTable with toolbar (search, status filter, department filter)
- Detail layout: profile card, package assignment, dependants tab, offboard dialog
- Import dropzone with template download
- Invite link copy + resend
## Related
- [packages.md](packages.md) — package assignment
- [member-experience.md](member-experience.md) — member portal after enrolment

104
docs/features/migration.md Normal file
View File

@ -0,0 +1,104 @@
# Feature: Data migration
Org-scoped bulk import wizard for departments, packages, members, overrides, dependants, and verification IDs.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Pages
| Route | Purpose |
|-------|---------|
| `/migration` | Job history hub — status, dataset type, dates |
| `/migration/new` | Start wizard — choose dataset, upload file |
| `/migration/[jobId]` | Job detail — map, validate, preview, commit, rollback |
## Entities
- [`MigrationJob`](../backend/entities/migration-job.md)
## Endpoints
- [`migration.md`](../backend/endpoints/migration.md)
## Who can use
| Role | Access |
|------|--------|
| SUPER_USER | Full — create, commit, rollback |
| HR_ADMIN | Create, commit |
| FINANCE | Read job history |
## Supported datasets
| Type | Canonical fields |
|------|------------------|
| `departments` | name, code, parent_code, sub_limit |
| `packages` | name, code, allowance, copay, categories |
| `members` | full_name, phone, email, employee_id, department_code, package_code, start_date |
| `overrides` | member_key, allowance_cap, copay_percent |
| `dependants` | primary_employee_id, full_name, phone |
| `verification_ids` | employee_id, verification_id |
| `full_onboarding_pack` | Multi-sheet workbook |
## Wizard steps
1. **Choose dataset** — or full onboarding pack
2. **Upload** — Excel/CSV; download template per type
3. **Map columns** — source → canonical; save mapping profile
4. **Validate** — row-level errors/warnings; duplicate detection
5. **Preview** — create/update/skip counts + samples
6. **Commit** — async job with progress
7. **Complete** — error report download; optional rollback window
## Events
| Event | When |
|-------|------|
| `migration.job_started` | Commit begins |
| `migration.job_completed` | Success |
| `migration.job_failed` | Fatal error |
## Out of scope
- Clinical/Rx history
- Retail orders
- Admin credit/price lists
## Validation / errors
| Scenario | Code |
|----------|------|
| Unresolved validation errors on commit | `VALIDATION_FAILED` |
| Org pending | `ORG_PENDING` |
| Job already committed | `MIGRATION_JOB_LOCKED` |
| Rollback window expired | `ROLLBACK_EXPIRED` |
| Duplicate member in file | `DUPLICATE_IN_FILE` |
## Clinical withhold
Migration datasets exclude clinical data. No withhold on API responses.
## Relation to `/members/import`
Quick import creates a `MigrationJob` with `dataset_type=members` — same engine, simplified UI (skip mapping profile save).
## UI components
- Job list DataTable with status badges
- Stepper wizard in PageShell
- Column mapper: dropdown per canonical field
- Validation report table (row, severity, message)
- Preview diff summary cards
- Commit progress bar + error report download
## Mock files
```
src/mocks/migration.ts
src/types/migration.ts
```
## Related
- [members.md](members.md) — quick import path
- [org-departments.md](org-departments.md) — department dataset

View File

@ -0,0 +1,92 @@
# Feature: Org & departments
Organisation profile, commercial field visibility, departments/sites, and verification settings.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Pages
| Route | Purpose |
|-------|---------|
| `/organisation` | Org profile — HR-managed fields + read-only commercial (when active) |
| `/departments` | Department/site list with search/site filter |
| `/departments/new` | Create department (site, sub-limit, optional parent) |
| `/settings/verification` | Verification method for member checkout |
## Entities
- [`Organisation`](../backend/entities/organisation.md)
- [`Department`](../backend/entities/department.md)
## Endpoints
- [`org.md`](../backend/endpoints/org.md)
- `GET /v1/org`, `PATCH /v1/org`
- `GET/POST/PATCH/DELETE /v1/org/departments*`
- `PATCH /v1/org/verification`
## Commercial fields (Admin-owned)
Displayed read-only on `/organisation` once active:
- Credit limit & usage
- Payment terms
- Price list reference
- Contract start/end
- Activation date
HR cannot mutate these — attempts return `403 FORBIDDEN`.
## HR-managed fields
Editable by SUPER_USER / HR_ADMIN:
- Display name
- Dependant limit
- Join rules (join code, email domains)
- Verification settings
## Departments
| Capability | Roles |
|------------|-------|
| List / view | SUPER_USER, HR_ADMIN, FINANCE |
| Create / edit / deactivate | SUPER_USER, HR_ADMIN |
Sub-limits optional per department for finance reporting and cap enforcement.
## Verification settings
| Method | Description |
|--------|-------------|
| `employee_id` | Member enters employee ID at checkout |
| `qr_code` | QR issued per member |
| `domain_email` | Email domain match |
| `manual_hr` | HR verifies manually |
`require_at_checkout`: when true, Ecom checkout blocks until verified.
## Validation / errors
| Scenario | Code |
|----------|------|
| Duplicate department name | `DUPLICATE_DEPARTMENT` |
| Delete dept with members | `DEPARTMENT_IN_USE` |
| Edit commercial from B2B | `FORBIDDEN` |
| Org pending | `ORG_PENDING` on mutations |
## Clinical withhold
No clinical data on org/department screens.
## UI notes
- Commercial section: card with credit utilization bar (mint/forest brand)
- Department table: name, code, parent, sub-limit, member count, actions
- Verification: radio group + switch for checkout requirement
## Related features
- [finance.md](finance.md) — spend by department
- [members.md](members.md) — member department assignment
- [migration.md](migration.md) — department bulk import

View File

@ -0,0 +1,95 @@
# Feature: Org registration
Self-serve organisation signup and assisted request-to-register before Admin activates commercial terms.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Pages
| Route | Purpose |
|-------|---------|
| `/register/organisation` | Full self-register form + first SUPER_USER account |
| `/register/request` | Lightweight lead form (email or phone) |
| `/login` | CTAs: "Register organisation", "Request registration" |
## Flows
### Self-register
```
User fills company + SUPER_USER details
→ POST /v1/org/register
→ Org created (pending_activation)
→ Session as SUPER_USER
→ Dashboard shows pending banner
→ Admin activates → org.activated event → full access
```
### Request to register
```
User submits contact + channel preference
→ POST /v1/org/register/request
→ Confirmation message
→ Gishen sales contacts client offline
→ Admin creates/activates org + invites SUPER_USER
```
## Entities
- [`OrgRegistration`](../backend/entities/org-registration.md)
- [`Organisation`](../backend/entities/organisation.md) (pending state)
## Endpoints
- [`org.md`](../backend/endpoints/org.md)
- `POST /v1/org/register`
- `POST /v1/org/register/request`
- `GET /v1/org/registration-status`
## Events
| Event | When |
|-------|------|
| `org.registration_submitted` | Self-register success |
| `org.registration_requested` | Request form success |
| `org.activated` | Admin activates (consumed) |
## Pending org restrictions
Until `org.status === active`:
| Allowed | Blocked |
|---------|---------|
| View registration status | Create active members |
| Edit submitted company details (limited) | Migration commit |
| Draft packages (optional) | Assign packages to members |
| SUPER_USER session | Finance statements |
Default: **block all entitlements** until activation.
## Validation
| Field | Rule |
|-------|------|
| `legal_name` | Required, min 2 chars |
| `tin` | Required, unique |
| `billing_contact.email` | Valid email |
| `billing_contact.phone` | E.164 |
| `super_user.password` | Min 8, 1 upper, 1 digit |
| Request `channel=email` | Email required |
| Request `channel=phone` | Phone required |
## Clinical withhold
Registration flows contain no clinical data.
## Coordination
- Admin registration queue: [`gishen-admin.md`](../coordination/gishen-admin.md)
## UI components
- Multi-step form (company → admin account → confirmation)
- Pending activation banner on AppShell
- Request form with channel radio (email / phone)

88
docs/features/packages.md Normal file
View File

@ -0,0 +1,88 @@
# Feature: Packages & entitlements
Corporate benefit plans — allowance, co-pay, categories, perks, caps, exclusions, and per-member overrides.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Pages
| Route | Purpose |
|-------|---------|
| `/packages` | Package list with search/status filter |
| `/packages/new` | Create corporate package (allowance, co-pay, categories, perks) |
| `/packages/[id]` | Package detail/edit — rules, assigned members |
| `/me` | Member view of assigned package + allowance (see member-experience) |
## Entities
- [`Package`](../backend/entities/package.md)
- [`Member.overrides`](../backend/entities/member.md)
## Endpoints
- [`packages.md`](../backend/endpoints/packages.md)
- `GET /v1/me/package` — member allowance view
## Package structure
| Component | Purpose |
|-----------|---------|
| Allowance | Monthly/quarterly/annual cap per member |
| Co-pay % | Member pays remainder on covered items |
| Category rules | Coverage % per product category (chronic, OTC, etc.) |
| Perks | Custom benefits (free delivery, priority dispense) |
| Caps | Max order amount, orders/month, Rx fills/month |
| Exclusions | Blocked SKUs or categories |
## Status lifecycle
```
draft → active → archived
```
Pending orgs: packages saved as `draft` only until Admin activates org.
## Per-member overrides
Set on member detail (`/members/[id]`):
- Allowance cap override
- Co-pay override
- Extra categories or perks
- Exclusions
Overrides layer on top of base package at entitlement calculation time.
## Validation / errors
| Scenario | Code |
|----------|------|
| Duplicate package code | `DUPLICATE_PACKAGE` |
| Archive with assigned members | `PACKAGE_IN_USE` |
| Activate while org pending | `ORG_PENDING` |
| No category rules | `VALIDATION_FAILED` |
## Clinical withhold
Packages define coverage rules only — visible to all roles. No patient data.
## Checkout integration
Remaining allowance computed from:
```
allowance_remaining = package.allowance - allowance_used + overrides
```
Displayed on B2B `/me`. Actual basket split at checkout is **Ecom** — see [`gishen-ecom.md`](../coordination/gishen-ecom.md).
## UI components
- Package list cards/table with status badges
- Detail form: allowance input, co-pay slider, category rule builder, caps section
- Assigned members tab with reassign action
## Related
- [migration.md](migration.md) — bulk package import
- [finance.md](finance.md) — spend by category reflects package rules

View File

@ -0,0 +1,105 @@
# Portal layout conventions (Yimaru / Fortune)
Shared shell patterns for list, detail, and create pages.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Components
| Piece | Path | Role |
|-------|------|------|
| `PageShell` | `src/components/layout/page-shell.tsx` | Full-width page header + content; optional `breadcrumb` |
| `DetailPageLayout` | `src/components/layout/detail-layout.tsx` | Detail wrapper; **breadcrumbs on by default** |
| `Breadcrumbs` / `Bc` | `src/components/layout/breadcrumbs.tsx` | Fortune-style trail; locale links via `@/i18n/routing` |
| `StatCard` / `StatCardGrid` | `src/components/layout/stat-card.tsx` | Flat MakerSys metrics (thin border, brand mint icon well) |
| `TablePanel` | `src/components/table/table-panel.tsx` | Flat white panel (`rounded-xl`, thin border, no shadow) wrapping toolbar + table |
| `TableToolbar` | `src/components/table/table-toolbar.tsx` | In-panel search / filter strip (hairline bottom) |
| `DataTable` | `src/components/table/data-table.tsx` | Column-driven table inside `TablePanel` |
| Table primitives | `src/components/ui/table.tsx` | Yimaru / Fortune density: muted uppercase headers, `px-4` cells, mint hover |
## Breadcrumbs API
```tsx
import { Breadcrumbs, Bc } from "@/components/layout/breadcrumbs";
import { PageShell } from "@/components/layout/page-shell";
import { DetailPageLayout } from "@/components/layout/detail-layout";
// Detail — breadcrumbs default true
<DetailPageLayout title="…">…</DetailPageLayout>
// Create / nested — opt in
<PageShell title="…" breadcrumb>…</PageShell>
// Custom trail (tests / one-offs)
<Breadcrumbs items={[{ href: "/", label: "Dashboard" }, { label: "Custom" }]} />
```
### Labels
- Static paths and dynamic parents map to `bc.*` keys in `src/messages/en.json` + `am.json`.
- Path map lives in `PATH_LABEL_KEYS` / `DYNAMIC_PARENT_KEYS` inside `breadcrumbs.tsx`.
- To extend: add a path key → `bc` message key, then Amharic/English strings.
## When to use what
| Surface | Stats | Tabs | Breadcrumbs | Width |
|---------|:-----:|:----:|:-----------:|-------|
| List dashboards | ✓ metric strip | — | off | full |
| Detail | metrics + sections | shadcn `Tabs` (line) | on | full |
| Create / import / nested settings | — | optional | on | full-width form card (no `max-w-*` unless interaction needs it) |
## List tables
Use the shared panel pattern so every list matches MakerSys / Yimaru:
```tsx
import { TablePanel } from "@/components/table/table-panel";
import { TableToolbar } from "@/components/table/table-toolbar";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
<TablePanel
loading={isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
resultCount={filtered.length}
resultLabel={t("common.results")}
/>
}
>
<Table>…</Table>
</TablePanel>
```
Conventions:
- **Panel:** `rounded-xl`, `border-border`, white `bg-card`, **no shadow** — do not wrap tables in padded `Card` / `CardContent`.
- **Header row:** muted background, compact uppercase labels (`text-[11px]`, tracking), `px-4`.
- **Body:** comfortable `py-4` rows, mint-tinted hover, last row border removed.
- **Empty / loading:** centered `h-28` empty cell or `TablePanel` `loading` state.
- **Mobile:** horizontal scroll via the table container (`overflow-x-auto`).
- **Detail embeds:** prefer `DetailTableSection` (same panel chrome) with flush table primitives.
## Tabs
Use `@/components/ui/tabs`. Default `TabsList` variant is **line** (Yimaru/Fortune underline). Keep 2–4 sections max.
```tsx
const tTabs = useTranslations("tabs");
<Tabs defaultValue="profile" className="w-full">
<TabsList>
<TabsTrigger value="profile">{tTabs("profile")}</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-4 pt-1">…</TabsContent>
</Tabs>
```
Labels live under root `tabs.*` in `en.json` / `am.json` (`profile`, `entitlement`, `dependants`, `clinical`, `rules`, `perks`, `summary`, `errors`, `breakdown`, `contract`, `people`, `spend`, `info`, `basics`). Prefer `useTranslations("tabs")` so missing keys never render as nested `tabs.tabs.*`.
## Related docs
- [i18n.md](i18n.md) — message files
- [INDEX.md](INDEX.md) — feature → route map

View File

@ -0,0 +1,41 @@
# Feature: Quick search / command directory
Keyboard-friendly portal navigation from the top bar (Cmd/Ctrl+K).
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Surfaces
| Location | Control |
|----------|---------|
| Portal topbar | Search trigger (desktop) / search icon (mobile) |
| Global shortcut | `⌘K` / `Ctrl+K` opens the directory dialog |
## Implementation
| Path | Role |
|------|------|
| `src/lib/nav/quick-directory.ts` | **Canonical directory** — ids, label keys, hrefs, keywords, roles, groups |
| `src/components/layout/quick-search.tsx` | Dialog UI: filter, arrow keys, Enter navigate, Esc close |
| `src/components/layout/app-topbar.tsx` | Wires `QuickSearch` into the top bar |
| `src/messages/en.json` / `am.json` | `quickSearch.*` strings |
Role visibility uses `roleAllows` from `src/lib/auth/session.ts` (same SUPER_USER bypass pattern as `src/lib/auth/nav.ts`).
## Standing rule
**When you add a portal route or primary create action, add an entry to `QUICK_DIRECTORY` in `src/lib/nav/quick-directory.ts`.** Reuse `nav.*` label keys when the item mirrors sidebar nav; use `quickSearch.actions.*` for create/import actions. Update en + am message files for any new keys.
Do not leave orphan routes out of the directory — this is the maintainable “command map” for the portal.
## Groups
| Group | Typical entries |
|-------|-----------------|
| `navigation` | Dashboard, org, departments, members, packages, migration |
| `create` | `/…/new`, import members |
| `finance` | Finance, statements, approvals |
| `member` | My benefits, orders, prescriptions |
| `settings` | Verification |
Dynamic detail URLs (`/members/[id]`, etc.) are intentionally omitted; users open the list then the record.

View File

@ -0,0 +1,134 @@
# §3 B2B feature checklist
Product brief for **Gishen-B2B** — institutional portal for organisations that cover medical/pharmacy expenses for employees and dependants.
**Workspace:** `/Users/kirukib/Desktop/Yaltopia Project/Gishen-B2B`
## Product framing
- The **organisation** holds the contract, credit limit, and monthly invoice.
- **HR** enrols people and configures coverage (packages / entitlements).
- **Finance** sees spend against coverage and statements — not clinical detail.
- **Members** use covered allowance; prescriptions and personal history stay with the person if they leave the org.
## Roles
| Role | Code | Portal access |
|------|------|---------------|
| Super User | `SUPER_USER` | Full access — HR + Finance + settings + any member record; clinical withhold **does not** apply |
| HR Administrator | `HR_ADMIN` | Org profile (read commercial), departments, members, import/invite, packages, verification, offboarding — **no** clinical detail |
| Finance Approver | `FINANCE` | Spend, Excel, statements, credit usage, threshold approve/flag — **no** clinical detail; no HR enrol mutations unless also granted |
| Member | `MEMBER` | Own plan/allowance, orders, Rx upload/status, loyalty — no org-wide HR/Finance |
Multiple roles per user are allowed in the data model. Mock login at `/login` includes a demo role selector for all four personas.
## Feature checklist
### Auth & shell
- [ ] Demo login with role selector (SUPER_USER / HR_ADMIN / FINANCE / MEMBER)
- [ ] Role-filtered navigation and route guards
- [ ] Clinical withhold enforcement for HR_ADMIN and FINANCE
- [ ] AppShell: collapsible sidebar, topbar (search, notifications, profile, language switcher)
- [ ] PageShell / detail / create layouts (Fortune Admin `ui` patterns + shadcn)
- [ ] Gishen brand tokens (mint `#a8c73a`, forest `#141414`)
### i18n
- [ ] English + Amharic at launch
- [ ] Language switcher on `/login` and topbar/profile
- [ ] `user.locale` preference persisted
### Org registration
- [ ] Self-register: company details + first SUPER_USER → `pending_activation`
- [ ] Request-to-register: email/phone capture → `org_registration_request`
- [ ] Pending org UX: status visible; entitlements blocked until Admin activates
- [ ] Events: `org.registration_submitted`, `org.registration_requested`, `org.activated`
### Org & departments
- [ ] Org profile — read commercial fields once active (credit, terms, price list, contract dates)
- [ ] Departments/sites with sub-limits
- [ ] Verification method setup (`/settings/verification`)
### Data migration
- [ ] Migration hub, new job wizard, job detail/progress
- [ ] Datasets: departments, packages, members, overrides, dependants, verification IDs
- [ ] Upload Excel/CSV, column mapper, validation report, preview, commit
- [ ] Events: `migration.job_started`, `migration.job_completed`, `migration.job_failed`
### Members & onboarding
- [ ] Members CRUD, detail, dependants
- [ ] Quick Excel import (`/members/import`)
- [ ] Invites (`/invite/[token]`), join codes, domain rules (`/join`)
- [ ] Offboarding
### Packages & entitlements
- [ ] Corporate packages (benefit plans): allowance, co-pay, categories, perks, caps/exclusions
- [ ] Per-member overrides
- [ ] Allowance views for members
### Finance
- [ ] Spend by department / member / category
- [ ] Excel export
- [ ] Monthly statements (`/statements`)
- [ ] Credit usage vs limit
- [ ] Threshold approve/flag (`/finance/approvals`)
### Member experience
- [ ] Member home: plan/allowance summary (`/me`)
- [ ] Order history (`/me/orders`)
- [ ] Prescription upload + status (`/prescriptions`, `/prescriptions/new`)
- [ ] Loyalty summary pointer (customer identity shared with Ecom)
- [ ] Remaining allowance visible before checkout (split UI lives in Ecom — see coordination)
### Prescriptions (member-facing)
- [ ] Multi-page photo/file upload (web)
- [ ] Status: submitted → under review → approved / queried / rejected
- [ ] Refill reminders when days-of-supply low
- [ ] Review/dispensing in Admin pharmacist queue
- [ ] Event: `prescription.submitted`
## Route map
| Area | Routes |
|------|--------|
| Auth | `/login` |
| Org signup | `/register/organisation`, `/register/request` |
| Org (HR) | `/organisation`, `/departments`, `/settings/verification` |
| Migration | `/migration`, `/migration/new`, `/migration/[jobId]` |
| People | `/members`, `/members/import`, `/members/[id]`, `/join`, `/invite/[token]` |
| Packages | `/packages`, `/packages/[id]` |
| Member | `/me`, `/me/orders`, `/prescriptions`, `/prescriptions/new` |
| Finance | `/finance`, `/finance/approvals`, `/statements` |
## Out of scope (this repo)
- Health trackers, educational content, community, AI Phase 2+, Referral Program v2
- Handwritten Rx OCR; mobile edge-detection camera (Mobile repo)
- Building Admin / Ecom / Mobile UIs
- Implementing the shared backend service
- Admin-owned credit/price list activation UI (Admin repo)
## Open cross-repo items
See [`coordination/open-items.md`](../coordination/open-items.md):
- Checkout entitlement split (org credit vs member gateway) — shared with Ecom
- Telegram Mini App rides Ecom backend spec
## Spec sync
Each checked feature above must have corresponding entries in:
- `docs/backend/entities/`
- `docs/backend/endpoints/`
- `docs/backend/events/` (when state crosses systems)
- `docs/features/INDEX.md`

18
eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

12
next.config.ts Normal file
View File

@ -0,0 +1,12 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
const nextConfig: NextConfig = {
images: {
remotePatterns: [],
},
};
export default withNextIntl(nextConfig);

11124
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@ -0,0 +1,43 @@
{
"name": "gishen-b2b",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@base-ui/react": "^1.7.0",
"@hookform/resolvers": "^5.7.1",
"@tanstack/react-query": "^5.101.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.29.0",
"next": "16.3.0",
"next-intl": "^4.13.5",
"next-themes": "^0.4.6",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-hook-form": "^7.84.0",
"recharts": "^3.10.1",
"shadcn": "^4.16.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vercel/config": "^0.5.6",
"eslint": "^9",
"eslint-config-next": "16.3.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,233 @@
"use client";
import { useMemo } from "react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import {
DetailCallout,
DetailField,
DetailFieldStack,
DetailLoading,
DetailPageLayout,
DetailSection,
DetailTableSection,
} from "@/components/layout/detail-layout";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { ChartCard, CategoryPieChart } from "@/components/charts/portal-charts";
import { StatusBadge } from "@/components/shared/status-badge";
import { mockApi } from "@/lib/api/mock-client";
import { formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ArrowLeft, Building2, MapPin, Percent, Users, Wallet } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function DepartmentDetailPage() {
const t = useTranslations();
const tTabs = useTranslations("tabs");
const params = useParams<{ id: string }>();
const id = params.id;
const deptQ = useQuery({
queryKey: ["department", id],
queryFn: () => mockApi.getDepartment(id),
enabled: !!id,
});
const spendQ = useQuery({ queryKey: ["spend"], queryFn: mockApi.getSpend });
const membersQ = useQuery({
queryKey: ["members", "SUPER_USER"],
queryFn: () => mockApi.getMembers("SUPER_USER"),
});
const deptsQ = useQuery({ queryKey: ["departments"], queryFn: mockApi.getDepartments });
const dept = deptQ.data;
const parentName = useMemo(
() => deptsQ.data?.find((d) => d.id === dept?.parentId)?.name,
[deptsQ.data, dept?.parentId],
);
const deptSpend = useMemo(
() => (spendQ.data ?? []).filter((s) => s.departmentId === id),
[spendQ.data, id],
);
const byCategory = useMemo(() => {
const map = new Map<string, number>();
for (const row of deptSpend) {
map.set(row.category, (map.get(row.category) ?? 0) + row.coveredEtb);
}
return Array.from(map.entries()).map(([name, value]) => ({ name, value }));
}, [deptSpend]);
const deptMembers = useMemo(
() => (membersQ.data ?? []).filter((m) => m.departmentId === id),
[membersQ.data, id],
);
const usagePct =
dept?.subLimitEtb && dept.subLimitEtb > 0
? Math.round((dept.spendEtb / dept.subLimitEtb) * 100)
: null;
return (
<DetailPageLayout
title={dept?.name ?? t("departments.title")}
description={dept?.siteName ?? t("details.departmentSubtitle")}
extra={
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" render={<Link href="/departments/new" />}>
{t("departments.add")}
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
render={<Link href="/departments" />}
>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
</div>
}
>
{deptQ.isLoading ? (
<DetailLoading label={t("common.loading")} />
) : !dept ? (
<DetailCallout>{t("common.empty")}</DetailCallout>
) : (
<>
<StatCardGrid cols={4}>
<StatCard
label={t("departments.subLimit")}
value={dept.subLimitEtb != null ? formatEtb(dept.subLimitEtb) : "—"}
icon={<Wallet />}
/>
<StatCard
label={t("departments.spend")}
value={formatEtb(dept.spendEtb)}
icon={<Building2 />}
/>
<StatCard
label={t("details.usage")}
value={usagePct != null ? `${usagePct}%` : "—"}
hint={dept.siteName}
icon={<Percent />}
/>
<StatCard
label={t("departments.members")}
value={dept.memberCount}
icon={<Users />}
/>
</StatCardGrid>
<Tabs defaultValue="info" className="w-full">
<TabsList>
<TabsTrigger value="info">{tTabs("info")}</TabsTrigger>
<TabsTrigger value="people">{tTabs("people")}</TabsTrigger>
<TabsTrigger value="spend">{tTabs("spend")}</TabsTrigger>
</TabsList>
<TabsContent value="info" className="space-y-4 pt-1">
<DetailSection title={t("details.departmentInfo")} icon={<Building2 />}>
<DetailFieldStack>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<DetailField label={t("departments.name")} value={dept.name} />
<DetailField
label={t("departments.site")}
value={
<span className="inline-flex items-center gap-1.5">
<MapPin className="size-3.5 text-muted-foreground" />
{dept.siteName}
</span>
}
/>
<DetailField
label={t("departments.parent")}
value={parentName ?? t("departments.noParent")}
/>
<DetailField label={t("departments.members")} value={dept.memberCount} />
</div>
</DetailFieldStack>
</DetailSection>
</TabsContent>
<TabsContent value="people" className="space-y-4 pt-1">
<DetailTableSection
title={t("nav.members")}
icon={<Users />}
extra={
<span className="text-xs text-muted-foreground tabular-nums">
{deptMembers.length}
</span>
}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("common.name")}</TableHead>
<TableHead>{t("members.employeeId")}</TableHead>
<TableHead>{t("common.status")}</TableHead>
<TableHead className="text-right">{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deptMembers.length ? (
deptMembers.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.name}</TableCell>
<TableCell className="font-mono text-xs">{m.employeeId}</TableCell>
<TableCell>
<StatusBadge status={m.status} />
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant="ghost"
render={<Link href={`/members/${m.id}`} />}
>
{t("common.view")}
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="h-28 text-center text-sm text-muted-foreground">
{t("common.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DetailTableSection>
</TabsContent>
<TabsContent value="spend" className="space-y-4 pt-1">
<ChartCard
title={t("details.spendByCategory")}
description={t("details.deptChartHint")}
>
{byCategory.length ? (
<CategoryPieChart data={byCategory} />
) : (
<p className="py-10 text-center text-sm text-muted-foreground">
{t("common.empty")}
</p>
)}
</ChartCard>
</TabsContent>
</Tabs>
</>
)}
</DetailPageLayout>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { useRouter, Link } from "@/i18n/routing";
import { CreatePageLayout } from "@/components/layout/create-layout";
import { mockApi } from "@/lib/api/mock-client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ArrowLeft } from "lucide-react";
export default function CreateDepartmentPage() {
const t = useTranslations();
const router = useRouter();
const [parentId, setParentId] = useState("none");
const deptsQ = useQuery({
queryKey: ["departments"],
queryFn: mockApi.getDepartments,
});
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
if (!data.get("name") || !data.get("siteName")) {
toast.error(t("common.requiredFields"));
return;
}
toast.success(t("departments.createSuccess"));
router.push("/departments");
}
return (
<CreatePageLayout
title={t("departments.add")}
description={t("departments.createSubtitle")}
extra={
<Button variant="outline" className="gap-2" render={<Link href="/departments" />}>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
}
>
<Card className="w-full rounded-xl shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("departments.createFormTitle")}</CardTitle>
<CardDescription>{t("departments.createSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="name">{t("departments.name")}</Label>
<Input id="name" name="name" required />
</div>
<div className="space-y-2">
<Label htmlFor="siteName">{t("departments.site")}</Label>
<Input id="siteName" name="siteName" required />
</div>
<div className="space-y-2">
<Label htmlFor="subLimit">{t("departments.subLimit")}</Label>
<Input id="subLimit" name="subLimit" type="number" min={0} />
</div>
<div className="space-y-2">
<Label>{t("departments.parent")}</Label>
<Select value={parentId} onValueChange={(v) => setParentId(v ?? "none")}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("departments.parent")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{t("departments.noParent")}</SelectItem>
{(deptsQ.data ?? []).map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit">{t("common.create")}</Button>
<Button type="button" variant="outline" render={<Link href="/departments" />}>
{t("common.cancel")}
</Button>
</div>
</form>
</CardContent>
</Card>
</CreatePageLayout>
);
}

View File

@ -0,0 +1,141 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
import { TablePanel } from "@/components/table/table-panel";
import { mockApi } from "@/lib/api/mock-client";
import { formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Building2, MapPin, Plus, Users, Wallet } from "lucide-react";
export default function DepartmentsPage() {
const t = useTranslations();
const [search, setSearch] = useState("");
const [siteFilter, setSiteFilter] = useState("all");
const { data: departments, isLoading } = useQuery({
queryKey: ["departments"],
queryFn: mockApi.getDepartments,
});
const sites = useMemo(() => {
const set = new Set((departments ?? []).map((d) => d.siteName));
return Array.from(set).sort();
}, [departments]);
const filtered = useMemo(() => {
return (departments ?? []).filter((dept) => {
if (siteFilter !== "all" && dept.siteName !== siteFilter) return false;
return matchesSearch([dept.name, dept.siteName], search);
});
}, [departments, search, siteFilter]);
const stats = useMemo(() => {
const rows = departments ?? [];
return {
total: rows.length,
sites: new Set(rows.map((d) => d.siteName)).size,
spend: rows.reduce((s, d) => s + d.spendEtb, 0),
members: rows.reduce((s, d) => s + d.memberCount, 0),
};
}, [departments]);
return (
<PageShell
title={t("departments.title")}
description={t("departments.subtitle")}
extra={
<Button className="gap-2" render={<Link href="/departments/new" />}>
<Plus className="size-4" />
{t("departments.add")}
</Button>
}
>
<StatCardGrid cols={4}>
<StatCard label={t("stats.total")} value={stats.total} icon={<Building2 />} />
<StatCard label={t("stats.sites")} value={stats.sites} icon={<MapPin />} />
<StatCard
label={t("stats.totalSpend")}
value={formatEtb(stats.spend)}
icon={<Wallet />}
/>
<StatCard label={t("departments.members")} value={stats.members} icon={<Users />} />
</StatCardGrid>
<TablePanel
loading={isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={siteFilter}
onFilterChange={setSiteFilter}
filterPlaceholder={t("departments.site")}
filterAllLabel={t("common.all")}
filterOptions={sites.map((s) => ({ value: s, label: s }))}
resultCount={filtered.length}
resultLabel={t("common.results")}
/>
}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("departments.name")}</TableHead>
<TableHead>{t("departments.site")}</TableHead>
<TableHead className="text-right">{t("departments.subLimit")}</TableHead>
<TableHead className="text-right">{t("departments.spend")}</TableHead>
<TableHead className="text-right">{t("departments.members")}</TableHead>
<TableHead>{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length ? (
filtered.map((dept) => (
<TableRow key={dept.id}>
<TableCell className="font-medium">{dept.name}</TableCell>
<TableCell>{dept.siteName}</TableCell>
<TableCell className="text-right">
{dept.subLimitEtb != null ? formatEtb(dept.subLimitEtb) : "—"}
</TableCell>
<TableCell className="text-right">{formatEtb(dept.spendEtb)}</TableCell>
<TableCell className="text-right">{dept.memberCount}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
render={<Link href={`/departments/${dept.id}`} />}
>
{t("common.view")}
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="h-28 text-center text-sm text-muted-foreground">
{t("common.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TablePanel>
</PageShell>
);
}

View File

@ -0,0 +1,163 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { PageShell } from "@/components/layout/page-shell";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { StatusBadge } from "@/components/shared/status-badge";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
import { TablePanel } from "@/components/table/table-panel";
import { mockApi } from "@/lib/api/mock-client";
import { formatEtb } from "@/lib/format";
import { Button } from "@/components/ui/button";
import { AlertTriangle, CheckCircle2, Clock } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export default function FinanceApprovalsPage() {
const t = useTranslations();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const { data: approvals, isLoading } = useQuery({
queryKey: ["approvals"],
queryFn: mockApi.getApprovals,
});
const filtered = useMemo(() => {
return (approvals ?? []).filter((ap) => {
if (statusFilter !== "all" && ap.status !== statusFilter) return false;
return matchesSearch(
[ap.memberName, ap.departmentName, ap.reason],
search,
);
});
}, [approvals, search, statusFilter]);
const stats = useMemo(() => {
const rows = approvals ?? [];
return {
pending: rows.filter((a) => a.status === "pending").length,
approved: rows.filter((a) => a.status === "approved").length,
flagged: rows.filter((a) => a.status === "flagged").length,
};
}, [approvals]);
const approve = useMutation({
mutationFn: (id: string) => mockApi.updateApprovalStatus(id, "approved"),
onSuccess: () => {
toast.success(t("details.approvalApproved"));
void qc.invalidateQueries({ queryKey: ["approvals"] });
},
onError: () => toast.error(t("details.actionFailed")),
});
const flag = useMutation({
mutationFn: (id: string) => mockApi.updateApprovalStatus(id, "flagged"),
onSuccess: () => {
toast.warning(t("details.approvalFlagged"));
void qc.invalidateQueries({ queryKey: ["approvals"] });
},
onError: () => toast.error(t("details.actionFailed")),
});
return (
<PageShell
title={t("finance.approvalsTitle")}
description={t("finance.approvalsSubtitle")}
breadcrumb
>
<StatCardGrid cols={3}>
<StatCard label={t("common.pending")} value={stats.pending} icon={<Clock />} />
<StatCard label={t("finance.approve")} value={stats.approved} icon={<CheckCircle2 />} />
<StatCard label={t("finance.flag")} value={stats.flagged} icon={<AlertTriangle />} />
</StatCardGrid>
<TablePanel
loading={isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterAllLabel={t("common.all")}
filterOptions={[
{ value: "pending", label: t("common.pending") },
{ value: "approved", label: t("finance.approve") },
{ value: "flagged", label: t("finance.flag") },
{ value: "rejected", label: "Rejected" },
]}
resultCount={filtered.length}
resultLabel={t("common.results")}
/>
}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("finance.byMember")}</TableHead>
<TableHead>{t("finance.byDept")}</TableHead>
<TableHead className="text-right">{t("common.amount")}</TableHead>
<TableHead>{t("finance.reason")}</TableHead>
<TableHead>{t("common.status")}</TableHead>
<TableHead>{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length ? (
filtered.map((ap) => (
<TableRow key={ap.id}>
<TableCell className="font-medium">{ap.memberName}</TableCell>
<TableCell>{ap.departmentName}</TableCell>
<TableCell className="text-right">{formatEtb(ap.amountEtb)}</TableCell>
<TableCell className="max-w-[200px] truncate">{ap.reason}</TableCell>
<TableCell>
<StatusBadge status={ap.status} />
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={ap.status !== "pending" || approve.isPending}
onClick={() => approve.mutate(ap.id)}
>
{t("finance.approve")}
</Button>
<Button
size="sm"
variant="ghost"
disabled={ap.status !== "pending" || flag.isPending}
onClick={() => flag.mutate(ap.id)}
>
{t("finance.flag")}
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="h-28 text-center text-sm text-muted-foreground">
{t("common.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TablePanel>
</PageShell>
);
}

View File

@ -0,0 +1,154 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { PageShell } from "@/components/layout/page-shell";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
import { TablePanel } from "@/components/table/table-panel";
import { mockApi } from "@/lib/api/mock-client";
import { formatDate, formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Download } from "lucide-react";
export default function FinancePage() {
const t = useTranslations();
const [search, setSearch] = useState("");
const [categoryFilter, setCategoryFilter] = useState("all");
const orgQ = useQuery({ queryKey: ["org"], queryFn: mockApi.getOrganisation });
const spendQ = useQuery({ queryKey: ["spend"], queryFn: mockApi.getSpend });
const categories = useMemo(() => {
const set = new Set((spendQ.data ?? []).map((r) => r.category));
return Array.from(set).sort();
}, [spendQ.data]);
const filtered = useMemo(() => {
return (spendQ.data ?? []).filter((row) => {
if (categoryFilter !== "all" && row.category !== categoryFilter) return false;
return matchesSearch(
[row.memberName, row.departmentName, row.category],
search,
);
});
}, [spendQ.data, search, categoryFilter]);
function handleExport() {
toast.success(t("common.export"));
}
const creditUsed = orgQ.data?.creditUsedEtb ?? 0;
const creditLimit = orgQ.data?.creditLimitEtb ?? 0;
const creditPct = creditLimit > 0 ? Math.round((creditUsed / creditLimit) * 100) : 0;
return (
<PageShell
title={t("finance.title")}
description={t("finance.subtitle")}
extra={
<Button variant="outline" className="gap-2" onClick={handleExport}>
<Download className="size-4" />
{t("common.export")}
</Button>
}
>
<StatCardGrid cols={3}>
<StatCard
label={t("finance.creditUsage")}
value={`${creditPct}%`}
hint={
orgQ.data
? `${formatEtb(creditUsed)} of ${formatEtb(creditLimit)}`
: undefined
}
/>
<StatCard
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>
<TablePanel
loading={spendQ.isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={categoryFilter}
onFilterChange={setCategoryFilter}
filterPlaceholder={t("finance.byCategory")}
filterAllLabel={t("common.all")}
filterOptions={categories.map((c) => ({
value: c,
label: c.charAt(0).toUpperCase() + c.slice(1),
}))}
resultCount={filtered.length}
resultLabel={t("common.results")}
/>
}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("finance.byMember")}</TableHead>
<TableHead>{t("finance.byDept")}</TableHead>
<TableHead>{t("finance.byCategory")}</TableHead>
<TableHead className="text-right">{t("common.amount")}</TableHead>
<TableHead className="text-right">{t("statements.covered")}</TableHead>
<TableHead>{t("common.date")}</TableHead>
<TableHead>{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length ? (
filtered.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-medium">{row.memberName}</TableCell>
<TableCell>{row.departmentName}</TableCell>
<TableCell className="capitalize">{row.category}</TableCell>
<TableCell className="text-right">{formatEtb(row.amountEtb)}</TableCell>
<TableCell className="text-right">{formatEtb(row.coveredEtb)}</TableCell>
<TableCell>{formatDate(row.occurredAt)}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
render={<Link href={`/finance/spend/${row.id}`} />}
>
{t("common.view")}
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="h-28 text-center text-sm text-muted-foreground">
{t("common.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TablePanel>
</PageShell>
);
}

View File

@ -0,0 +1,122 @@
"use client";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import {
DetailCallout,
DetailField,
DetailFieldStack,
DetailGrid,
DetailLoading,
DetailMetric,
DetailMetricStrip,
DetailPageLayout,
DetailSection,
} from "@/components/layout/detail-layout";
import { mockApi } from "@/lib/api/mock-client";
import { formatDate, formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Flag, Lock, Receipt } from "lucide-react";
import { useAuth } from "@/providers/auth-provider";
export default function SpendDetailPage() {
const t = useTranslations();
const params = useParams<{ id: string }>();
const { user } = useAuth();
const id = params.id;
const spendQ = useQuery({
queryKey: ["spend-row", id],
queryFn: () => mockApi.getSpendRow(id),
enabled: !!id,
});
const row = spendQ.data;
const canFlag =
user?.activeRole === "SUPER_USER" || user?.activeRole === "FINANCE";
return (
<DetailPageLayout
title={row?.memberName ?? t("finance.title")}
description={
row
? `${row.orderRef} · ${formatDate(row.occurredAt)}`
: t("details.spendSubtitle")
}
extra={
<div className="flex flex-wrap items-center gap-2">
{row ? (
<Badge variant="secondary" className="capitalize">
{row.category}
</Badge>
) : null}
{canFlag && row ? (
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => toast.warning(t("details.spendFlagged"))}
>
<Flag className="size-3.5" />
{t("finance.flag")}
</Button>
) : null}
<Button
variant="outline"
size="sm"
className="gap-2"
render={<Link href="/finance" />}
>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
</div>
}
>
{spendQ.isLoading ? (
<DetailLoading label={t("common.loading")} />
) : !row ? (
<DetailCallout>{t("common.empty")}</DetailCallout>
) : (
<>
<DetailMetricStrip cols={3}>
<DetailMetric label={t("common.amount")} value={formatEtb(row.amountEtb)} />
<DetailMetric label={t("statements.covered")} value={formatEtb(row.coveredEtb)} />
<DetailMetric
label={t("member.memberPaid")}
value={formatEtb(row.memberPaidEtb)}
/>
</DetailMetricStrip>
<DetailGrid>
<DetailSection title={t("details.spendInfo")} icon={<Receipt />}>
<DetailFieldStack>
<DetailField label={t("finance.byMember")} value={row.memberName} />
<DetailField label={t("finance.byDept")} value={row.departmentName} />
<DetailField label={t("finance.byCategory")} value={row.category} />
<DetailField label={t("member.order")} value={row.orderRef} />
<DetailField label={t("common.date")} value={formatDate(row.occurredAt)} />
</DetailFieldStack>
</DetailSection>
<DetailSection title={t("details.privacy")} icon={<Lock />}>
<p className="mb-4 text-sm leading-relaxed text-muted-foreground">
{t("details.spendPrivacyNote")}
</p>
<Button
size="sm"
variant="outline"
render={<Link href={`/members/${row.memberId}`} />}
>
{t("details.viewMember")}
</Button>
</DetailSection>
</DetailGrid>
</>
)}
</DetailPageLayout>
);
}

View File

@ -0,0 +1,5 @@
import { AppShell } from "@/components/layout/app-shell";
export default function PortalLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}

View File

@ -0,0 +1,122 @@
"use client";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import {
DetailCallout,
DetailField,
DetailFieldStack,
DetailGrid,
DetailLoading,
DetailMetric,
DetailMetricStrip,
DetailPageLayout,
DetailSection,
} from "@/components/layout/detail-layout";
import { StatusBadge } from "@/components/shared/status-badge";
import { mockApi } from "@/lib/api/mock-client";
import { formatDate, formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Package, RotateCcw, Split } from "lucide-react";
export default function OrderDetailPage() {
const t = useTranslations();
const params = useParams<{ id: string }>();
const id = params.id;
const orderQ = useQuery({
queryKey: ["member-order", id],
queryFn: () => mockApi.getMemberOrder(id),
enabled: !!id,
});
const order = orderQ.data;
const coveredPct =
order && order.totalEtb > 0
? Math.round((order.coveredEtb / order.totalEtb) * 100)
: 0;
return (
<DetailPageLayout
title={order?.id ?? t("member.ordersTitle")}
description={
order ? formatDate(order.placedAt) : t("details.orderSubtitle")
}
extra={
<div className="flex flex-wrap items-center gap-2">
{order ? <StatusBadge status={order.status} /> : null}
{order ? (
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => toast.success(t("details.reorderQueued"))}
>
<RotateCcw className="size-3.5" />
{t("details.reorder")}
</Button>
) : null}
<Button
variant="outline"
size="sm"
className="gap-2"
render={<Link href="/me/orders" />}
>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
</div>
}
>
{orderQ.isLoading ? (
<DetailLoading label={t("common.loading")} />
) : !order ? (
<DetailCallout>{t("common.empty")}</DetailCallout>
) : (
<>
<DetailMetricStrip cols={4}>
<DetailMetric label={t("statements.total")} value={formatEtb(order.totalEtb)} />
<DetailMetric label={t("statements.covered")} value={formatEtb(order.coveredEtb)} />
<DetailMetric
label={t("member.memberPaid")}
value={formatEtb(order.memberPaidEtb)}
/>
<DetailMetric label={t("details.coveredPct")} value={`${coveredPct}%`} />
</DetailMetricStrip>
<DetailGrid>
<DetailSection title={t("details.orderInfo")} icon={<Package />}>
<DetailFieldStack>
<DetailField label={t("member.order")} value={order.id} />
<DetailField
label={t("common.status")}
value={<StatusBadge status={order.status} />}
/>
<DetailField label={t("member.placed")} value={formatDate(order.placedAt)} />
<DetailField label={t("member.items")} value={order.itemCount} />
</DetailFieldStack>
</DetailSection>
<DetailSection title={t("details.split")} icon={<Split />}>
<p className="mb-3 text-sm leading-relaxed text-muted-foreground">
{t("details.splitNote")}
</p>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-brand"
style={{ width: `${coveredPct}%` }}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
{coveredPct}% {t("statements.covered").toLowerCase()} · {100 - coveredPct}%{" "}
{t("member.memberPaid").toLowerCase()}
</p>
</DetailSection>
</DetailGrid>
</>
)}
</DetailPageLayout>
);
}

View File

@ -0,0 +1,130 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { StatusBadge } from "@/components/shared/status-badge";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
import { TablePanel } from "@/components/table/table-panel";
import { useAuth } from "@/providers/auth-provider";
import { mockApi } from "@/lib/api/mock-client";
import { formatDate, formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ArrowLeft } from "lucide-react";
export default function MemberOrdersPage() {
const t = useTranslations();
const { user } = useAuth();
const memberId = user?.memberId ?? "mem_001";
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const { data: orders, isLoading } = useQuery({
queryKey: ["member-orders", memberId],
queryFn: () => mockApi.getMemberOrders(memberId),
enabled: !!user,
});
const filtered = useMemo(() => {
return (orders ?? []).filter((order) => {
if (statusFilter !== "all" && order.status !== statusFilter) return false;
return matchesSearch([order.id, order.status], search);
});
}, [orders, search, statusFilter]);
if (!user) return null;
return (
<PageShell
title={t("member.ordersTitle")}
description={t("member.ordersSubtitle")}
breadcrumb
extra={
<Button variant="outline" className="gap-2" render={<Link href="/me" />}>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
}
>
<TablePanel
loading={isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterAllLabel={t("common.all")}
filterOptions={[
{ value: "placed", label: "Placed" },
{ value: "processing", label: "Processing" },
{ value: "delivered", label: "Delivered" },
{ value: "cancelled", label: "Cancelled" },
]}
resultCount={filtered.length}
resultLabel={t("common.results")}
/>
}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("member.order")}</TableHead>
<TableHead className="text-right">{t("statements.total")}</TableHead>
<TableHead className="text-right">{t("statements.covered")}</TableHead>
<TableHead className="text-right">{t("member.memberPaid")}</TableHead>
<TableHead>{t("member.items")}</TableHead>
<TableHead>{t("common.status")}</TableHead>
<TableHead>{t("member.placed")}</TableHead>
<TableHead>{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length ? (
filtered.map((order) => (
<TableRow key={order.id}>
<TableCell className="font-mono text-xs">{order.id}</TableCell>
<TableCell className="text-right">{formatEtb(order.totalEtb)}</TableCell>
<TableCell className="text-right">{formatEtb(order.coveredEtb)}</TableCell>
<TableCell className="text-right">{formatEtb(order.memberPaidEtb)}</TableCell>
<TableCell>{order.itemCount}</TableCell>
<TableCell>
<StatusBadge status={order.status} />
</TableCell>
<TableCell>{formatDate(order.placedAt)}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
render={<Link href={`/me/orders/${order.id}`} />}
>
{t("common.view")}
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="h-28 text-center text-sm text-muted-foreground">
{t("common.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TablePanel>
</PageShell>
);
}

View File

@ -0,0 +1,139 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import {
DETAIL_PANEL,
DetailSection,
} from "@/components/layout/detail-layout";
import { useAuth } from "@/providers/auth-provider";
import { mockApi } from "@/lib/api/mock-client";
import { formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { Gift, Package, Wallet } from "lucide-react";
import { cn } from "@/lib/utils";
export default function MemberHomePage() {
const t = useTranslations();
const { user } = useAuth();
const memberId = user?.memberId ?? "mem_001";
const memberQ = useQuery({
queryKey: ["member", memberId, user?.activeRole],
queryFn: () => mockApi.getMember(memberId, user!.activeRole),
enabled: !!user,
});
const pkgQ = useQuery({
queryKey: ["package", memberQ.data?.packageId],
queryFn: () => mockApi.getPackage(memberQ.data!.packageId),
enabled: !!memberQ.data?.packageId,
});
const loyaltyQ = useQuery({
queryKey: ["loyalty"],
queryFn: mockApi.getLoyalty,
});
const allowancePct = useMemo(() => {
const m = memberQ.data;
if (!m || m.allowanceTotalEtb === 0) return 0;
return Math.round((m.allowanceRemainingEtb / m.allowanceTotalEtb) * 100);
}, [memberQ.data]);
if (!user) return null;
return (
<PageShell title={t("member.homeTitle")} description={t("member.homeSubtitle")}>
<StatCardGrid cols={3}>
<StatCard
label={t("member.remaining")}
value={
memberQ.data ? formatEtb(memberQ.data.allowanceRemainingEtb) : "—"
}
hint={
memberQ.data
? `of ${formatEtb(memberQ.data.allowanceTotalEtb)}`
: undefined
}
icon={<Wallet />}
/>
<StatCard
label={t("member.plan")}
value={pkgQ.data?.name ?? "—"}
icon={<Package />}
/>
<StatCard
label={t("stats.points")}
value={loyaltyQ.data ? `${loyaltyQ.data.points}` : "—"}
hint={loyaltyQ.data?.tier}
icon={<Gift />}
/>
</StatCardGrid>
<div className="grid gap-4 lg:grid-cols-3">
<DetailSection
title={t("member.plan")}
icon={<Package />}
className="lg:col-span-2"
extra={
<Button size="sm" variant="outline" render={<Link href="/me/orders" />}>
{t("member.ordersTitle")}
</Button>
}
>
{memberQ.isLoading ? (
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
) : (
<div className="space-y-4">
<div>
<p className="font-display text-lg font-semibold">
{pkgQ.data?.name ?? "—"}
</p>
<p className="text-sm text-muted-foreground">{memberQ.data?.name}</p>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{t("member.remaining")}</span>
<span className="font-medium tabular-nums">
{memberQ.data
? `${formatEtb(memberQ.data.allowanceRemainingEtb)} / ${formatEtb(memberQ.data.allowanceTotalEtb)}`
: "—"}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-brand transition-all"
style={{ width: `${allowancePct}%` }}
/>
</div>
</div>
</div>
)}
</DetailSection>
<div className={cn(DETAIL_PANEL, "p-5")}>
<p className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
{t("member.loyalty")}
</p>
{loyaltyQ.isLoading ? (
<p className="mt-3 text-sm text-muted-foreground">{t("common.loading")}</p>
) : loyaltyQ.data ? (
<div className="mt-3 space-y-1.5">
<p className="font-display text-2xl font-semibold tabular-nums">
{loyaltyQ.data.points} pts
</p>
<p className="text-sm font-medium">{loyaltyQ.data.tier}</p>
<p className="text-xs text-muted-foreground">{loyaltyQ.data.note}</p>
</div>
) : null}
</div>
</div>
</PageShell>
);
}

View File

@ -0,0 +1,366 @@
"use client";
import { useMemo } from "react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
DetailCallout,
DetailField,
DetailFieldStack,
DetailLoading,
DetailPageColumns,
DetailPageLayout,
DetailProfileCard,
DetailReadOnlyBox,
DetailSection,
} from "@/components/layout/detail-layout";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { StatusBadge } from "@/components/shared/status-badge";
import { useAuth } from "@/providers/auth-provider";
import { mockApi } from "@/lib/api/mock-client";
import { formatDate, formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ArrowLeft,
Building2,
Mail,
Percent,
Phone,
Shield,
UserCheck,
UserMinus,
Users,
Wallet,
} from "lucide-react";
function initials(name: string) {
return name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("");
}
export default function MemberDetailPage() {
const t = useTranslations();
const tTabs = useTranslations("tabs");
const params = useParams<{ id: string }>();
const { user } = useAuth();
const qc = useQueryClient();
const memberId = params.id;
const memberQ = useQuery({
queryKey: ["member", memberId, user?.activeRole],
queryFn: () => mockApi.getMember(memberId, user!.activeRole),
enabled: !!user && !!memberId,
});
const deptsQ = useQuery({ queryKey: ["departments"], queryFn: mockApi.getDepartments });
const pkgsQ = useQuery({ queryKey: ["packages"], queryFn: mockApi.getPackages });
const canManage =
user?.activeRole === "SUPER_USER" || user?.activeRole === "HR_ADMIN";
const isClinicalWithheld =
user?.activeRole === "HR_ADMIN" || user?.activeRole === "FINANCE";
const canSeeClinical = user?.activeRole === "SUPER_USER";
const deptName = useMemo(
() => deptsQ.data?.find((d) => d.id === memberQ.data?.departmentId)?.name,
[deptsQ.data, memberQ.data?.departmentId],
);
const pkgName = useMemo(
() => pkgsQ.data?.find((p) => p.id === memberQ.data?.packageId)?.name,
[pkgsQ.data, memberQ.data?.packageId],
);
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ["member", memberId] });
void qc.invalidateQueries({ queryKey: ["members"] });
};
const resend = useMutation({
mutationFn: () => mockApi.resendMemberInvite(memberId),
onSuccess: () => {
toast.success(t("details.inviteResent"));
invalidate();
},
onError: () => toast.error(t("details.actionFailed")),
});
const offboard = useMutation({
mutationFn: () => mockApi.updateMemberStatus(memberId, "offboarded"),
onSuccess: () => {
toast.success(t("details.memberOffboarded"));
invalidate();
},
onError: () => toast.error(t("details.actionFailed")),
});
const reactivate = useMutation({
mutationFn: () => mockApi.updateMemberStatus(memberId, "active"),
onSuccess: () => {
toast.success(t("details.memberReactivated"));
invalidate();
},
onError: () => toast.error(t("details.actionFailed")),
});
if (!user) return null;
const member = memberQ.data;
const usedPct =
member && member.allowanceTotalEtb > 0
? Math.round(
((member.allowanceTotalEtb - member.allowanceRemainingEtb) /
member.allowanceTotalEtb) *
100,
)
: 0;
return (
<DetailPageLayout
title={member?.name ?? t("members.detail")}
description={member ? member.employeeId : t("details.memberSubtitle")}
extra={
<Button variant="outline" size="sm" className="gap-2" render={<Link href="/members" />}>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
}
>
{memberQ.isLoading ? (
<DetailLoading label={t("common.loading")} />
) : !member ? (
<DetailCallout>{t("common.empty")}</DetailCallout>
) : (
<>
{isClinicalWithheld ? (
<DetailCallout>{t("common.withheld")}</DetailCallout>
) : null}
<StatCardGrid cols={4}>
<StatCard
label={t("members.allowance")}
value={formatEtb(member.allowanceRemainingEtb)}
hint={`${t("details.ofTotal")} ${formatEtb(member.allowanceTotalEtb)}`}
icon={<Wallet />}
/>
<StatCard
label={t("details.used")}
value={`${usedPct}%`}
hint={pkgName ?? undefined}
icon={<Percent />}
/>
<StatCard
label={t("members.dependants")}
value={member.dependants.length}
icon={<Users />}
/>
<StatCard
label={t("common.status")}
value={<span className="text-lg capitalize">{member.status}</span>}
hint={deptName ?? undefined}
icon={<UserCheck />}
/>
</StatCardGrid>
<DetailPageColumns
aside={
<DetailProfileCard
avatar={
<Avatar className="size-20 border-4 border-card text-lg shadow-none">
<AvatarFallback className="bg-brand/20 font-display text-base font-semibold text-foreground">
{initials(member.name)}
</AvatarFallback>
</Avatar>
}
title={member.name}
subtitle={member.email}
meta={<StatusBadge status={member.status} />}
actions={
canManage ? (
<>
<Button
size="sm"
variant="outline"
className="w-full justify-start gap-2"
disabled={resend.isPending || member.status === "active"}
onClick={() => resend.mutate()}
>
<Mail className="size-3.5" />
{t("details.resendInvite")}
</Button>
{member.status !== "offboarded" ? (
<Button
size="sm"
variant="outline"
className="w-full justify-start gap-2"
disabled={offboard.isPending}
onClick={() => offboard.mutate()}
>
<UserMinus className="size-3.5" />
{t("members.offboard")}
</Button>
) : (
<Button
size="sm"
className="w-full justify-start gap-2"
disabled={reactivate.isPending}
onClick={() => reactivate.mutate()}
>
<UserCheck className="size-3.5" />
{t("details.reactivate")}
</Button>
)}
</>
) : null
}
>
<DetailFieldStack className="gap-3">
<div className="flex items-center gap-2.5 text-sm">
<Phone className="size-3.5 shrink-0 text-muted-foreground" />
<span>{member.phone}</span>
</div>
<div className="flex items-center gap-2.5 text-sm">
<Building2 className="size-3.5 shrink-0 text-muted-foreground" />
<span>{deptName ?? "—"}</span>
</div>
<div className="flex items-center gap-2.5 text-sm">
<Wallet className="size-3.5 shrink-0 text-muted-foreground" />
<span>{pkgName ?? "—"}</span>
</div>
</DetailFieldStack>
</DetailProfileCard>
}
>
<Tabs defaultValue="profile" className="w-full">
<TabsList>
<TabsTrigger value="profile">{tTabs("profile")}</TabsTrigger>
<TabsTrigger value="entitlement">{tTabs("entitlement")}</TabsTrigger>
<TabsTrigger value="dependants">{tTabs("dependants")}</TabsTrigger>
{canSeeClinical ? (
<TabsTrigger value="clinical">{tTabs("clinical")}</TabsTrigger>
) : null}
</TabsList>
<TabsContent value="profile" className="space-y-4 pt-1">
<DetailSection title={t("details.profile")} icon={<Shield />}>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<DetailReadOnlyBox label={t("members.employeeId")}>
{member.employeeId}
</DetailReadOnlyBox>
<DetailReadOnlyBox label={t("common.email")}>
{member.email}
</DetailReadOnlyBox>
<DetailReadOnlyBox label={t("common.phone")}>
{member.phone}
</DetailReadOnlyBox>
<DetailReadOnlyBox label={t("details.startDate")}>
{formatDate(member.startDate)}
</DetailReadOnlyBox>
<DetailReadOnlyBox label={t("members.department")}>
{deptName ?? "—"}
</DetailReadOnlyBox>
{member.verificationId ? (
<DetailReadOnlyBox label={t("details.verificationId")}>
{member.verificationId}
</DetailReadOnlyBox>
) : null}
</div>
</DetailSection>
</TabsContent>
<TabsContent value="entitlement" className="space-y-4 pt-1">
<DetailSection title={tTabs("entitlement")} icon={<Wallet />}>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<DetailReadOnlyBox label={t("members.package")}>
{pkgName ?? "—"}
</DetailReadOnlyBox>
<DetailReadOnlyBox label={t("members.allowance")}>
{formatEtb(member.allowanceRemainingEtb)} /{" "}
{formatEtb(member.allowanceTotalEtb)}
</DetailReadOnlyBox>
<DetailReadOnlyBox label={t("details.used")}>
{usedPct}%
</DetailReadOnlyBox>
{member.override ? (
<DetailReadOnlyBox label={t("details.override")}>
{member.override.notes ??
formatEtb(member.override.monthlyAllowanceEtb ?? 0)}
</DetailReadOnlyBox>
) : null}
</div>
</DetailSection>
</TabsContent>
<TabsContent value="dependants" className="space-y-4 pt-1">
<DetailSection
title={t("members.dependants")}
icon={<Users />}
extra={
<span className="text-xs text-muted-foreground tabular-nums">
{member.dependants.length}
</span>
}
>
{member.dependants.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("common.empty")}</p>
) : (
<ul className="divide-y divide-border">
{member.dependants.map((dep) => (
<li
key={dep.id}
className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
>
<div className="min-w-0">
<p className="text-sm font-medium">{dep.name}</p>
<p className="text-xs text-muted-foreground capitalize">
{dep.relationship} · {formatDate(dep.dateOfBirth)}
</p>
</div>
<StatusBadge status={dep.status} />
</li>
))}
</ul>
)}
</DetailSection>
</TabsContent>
{canSeeClinical ? (
<TabsContent value="clinical" className="space-y-4 pt-1">
<DetailSection title={t("details.clinicalSupport")} icon={<Shield />}>
<DetailFieldStack>
<DetailField
label={t("details.activeRx")}
value={member.hasActiveRx ? t("common.active") : "—"}
/>
{member.lastRxStatus ? (
<DetailField
label={t("member.rxStatus")}
value={<StatusBadge status={member.lastRxStatus} />}
/>
) : null}
</DetailFieldStack>
<div className="pt-3">
<Button
size="sm"
variant="outline"
render={<Link href="/prescriptions" />}
>
{t("nav.prescriptions")}
</Button>
</div>
</DetailSection>
</TabsContent>
) : null}
</Tabs>
</DetailPageColumns>
</>
)}
</DetailPageLayout>
);
}

View File

@ -0,0 +1,125 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { CreatePageLayout } from "@/components/layout/create-layout";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { TablePanel } from "@/components/table/table-panel";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ArrowLeft, Download, Upload } from "lucide-react";
type Step = "upload" | "preview";
const PREVIEW_ROWS = [
{ employeeId: "YT-1201", name: "Tigist Alemu", department: "Engineering", package: "Gold Corporate" },
{ employeeId: "YT-1202", name: "Biniam Tesfaye", department: "Sales", package: "Silver Standard" },
{ employeeId: "YT-1203", name: "Hanna Bekele", department: "Operations", package: "Bronze Essentials" },
];
export default function MembersImportPage() {
const t = useTranslations();
const [step, setStep] = useState<Step>("upload");
const [fileName, setFileName] = useState<string | null>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) {
setFileName(file.name);
setStep("preview");
}
}
function handleImport() {
toast.success(t("common.pending"));
}
return (
<CreatePageLayout
title={t("members.importTitle")}
description={t("members.importSubtitle")}
extra={
<Button variant="outline" className="gap-2" render={<Link href="/members" />}>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
}
>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Badge variant={step === "upload" ? "default" : "secondary"}>1. {t("common.upload")}</Badge>
<Badge variant={step === "preview" ? "default" : "secondary"}>2. {t("common.preview")}</Badge>
</div>
{step === "upload" && (
<Card className="rounded-2xl shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("common.upload")}</CardTitle>
<CardDescription>Upload an Excel file or download the template first.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button variant="outline" className="gap-2" onClick={() => toast.info(t("common.template"))}>
<Download className="size-4" />
{t("common.template")}
</Button>
<div className="flex items-center gap-3">
<Input type="file" accept=".xlsx,.xls,.csv" onChange={handleFileChange} />
<Upload className="size-5 shrink-0 text-muted-foreground" />
</div>
</CardContent>
</Card>
)}
{step === "preview" && fileName && (
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">{t("common.preview")}</p>
<p className="text-sm text-muted-foreground">
{fileName} — 3 rows ready to import
</p>
</div>
<TablePanel>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("members.employeeId")}</TableHead>
<TableHead>Name</TableHead>
<TableHead>{t("members.department")}</TableHead>
<TableHead>{t("members.package")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{PREVIEW_ROWS.map((row) => (
<TableRow key={row.employeeId}>
<TableCell className="font-mono text-xs">{row.employeeId}</TableCell>
<TableCell>{row.name}</TableCell>
<TableCell>{row.department}</TableCell>
<TableCell>{row.package}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TablePanel>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep("upload")}>
{t("common.back")}
</Button>
<Button onClick={handleImport}>{t("common.submit")}</Button>
</div>
</div>
)}
</div>
</CreatePageLayout>
);
}

View File

@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { useRouter, Link } from "@/i18n/routing";
import { CreatePageLayout } from "@/components/layout/create-layout";
import { mockApi } from "@/lib/api/mock-client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ArrowLeft } from "lucide-react";
export default function InviteMemberPage() {
const t = useTranslations();
const router = useRouter();
const [departmentId, setDepartmentId] = useState("");
const [packageId, setPackageId] = useState("");
const deptsQ = useQuery({
queryKey: ["departments"],
queryFn: mockApi.getDepartments,
});
const pkgsQ = useQuery({
queryKey: ["packages"],
queryFn: mockApi.getPackages,
});
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
if (!data.get("name") || !data.get("phone") || !data.get("employeeId")) {
toast.error(t("common.requiredFields"));
return;
}
if (!departmentId || !packageId) {
toast.error(t("common.requiredFields"));
return;
}
toast.success(t("members.inviteSuccess"));
router.push("/members");
}
return (
<CreatePageLayout
title={t("members.invite")}
description={t("members.inviteSubtitle")}
extra={
<Button variant="outline" className="gap-2" render={<Link href="/members" />}>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
}
>
<Card className="w-full rounded-xl shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("members.inviteFormTitle")}</CardTitle>
<CardDescription>{t("members.inviteSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="name">{t("common.name")}</Label>
<Input id="name" name="name" required />
</div>
<div className="space-y-2">
<Label htmlFor="employeeId">{t("members.employeeId")}</Label>
<Input id="employeeId" name="employeeId" required />
</div>
<div className="space-y-2">
<Label htmlFor="email">{t("common.email")}</Label>
<Input id="email" name="email" type="email" />
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t("common.phone")}</Label>
<Input id="phone" name="phone" required />
</div>
<div className="space-y-2">
<Label>{t("members.department")}</Label>
<Select value={departmentId} onValueChange={(v) => setDepartmentId(v ?? "")}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("members.department")} />
</SelectTrigger>
<SelectContent>
{(deptsQ.data ?? []).map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("members.package")}</Label>
<Select value={packageId} onValueChange={(v) => setPackageId(v ?? "")}>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("members.package")} />
</SelectTrigger>
<SelectContent>
{(pkgsQ.data ?? []).map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit">{t("members.sendInvite")}</Button>
<Button type="button" variant="outline" render={<Link href="/members" />}>
{t("common.cancel")}
</Button>
</div>
</form>
</CardContent>
</Card>
</CreatePageLayout>
);
}

View File

@ -0,0 +1,176 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { StatusBadge } from "@/components/shared/status-badge";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
import { TablePanel } from "@/components/table/table-panel";
import { useAuth } from "@/providers/auth-provider";
import { mockApi } from "@/lib/api/mock-client";
import { formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Upload, UserCheck, UserMinus, UserPlus, Users } from "lucide-react";
export default function MembersPage() {
const t = useTranslations();
const { user } = useAuth();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const membersQ = useQuery({
queryKey: ["members", user?.activeRole],
queryFn: () => mockApi.getMembers(user!.activeRole),
enabled: !!user,
});
const deptsQ = useQuery({
queryKey: ["departments"],
queryFn: mockApi.getDepartments,
});
const pkgsQ = useQuery({
queryKey: ["packages"],
queryFn: mockApi.getPackages,
});
const deptMap = useMemo(
() => new Map(deptsQ.data?.map((d) => [d.id, d.name])),
[deptsQ.data],
);
const pkgMap = useMemo(
() => new Map(pkgsQ.data?.map((p) => [p.id, p.name])),
[pkgsQ.data],
);
const filtered = useMemo(() => {
return (membersQ.data ?? []).filter((m) => {
if (statusFilter !== "all" && m.status !== statusFilter) return false;
return matchesSearch(
[m.name, m.email, m.employeeId, m.phone, deptMap.get(m.departmentId), pkgMap.get(m.packageId)],
search,
);
});
}, [membersQ.data, search, statusFilter, deptMap, pkgMap]);
const stats = useMemo(() => {
const rows = membersQ.data ?? [];
return {
total: rows.length,
active: rows.filter((m) => m.status === "active").length,
invited: rows.filter((m) => m.status === "invited").length,
offboarded: rows.filter((m) => m.status === "offboarded").length,
};
}, [membersQ.data]);
if (!user) return null;
return (
<PageShell
title={t("members.title")}
description={t("members.subtitle")}
extra={
<>
<Button variant="outline" className="gap-2" render={<Link href="/members/import" />}>
<Upload className="size-4" />
{t("members.import")}
</Button>
<Button className="gap-2" render={<Link href="/members/new" />}>
<UserPlus className="size-4" />
{t("members.invite")}
</Button>
</>
}
>
<StatCardGrid cols={4}>
<StatCard label={t("stats.total")} value={stats.total} icon={<Users />} />
<StatCard label={t("stats.active")} value={stats.active} icon={<UserCheck />} />
<StatCard label={t("stats.invited")} value={stats.invited} icon={<UserPlus />} />
<StatCard label={t("stats.offboarded")} value={stats.offboarded} icon={<UserMinus />} />
</StatCardGrid>
<TablePanel
loading={membersQ.isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterPlaceholder={t("common.status")}
filterAllLabel={t("common.all")}
filterOptions={[
{ value: "active", label: t("common.active") },
{ value: "invited", label: t("members.statusInvited") },
{ value: "inactive", label: t("members.statusInactive") },
{ value: "offboarded", label: t("members.statusOffboarded") },
]}
resultCount={filtered.length}
resultLabel={t("common.results")}
/>
}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("members.employeeId")}</TableHead>
<TableHead>{t("common.name")}</TableHead>
<TableHead>{t("members.department")}</TableHead>
<TableHead>{t("members.package")}</TableHead>
<TableHead className="text-right">{t("members.allowance")}</TableHead>
<TableHead>{t("common.status")}</TableHead>
<TableHead>{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length ? (
filtered.map((member) => (
<TableRow key={member.id}>
<TableCell className="font-mono text-xs">{member.employeeId}</TableCell>
<TableCell className="font-medium">{member.name}</TableCell>
<TableCell>{deptMap.get(member.departmentId) ?? "—"}</TableCell>
<TableCell>{pkgMap.get(member.packageId) ?? "—"}</TableCell>
<TableCell className="text-right">
{formatEtb(member.allowanceRemainingEtb)} /{" "}
{formatEtb(member.allowanceTotalEtb)}
</TableCell>
<TableCell>
<StatusBadge status={member.status} />
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
render={<Link href={`/members/${member.id}`} />}
>
{t("common.view")}
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="h-28 text-center text-sm text-muted-foreground">
{t("common.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TablePanel>
</PageShell>
);
}

View File

@ -0,0 +1,200 @@
"use client";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
DetailCallout,
DetailField,
DetailFieldStack,
DetailLoading,
DetailMetric,
DetailMetricStrip,
DetailPageLayout,
DetailSection,
DetailTableSection,
} from "@/components/layout/detail-layout";
import { StatusBadge } from "@/components/shared/status-badge";
import { mockApi } from "@/lib/api/mock-client";
import { formatDate } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ArrowLeft, CheckCircle2, FileSpreadsheet, RotateCcw } from "lucide-react";
import { useAuth } from "@/providers/auth-provider";
export default function MigrationJobDetailPage() {
const t = useTranslations();
const tTabs = useTranslations("tabs");
const params = useParams<{ jobId: string }>();
const { user } = useAuth();
const qc = useQueryClient();
const id = params.jobId;
const jobQ = useQuery({
queryKey: ["migration-job", id],
queryFn: () => mockApi.getMigrationJob(id),
enabled: !!id,
});
const canManage =
user?.activeRole === "SUPER_USER" || user?.activeRole === "HR_ADMIN";
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ["migration-job", id] });
void qc.invalidateQueries({ queryKey: ["migration-jobs"] });
};
const commit = useMutation({
mutationFn: () => mockApi.commitMigrationJob(id),
onSuccess: () => {
toast.success(t("details.migrationCommitted"));
invalidate();
},
onError: () => toast.error(t("details.actionFailed")),
});
const rollback = useMutation({
mutationFn: () => mockApi.rollbackMigrationJob(id),
onSuccess: () => {
toast.success(t("details.migrationRolledBack"));
invalidate();
},
onError: () => toast.error(t("details.actionFailed")),
});
const job = jobQ.data;
return (
<DetailPageLayout
title={job?.fileName ?? t("migration.title")}
description={
job ? job.dataset.replaceAll("_", " ") : t("details.migrationSubtitle")
}
extra={
<div className="flex flex-wrap items-center gap-2">
{job ? <StatusBadge status={job.status} /> : null}
{canManage && job ? (
<>
<Button
size="sm"
className="gap-1.5"
disabled={
commit.isPending ||
job.status === "completed" ||
job.status === "rolled_back"
}
onClick={() => commit.mutate()}
>
<CheckCircle2 className="size-3.5" />
{t("common.commit")}
</Button>
<Button
size="sm"
variant="outline"
className="gap-1.5"
disabled={
rollback.isPending ||
job.status === "rolled_back" ||
job.status === "draft"
}
onClick={() => rollback.mutate()}
>
<RotateCcw className="size-3.5" />
{t("details.rollback")}
</Button>
</>
) : null}
<Button
variant="outline"
size="sm"
className="gap-2"
render={<Link href="/migration" />}
>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
</div>
}
>
{jobQ.isLoading ? (
<DetailLoading label={t("common.loading")} />
) : !job ? (
<DetailCallout>{t("common.empty")}</DetailCallout>
) : (
<>
<DetailMetricStrip cols={4}>
<DetailMetric label={t("migration.rows")} value={job.rowCount} />
<DetailMetric label={t("details.creates")} value={job.createCount} />
<DetailMetric label={t("details.updates")} value={job.updateCount} />
<DetailMetric
label={t("details.errors")}
value={job.errorCount}
hint={`${job.skipCount} skipped`}
accent={job.errorCount > 0 ? "danger" : "success"}
/>
</DetailMetricStrip>
<Tabs defaultValue="summary" className="w-full">
<TabsList>
<TabsTrigger value="summary">{tTabs("summary")}</TabsTrigger>
<TabsTrigger value="errors">
{tTabs("errors")}
{job.errorCount > 0 ? ` (${job.errorCount})` : ""}
</TabsTrigger>
</TabsList>
<TabsContent value="summary" className="space-y-4 pt-1">
<DetailSection title={t("details.jobInfo")} icon={<FileSpreadsheet />}>
<DetailFieldStack>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<DetailField label={t("migration.dataset")} value={job.dataset} />
<DetailField label={t("migration.file")} value={job.fileName} />
<DetailField label={t("common.created")} value={formatDate(job.createdAt)} />
<DetailField label={t("details.createdBy")} value={job.createdBy} />
</div>
</DetailFieldStack>
</DetailSection>
</TabsContent>
<TabsContent value="errors" className="space-y-4 pt-1">
{job.errors.length === 0 ? (
<DetailSection title={t("details.validationErrors")}>
<p className="text-sm text-muted-foreground">{t("details.noErrors")}</p>
</DetailSection>
) : (
<DetailTableSection title={t("details.validationErrors")}>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("details.row")}</TableHead>
<TableHead>{t("details.field")}</TableHead>
<TableHead>{t("details.message")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{job.errors.map((err, i) => (
<TableRow key={`${err.row}-${i}`}>
<TableCell className="tabular-nums">{err.row}</TableCell>
<TableCell className="font-mono text-xs">{err.field}</TableCell>
<TableCell className="whitespace-normal">{err.message}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</DetailTableSection>
)}
</TabsContent>
</Tabs>
</>
)}
</DetailPageLayout>
);
}

View File

@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import type { MigrationDataset } from "@/types";
import { CreatePageLayout } from "@/components/layout/create-layout";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ArrowLeft } from "lucide-react";
const STEPS = ["dataset", "upload", "map", "validate", "preview", "commit"] as const;
type Step = (typeof STEPS)[number];
const DATASETS: MigrationDataset[] = [
"departments",
"packages",
"members",
"overrides",
"dependants",
"verification_ids",
"full_onboarding",
];
export default function NewMigrationPage() {
const t = useTranslations();
const [stepIndex, setStepIndex] = useState(0);
const [dataset, setDataset] = useState<MigrationDataset>("members");
const [fileName, setFileName] = useState<string | null>(null);
const step = STEPS[stepIndex];
function next() {
if (stepIndex < STEPS.length - 1) setStepIndex((i) => i + 1);
}
function back() {
if (stepIndex > 0) setStepIndex((i) => i - 1);
}
function handleCommit() {
toast.success(t("common.commit"));
}
return (
<CreatePageLayout
title={t("migration.wizardTitle")}
description={t("migration.subtitle")}
extra={
<Button variant="outline" className="gap-2" render={<Link href="/migration" />}>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
}
>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{STEPS.map((s, i) => (
<Badge key={s} variant={i === stepIndex ? "default" : i < stepIndex ? "secondary" : "outline"}>
{i + 1}. {s === "upload" ? t("migration.stepUpload") : s === "map" ? t("migration.stepMap") : s === "validate" ? t("migration.stepValidate") : s === "preview" ? t("migration.stepPreview") : s === "commit" ? t("migration.stepCommit") : t("migration.dataset")}
</Badge>
))}
</div>
<Card className="w-full rounded-xl shadow-none">
<CardHeader>
<CardTitle className="text-base capitalize">{step.replaceAll("_", " ")}</CardTitle>
<CardDescription>{t("migration.subtitle")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{step === "dataset" && (
<div className="space-y-2">
<Label>{t("migration.dataset")}</Label>
<Select
value={dataset}
onValueChange={(v) => {
if (DATASETS.includes(v as MigrationDataset)) setDataset(v as MigrationDataset);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATASETS.map((d) => (
<SelectItem key={d} value={d}>
{d.replaceAll("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{step === "upload" && (
<div className="space-y-2">
<Label>{t("migration.stepUpload")}</Label>
<Input
type="file"
accept=".xlsx,.xls,.csv"
onChange={(e) => setFileName(e.target.files?.[0]?.name ?? null)}
/>
{fileName && (
<p className="text-sm text-muted-foreground">Selected: {fileName}</p>
)}
</div>
)}
{step === "map" && (
<div className="space-y-2 text-sm">
<p className="text-muted-foreground">Column mapping (mock)</p>
<div className="grid gap-2 rounded-lg border border-border p-4">
<div className="flex justify-between">
<span>employee_id</span>
<span className="text-muted-foreground">→ employeeId</span>
</div>
<div className="flex justify-between">
<span>full_name</span>
<span className="text-muted-foreground">→ name</span>
</div>
<div className="flex justify-between">
<span>dept</span>
<span className="text-muted-foreground">→ departmentId</span>
</div>
</div>
</div>
)}
{step === "validate" && (
<div className="rounded-lg border border-border bg-muted/40 p-4 text-sm">
<p className="font-medium text-primary">Validation passed</p>
<p className="text-muted-foreground">120 rows · 0 errors · 4 warnings</p>
</div>
)}
{step === "preview" && (
<div className="rounded-lg border border-border p-4 text-sm">
<p>Preview: 98 creates, 18 updates, 4 skips</p>
<p className="text-muted-foreground">Dataset: {dataset.replaceAll("_", " ")}</p>
</div>
)}
{step === "commit" && (
<div className="rounded-lg border border-border bg-primary/5 p-4 text-sm">
<p className="font-medium">Ready to commit</p>
<p className="text-muted-foreground">
This will apply changes to your organisation data.
</p>
</div>
)}
<div className="flex gap-2">
{stepIndex > 0 && (
<Button variant="outline" onClick={back}>
{t("common.back")}
</Button>
)}
{stepIndex < STEPS.length - 1 ? (
<Button onClick={next}>{t("common.continue")}</Button>
) : (
<Button onClick={handleCommit}>{t("common.commit")}</Button>
)}
</div>
</CardContent>
</Card>
</div>
</CreatePageLayout>
);
}

View File

@ -0,0 +1,143 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { StatusBadge } from "@/components/shared/status-badge";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
import { TablePanel } from "@/components/table/table-panel";
import { mockApi } from "@/lib/api/mock-client";
import { formatDate } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AlertTriangle, CheckCircle2, FileSpreadsheet, Plus } from "lucide-react";
export default function MigrationPage() {
const t = useTranslations();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const { data: jobs, isLoading } = useQuery({
queryKey: ["migration-jobs"],
queryFn: mockApi.getMigrationJobs,
});
const filtered = useMemo(() => {
return (jobs ?? []).filter((job) => {
if (statusFilter !== "all" && job.status !== statusFilter) return false;
return matchesSearch([job.dataset, job.fileName, job.id], search);
});
}, [jobs, search, statusFilter]);
const stats = useMemo(() => {
const rows = jobs ?? [];
return {
total: rows.length,
completed: rows.filter((j) => j.status === "completed").length,
draft: rows.filter((j) => j.status === "draft" || j.status === "preview").length,
withErrors: rows.filter((j) => j.errorCount > 0 || j.status === "failed").length,
};
}, [jobs]);
return (
<PageShell
title={t("migration.title")}
description={t("migration.subtitle")}
extra={
<Button className="gap-2" render={<Link href="/migration/new" />}>
<Plus className="size-4" />
{t("migration.new")}
</Button>
}
>
<StatCardGrid cols={4}>
<StatCard label={t("stats.jobs")} value={stats.total} icon={<FileSpreadsheet />} />
<StatCard label={t("stats.completed")} value={stats.completed} icon={<CheckCircle2 />} />
<StatCard label={t("stats.draft")} value={stats.draft} />
<StatCard
label={t("stats.withErrors")}
value={stats.withErrors}
icon={<AlertTriangle />}
/>
</StatCardGrid>
<TablePanel
loading={isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterAllLabel={t("common.all")}
filterOptions={[
{ value: "draft", label: "Draft" },
{ value: "preview", label: "Preview" },
{ value: "completed", label: t("common.active") },
{ value: "failed", label: "Failed" },
]}
resultCount={filtered.length}
resultLabel={t("common.results")}
/>
}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("migration.dataset")}</TableHead>
<TableHead>{t("migration.file")}</TableHead>
<TableHead>{t("common.status")}</TableHead>
<TableHead>{t("migration.rows")}</TableHead>
<TableHead>{t("common.created")}</TableHead>
<TableHead>{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length ? (
filtered.map((job) => (
<TableRow key={job.id}>
<TableCell className="font-medium capitalize">
{job.dataset.replaceAll("_", " ")}
</TableCell>
<TableCell>{job.fileName}</TableCell>
<TableCell>
<StatusBadge status={job.status} />
</TableCell>
<TableCell>{job.rowCount}</TableCell>
<TableCell>{formatDate(job.createdAt)}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
render={<Link href={`/migration/${job.id}`} />}
>
{t("common.view")}
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="h-28 text-center text-sm text-muted-foreground">
{t("common.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TablePanel>
</PageShell>
);
}

View File

@ -0,0 +1,158 @@
"use client";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import {
DetailCallout,
DetailField,
DetailFieldStack,
DetailLoading,
DetailMetric,
DetailMetricStrip,
DetailPageLayout,
DetailSection,
} from "@/components/layout/detail-layout";
import { StatusBadge } from "@/components/shared/status-badge";
import { mockApi } from "@/lib/api/mock-client";
import { formatDate, formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Building2, FileText, Wallet } from "lucide-react";
export default function OrganisationPage() {
const t = useTranslations();
const tTabs = useTranslations("tabs");
const { data: org, isLoading } = useQuery({
queryKey: ["org"],
queryFn: mockApi.getOrganisation,
});
const creditPct =
org?.creditLimitEtb && org.creditLimitEtb > 0
? Math.round((org.creditUsedEtb / org.creditLimitEtb) * 100)
: 0;
return (
<DetailPageLayout
title={org?.tradeName ?? t("organisation.title")}
description={org?.legalName ?? t("organisation.subtitle")}
breadcrumb={false}
extra={
<div className="flex flex-wrap items-center gap-2">
{org ? <StatusBadge status={org.status} /> : null}
<Button size="sm" variant="outline" render={<Link href="/departments" />}>
{t("nav.departments")}
</Button>
<Button size="sm" variant="outline" render={<Link href="/settings/verification" />}>
{t("nav.verification")}
</Button>
</div>
}
>
{isLoading ? (
<DetailLoading label={t("common.loading")} />
) : org ? (
<>
{org.status === "pending_activation" ? (
<DetailCallout tone="warning">{t("organisation.pendingNote")}</DetailCallout>
) : null}
<DetailMetricStrip cols={3}>
<DetailMetric
label={t("organisation.creditLimit")}
value={org.creditLimitEtb != null ? formatEtb(org.creditLimitEtb) : "—"}
icon={<Wallet />}
/>
<DetailMetric
label={t("dashboard.credit")}
value={formatEtb(org.creditUsedEtb)}
hint={`${creditPct}% used`}
accent={creditPct > 80 ? "danger" : "default"}
/>
<DetailMetric
label={t("organisation.paymentTerms")}
value={
org.paymentTermsDays != null ? `${org.paymentTermsDays} days` : "—"
}
/>
</DetailMetricStrip>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{t("dashboard.credit")}</span>
<span className="tabular-nums">{creditPct}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-brand transition-all"
style={{ width: `${Math.min(creditPct, 100)}%` }}
/>
</div>
</div>
<Tabs defaultValue="profile" className="w-full">
<TabsList>
<TabsTrigger value="profile">{tTabs("profile")}</TabsTrigger>
<TabsTrigger value="contract">{tTabs("contract")}</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-4 pt-1">
<DetailSection title={t("organisation.title")} icon={<Building2 />}>
<DetailFieldStack>
<div className="grid gap-4 sm:grid-cols-2">
<DetailField label={t("organisation.legalName")} value={org.legalName} />
<DetailField label={t("organisation.tin")} value={org.tin} />
<DetailField
label={t("organisation.billing")}
value={`${org.billingEmail} · ${org.billingPhone}`}
/>
<DetailField label={t("organisation.joinCode")} value={org.joinCode} />
<DetailField
label={t("organisation.domains")}
value={org.allowedEmailDomains.join(", ")}
/>
<DetailField
label={t("dashboard.members")}
value={org.approxHeadcount}
/>
</div>
</DetailFieldStack>
</DetailSection>
</TabsContent>
<TabsContent value="contract" className="space-y-4 pt-1">
<DetailSection title={t("organisation.contract")} icon={<FileText />}>
<DetailFieldStack>
<div className="grid gap-4 sm:grid-cols-2">
<DetailField label={t("organisation.priceList")} value={org.priceListCode} />
<DetailField
label={t("organisation.contract")}
value={
org.contractStart && org.contractEnd
? `${formatDate(org.contractStart)} – ${formatDate(org.contractEnd)}`
: "—"
}
/>
<DetailField
label={t("organisation.creditLimit")}
value={
org.creditLimitEtb != null ? formatEtb(org.creditLimitEtb) : "—"
}
/>
<DetailField
label={t("organisation.paymentTerms")}
value={
org.paymentTermsDays != null
? `${org.paymentTermsDays} days`
: "—"
}
/>
</div>
</DetailFieldStack>
</DetailSection>
</TabsContent>
</Tabs>
</>
) : null}
</DetailPageLayout>
);
}

View File

@ -0,0 +1,157 @@
"use client";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
DetailCallout,
DetailField,
DetailFieldStack,
DetailLoading,
DetailMetric,
DetailMetricStrip,
DetailPageLayout,
DetailSection,
} from "@/components/layout/detail-layout";
import { StatusBadge } from "@/components/shared/status-badge";
import { mockApi } from "@/lib/api/mock-client";
import { formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ArrowLeft, Gift, Layers, Power, Users, Wallet } from "lucide-react";
import { useAuth } from "@/providers/auth-provider";
export default function PackageDetailPage() {
const t = useTranslations();
const tTabs = useTranslations("tabs");
const params = useParams<{ id: string }>();
const { user } = useAuth();
const qc = useQueryClient();
const id = params.id;
const pkgQ = useQuery({
queryKey: ["package", id],
queryFn: () => mockApi.getPackage(id),
enabled: !!id,
});
const canManage =
user?.activeRole === "SUPER_USER" || user?.activeRole === "HR_ADMIN";
const toggle = useMutation({
mutationFn: () => mockApi.togglePackageActive(id),
onSuccess: (pkg) => {
toast.success(
pkg.active ? t("details.packageActivated") : t("details.packageDeactivated"),
);
void qc.invalidateQueries({ queryKey: ["package", id] });
void qc.invalidateQueries({ queryKey: ["packages"] });
},
onError: () => toast.error(t("details.actionFailed")),
});
const pkg = pkgQ.data;
return (
<DetailPageLayout
title={pkg?.name ?? t("packages.title")}
description={pkg ? pkg.code : t("details.packageSubtitle")}
extra={
<div className="flex flex-wrap gap-2">
{pkg ? <StatusBadge status={pkg.active ? "active" : "inactive"} /> : null}
{canManage && pkg ? (
<Button
size="sm"
variant="outline"
className="gap-1.5"
disabled={toggle.isPending}
onClick={() => toggle.mutate()}
>
<Power className="size-3.5" />
{pkg.active ? t("details.deactivate") : t("details.activate")}
</Button>
) : null}
<Button variant="outline" size="sm" className="gap-2" render={<Link href="/packages" />}>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
</div>
}
>
{pkgQ.isLoading ? (
<DetailLoading label={t("common.loading")} />
) : !pkg ? (
<DetailCallout>{t("common.empty")}</DetailCallout>
) : (
<>
<DetailMetricStrip cols={3}>
<DetailMetric
label={t("packages.allowance")}
value={formatEtb(pkg.monthlyAllowanceEtb)}
icon={<Wallet />}
/>
<DetailMetric label={t("packages.copay")} value={`${pkg.copayPercent}%`} />
<DetailMetric
label={t("departments.members")}
value={pkg.memberCount}
hint={
pkg.annualCapEtb != null
? `${t("packages.cap")}: ${formatEtb(pkg.annualCapEtb)}`
: undefined
}
icon={<Users />}
/>
</DetailMetricStrip>
<Tabs defaultValue="rules" className="w-full">
<TabsList>
<TabsTrigger value="rules">{tTabs("rules")}</TabsTrigger>
<TabsTrigger value="perks">{tTabs("perks")}</TabsTrigger>
</TabsList>
<TabsContent value="rules" className="space-y-4 pt-1">
<DetailSection title={t("packages.categories")} icon={<Layers />}>
<DetailFieldStack>
<div className="flex flex-wrap gap-1.5">
{pkg.categories.map((c) => (
<Badge key={c} variant="secondary" className="capitalize">
{c}
</Badge>
))}
</div>
<DetailField
label={t("packages.exclusions")}
value={pkg.exclusions.length ? pkg.exclusions.join(", ") : "—"}
/>
{pkg.annualCapEtb != null ? (
<DetailField
label={t("packages.cap")}
value={formatEtb(pkg.annualCapEtb)}
/>
) : null}
</DetailFieldStack>
</DetailSection>
</TabsContent>
<TabsContent value="perks" className="space-y-4 pt-1">
<DetailSection title={t("packages.perks")} icon={<Gift />}>
{pkg.perks.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("common.empty")}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{pkg.perks.map((p) => (
<Badge key={p} variant="outline" className="capitalize">
{p.replaceAll("_", " ")}
</Badge>
))}
</div>
)}
</DetailSection>
</TabsContent>
</Tabs>
</>
)}
</DetailPageLayout>
);
}

View File

@ -0,0 +1,147 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useRouter, Link } from "@/i18n/routing";
import { CreatePageLayout } from "@/components/layout/create-layout";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { ArrowLeft } from "lucide-react";
const CATEGORY_OPTIONS = [
"rx",
"otc",
"devices",
"cosmetics",
"baby",
"vitamins",
] as const;
const PERK_OPTIONS = [
"free_delivery",
"priority_dispatch",
"discount_percent",
"annual_screening",
] as const;
export default function CreatePackagePage() {
const t = useTranslations();
const router = useRouter();
const [categories, setCategories] = useState<string[]>(["rx", "otc"]);
const [perks, setPerks] = useState<string[]>(["free_delivery"]);
function toggle(list: string[], value: string, setter: (v: string[]) => void) {
setter(list.includes(value) ? list.filter((x) => x !== value) : [...list, value]);
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
if (!data.get("code") || !data.get("name") || !data.get("allowance")) {
toast.error(t("common.requiredFields"));
return;
}
if (categories.length === 0) {
toast.error(t("packages.categoriesRequired"));
return;
}
toast.success(t("packages.createSuccess"));
router.push("/packages");
}
return (
<CreatePageLayout
title={t("packages.create")}
description={t("packages.createSubtitle")}
extra={
<Button variant="outline" className="gap-2" render={<Link href="/packages" />}>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
}
>
<Card className="w-full rounded-xl shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("packages.createFormTitle")}</CardTitle>
<CardDescription>{t("packages.createSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="code">{t("packages.code")}</Label>
<Input id="code" name="code" placeholder="STAFF" required />
</div>
<div className="space-y-2">
<Label htmlFor="name">{t("common.name")}</Label>
<Input id="name" name="name" placeholder="Staff" required />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="allowance">{t("packages.allowance")}</Label>
<Input id="allowance" name="allowance" type="number" min={0} required />
</div>
<div className="space-y-2">
<Label htmlFor="copay">{t("packages.copay")}</Label>
<Input id="copay" name="copay" type="number" min={0} max={100} defaultValue={10} />
</div>
<div className="space-y-2">
<Label htmlFor="cap">{t("packages.cap")}</Label>
<Input id="cap" name="cap" type="number" min={0} />
</div>
</div>
<div className="space-y-2">
<Label>{t("packages.categories")}</Label>
<div className="grid gap-2 sm:grid-cols-3 xl:grid-cols-6">
{CATEGORY_OPTIONS.map((cat) => (
<label key={cat} className="flex items-center gap-2 text-sm capitalize">
<Checkbox
checked={categories.includes(cat)}
onCheckedChange={() => toggle(categories, cat, setCategories)}
/>
{cat}
</label>
))}
</div>
</div>
<div className="space-y-2">
<Label>{t("packages.perks")}</Label>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
{PERK_OPTIONS.map((perk) => (
<label key={perk} className="flex items-center gap-2 text-sm">
<Checkbox
checked={perks.includes(perk)}
onCheckedChange={() => toggle(perks, perk, setPerks)}
/>
{perk.replaceAll("_", " ")}
</label>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="exclusions">{t("packages.exclusions")}</Label>
<Textarea
id="exclusions"
name="exclusions"
placeholder="cosmetics, luxury"
rows={2}
/>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit">{t("common.create")}</Button>
<Button type="button" variant="outline" render={<Link href="/packages" />}>
{t("common.cancel")}
</Button>
</div>
</form>
</CardContent>
</Card>
</CreatePageLayout>
);
}

View File

@ -0,0 +1,141 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
import { TablePanel } from "@/components/table/table-panel";
import { mockApi } from "@/lib/api/mock-client";
import { formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Package, Plus, PowerOff, Users } from "lucide-react";
export default function PackagesPage() {
const t = useTranslations();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const { data: packages, isLoading } = useQuery({
queryKey: ["packages"],
queryFn: mockApi.getPackages,
});
const filtered = useMemo(() => {
return (packages ?? []).filter((pkg) => {
if (statusFilter === "active" && !pkg.active) return false;
if (statusFilter === "inactive" && pkg.active) return false;
return matchesSearch(
[pkg.code, pkg.name, pkg.categories.join(" "), pkg.perks.join(" ")],
search,
);
});
}, [packages, search, statusFilter]);
const stats = useMemo(() => {
const rows = packages ?? [];
return {
total: rows.length,
active: rows.filter((p) => p.active).length,
inactive: rows.filter((p) => !p.active).length,
enrolled: rows.reduce((s, p) => s + p.memberCount, 0),
};
}, [packages]);
return (
<PageShell
title={t("packages.title")}
description={t("packages.subtitle")}
extra={
<Button className="gap-2" render={<Link href="/packages/new" />}>
<Plus className="size-4" />
{t("packages.create")}
</Button>
}
>
<StatCardGrid cols={4}>
<StatCard label={t("stats.total")} value={stats.total} icon={<Package />} />
<StatCard label={t("stats.active")} value={stats.active} />
<StatCard label={t("stats.inactive")} value={stats.inactive} icon={<PowerOff />} />
<StatCard label={t("stats.enrolled")} value={stats.enrolled} icon={<Users />} />
</StatCardGrid>
<TablePanel
loading={isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterAllLabel={t("common.all")}
filterOptions={[
{ value: "active", label: t("common.active") },
{ value: "inactive", label: t("packages.inactive") },
]}
resultCount={filtered.length}
resultLabel={t("common.results")}
/>
}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("packages.code")}</TableHead>
<TableHead>{t("common.name")}</TableHead>
<TableHead className="text-right">{t("packages.allowance")}</TableHead>
<TableHead className="text-right">{t("packages.copay")}</TableHead>
<TableHead className="text-right">{t("departments.members")}</TableHead>
<TableHead>{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length ? (
filtered.map((pkg) => (
<TableRow key={pkg.id}>
<TableCell>
<Badge variant="secondary">{pkg.code}</Badge>
</TableCell>
<TableCell className="font-medium">{pkg.name}</TableCell>
<TableCell className="text-right">
{formatEtb(pkg.monthlyAllowanceEtb)}
</TableCell>
<TableCell className="text-right">{pkg.copayPercent}%</TableCell>
<TableCell className="text-right">{pkg.memberCount}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
render={<Link href={`/packages/${pkg.id}`} />}
>
{t("common.view")}
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="h-28 text-center text-sm text-muted-foreground">
{t("common.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TablePanel>
</PageShell>
);
}

View File

@ -0,0 +1,206 @@
"use client";
import { useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { StatusBadge } from "@/components/shared/status-badge";
import {
ChartCard,
CategoryPieChart,
DepartmentSpendChart,
MembersByPackageChart,
SpendTrendChart,
} from "@/components/charts/portal-charts";
import { useAuth } from "@/providers/auth-provider";
import { mockApi } from "@/lib/api/mock-client";
import { formatEtb } from "@/lib/format";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ROLE_META } from "@/lib/auth/session";
import { Link, useRouter } from "@/i18n/routing";
import { Building2, Package, Users, Wallet } from "lucide-react";
export default function DashboardPage() {
const t = useTranslations();
const { user } = useAuth();
const router = useRouter();
const orgQ = useQuery({ queryKey: ["org"], queryFn: mockApi.getOrganisation });
const membersQ = useQuery({
queryKey: ["members", user?.activeRole],
queryFn: () => mockApi.getMembers(user!.activeRole),
enabled: !!user && user.activeRole !== "MEMBER",
});
const approvalsQ = useQuery({
queryKey: ["approvals"],
queryFn: mockApi.getApprovals,
enabled: !!user && user.activeRole !== "MEMBER",
});
const deptsQ = useQuery({
queryKey: ["departments"],
queryFn: mockApi.getDepartments,
enabled: !!user && user.activeRole !== "MEMBER",
});
const statementsQ = useQuery({
queryKey: ["statements"],
queryFn: mockApi.getStatements,
enabled: !!user && user.activeRole !== "MEMBER",
});
const spendQ = useQuery({
queryKey: ["spend"],
queryFn: mockApi.getSpend,
enabled: !!user && user.activeRole !== "MEMBER",
});
const packagesQ = useQuery({
queryKey: ["packages"],
queryFn: mockApi.getPackages,
enabled: !!user && user.activeRole !== "MEMBER",
});
useEffect(() => {
if (user?.activeRole === "MEMBER") router.replace("/me");
}, [user, router]);
const trendData = useMemo(
() =>
(statementsQ.data ?? [])
.slice()
.sort((a, b) => a.period.localeCompare(b.period))
.map((s) => ({
period: s.period.slice(5),
covered: s.coveredEtb,
total: s.totalSpendEtb,
})),
[statementsQ.data],
);
const deptChart = useMemo(
() =>
(deptsQ.data ?? []).map((d) => ({
name: d.name.split(" ")[0] ?? d.name,
spend: d.spendEtb,
limit: d.subLimitEtb ?? 0,
})),
[deptsQ.data],
);
const categoryChart = useMemo(() => {
const map = new Map<string, number>();
for (const row of spendQ.data ?? []) {
map.set(row.category, (map.get(row.category) ?? 0) + row.coveredEtb);
}
return Array.from(map.entries()).map(([name, value]) => ({ name, value }));
}, [spendQ.data]);
const packageChart = useMemo(
() =>
(packagesQ.data ?? []).map((p) => ({
name: p.name,
members: p.memberCount,
})),
[packagesQ.data],
);
if (!user || user.activeRole === "MEMBER") return null;
const activeMembers = membersQ.data?.filter((m) => m.status === "active").length ?? 0;
const pendingApprovals =
approvalsQ.data?.filter((a) => a.status === "pending").length ?? 0;
const creditUsed = orgQ.data?.creditUsedEtb ?? 0;
const creditLimit = orgQ.data?.creditLimitEtb ?? 0;
const creditPct = creditLimit > 0 ? Math.round((creditUsed / creditLimit) * 100) : 0;
return (
<PageShell title={t("dashboard.title")} description={t("dashboard.subtitle")}>
<StatCardGrid cols={4}>
<StatCard
label={t("dashboard.members")}
value={activeMembers}
hint={t("dashboard.liveHint")}
icon={<Users className="size-4" />}
/>
<StatCard
label={t("dashboard.credit")}
value={`${creditPct}%`}
hint={
orgQ.data
? `${formatEtb(creditUsed)} / ${formatEtb(creditLimit)}`
: undefined
}
icon={<Wallet className="size-4" />}
/>
<StatCard
label={t("dashboard.departments")}
value={deptsQ.data?.length ?? "—"}
icon={<Building2 className="size-4" />}
/>
<StatCard
label={t("dashboard.pendingApprovals")}
value={pendingApprovals}
icon={<Package className="size-4" />}
/>
</StatCardGrid>
<div className="grid gap-4 xl:grid-cols-2">
<ChartCard
title={t("dashboard.spendTrend")}
description={t("dashboard.spendTrendHint")}
>
<SpendTrendChart data={trendData} />
</ChartCard>
<ChartCard
title={t("dashboard.deptSpend")}
description={t("dashboard.deptSpendHint")}
>
<DepartmentSpendChart data={deptChart} />
</ChartCard>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<ChartCard title={t("details.spendByCategory")}>
<CategoryPieChart data={categoryChart} />
</ChartCard>
<ChartCard title={t("dashboard.membersByPackage")}>
<MembersByPackageChart data={packageChart} />
</ChartCard>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card className="rounded-2xl shadow-none">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">{t("dashboard.orgStatus")}</CardTitle>
{orgQ.data ? <StatusBadge status={orgQ.data.status} /> : null}
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p className="font-medium">{orgQ.data?.tradeName}</p>
<p className="text-muted-foreground">
{user.name} · {t(ROLE_META[user.activeRole].labelKey)}
</p>
<div className="flex flex-wrap gap-2 pt-1">
<Button size="sm" variant="outline" render={<Link href="/organisation" />}>
{t("nav.organisation")}
</Button>
<Button size="sm" variant="outline" render={<Link href="/finance" />}>
{t("nav.finance")}
</Button>
<Button size="sm" variant="outline" render={<Link href="/members" />}>
{t("nav.members")}
</Button>
</div>
</CardContent>
</Card>
<Card className="rounded-2xl border-l-4 border-l-brand shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("dashboard.guidance")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>{t("dashboard.guidanceHr")}</p>
<p>{t("dashboard.guidanceFinance")}</p>
<p>{t("dashboard.guidanceSuper")}</p>
</CardContent>
</Card>
</div>
</PageShell>
);
}

View File

@ -0,0 +1,141 @@
"use client";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import {
DetailCallout,
DetailField,
DetailFieldStack,
DetailGrid,
DetailLoading,
DetailMetric,
DetailMetricStrip,
DetailPageLayout,
DetailReadOnlyBox,
DetailSection,
} from "@/components/layout/detail-layout";
import { StatusBadge } from "@/components/shared/status-badge";
import { useAuth } from "@/providers/auth-provider";
import { mockApi } from "@/lib/api/mock-client";
import { formatDate } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { ArrowLeft, FileImage, MessageSquare, Pill } from "lucide-react";
export default function PrescriptionDetailPage() {
const t = useTranslations();
const params = useParams<{ id: string }>();
const { user } = useAuth();
const id = params.id;
const isBlocked = user?.activeRole === "HR_ADMIN" || user?.activeRole === "FINANCE";
const rxQ = useQuery({
queryKey: ["prescription", id, user?.activeRole],
queryFn: () => mockApi.getPrescription(id, user!.activeRole),
enabled: !!user && !!id && !isBlocked,
retry: false,
});
if (!user) return null;
if (isBlocked) {
return (
<DetailPageLayout title={t("member.rxTitle")} description={t("member.rxSubtitle")}>
<DetailCallout>{t("common.withheld")}</DetailCallout>
<Button variant="outline" size="sm" render={<Link href="/prescriptions" />}>
{t("common.back")}
</Button>
</DetailPageLayout>
);
}
const rx = rxQ.data;
return (
<DetailPageLayout
title={rx?.id ?? t("member.rxTitle")}
description={
rx ? formatDate(rx.submittedAt) : t("details.rxSubtitle")
}
extra={
<div className="flex flex-wrap items-center gap-2">
{rx ? <StatusBadge status={rx.status} /> : null}
{rx ? (
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => toast.success(t("details.rxQuerySent"))}
>
<MessageSquare className="size-3.5" />
{t("details.requestInfo")}
</Button>
) : null}
<Button
variant="outline"
size="sm"
className="gap-2"
render={<Link href="/prescriptions" />}
>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
</div>
}
>
{rxQ.isLoading ? (
<DetailLoading label={t("common.loading")} />
) : rxQ.isError || !rx ? (
<DetailCallout>{t("common.empty")}</DetailCallout>
) : (
<>
<DetailMetricStrip cols={3}>
<DetailMetric label={t("member.pages")} value={rx.pageCount} />
<DetailMetric
label={t("details.daysSupply")}
value={rx.daysOfSupply ?? "—"}
/>
<DetailMetric label={t("member.updated")} value={formatDate(rx.updatedAt)} />
</DetailMetricStrip>
<DetailGrid>
<DetailSection title={t("details.rxInfo")} icon={<Pill />}>
<DetailFieldStack>
<DetailField label="ID" value={rx.id} />
<DetailField
label={t("member.rxStatus")}
value={<StatusBadge status={rx.status} />}
/>
<DetailField
label={t("member.submitted")}
value={formatDate(rx.submittedAt)}
/>
{rx.pharmacistNote ? (
<DetailReadOnlyBox label={t("details.pharmacistNote")}>
{rx.pharmacistNote}
</DetailReadOnlyBox>
) : null}
</DetailFieldStack>
</DetailSection>
<DetailSection title={t("details.files")} icon={<FileImage />}>
<ul className="divide-y divide-border">
{rx.fileNames.map((f) => (
<li key={f} className="flex items-center gap-2 py-2.5 text-sm first:pt-0 last:pb-0">
<FileImage className="size-3.5 shrink-0 text-muted-foreground" />
<span className="font-medium">{f}</span>
</li>
))}
</ul>
<p className="mt-3 text-xs text-muted-foreground">
{t("details.rxStorageNote")}
</p>
</DetailSection>
</DetailGrid>
</>
)}
</DetailPageLayout>
);
}

View File

@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { CreatePageLayout } from "@/components/layout/create-layout";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft, Upload, X } from "lucide-react";
export default function NewPrescriptionPage() {
const t = useTranslations();
const [files, setFiles] = useState<string[]>([]);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const selected = e.target.files;
if (!selected) return;
const names = Array.from(selected).map((f) => f.name);
setFiles((prev) => [...prev, ...names]);
e.target.value = "";
}
function removeFile(name: string) {
setFiles((prev) => prev.filter((f) => f !== name));
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (files.length === 0) {
toast.error("Add at least one file");
return;
}
toast.success(t("common.pending"));
}
return (
<CreatePageLayout
title={t("member.rxNew")}
description={t("member.rxUploadHelp")}
extra={
<Button variant="outline" className="gap-2" render={<Link href="/prescriptions" />}>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
}
>
<Card className="w-full rounded-xl shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("member.rxNew")}</CardTitle>
<CardDescription>{t("member.rxUploadHelp")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="max-w-3xl space-y-4">
<div className="space-y-2">
<Label htmlFor="rxFiles">Prescription files</Label>
<div className="flex items-center gap-3">
<Input
id="rxFiles"
type="file"
accept="image/*,.pdf"
multiple
onChange={handleFileChange}
/>
<Upload className="size-5 shrink-0 text-muted-foreground" />
</div>
</div>
{files.length > 0 && (
<div className="flex flex-wrap gap-2">
{files.map((name) => (
<Badge key={name} variant="secondary" className="gap-1 pr-1">
{name}
<button
type="button"
onClick={() => removeFile(name)}
className="rounded-full p-0.5 hover:bg-muted"
aria-label={`Remove ${name}`}
>
<X className="size-3" />
</button>
</Badge>
))}
</div>
)}
<div className="flex gap-2 pt-2">
<Button type="submit">{t("common.submit")}</Button>
<Button type="button" variant="outline" render={<Link href="/prescriptions" />}>
{t("common.cancel")}
</Button>
</div>
</form>
</CardContent>
</Card>
</CreatePageLayout>
);
}

View File

@ -0,0 +1,145 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { StatusBadge } from "@/components/shared/status-badge";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
import { TablePanel } from "@/components/table/table-panel";
import { useAuth } from "@/providers/auth-provider";
import { mockApi } from "@/lib/api/mock-client";
import { formatDate } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Plus } from "lucide-react";
export default function PrescriptionsPage() {
const t = useTranslations();
const { user } = useAuth();
const memberId = user?.memberId ?? "mem_001";
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const isBlocked = user?.activeRole === "HR_ADMIN" || user?.activeRole === "FINANCE";
const rxQ = useQuery({
queryKey: ["prescriptions", memberId, user?.activeRole],
queryFn: () => mockApi.getPrescriptions(memberId, user!.activeRole),
enabled: !!user && !isBlocked,
retry: false,
});
const filtered = useMemo(() => {
return (rxQ.data ?? []).filter((rx) => {
if (statusFilter !== "all" && rx.status !== statusFilter) return false;
return matchesSearch([rx.id, rx.status], search);
});
}, [rxQ.data, search, statusFilter]);
if (!user) return null;
if (isBlocked) {
return (
<PageShell title={t("member.rxTitle")} description={t("member.rxSubtitle")}>
<p className="rounded-xl border border-border bg-muted/40 p-4 text-sm text-muted-foreground">
{t("common.withheld")}
</p>
</PageShell>
);
}
return (
<PageShell
title={t("member.rxTitle")}
description={t("member.rxSubtitle")}
extra={
<Button className="gap-2" render={<Link href="/prescriptions/new" />}>
<Plus className="size-4" />
{t("member.rxNew")}
</Button>
}
>
<TablePanel
loading={rxQ.isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterAllLabel={t("common.all")}
filterOptions={[
{ value: "submitted", label: "Submitted" },
{ value: "under_review", label: "Under review" },
{ value: "approved", label: "Approved" },
{ value: "queried", label: "Queried" },
{ value: "rejected", label: "Rejected" },
]}
resultCount={filtered.length}
resultLabel={t("common.results")}
/>
}
>
{rxQ.isError ? (
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
{t("common.withheld")}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>{t("member.rxStatus")}</TableHead>
<TableHead>{t("member.pages")}</TableHead>
<TableHead>{t("member.submitted")}</TableHead>
<TableHead>{t("member.updated")}</TableHead>
<TableHead>{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length ? (
filtered.map((rx) => (
<TableRow key={rx.id}>
<TableCell className="font-mono text-xs">{rx.id}</TableCell>
<TableCell>
<StatusBadge status={rx.status} />
</TableCell>
<TableCell>{rx.pageCount}</TableCell>
<TableCell>{formatDate(rx.submittedAt)}</TableCell>
<TableCell>{formatDate(rx.updatedAt)}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
render={<Link href={`/prescriptions/${rx.id}`} />}
>
{t("common.view")}
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={6} className="h-28 text-center text-sm text-muted-foreground">
{t("common.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</TablePanel>
</PageShell>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { CreatePageLayout } from "@/components/layout/create-layout";
import { mockApi } from "@/lib/api/mock-client";
import type { Organisation } from "@/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type VerificationMethod = Organisation["verificationMethod"];
const METHODS: VerificationMethod[] = ["member_id", "qr", "phone_otp", "none"];
const METHOD_LABELS: Record<VerificationMethod, string> = {
member_id: "memberId",
qr: "qr",
phone_otp: "phoneOtp",
none: "none",
};
export default function VerificationSettingsPage() {
const t = useTranslations();
const { data: org, isLoading } = useQuery({
queryKey: ["org"],
queryFn: mockApi.getOrganisation,
});
const [method, setMethod] = useState<VerificationMethod | null>(null);
const [maxDependants, setMaxDependants] = useState<number | null>(null);
const currentMethod = method ?? org?.verificationMethod ?? "member_id";
const currentMax = maxDependants ?? org?.maxDependantsPerMember ?? 0;
function handleSave() {
toast.success(t("common.save"));
}
return (
<CreatePageLayout
title={t("verification.title")}
description={t("verification.subtitle")}
>
{isLoading ? (
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
) : (
<Card className="w-full rounded-xl shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("verification.method")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>{t("verification.method")}</Label>
<Select
value={currentMethod}
onValueChange={(v) => {
if (METHODS.includes(v as VerificationMethod)) {
setMethod(v as VerificationMethod);
}
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{METHODS.map((m) => (
<SelectItem key={m} value={m}>
{t(`verification.${METHOD_LABELS[m]}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="maxDependants">{t("verification.maxDependants")}</Label>
<Input
id="maxDependants"
type="number"
min={0}
value={currentMax}
onChange={(e) => setMaxDependants(Number(e.target.value))}
/>
</div>
<Button onClick={handleSave}>{t("common.save")}</Button>
</CardContent>
</Card>
)}
</CreatePageLayout>
);
}

View File

@ -0,0 +1,200 @@
"use client";
import { useMemo } from "react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
DetailCallout,
DetailField,
DetailFieldStack,
DetailLoading,
DetailMetric,
DetailMetricStrip,
DetailPageLayout,
DetailSection,
} from "@/components/layout/detail-layout";
import { ChartCard, CategoryPieChart } from "@/components/charts/portal-charts";
import { StatusBadge } from "@/components/shared/status-badge";
import { mockApi } from "@/lib/api/mock-client";
import { formatDate, formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ArrowLeft, Banknote, Download, FileCheck2, FileText } from "lucide-react";
import { useAuth } from "@/providers/auth-provider";
export default function StatementDetailPage() {
const t = useTranslations();
const tTabs = useTranslations("tabs");
const params = useParams<{ id: string }>();
const { user } = useAuth();
const qc = useQueryClient();
const id = params.id;
const stQ = useQuery({
queryKey: ["statement", id],
queryFn: () => mockApi.getStatement(id),
enabled: !!id,
});
const spendQ = useQuery({ queryKey: ["spend"], queryFn: mockApi.getSpend });
const canManage =
user?.activeRole === "SUPER_USER" || user?.activeRole === "FINANCE";
const st = stQ.data;
const periodSpend = useMemo(() => {
if (!st) return [];
return (spendQ.data ?? []).filter((row) =>
row.occurredAt.startsWith(st.period),
);
}, [spendQ.data, st]);
const byCategory = useMemo(() => {
const map = new Map<string, number>();
for (const row of periodSpend) {
map.set(row.category, (map.get(row.category) ?? 0) + row.coveredEtb);
}
return Array.from(map.entries()).map(([name, value]) => ({ name, value }));
}, [periodSpend]);
const invalidate = () => {
void qc.invalidateQueries({ queryKey: ["statement", id] });
void qc.invalidateQueries({ queryKey: ["statements"] });
};
const issue = useMutation({
mutationFn: () => mockApi.issueStatement(id),
onSuccess: () => {
toast.success(t("details.statementIssued"));
invalidate();
},
onError: () => toast.error(t("details.actionFailed")),
});
const markPaid = useMutation({
mutationFn: () => mockApi.markStatementPaid(id),
onSuccess: () => {
toast.success(t("details.statementPaid"));
invalidate();
},
onError: () => toast.error(t("details.actionFailed")),
});
return (
<DetailPageLayout
title={st?.period ?? t("statements.title")}
description={
st
? st.issuedAt
? formatDate(st.issuedAt)
: t("common.pending")
: t("details.statementSubtitle")
}
extra={
<div className="flex flex-wrap items-center gap-2">
{st ? <StatusBadge status={st.status} /> : null}
{canManage && st ? (
<>
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => toast.success(t("common.download"))}
>
<Download className="size-3.5" />
{t("common.download")}
</Button>
{st.status === "draft" ? (
<Button
size="sm"
className="gap-1.5"
disabled={issue.isPending}
onClick={() => issue.mutate()}
>
<FileCheck2 className="size-3.5" />
{t("details.issue")}
</Button>
) : null}
{st.status === "issued" ? (
<Button
size="sm"
className="gap-1.5"
disabled={markPaid.isPending}
onClick={() => markPaid.mutate()}
>
<Banknote className="size-3.5" />
{t("details.markPaid")}
</Button>
) : null}
</>
) : null}
<Button
variant="outline"
size="sm"
className="gap-2"
render={<Link href="/statements" />}
>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
</div>
}
>
{stQ.isLoading ? (
<DetailLoading label={t("common.loading")} />
) : !st ? (
<DetailCallout>{t("common.empty")}</DetailCallout>
) : (
<>
<DetailMetricStrip cols={3}>
<DetailMetric label={t("statements.total")} value={formatEtb(st.totalSpendEtb)} />
<DetailMetric label={t("statements.covered")} value={formatEtb(st.coveredEtb)} />
<DetailMetric label={t("statements.credit")} value={formatEtb(st.creditUsedEtb)} />
</DetailMetricStrip>
<Tabs defaultValue="summary" className="w-full">
<TabsList>
<TabsTrigger value="summary">{tTabs("summary")}</TabsTrigger>
<TabsTrigger value="breakdown">{tTabs("breakdown")}</TabsTrigger>
</TabsList>
<TabsContent value="summary" className="space-y-4 pt-1">
<DetailSection title={t("details.statementInfo")} icon={<FileText />}>
<DetailFieldStack>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<DetailField label={t("statements.period")} value={st.period} />
<DetailField
label={t("common.status")}
value={<StatusBadge status={st.status} />}
/>
<DetailField
label={t("statements.issued")}
value={st.issuedAt ? formatDate(st.issuedAt) : "—"}
/>
<DetailField
label={t("details.lineItems")}
value={periodSpend.length || t("details.aggregated")}
/>
</div>
</DetailFieldStack>
</DetailSection>
</TabsContent>
<TabsContent value="breakdown" className="space-y-4 pt-1">
<ChartCard title={t("details.spendByCategory")}>
{byCategory.length ? (
<CategoryPieChart data={byCategory} />
) : (
<p className="py-10 text-center text-sm text-muted-foreground">
{t("details.noPeriodLines")}
</p>
)}
</ChartCard>
</TabsContent>
</Tabs>
</>
)}
</DetailPageLayout>
);
}

View File

@ -0,0 +1,139 @@
"use client";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { PageShell } from "@/components/layout/page-shell";
import { StatCard, StatCardGrid } from "@/components/layout/stat-card";
import { StatusBadge } from "@/components/shared/status-badge";
import { TableToolbar, matchesSearch } from "@/components/table/table-toolbar";
import { TablePanel } from "@/components/table/table-panel";
import { mockApi } from "@/lib/api/mock-client";
import { formatDate, formatEtb } from "@/lib/format";
import { Link } from "@/i18n/routing";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CreditCard, FileText, Wallet } from "lucide-react";
export default function StatementsPage() {
const t = useTranslations();
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const { data: statements, isLoading } = useQuery({
queryKey: ["statements"],
queryFn: mockApi.getStatements,
});
const filtered = useMemo(() => {
return (statements ?? []).filter((st) => {
if (statusFilter !== "all" && st.status !== statusFilter) return false;
return matchesSearch([st.period, st.id, st.status], search);
});
}, [statements, search, statusFilter]);
const stats = useMemo(() => {
const rows = statements ?? [];
return {
covered: rows.reduce((s, st) => s + st.coveredEtb, 0),
credit: rows.reduce((s, st) => s + st.creditUsedEtb, 0),
draft: rows.filter((st) => st.status === "draft").length,
issued: rows.filter((st) => st.status === "issued" || st.status === "paid").length,
};
}, [statements]);
return (
<PageShell title={t("statements.title")} description={t("statements.subtitle")}>
<StatCardGrid cols={4}>
<StatCard
label={t("stats.coveredTotal")}
value={formatEtb(stats.covered)}
icon={<Wallet />}
/>
<StatCard
label={t("stats.creditUsed")}
value={formatEtb(stats.credit)}
icon={<CreditCard />}
/>
<StatCard label={t("stats.draft")} value={stats.draft} icon={<FileText />} />
<StatCard label={t("stats.issued")} value={stats.issued} />
</StatCardGrid>
<TablePanel
loading={isLoading}
loadingLabel={t("common.loading")}
toolbar={
<TableToolbar
search={search}
onSearchChange={setSearch}
searchPlaceholder={t("common.searchPlaceholder")}
filterValue={statusFilter}
onFilterChange={setStatusFilter}
filterAllLabel={t("common.all")}
filterOptions={[
{ value: "draft", label: "Draft" },
{ value: "issued", label: "Issued" },
{ value: "paid", label: "Paid" },
]}
resultCount={filtered.length}
resultLabel={t("common.results")}
/>
}
>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("statements.period")}</TableHead>
<TableHead className="text-right">{t("statements.total")}</TableHead>
<TableHead className="text-right">{t("statements.covered")}</TableHead>
<TableHead className="text-right">{t("statements.credit")}</TableHead>
<TableHead>{t("common.status")}</TableHead>
<TableHead>{t("statements.issued")}</TableHead>
<TableHead>{t("common.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length ? (
filtered.map((st) => (
<TableRow key={st.id}>
<TableCell className="font-medium">{st.period}</TableCell>
<TableCell className="text-right">{formatEtb(st.totalSpendEtb)}</TableCell>
<TableCell className="text-right">{formatEtb(st.coveredEtb)}</TableCell>
<TableCell className="text-right">{formatEtb(st.creditUsedEtb)}</TableCell>
<TableCell>
<StatusBadge status={st.status} />
</TableCell>
<TableCell>
{st.issuedAt ? formatDate(st.issuedAt) : "—"}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
render={<Link href={`/statements/${st.id}`} />}
>
{t("common.view")}
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="h-28 text-center text-sm text-muted-foreground">
{t("common.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TablePanel>
</PageShell>
);
}

View File

@ -0,0 +1,70 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { brand } from "@/data/brand";
import { Link } from "@/i18n/routing";
import { LanguageSwitcher } from "@/components/layout/language-switcher";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ArrowLeft } from "lucide-react";
export default function AcceptInvitePage() {
const t = useTranslations();
const params = useParams<{ token: string }>();
const token = params.token;
const [accepted, setAccepted] = useState(false);
function handleAccept() {
setAccepted(true);
toast.success(t("common.active"));
}
return (
<div className="relative flex min-h-screen flex-col px-4 py-8 md:px-8">
<div className="mx-auto flex w-full max-w-lg items-center justify-between">
<div className="flex items-center gap-3">
<Image src={brand.logo} alt={brand.shortName} width={36} height={36} />
<p className="font-display text-lg font-semibold">{t("common.appName")}</p>
</div>
<LanguageSwitcher />
</div>
<div className="mx-auto mt-10 w-full max-w-lg">
<Button variant="ghost" size="sm" className="mb-4 gap-2" render={<Link href="/login" />}>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
<Card className="rounded-2xl shadow-none">
<CardHeader>
<CardTitle>{t("invite.title")}</CardTitle>
<CardDescription>{t("invite.subtitle")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-xl border border-border bg-muted/40 p-4">
<span className="text-sm text-muted-foreground">Invite token</span>
<Badge variant="secondary" className="font-mono text-xs">
{token}
</Badge>
</div>
{accepted ? (
<p className="text-sm text-muted-foreground">
Membership activated — sign in to access your benefits.
</p>
) : (
<Button className="w-full" onClick={handleAccept}>
Accept invite
</Button>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,68 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { brand } from "@/data/brand";
import { Link } from "@/i18n/routing";
import { LanguageSwitcher } from "@/components/layout/language-switcher";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ArrowLeft } from "lucide-react";
export default function JoinPage() {
const t = useTranslations();
const [submitted, setSubmitted] = useState(false);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSubmitted(true);
toast.success(t("common.pending"));
}
return (
<div className="relative flex min-h-screen flex-col px-4 py-8 md:px-8">
<div className="mx-auto flex w-full max-w-lg items-center justify-between">
<div className="flex items-center gap-3">
<Image src={brand.logo} alt={brand.shortName} width={36} height={36} />
<p className="font-display text-lg font-semibold">{t("common.appName")}</p>
</div>
<LanguageSwitcher />
</div>
<div className="mx-auto mt-10 w-full max-w-lg">
<Button variant="ghost" size="sm" className="mb-4 gap-2" render={<Link href="/login" />}>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
<Card className="rounded-2xl shadow-none">
<CardHeader>
<CardTitle>{t("join.title")}</CardTitle>
<CardDescription>{t("join.subtitle")}</CardDescription>
</CardHeader>
<CardContent>
{submitted ? (
<p className="rounded-xl border border-border bg-muted/40 p-4 text-sm">
{t("common.pending")}
</p>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="code">{t("join.code")}</Label>
<Input id="code" name="code" placeholder="YALT-2026" required />
</div>
<Button type="submit" className="w-full">
{t("common.continue")}
</Button>
</form>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
import { NextIntlClientProvider, hasLocale } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";
import { AppProviders } from "@/providers/app-providers";
type Props = {
children: React.ReactNode;
params: Promise<{ locale: string }>;
};
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({ children, params }: Props) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) notFound();
setRequestLocale(locale);
const messages = await getMessages();
return (
<NextIntlClientProvider locale={locale} messages={messages}>
<AppProviders>{children}</AppProviders>
</NextIntlClientProvider>
);
}

View File

@ -0,0 +1,97 @@
"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { brand } from "@/data/brand";
import { useAuth } from "@/providers/auth-provider";
import { Link, useRouter } from "@/i18n/routing";
import { ROLE_META } from "@/lib/auth/session";
import type { Role } from "@/types";
import { LanguageSwitcher } from "@/components/layout/language-switcher";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Building2, Handshake, KeyRound } from "lucide-react";
const ROLES: Role[] = ["SUPER_USER", "HR_ADMIN", "FINANCE", "MEMBER"];
export default function LoginPage() {
const t = useTranslations();
const { loginAs } = useAuth();
const router = useRouter();
return (
<div className="relative flex min-h-screen flex-col px-4 py-8 md:px-8">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between">
<div className="flex items-center gap-3">
<Image src={brand.logo} alt={brand.shortName} width={40} height={40} />
<div>
<p className="font-display text-lg font-semibold">{t("common.appName")}</p>
<p className="text-xs text-muted-foreground">{brand.tagline}</p>
</div>
</div>
<LanguageSwitcher />
</div>
<div className="mx-auto mt-10 grid w-full max-w-5xl gap-8 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-4">
<h1 className="font-display text-3xl font-semibold tracking-tight md:text-4xl">
{t("login.title")}
</h1>
<p className="max-w-xl text-muted-foreground">{t("login.subtitle")}</p>
<div className="grid gap-3 sm:grid-cols-2">
{ROLES.map((role) => (
<Card
key={role}
className="rounded-2xl border-border bg-card/90 shadow-none transition-colors hover:border-primary"
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base">{t(ROLE_META[role].labelKey)}</CardTitle>
<Badge variant="secondary">{role}</Badge>
</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>
<Card className="h-fit rounded-2xl border-border bg-card shadow-none">
<CardHeader>
<CardTitle>Get started</CardTitle>
<CardDescription>
Organisations can self-register or request assisted onboarding.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button variant="outline" className="w-full justify-start gap-2" render={<Link href="/register/organisation" />}>
<Building2 className="size-4" />
{t("login.registerOrg")}
</Button>
<Button variant="outline" className="w-full justify-start gap-2" render={<Link href="/register/request" />}>
<Handshake className="size-4" />
{t("login.requestRegister")}
</Button>
<Button variant="outline" className="w-full justify-start gap-2" render={<Link href="/join" />}>
<KeyRound className="size-4" />
{t("login.joinWithCode")}
</Button>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { brand } from "@/data/brand";
import { Link } from "@/i18n/routing";
import { LanguageSwitcher } from "@/components/layout/language-switcher";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ArrowLeft } from "lucide-react";
export default function RegisterOrganisationPage() {
const t = useTranslations();
const [submitted, setSubmitted] = useState(false);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSubmitted(true);
toast.success(t("register.success"));
}
return (
<div className="relative flex min-h-screen flex-col px-4 py-8 md:px-8">
<div className="mx-auto flex w-full max-w-lg items-center justify-between">
<div className="flex items-center gap-3">
<Image src={brand.logo} alt={brand.shortName} width={36} height={36} />
<p className="font-display text-lg font-semibold">{t("common.appName")}</p>
</div>
<LanguageSwitcher />
</div>
<div className="mx-auto mt-10 w-full max-w-lg">
<Button variant="ghost" size="sm" className="mb-4 gap-2" render={<Link href="/login" />}>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
<Card className="rounded-2xl shadow-none">
<CardHeader>
<CardTitle>{t("register.orgTitle")}</CardTitle>
<CardDescription>{t("register.orgSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
{submitted ? (
<div className="space-y-3 rounded-xl border border-border bg-muted/40 p-4 text-sm">
<p className="font-medium">{t("register.success")}</p>
<p className="text-muted-foreground">{t("organisation.pendingNote")}</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="legalName">{t("register.legalName")}</Label>
<Input id="legalName" name="legalName" required />
</div>
<div className="space-y-2">
<Label htmlFor="tin">{t("register.tin")}</Label>
<Input id="tin" name="tin" required />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="billingEmail">{t("register.billingEmail")}</Label>
<Input id="billingEmail" name="billingEmail" type="email" required />
</div>
<div className="space-y-2">
<Label htmlFor="billingPhone">{t("register.billingPhone")}</Label>
<Input id="billingPhone" name="billingPhone" type="tel" required />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="headcount">{t("register.headcount")}</Label>
<Input id="headcount" name="headcount" type="number" min={1} required />
</div>
<div className="space-y-2">
<Label htmlFor="adminName">{t("register.adminName")}</Label>
<Input id="adminName" name="adminName" required />
</div>
<div className="space-y-2">
<Label htmlFor="adminEmail">{t("register.adminEmail")}</Label>
<Input id="adminEmail" name="adminEmail" type="email" required />
</div>
<Button type="submit" className="w-full">
{t("common.submit")}
</Button>
</form>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,114 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { brand } from "@/data/brand";
import { Link } from "@/i18n/routing";
import type { ContactChannel } from "@/types";
import { LanguageSwitcher } from "@/components/layout/language-switcher";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ArrowLeft } from "lucide-react";
export default function RegisterRequestPage() {
const t = useTranslations();
const [channel, setChannel] = useState<ContactChannel>("email");
const [submitted, setSubmitted] = useState(false);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setSubmitted(true);
toast.success(t("register.requestSuccess"));
}
return (
<div className="relative flex min-h-screen flex-col px-4 py-8 md:px-8">
<div className="mx-auto flex w-full max-w-lg items-center justify-between">
<div className="flex items-center gap-3">
<Image src={brand.logo} alt={brand.shortName} width={36} height={36} />
<p className="font-display text-lg font-semibold">{t("common.appName")}</p>
</div>
<LanguageSwitcher />
</div>
<div className="mx-auto mt-10 w-full max-w-lg">
<Button variant="ghost" size="sm" className="mb-4 gap-2" render={<Link href="/login" />}>
<ArrowLeft className="size-4" />
{t("common.back")}
</Button>
<Card className="rounded-2xl shadow-none">
<CardHeader>
<CardTitle>{t("register.requestTitle")}</CardTitle>
<CardDescription>{t("register.requestSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
{submitted ? (
<p className="rounded-xl border border-border bg-muted/40 p-4 text-sm">
{t("register.requestSuccess")}
</p>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="contactName">{t("register.contactName")}</Label>
<Input id="contactName" name="contactName" required />
</div>
<div className="space-y-2">
<Label htmlFor="orgName">{t("register.orgName")}</Label>
<Input id="orgName" name="orgName" required />
</div>
<div className="space-y-2">
<Label>{t("register.channel")}</Label>
<Select
value={channel}
onValueChange={(v) => {
if (v === "email" || v === "phone") setChannel(v);
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="phone">Phone</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="contactValue">
{channel === "email" ? t("register.billingEmail") : t("register.billingPhone")}
</Label>
<Input
id="contactValue"
name="contactValue"
type={channel === "email" ? "email" : "tel"}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="notes">{t("register.notes")}</Label>
<Textarea id="notes" name="notes" rows={3} />
</div>
<Button type="submit" className="w-full">
{t("common.submit")}
</Button>
</form>
)}
</CardContent>
</Card>
</div>
</div>
);
}

114
src/app/globals.css Normal file
View File

@ -0,0 +1,114 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-jakarta), var(--font-ethiopic), system-ui, sans-serif;
--font-mono: var(--font-geist-mono);
--font-display: var(--font-sora), var(--font-ethiopic), system-ui, sans-serif;
--font-heading: var(--font-sora), var(--font-ethiopic), system-ui, sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-brand: var(--brand);
--color-brand-deep: var(--brand-deep);
--color-mint: var(--mint);
--color-forest: var(--forest);
--color-line: var(--line);
--color-surface: var(--surface);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
}
:root {
--brand: #a8c73a;
--brand-deep: #8fad2f;
--mint: #a8c73a;
--forest: #141414;
--line: #e2e5d8;
--surface: #ffffff;
--background: #f6f7f2;
--foreground: #141414;
--card: #ffffff;
--card-foreground: #141414;
--popover: #ffffff;
--popover-foreground: #141414;
--primary: #a8c73a;
--primary-foreground: #141414;
--secondary: #eef1e3;
--secondary-foreground: #141414;
--muted: #eef1e3;
--muted-foreground: #5c5f56;
--accent: #e28a1a;
--accent-foreground: #141414;
--destructive: #c0392b;
--border: #e2e5d8;
--input: #e2e5d8;
--ring: #a8c73a;
--chart-1: #a8c73a;
--chart-2: #8fad2f;
--chart-3: #e28a1a;
--chart-4: #5c5f56;
--chart-5: #141414;
--radius: 0.75rem;
--sidebar: #fafbf7;
--sidebar-foreground: #141414;
--sidebar-primary: #a8c73a;
--sidebar-primary-foreground: #141414;
--sidebar-accent: #eef1e3;
--sidebar-accent-foreground: #141414;
--sidebar-border: #e2e5d8;
--sidebar-ring: #a8c73a;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground antialiased;
background-image:
radial-gradient(1100px 420px at 8% -8%, rgba(168, 199, 58, 0.16), transparent 55%),
radial-gradient(800px 360px at 92% 0%, rgba(20, 20, 20, 0.05), transparent 50%);
background-attachment: fixed;
}
html {
@apply font-sans;
}
.font-display {
font-family: var(--font-sora), var(--font-ethiopic), var(--font-sans), system-ui, sans-serif;
}
}

36
src/app/layout.tsx Normal file
View File

@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { Noto_Sans_Ethiopic, Plus_Jakarta_Sans, Sora } from "next/font/google";
import "./globals.css";
const jakarta = Plus_Jakarta_Sans({
variable: "--font-jakarta",
subsets: ["latin"],
});
const sora = Sora({
variable: "--font-sora",
subsets: ["latin"],
});
/** Ethiopic glyphs for Amharic UI (Jakarta/Sora are latin-only). */
const notoEthiopic = Noto_Sans_Ethiopic({
variable: "--font-ethiopic",
subsets: ["ethiopic"],
weight: ["400", "500", "600", "700"],
});
export const metadata: Metadata = {
title: "Gishen B2B",
description: "Institutional medical benefits portal for Gishen corporate clients",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${jakarta.variable} ${sora.variable} ${notoEthiopic.variable} h-full`}
>
<body className="min-h-full flex flex-col font-sans">{children}</body>
</html>
);
}

5
src/app/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/en/login");
}

View File

@ -0,0 +1,192 @@
"use client";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const BRAND = "#a8c73a";
const BRAND_DEEP = "#8fad2f";
const ACCENT = "#e28a1a";
const FOREST = "#141414";
const MUTED = "#5c5f56";
const LINE = "#e2e5d8";
const PIE_COLORS = [BRAND, BRAND_DEEP, ACCENT, FOREST, "#96b532", "#c97310"];
type ChartCardProps = {
title: string;
description?: string;
children: React.ReactNode;
className?: string;
};
export function ChartCard({ title, description, children, className }: ChartCardProps) {
return (
<Card className={`h-full rounded-xl border-border shadow-none ${className ?? ""}`}>
<CardHeader className="border-b border-border px-5 py-3.5">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
{description ? (
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</CardHeader>
<CardContent className="p-5">{children}</CardContent>
</Card>
);
}
export function SpendTrendChart({
data,
}: {
data: { period: string; covered: number; total: number }[];
}) {
return (
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gCovered" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={BRAND} stopOpacity={0.35} />
<stop offset="100%" stopColor={BRAND} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid stroke={LINE} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="period" tick={{ fill: MUTED, fontSize: 12 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: MUTED, fontSize: 12 }} axisLine={false} tickLine={false} width={48} />
<Tooltip
contentStyle={{
borderRadius: 12,
border: `1px solid ${LINE}`,
boxShadow: "none",
}}
/>
<Legend />
<Area
type="monotone"
dataKey="total"
name="Total spend"
stroke={FOREST}
fill="transparent"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="covered"
name="Covered"
stroke={BRAND_DEEP}
fill="url(#gCovered)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
export function DepartmentSpendChart({
data,
}: {
data: { name: string; spend: number; limit: number }[];
}) {
return (
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid stroke={LINE} strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="name" tick={{ fill: MUTED, fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: MUTED, fontSize: 12 }} axisLine={false} tickLine={false} width={48} />
<Tooltip
contentStyle={{
borderRadius: 12,
border: `1px solid ${LINE}`,
boxShadow: "none",
}}
/>
<Legend />
<Bar dataKey="spend" name="Spend" fill={BRAND} radius={[6, 6, 0, 0]} />
<Bar dataKey="limit" name="Sub-limit" fill={LINE} radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}
export function CategoryPieChart({
data,
}: {
data: { name: string; value: number }[];
}) {
return (
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="name"
innerRadius={55}
outerRadius={90}
paddingAngle={3}
>
{data.map((_, i) => (
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
borderRadius: 12,
border: `1px solid ${LINE}`,
boxShadow: "none",
}}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
);
}
export function MembersByPackageChart({
data,
}: {
data: { name: string; members: number }[];
}) {
return (
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} layout="vertical" margin={{ top: 8, right: 16, left: 8, bottom: 0 }}>
<CartesianGrid stroke={LINE} strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" tick={{ fill: MUTED, fontSize: 12 }} axisLine={false} tickLine={false} />
<YAxis
type="category"
dataKey="name"
width={90}
tick={{ fill: MUTED, fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
borderRadius: 12,
border: `1px solid ${LINE}`,
boxShadow: "none",
}}
/>
<Bar dataKey="members" name="Members" fill={ACCENT} radius={[0, 6, 6, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@ -0,0 +1,55 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { useRouter } from "@/i18n/routing";
import { useAuth } from "@/providers/auth-provider";
import { AppSidebar } from "./app-sidebar";
import { AppTopbar } from "./app-topbar";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
export function AppShell({ children }: { children: ReactNode }) {
const { user, ready } = useAuth();
const router = useRouter();
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
if (ready && !user) router.replace("/login");
}, [ready, user, router]);
if (!ready || !user) {
return (
<div className="flex min-h-screen items-center justify-center p-8">
<div className="w-full max-w-md space-y-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-40 w-full" />
</div>
</div>
);
}
return (
<div className="flex min-h-screen bg-background">
<div className="hidden md:flex">
<AppSidebar collapsed={collapsed} onToggle={() => setCollapsed((v) => !v)} />
</div>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" showCloseButton={false} className="w-[280px] border-sidebar-border p-0">
<AppSidebar
collapsed={false}
showClose
onToggle={() => setMobileOpen(false)}
onNavigate={() => setMobileOpen(false)}
/>
</SheetContent>
</Sheet>
<div className="flex min-w-0 flex-1 flex-col">
<AppTopbar onMenuClick={() => setMobileOpen(true)} />
<main className="flex-1 p-4 md:p-6">{children}</main>
</div>
</div>
);
}

View File

@ -0,0 +1,151 @@
"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/routing";
import { brand } from "@/data/brand";
import { navForRole } from "@/lib/auth/nav";
import { useAuth } from "@/providers/auth-provider";
import { cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
export function AppSidebar({
collapsed,
onToggle,
onNavigate,
showClose = false,
}: {
collapsed: boolean;
onToggle: () => void;
onNavigate?: () => void;
showClose?: boolean;
}) {
const t = useTranslations();
const pathname = usePathname();
const { user } = useAuth();
const items = user ? navForRole(user.activeRole) : [];
return (
<aside
className={cn(
"group/sidebar flex h-full flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-[width,padding] duration-300",
collapsed ? "w-[88px] px-2 py-5" : "w-[264px] px-4 py-5",
)}
>
<div
className={cn(
"flex items-center gap-2 px-2",
collapsed ? "justify-center" : "justify-between",
)}
>
<div
className={cn(
"flex min-w-0 items-center gap-3",
collapsed && "justify-center",
)}
>
<span
className={cn(
"relative flex shrink-0 items-center justify-center overflow-hidden rounded-xl bg-secondary",
collapsed ? "size-10" : "size-11",
)}
>
<Image
src={brand.logo}
alt={brand.shortName}
width={44}
height={44}
className="size-full object-contain p-1.5"
priority
/>
</span>
{!collapsed && (
<div className="min-w-0">
<p className="truncate font-display text-sm font-semibold tracking-tight">
{t("common.appName")}
</p>
<p className="truncate text-[11px] leading-snug text-muted-foreground">
{brand.tagline}
</p>
</div>
)}
</div>
{showClose ? (
<button
type="button"
className="grid size-8 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onClick={onToggle}
aria-label="Close sidebar"
>
<X className="size-5" />
</button>
) : (
<button
type="button"
className={cn(
"hidden size-8 shrink-0 place-items-center rounded-lg text-muted-foreground transition-all hover:bg-secondary hover:text-foreground md:grid",
"md:pointer-events-none md:opacity-0 md:group-hover/sidebar:pointer-events-auto md:group-hover/sidebar:opacity-100",
"focus-visible:pointer-events-auto focus-visible:opacity-100",
collapsed && "translate-x-1",
)}
onClick={onToggle}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? (
<ChevronRight className="size-5" />
) : (
<ChevronLeft className="size-5" />
)}
</button>
)}
</div>
<ScrollArea className="mt-6 flex-1">
<nav className="space-y-1 pr-1">
{items.map((item) => {
const active =
item.href === "/"
? pathname === "/"
: pathname === item.href || pathname.startsWith(`${item.href}/`);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
"group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-muted-foreground transition-colors",
"hover:bg-secondary hover:text-brand-deep",
active &&
"bg-brand/10 text-brand-deep shadow-[0_1px_0_rgba(0,0,0,0.02)] ring-1 ring-brand/20",
collapsed && "justify-center px-2",
)}
title={t(item.labelKey)}
>
<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>
);
})}
</nav>
</ScrollArea>
</aside>
);
}

View File

@ -0,0 +1,99 @@
"use client";
import { Bell, Menu } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/routing";
import { useAuth } from "@/providers/auth-provider";
import { ROLE_META } from "@/lib/auth/session";
import { LanguageSwitcher } from "./language-switcher";
import { QuickSearch } from "./quick-search";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
export function AppTopbar({ onMenuClick }: { onMenuClick?: () => void }) {
const t = useTranslations();
const { user, logout, switchDemoUser } = useAuth();
const router = useRouter();
if (!user) return null;
return (
<header className="flex h-14 items-center gap-3 border-b border-border bg-card/80 px-4 backdrop-blur">
<Button
variant="ghost"
size="icon-sm"
className="md:hidden"
onClick={onMenuClick}
aria-label="Open menu"
>
<Menu className="size-4" />
</Button>
<div className="flex min-w-0 flex-1 items-center gap-2">
<QuickSearch />
</div>
<div className="ml-auto flex items-center gap-2">
<LanguageSwitcher className="w-[120px]" />
<Button variant="ghost" size="icon-sm" aria-label={t("common.notifications")}>
<Bell className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" className="h-9 gap-2 px-2">
<Avatar className="size-7">
<AvatarFallback className="bg-primary/20 text-xs font-semibold">
{user.avatarInitials}
</AvatarFallback>
</Avatar>
<span className="hidden text-left sm:block">
<span className="block text-sm font-medium leading-none">{user.name}</span>
<span className="text-[11px] text-muted-foreground">
{t(ROLE_META[user.activeRole].labelKey)}
</span>
</span>
</Button>
}
/>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="space-y-1">
<div>{user.name}</div>
<div className="text-xs font-normal text-muted-foreground">{user.email}</div>
<Badge variant="secondary" className="mt-1">
{t(ROLE_META[user.activeRole].labelKey)}
</Badge>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
switchDemoUser();
router.push("/login");
}}
>
{t("common.switchDemo")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
logout();
router.push("/login");
}}
>
{t("common.signOut")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}

View File

@ -0,0 +1,182 @@
"use client";
import { Fragment } from "react";
import { useTranslations } from "next-intl";
import { ChevronRight } from "lucide-react";
import { Link, usePathname } from "@/i18n/routing";
import { cn } from "@/lib/utils";
/** Static path → next-intl key under `bc.*`. */
const PATH_LABEL_KEYS: Record<string, string> = {
"/organisation": "organisation",
"/departments": "departments",
"/departments/new": "newDepartment",
"/members": "members",
"/members/new": "inviteMember",
"/members/import": "importMembers",
"/packages": "packages",
"/packages/new": "newPackage",
"/migration": "migration",
"/migration/new": "newMigration",
"/finance": "finance",
"/finance/approvals": "approvals",
"/finance/spend": "spend",
"/statements": "statements",
"/settings": "settings",
"/settings/verification": "verification",
"/me": "myBenefits",
"/me/orders": "myOrders",
"/prescriptions": "prescriptions",
"/prescriptions/new": "newPrescription",
};
/** Lone segment fallbacks when full path is unmapped. */
const SEGMENT_LABEL_KEYS: Record<string, string> = {
new: "new",
import: "import",
verification: "verification",
approvals: "approvals",
spend: "spend",
orders: "myOrders",
};
/** Parent segment → label key for dynamic child ids. */
const DYNAMIC_PARENT_KEYS: Record<string, string> = {
members: "memberDetail",
packages: "packageDetail",
departments: "departmentDetail",
migration: "migrationDetail",
statements: "statementDetail",
spend: "spendDetail",
orders: "orderDetail",
prescriptions: "prescriptionDetail",
};
function titleCaseSegment(segment: string): string {
return segment
.split("-")
.filter(Boolean)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
}
function looksLikeId(segment: string): boolean {
return (
/^[0-9a-f-]{8,}$/i.test(segment) ||
/^(mem|pkg|dept|job|st|sp|ord|rx|mig)_/i.test(segment) ||
/^[a-z]+_\w+$/i.test(segment)
);
}
export type BreadcrumbItem = {
href?: string;
label: string;
};
export function resolveBreadcrumbItems(
pathname: string,
t: (key: string) => string,
): BreadcrumbItem[] {
const normalized = (pathname || "").split("?")[0] || "";
const segments = normalized.split("/").filter(Boolean);
const items: BreadcrumbItem[] = [
{
href: segments.length === 0 ? undefined : "/",
label: t("dashboard"),
},
];
if (segments.length === 0) {
return items;
}
let acc = "";
for (let i = 0; i < segments.length; i++) {
const segment = segments[i]!;
acc += `/${segment}`;
const isLast = i === segments.length - 1;
const staticKey = PATH_LABEL_KEYS[acc];
const segmentKey = SEGMENT_LABEL_KEYS[segment];
let label: string;
if (staticKey) {
label = t(staticKey);
} else if (looksLikeId(segment)) {
const parent = segments[i - 1] ?? "";
const dynKey = DYNAMIC_PARENT_KEYS[parent];
label = dynKey ? t(dynKey) : titleCaseSegment(segment);
} else if (segmentKey) {
label = t(segmentKey);
} else if (i > 0) {
const parent = segments[i - 1] ?? "";
const dynKey = DYNAMIC_PARENT_KEYS[parent];
label = dynKey ? t(dynKey) : titleCaseSegment(segment);
} else {
label = titleCaseSegment(segment);
}
items.push({
href: isLast ? undefined : acc,
label,
});
}
return items;
}
/** Fortune Admin–style breadcrumbs — locale-aware via `@/i18n/routing`. */
export function Breadcrumbs({
className,
items: itemsProp,
}: {
className?: string;
/** Override auto path resolution (tests / custom trails). */
items?: BreadcrumbItem[];
}) {
const t = useTranslations("bc");
const pathname = usePathname() ?? "";
const items = itemsProp ?? resolveBreadcrumbItems(pathname, t);
if (items.length <= 1) return null;
return (
<nav
aria-label={t("aria")}
className={cn("mb-1 flex flex-wrap items-center gap-1 text-sm", className)}
>
{items.map((item, i) => {
const isLast = i === items.length - 1;
return (
<Fragment key={`${item.label}-${i}`}>
{i > 0 ? (
<ChevronRight
className="size-3.5 shrink-0 text-muted-foreground/70"
aria-hidden
/>
) : null}
{item.href && !isLast ? (
<Link
href={item.href}
className="text-muted-foreground transition-colors hover:text-foreground"
>
{item.label}
</Link>
) : (
<span
className={cn(
isLast ? "font-medium text-foreground" : "text-muted-foreground",
)}
aria-current={isLast ? "page" : undefined}
>
{item.label}
</span>
)}
</Fragment>
);
})}
</nav>
);
}
/** Alias matching Fortune Admin `Bc`. */
export const Bc = Breadcrumbs;

View File

@ -0,0 +1,34 @@
import type { ReactNode } from "react";
import { PageShell } from "@/components/layout/page-shell";
/**
* Fortune CreatePageLayout — breadcrumbs on by default for create / import / nested forms.
*/
export function CreatePageLayout({
title,
description,
extra,
children,
className,
breadcrumb = true,
}: {
title: ReactNode;
description?: ReactNode;
extra?: ReactNode;
children: ReactNode;
className?: string;
/** Defaults on — Fortune CreatePageLayout + Bc */
breadcrumb?: boolean;
}) {
return (
<PageShell
title={title}
description={description}
extra={extra}
className={className}
breadcrumb={breadcrumb}
>
{children}
</PageShell>
);
}

View File

@ -0,0 +1,442 @@
import type { ReactNode } from "react";
import { PageShell } from "@/components/layout/page-shell";
import { cn } from "@/lib/utils";
/** Flat MakerSys / Yimaru panel — thin border, no shadow */
export const DETAIL_PANEL =
"rounded-xl border border-border bg-card shadow-none";
export function DetailPageLayout({
title,
description,
extra,
children,
className,
breadcrumb = true,
}: {
title: ReactNode;
description?: ReactNode;
extra?: ReactNode;
children: ReactNode;
className?: string;
/** Defaults on — Fortune DetailPageLayout + Bc */
breadcrumb?: boolean;
}) {
return (
<PageShell
title={title}
description={description}
extra={extra}
className={className}
breadcrumb={breadcrumb}
>
<div className="w-full space-y-6">{children}</div>
</PageShell>
);
}
export function DetailLoading({ label }: { label?: string }) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<p className="text-sm text-muted-foreground">{label ?? "Loading…"}</p>
</div>
);
}
/** Slim summary strip — left brand accent */
export function DetailHero({
title,
subtitle,
meta,
actions,
className,
}: {
title: ReactNode;
subtitle?: ReactNode;
meta?: ReactNode;
actions?: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
DETAIL_PANEL,
"border-l-4 border-l-brand p-5 sm:p-6",
className,
)}
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1 space-y-2.5">
<h2 className="font-display text-xl font-semibold tracking-tight text-foreground">
{title}
</h2>
{subtitle != null ? (
<p className="text-sm text-muted-foreground">{subtitle}</p>
) : null}
{meta != null ? (
<div className="flex flex-wrap items-center gap-2">{meta}</div>
) : null}
</div>
{actions != null ? (
<div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>
) : null}
</div>
</div>
);
}
export function DetailSection({
title,
icon,
extra,
children,
className,
bodyClassName,
}: {
title?: ReactNode;
icon?: ReactNode;
extra?: ReactNode;
children?: ReactNode;
className?: string;
bodyClassName?: string;
}) {
return (
<div className={cn(DETAIL_PANEL, "flex h-full flex-col overflow-hidden", className)}>
{title != null ? (
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-3.5">
<span className="inline-flex items-center gap-2.5 text-sm font-semibold text-foreground">
{icon != null ? (
<span className="inline-flex size-8 items-center justify-center rounded-lg bg-brand/15 text-brand-deep [&_svg]:size-4">
{icon}
</span>
) : null}
{title}
</span>
{extra}
</div>
) : null}
<div className={cn("flex-1 p-5", bodyClassName)}>{children}</div>
</div>
);
}
/** Fortune description-list field — uppercase micro label */
export function DetailField({
label,
value,
children,
className,
}: {
label: ReactNode;
value?: ReactNode;
children?: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-3",
className,
)}
>
<span className="shrink-0 text-[11px] font-medium tracking-wider text-muted-foreground uppercase sm:min-w-[7.5rem]">
{label}
</span>
<span className="min-w-0 text-sm font-medium text-foreground">
{children ?? value ?? "—"}
</span>
</div>
);
}
export function DetailFieldStack({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={cn("flex flex-col gap-3.5", className)}>{children}</div>;
}
export function DetailGroupTitle({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<h3
className={cn(
"text-xs font-semibold tracking-[0.12em] text-muted-foreground uppercase",
className,
)}
>
{children}
</h3>
);
}
/** Inset read-only value box (Yimaru ReadOnlyField) */
export function DetailReadOnlyBox({
label,
children,
className,
}: {
label?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<div className={cn("space-y-1.5", className)}>
{label != null ? (
<span className="block text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
{label}
</span>
) : null}
<div className="rounded-lg border border-border bg-background px-3 py-2.5 text-sm text-foreground">
{children}
</div>
</div>
);
}
export function DetailMetric({
label,
value,
hint,
icon,
accent = "default",
className,
}: {
label: string;
value: ReactNode;
hint?: string;
icon?: ReactNode;
accent?: "default" | "success" | "danger";
className?: string;
}) {
const accentClass =
accent === "success"
? "text-emerald-700"
: accent === "danger"
? "text-rose-700"
: "text-foreground";
return (
<div
className={cn(
DETAIL_PANEL,
"flex min-h-[5.5rem] flex-col justify-center gap-1.5 p-4",
className,
)}
>
<div className="flex items-center gap-2">
{icon != null ? (
<span className="inline-flex size-7 items-center justify-center rounded-md bg-brand/15 text-brand-deep [&_svg]:size-3.5">
{icon}
</span>
) : null}
<span className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
{label}
</span>
</div>
<p className={cn("font-display text-lg font-semibold tabular-nums tracking-tight", accentClass)}>
{value}
</p>
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
</div>
);
}
export function DetailMetricStrip({
children,
cols = 3,
className,
}: {
children: ReactNode;
cols?: 2 | 3 | 4 | 5;
className?: string;
}) {
return (
<div
className={cn(
"grid gap-3",
cols === 2 && "sm:grid-cols-2",
cols === 3 && "sm:grid-cols-3",
cols === 4 && "sm:grid-cols-2 xl:grid-cols-4",
cols === 5 && "sm:grid-cols-2 lg:grid-cols-5",
className,
)}
>
{children}
</div>
);
}
export function DetailGrid({
children,
cols = 2,
className,
}: {
children: ReactNode;
cols?: 1 | 2 | 3;
className?: string;
}) {
return (
<div
className={cn(
"grid gap-4",
cols === 1 && "grid-cols-1",
cols === 2 && "md:grid-cols-2",
cols === 3 && "md:grid-cols-2 xl:grid-cols-3",
className,
)}
>
{children}
</div>
);
}
export function DetailTableSection({
title,
icon,
extra,
children,
className,
}: {
title?: ReactNode;
icon?: ReactNode;
extra?: ReactNode;
children?: ReactNode;
className?: string;
}) {
return (
<div className={cn(DETAIL_PANEL, "overflow-hidden", className)}>
{title != null ? (
<div className="flex items-center justify-between gap-3 border-b border-border px-5 py-3.5">
<span className="inline-flex items-center gap-2.5 text-sm font-semibold text-foreground">
{icon != null ? (
<span className="inline-flex size-8 items-center justify-center rounded-lg bg-brand/15 text-brand-deep [&_svg]:size-4">
{icon}
</span>
) : null}
{title}
</span>
{extra}
</div>
) : null}
<div className="overflow-x-auto">{children}</div>
</div>
);
}
export function DetailMetaItem({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<span className="inline-flex items-center gap-1.5 text-sm">
<span className="font-medium text-muted-foreground">{label}</span>
<span className="text-foreground">{children}</span>
</span>
);
}
/** Yimaru / Fortune 340px profile rail + main */
export function DetailPageColumns({
aside,
children,
className,
}: {
aside: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,340px)_minmax(0,1fr)] lg:items-start",
className,
)}
>
<aside className="space-y-4">{aside}</aside>
<main className="space-y-4">{children}</main>
</div>
);
}
/** Left-rail profile summary */
export function DetailProfileCard({
avatar,
title,
subtitle,
meta,
children,
actions,
className,
}: {
avatar: ReactNode;
title: ReactNode;
subtitle?: ReactNode;
meta?: ReactNode;
children?: ReactNode;
actions?: ReactNode;
className?: string;
}) {
return (
<div className={cn(DETAIL_PANEL, "overflow-hidden", className)}>
<div className="h-16 bg-[color-mix(in_srgb,var(--brand)_16%,var(--card))]" />
<div className="space-y-4 px-5 pb-5">
<div className="-mt-8 flex justify-center">{avatar}</div>
<div className="space-y-1.5 text-center">
<h2 className="font-display text-lg font-semibold tracking-tight text-foreground">
{title}
</h2>
{subtitle != null ? (
<p className="text-sm text-muted-foreground">{subtitle}</p>
) : null}
{meta != null ? (
<div className="flex flex-wrap items-center justify-center gap-2 pt-1">
{meta}
</div>
) : null}
</div>
{children != null ? (
<div className="space-y-3 border-t border-border pt-4">{children}</div>
) : null}
{actions != null ? (
<div className="flex flex-col gap-2 border-t border-border pt-4">{actions}</div>
) : null}
</div>
</div>
);
}
export function DetailCallout({
children,
tone = "muted",
className,
}: {
children: ReactNode;
tone?: "muted" | "warning" | "brand";
className?: string;
}) {
return (
<p
className={cn(
"rounded-xl border px-4 py-3 text-sm text-muted-foreground",
tone === "muted" && "border-border bg-muted/40",
tone === "warning" && "border-accent/30 bg-accent/10",
tone === "brand" && "border-brand/30 bg-brand/10",
className,
)}
>
{children}
</p>
);
}

View File

@ -0,0 +1,42 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/routing";
import { LANGUAGE_REGISTRY, STORAGE_LANGUAGE_KEY } from "@/i18n/registry";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function LanguageSwitcher({ className }: { className?: string }) {
const t = useTranslations("common");
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
return (
<Select
value={locale}
onValueChange={(next) => {
if (!next) return;
localStorage.setItem(STORAGE_LANGUAGE_KEY, next);
document.cookie = `NEXT_LOCALE=${next}; path=/; max-age=31536000; samesite=lax`;
router.replace(pathname, { locale: next });
}}
>
<SelectTrigger className={className ?? "w-[140px]"} size="sm" aria-label={t("language")}>
<SelectValue placeholder={t("language")} />
</SelectTrigger>
<SelectContent>
{LANGUAGE_REGISTRY.map((lang) => (
<SelectItem key={lang.code} value={lang.code}>
{lang.nativeName}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@ -0,0 +1,219 @@
import { Skeleton } from "@/components/ui/skeleton";
import { StatCardGrid } from "@/components/layout/stat-card";
import { TABLE_PANEL_CLASS } from "@/components/table/table-panel";
import { cn } from "@/lib/utils";
/** Flat panel chrome — mirrors DETAIL_PANEL (avoid circular import with detail-layout). */
const PANEL = "rounded-xl border border-border bg-card shadow-none";
/** Row of flat MakerSys skeleton metric cards. */
export function PageStatSkeleton({
cols = 4,
className,
}: {
cols?: 2 | 3 | 4;
className?: string;
}) {
return (
<StatCardGrid cols={cols} className={className}>
{Array.from({ length: cols }).map((_, i) => (
<div
key={i}
className="flex min-h-[5.75rem] flex-col justify-center gap-2 rounded-xl border border-border bg-card p-4 shadow-none"
>
<Skeleton className="h-3 w-20" />
<Skeleton className="h-8 w-28" />
<Skeleton className="h-3 w-16" />
</div>
))}
</StatCardGrid>
);
}
/** Flat table panel with toolbar + row skeletons. */
export function PageTableSkeleton({
rows = 6,
className,
}: {
rows?: number;
className?: string;
}) {
return (
<div className={cn(TABLE_PANEL_CLASS, className)}>
<div className="flex flex-col gap-3 border-b border-border px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<Skeleton className="h-9 w-full max-w-xs" />
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-20" />
</div>
</div>
<div className="divide-y divide-border">
{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 w-40 sm:block" />
<Skeleton className="ml-auto h-4 w-20" />
<Skeleton className="hidden h-6 w-16 rounded-full md:block" />
</div>
))}
</div>
</div>
);
}
/** List page footprint: stats strip + table panel. */
export function PageListSkeleton({
statsCols = 4,
rows = 6,
className,
}: {
statsCols?: 2 | 3 | 4;
rows?: number;
className?: string;
}) {
return (
<div className={cn("space-y-6", className)}>
<PageStatSkeleton cols={statsCols} />
<PageTableSkeleton rows={rows} />
</div>
);
}
/** Detail page footprint: metrics + content panels. */
export function PageDetailSkeleton({
metrics = 3,
className,
}: {
metrics?: 2 | 3 | 4;
className?: string;
}) {
return (
<div className={cn("space-y-6", className)}>
<div
className={cn(
"grid gap-3",
metrics === 2 && "sm:grid-cols-2",
metrics === 3 && "sm:grid-cols-3",
metrics === 4 && "sm:grid-cols-2 xl:grid-cols-4",
)}
>
{Array.from({ length: metrics }).map((_, i) => (
<div
key={i}
className={cn(PANEL, "flex min-h-[5.5rem] flex-col justify-center gap-2 p-4")}
>
<Skeleton className="h-3 w-24" />
<Skeleton className="h-6 w-32" />
</div>
))}
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,340px)_minmax(0,1fr)]">
<div className={cn(PANEL, "space-y-4 overflow-hidden p-5")}>
<div className="flex flex-col items-center gap-3">
<Skeleton className="size-16 rounded-full" />
<Skeleton className="h-5 w-36" />
<Skeleton className="h-3 w-24" />
</div>
<div className="space-y-3 border-t border-border pt-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/6" />
</div>
</div>
<div className="space-y-4">
<div className={cn(PANEL, "space-y-4 p-5")}>
<Skeleton className="h-4 w-28" />
<div className="grid gap-3 sm:grid-cols-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</div>
<div className={cn(PANEL, "space-y-3 p-5")}>
<Skeleton className="h-4 w-32" />
<Skeleton className="h-20 w-full" />
</div>
</div>
</div>
</div>
);
}
/** Create / settings form footprint. */
export function PageFormSkeleton({ className }: { className?: string }) {
return (
<div
className={cn(
PANEL,
"w-full space-y-6 p-5 sm:p-6",
className,
)}
>
<div className="space-y-2">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-3 w-64" />
</div>
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-10 w-full" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-10 w-full" />
</div>
</div>
<Skeleton className="h-24 w-full" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-28" />
</div>
</div>
);
}
/** Dashboard: stats + chart panels. */
export function PageDashboardSkeleton({ className }: { className?: string }) {
return (
<div className={cn("space-y-6", className)}>
<PageStatSkeleton cols={4} />
<div className="grid gap-4 xl:grid-cols-2">
<div className={cn(PANEL, "space-y-4 p-5")}>
<Skeleton className="h-4 w-36" />
<Skeleton className="h-3 w-48" />
<Skeleton className="h-52 w-full" />
</div>
<div className={cn(PANEL, "space-y-4 p-5")}>
<Skeleton className="h-4 w-36" />
<Skeleton className="h-3 w-48" />
<Skeleton className="h-52 w-full" />
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<div className={cn(PANEL, "space-y-4 p-5")}>
<Skeleton className="h-4 w-40" />
<Skeleton className="h-52 w-full" />
</div>
<div className={cn(PANEL, "space-y-4 p-5")}>
<Skeleton className="h-4 w-40" />
<Skeleton className="h-52 w-full" />
</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More