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:
parent
695c172aba
commit
3778801ef5
9
.env.example
Normal file
9
.env.example
Normal 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
41
.gitignore
vendored
Normal 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
9
AGENTS.md
Normal 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 -->
|
||||
98
README.md
98
README.md
|
|
@ -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
25
components.json
Normal 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
74
docs/README.md
Normal 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
191
docs/backend/OVERVIEW.md
Normal 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)
|
||||
67
docs/backend/endpoints/README.md
Normal file
67
docs/backend/endpoints/README.md
Normal 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.
|
||||
196
docs/backend/endpoints/auth.md
Normal file
196
docs/backend/endpoints/auth.md
Normal 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.
|
||||
255
docs/backend/endpoints/finance.md
Normal file
255
docs/backend/endpoints/finance.md
Normal 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.
|
||||
309
docs/backend/endpoints/members.md
Normal file
309
docs/backend/endpoints/members.md
Normal 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.
|
||||
253
docs/backend/endpoints/migration.md
Normal file
253
docs/backend/endpoints/migration.md
Normal 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`.
|
||||
291
docs/backend/endpoints/org.md
Normal file
291
docs/backend/endpoints/org.md
Normal 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.
|
||||
185
docs/backend/endpoints/packages.md
Normal file
185
docs/backend/endpoints/packages.md
Normal 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).
|
||||
228
docs/backend/endpoints/prescriptions.md
Normal file
228
docs/backend/endpoints/prescriptions.md
Normal 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
|
||||
91
docs/backend/entities/README.md
Normal file
91
docs/backend/entities/README.md
Normal 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.
|
||||
71
docs/backend/entities/department.md
Normal file
71
docs/backend/entities/department.md
Normal 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
|
||||
169
docs/backend/entities/finance.md
Normal file
169
docs/backend/entities/finance.md
Normal 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)
|
||||
145
docs/backend/entities/member.md
Normal file
145
docs/backend/entities/member.md
Normal 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)
|
||||
166
docs/backend/entities/migration-job.md
Normal file
166
docs/backend/entities/migration-job.md
Normal 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.
|
||||
162
docs/backend/entities/org-registration.md
Normal file
162
docs/backend/entities/org-registration.md
Normal 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
|
||||
155
docs/backend/entities/organisation.md
Normal file
155
docs/backend/entities/organisation.md
Normal 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)
|
||||
135
docs/backend/entities/package.md
Normal file
135
docs/backend/entities/package.md
Normal 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)
|
||||
140
docs/backend/entities/prescription.md
Normal file
140
docs/backend/entities/prescription.md
Normal 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.
|
||||
101
docs/backend/entities/session-user.md
Normal file
101
docs/backend/entities/session-user.md
Normal 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)
|
||||
167
docs/backend/events/README.md
Normal file
167
docs/backend/events/README.md
Normal 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)
|
||||
70
docs/coordination/gishen-admin.md
Normal file
70
docs/coordination/gishen-admin.md
Normal 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.
|
||||
96
docs/coordination/gishen-ecom.md
Normal file
96
docs/coordination/gishen-ecom.md
Normal 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
|
||||
79
docs/coordination/open-items.md
Normal file
79
docs/coordination/open-items.md
Normal 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
108
docs/features/INDEX.md
Normal 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
|
||||
```
|
||||
79
docs/features/auth-roles.md
Normal file
79
docs/features/auth-roles.md
Normal 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
115
docs/features/finance.md
Normal 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
113
docs/features/i18n.md
Normal 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.
|
||||
124
docs/features/member-experience.md
Normal file
124
docs/features/member-experience.md
Normal 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
100
docs/features/members.md
Normal 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
104
docs/features/migration.md
Normal 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
|
||||
92
docs/features/org-departments.md
Normal file
92
docs/features/org-departments.md
Normal 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
|
||||
95
docs/features/org-registration.md
Normal file
95
docs/features/org-registration.md
Normal 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
88
docs/features/packages.md
Normal 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
|
||||
105
docs/features/portal-layout.md
Normal file
105
docs/features/portal-layout.md
Normal 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
|
||||
41
docs/features/quick-search.md
Normal file
41
docs/features/quick-search.md
Normal 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.
|
||||
134
docs/product/b2b-features.md
Normal file
134
docs/product/b2b-features.md
Normal 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
18
eslint.config.mjs
Normal 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
12
next.config.ts
Normal 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
11124
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
package.json
Normal file
43
package.json
Normal 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
7
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
public/brand/gishen-logo.png
Normal file
BIN
public/brand/gishen-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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 |
233
src/app/[locale]/(portal)/departments/[id]/page.tsx
Normal file
233
src/app/[locale]/(portal)/departments/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/app/[locale]/(portal)/departments/new/page.tsx
Normal file
103
src/app/[locale]/(portal)/departments/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/app/[locale]/(portal)/departments/page.tsx
Normal file
141
src/app/[locale]/(portal)/departments/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
src/app/[locale]/(portal)/finance/approvals/page.tsx
Normal file
163
src/app/[locale]/(portal)/finance/approvals/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
src/app/[locale]/(portal)/finance/page.tsx
Normal file
154
src/app/[locale]/(portal)/finance/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
src/app/[locale]/(portal)/finance/spend/[id]/page.tsx
Normal file
122
src/app/[locale]/(portal)/finance/spend/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/[locale]/(portal)/layout.tsx
Normal file
5
src/app/[locale]/(portal)/layout.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { AppShell } from "@/components/layout/app-shell";
|
||||
|
||||
export default function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
122
src/app/[locale]/(portal)/me/orders/[id]/page.tsx
Normal file
122
src/app/[locale]/(portal)/me/orders/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/app/[locale]/(portal)/me/orders/page.tsx
Normal file
130
src/app/[locale]/(portal)/me/orders/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
src/app/[locale]/(portal)/me/page.tsx
Normal file
139
src/app/[locale]/(portal)/me/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
366
src/app/[locale]/(portal)/members/[id]/page.tsx
Normal file
366
src/app/[locale]/(portal)/members/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
src/app/[locale]/(portal)/members/import/page.tsx
Normal file
125
src/app/[locale]/(portal)/members/import/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/app/[locale]/(portal)/members/new/page.tsx
Normal file
130
src/app/[locale]/(portal)/members/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
src/app/[locale]/(portal)/members/page.tsx
Normal file
176
src/app/[locale]/(portal)/members/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
src/app/[locale]/(portal)/migration/[jobId]/page.tsx
Normal file
200
src/app/[locale]/(portal)/migration/[jobId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
src/app/[locale]/(portal)/migration/new/page.tsx
Normal file
179
src/app/[locale]/(portal)/migration/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
src/app/[locale]/(portal)/migration/page.tsx
Normal file
143
src/app/[locale]/(portal)/migration/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
src/app/[locale]/(portal)/organisation/page.tsx
Normal file
158
src/app/[locale]/(portal)/organisation/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
src/app/[locale]/(portal)/packages/[id]/page.tsx
Normal file
157
src/app/[locale]/(portal)/packages/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
src/app/[locale]/(portal)/packages/new/page.tsx
Normal file
147
src/app/[locale]/(portal)/packages/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/app/[locale]/(portal)/packages/page.tsx
Normal file
141
src/app/[locale]/(portal)/packages/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
206
src/app/[locale]/(portal)/page.tsx
Normal file
206
src/app/[locale]/(portal)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/app/[locale]/(portal)/prescriptions/[id]/page.tsx
Normal file
141
src/app/[locale]/(portal)/prescriptions/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/app/[locale]/(portal)/prescriptions/new/page.tsx
Normal file
101
src/app/[locale]/(portal)/prescriptions/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
src/app/[locale]/(portal)/prescriptions/page.tsx
Normal file
145
src/app/[locale]/(portal)/prescriptions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/app/[locale]/(portal)/settings/verification/page.tsx
Normal file
103
src/app/[locale]/(portal)/settings/verification/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
src/app/[locale]/(portal)/statements/[id]/page.tsx
Normal file
200
src/app/[locale]/(portal)/statements/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
src/app/[locale]/(portal)/statements/page.tsx
Normal file
139
src/app/[locale]/(portal)/statements/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/app/[locale]/invite/[token]/page.tsx
Normal file
70
src/app/[locale]/invite/[token]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
src/app/[locale]/join/page.tsx
Normal file
68
src/app/[locale]/join/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/app/[locale]/layout.tsx
Normal file
27
src/app/[locale]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/app/[locale]/login/page.tsx
Normal file
97
src/app/[locale]/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
src/app/[locale]/register/organisation/page.tsx
Normal file
95
src/app/[locale]/register/organisation/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/app/[locale]/register/request/page.tsx
Normal file
114
src/app/[locale]/register/request/page.tsx
Normal 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
114
src/app/globals.css
Normal 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
36
src/app/layout.tsx
Normal 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
5
src/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/en/login");
|
||||
}
|
||||
192
src/components/charts/portal-charts.tsx
Normal file
192
src/components/charts/portal-charts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/layout/app-shell.tsx
Normal file
55
src/components/layout/app-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
src/components/layout/app-sidebar.tsx
Normal file
151
src/components/layout/app-sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
src/components/layout/app-topbar.tsx
Normal file
99
src/components/layout/app-topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
src/components/layout/breadcrumbs.tsx
Normal file
182
src/components/layout/breadcrumbs.tsx
Normal 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;
|
||||
34
src/components/layout/create-layout.tsx
Normal file
34
src/components/layout/create-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
442
src/components/layout/detail-layout.tsx
Normal file
442
src/components/layout/detail-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
src/components/layout/language-switcher.tsx
Normal file
42
src/components/layout/language-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
src/components/layout/page-loading.tsx
Normal file
219
src/components/layout/page-loading.tsx
Normal 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
Reference in New Issue
Block a user