Align org activation and Money with B2B platform contracts

Use pending_activation, commercial Money terms, and activate flows so Admin master/spec and mocks match the shared organisation lifecycle.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kirubel-Kibru-Yaltopia 2026-08-08 00:29:44 +03:00
parent 40771434d1
commit 8616c03645
10 changed files with 250 additions and 60 deletions

View File

@ -6,8 +6,8 @@
| --- | --- | | --- | --- |
| **Home repo (this file)** | `Gishen-Admin` | | **Home repo (this file)** | `Gishen-Admin` |
| **Sibling root** | `/Users/kirukib/Desktop/Yaltopia Project/` | | **Sibling root** | `/Users/kirukib/Desktop/Yaltopia Project/` |
| **Shared API base (TBD)** | `https://api.gishen.example/v1` (Ecom/Mob/Admin) · B2B draft: `https://api.gishenpharmacy.org/v1` — **unify TBD** | | **Shared API base (TBD)** | Prefer `https://api.gishenpharmacy.org/v1` (B2B draft / Admin alignment). Legacy sheets still show `api.gishen.example` — unify TBD |
| **Last reconciled** | 2026-08-07 | | **Last reconciled** | 2026-08-08 |
--- ---
@ -15,6 +15,7 @@
| Date | Change | | Date | Change |
| --- | --- | | --- | --- |
| 2026-08-08 | **Org/Money alignment:** Canonical org status `pending_activation`; Admin activate path + `CommercialTerms`/`Money`; B2B register `POST /v1/org/register` + `org.activated`/`org.suspended`. Resolved Money default (decimal major ETB). Refreshed divergence table + Sources stamp after sibling B2B/Ecom skim. |
| 2026-08-07 | **Seed batch:** Created master spec from Admin living contract + sibling Mob/Ecom/B2B/Dispatch sheets. Documented product surface map, shared domain objects (auth, orgs, doctors, orders/guest, Rx dosing, catalogue multi-UOM + PDP, stock, loyalty), auth provider matrix, divergence & sync notes, deeper-doc links, Sources stamp, and pre-push update rule. Wired `.cursor/rules` + optional `.githooks/pre-push` reminder. Linked from Admin `README.md` and `admin-backend-spec.md`. | | 2026-08-07 | **Seed batch:** Created master spec from Admin living contract + sibling Mob/Ecom/B2B/Dispatch sheets. Documented product surface map, shared domain objects (auth, orgs, doctors, orders/guest, Rx dosing, catalogue multi-UOM + PDP, stock, loyalty), auth provider matrix, divergence & sync notes, deeper-doc links, Sources stamp, and pre-push update rule. Wired `.cursor/rules` + optional `.githooks/pre-push` reminder. Linked from Admin `README.md` and `admin-backend-spec.md`. |
--- ---
@ -39,10 +40,10 @@ Update these dates whenever you reconcile against a sibling.
| Source | Path (sibling area) | Found | Spec / README | Last skimmed | | Source | Path (sibling area) | Found | Spec / README | Last skimmed |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| **Admin** | `Gishen-Admin/` | ✅ | [`docs/admin-backend-spec.md`](./admin-backend-spec.md) | 2026-08-07 | | **Admin** | `Gishen-Admin/` | ✅ | [`docs/admin-backend-spec.md`](./admin-backend-spec.md) | 2026-08-08 |
| **Mob** | `Gishen-Mob/` | ✅ | [`docs/BACKEND_SPEC.md`](../../Gishen-Mob/docs/BACKEND_SPEC.md) (relative from sibling root) | 2026-08-07 | | **Mob** | `Gishen-Mob/` | ✅ | [`docs/BACKEND_SPEC.md`](../../Gishen-Mob/docs/BACKEND_SPEC.md) (relative from sibling root) | 2026-08-07 |
| **Ecom** | `Gishen-Ecom/` | ✅ | [`docs/backend.md`](../../Gishen-Ecom/docs/backend.md) | 2026-08-07 | | **Ecom** | `Gishen-Ecom/` | ✅ | [`docs/backend.md`](../../Gishen-Ecom/docs/backend.md) | 2026-08-08 |
| **B2B** | `Gishen-B2B/` | ✅ | [`docs/backend/OVERVIEW.md`](../../Gishen-B2B/docs/backend/OVERVIEW.md) + coordination | 2026-08-07 | | **B2B** | `Gishen-B2B/` | ✅ | [`docs/backend/OVERVIEW.md`](../../Gishen-B2B/docs/backend/OVERVIEW.md) + coordination | 2026-08-08 |
| **Dispatch Bot** | `Gishen-Dispatch-Bot/` | ✅ (related) | `docs/BACKEND_SPEC.md` | 2026-08-07 | | **Dispatch Bot** | `Gishen-Dispatch-Bot/` | ✅ (related) | `docs/BACKEND_SPEC.md` | 2026-08-07 |
| **Telegram Mini App** | `Gishen-Telegram-MiniApp/` | ⚠️ empty scaffold | — | 2026-08-07 | | **Telegram Mini App** | `Gishen-Telegram-MiniApp/` | ⚠️ empty scaffold | — | 2026-08-07 |
@ -120,7 +121,7 @@ See [§3 Auth matrix](#3-auth-provider-matrix).
| `corporate` \| `ngo` \| `other` | B2B benefits clients — credit, packages, members | | `corporate` \| `ngo` \| `other` | B2B benefits clients — credit, packages, members |
| `hospital` \| `clinic` | Doctor affiliation; clinical Rx/orders | | `hospital` \| `clinic` | Doctor affiliation; clinical Rx/orders |
**Lifecycle (B2B):** `pending_activation` → `active` → `suspended` \| `closed`. Admin writes commercial terms (`credit`, payment terms, `price_list_id`, contract dates) and emits activation events. B2B self-register UI → Admin approval queue. **Lifecycle (canonical):** `pending_activation` → `active` → `suspended` \| `closed` (+ `rejected` for denied self-reg). Admin writes `commercial` (`Money` credit_limit/used, `payment_terms_days`, `price_list_id`, contract dates) via create-active or `POST /admin/organisations/:id/activate` and emits `org.activated`. B2B self-register: `POST /v1/org/register` → `org.registration_submitted` → Admin queue.
### 2.3 Doctors ### 2.3 Doctors
@ -202,13 +203,14 @@ Env (Admin SPA): `VITE_GOOGLE_CLIENT_ID`, `VITE_TELEGRAM_BOT_USERNAME` (empty =
## 4. Divergence & sync notes ## 4. Divergence & sync notes
Accurate as of **2026-08-07** skim. Prefer fixing sheets over inventing a third truth here. Accurate as of **2026-08-08** skim. Prefer fixing sheets over inventing a third truth here.
| Topic | Divergence | Sync action | | Topic | Divergence | Sync action |
| --- | --- | --- | | --- | --- | --- |
| API base host | Admin/Mob/Ecom use `api.gishen.example`; B2B OVERVIEW uses `api.gishenpharmacy.org` | Pick one production host | | API base host | Some sheets still use `api.gishen.example`; B2B + Admin alignment prefer `api.gishenpharmacy.org` | Pick one production host |
| Money | Admin demos major ETB; Ecom sheet says cents optional | Platform-wide decision (Admin open Q1) | | Money | **Resolved default:** `{ amount: "1250.00", currency: "ETB" }` decimal major units (B2B + Admin master). Ecom open Q should close to match; Admin UI may keep numeric helpers synced from `commercial` | Done for Admin/B2B; close Ecom |
| Auth path prefixes | Staff `/auth/staff/*`, Mob `/auth/otp/*`, B2B `/v1/auth/*` | Shared IdP façade TBD | | Auth path prefixes | Staff `/auth/staff/*`, Mob `/auth/otp/*`, B2B `/v1/auth/*` | Shared IdP façade TBD |
| Org status naming | Admin mocks/UI now use `pending_activation` (was bare `pending`); activate (was `/approve`) | Keep Admin + B2B sheets in sync |
| Guest orders | Admin: staff/doctor `customerType=guest`; Mob/Ecom: guest session + `GSH-XXXX` codes | Document both flows; Admin POS `by-code` must match Mob code shape | | Guest orders | Admin: staff/doctor `customerType=guest`; Mob/Ecom: guest session + `GSH-XXXX` codes | Document both flows; Admin POS `by-code` must match Mob code shape |
| Guest Admin endpoints | Mob lists `GET /admin/orders/by-code/:code` | Ensure Admin sheet mirrors when POS wire-up lands | | Guest Admin endpoints | Mob lists `GET /admin/orders/by-code/:code` | Ensure Admin sheet mirrors when POS wire-up lands |
| Rx dosing | Admin structured `frequency`/`times`; Mob reminders separate resource | On approve, map Admin times → `/health/reminders` seed | | Rx dosing | Admin structured `frequency`/`times`; Mob reminders separate resource | On approve, map Admin times → `/health/reminders` seed |

View File

@ -12,6 +12,7 @@
| Date | Change | | Date | Change |
| --- | --- | | --- | --- |
| 2026-08-08 | **B2B org alignment:** `pending_activation`, `POST .../activate` + `CommercialTerms`/`Money`, events `org.activated`/`org.suspended`; B2B register path `POST /v1/org/register` |
| 2026-08-07 | Pointer to cross-app [`GISHEN-MASTER-SPEC.md`](./GISHEN-MASTER-SPEC.md) + pre-push refresh rule | | 2026-08-07 | Pointer to cross-app [`GISHEN-MASTER-SPEC.md`](./GISHEN-MASTER-SPEC.md) + pre-push refresh rule |
| 2026-08-06 | Scaffold: conventions, roles, auth, empty module sections | | 2026-08-06 | Scaffold: conventions, roles, auth, empty module sections |
| 2026-08-06 | Shell + RBAC + preferredLocale | | 2026-08-06 | Shell + RBAC + preferredLocale |
@ -53,7 +54,7 @@
| --- | --- | | --- | --- |
| Content type | `application/json` (multipart for imports / uploads) | | Content type | `application/json` (multipart for imports / uploads) |
| IDs | UUID v4 | | IDs | UUID v4 |
| Money | Integer ETB (major units for Admin UI demos; align platform-wide later) | | Money | **`Money`:** `{ "amount": "1250.00", "currency": "ETB" }` (decimal string, major units). Admin SPA may also keep numeric major-ETB helpers for UI demos — sync with `commercial` on activate. |
| Errors | `{ "error": { "code": string, "message": string, "details"?: object } }` | | Errors | `{ "error": { "code": string, "message": string, "details"?: object } }` |
| Pagination | `?page=&limit=` → `{ data, meta: { page, limit, total } }` | | Pagination | `?page=&limit=` → `{ data, meta: { page, limit, total } }` |
@ -446,24 +447,42 @@ Mock: `mocks/data.procurementRequests` · UI: `/procurement/:id`
| POST | `/admin/finance/refunds` | finance, super_admin | Refund handling | | POST | `/admin/finance/refunds` | finance, super_admin | Refund handling |
| GET | `/admin/finance/loyalty-liability` | finance, super_admin | Outstanding Birr liability (points × redemption rate; must reconcile with Loyalty KPI) | | GET | `/admin/finance/loyalty-liability` | finance, super_admin | Outstanding Birr liability (points × redemption rate; must reconcile with Loyalty KPI) |
| GET | `/admin/organisations` | finance, super_admin | List; filter `orgType`, `status`, `source` | | GET | `/admin/organisations` | finance, super_admin | List; filter `orgType`, `status`, `source` |
| POST | `/admin/organisations` | finance, super_admin | **Create org from Admin** (active); body includes `orgType` | | POST | `/admin/organisations` | finance, super_admin | **Create org from Admin** (`active` + `commercial`); body includes `orgType` |
| GET | `/admin/organisations/pending` | finance, super_admin | Self-register queue | | GET | `/admin/organisations/pending` | finance, super_admin | Self-register queue (`status=pending_activation`) |
| POST | `/admin/organisations/:id/approve` | finance, super_admin | Activate credit | | GET | `/admin/organisations/registration-requests` | finance, super_admin | CRM leads from `org.registration_requested` |
| POST | `/admin/organisations/:id/reject` | finance, super_admin | Reject | | POST | `/admin/organisations/:id/activate` | finance, super_admin | Body: `commercial` (`credit_limit` Money, payment terms, price list, contract dates). Emits `org.activated` |
| GET | `/admin/organisations/:id/statements` | finance, super_admin | Consolidated invoices | | POST | `/admin/organisations/:id/reject` | finance, super_admin | Reject registration |
| POST | `/admin/organisations/:id/suspend` | finance, super_admin | Emit `org.suspended` — B2B blocks mutations |
| GET | `/admin/organisations/:id/statements` | finance, super_admin | Consolidated invoices (`pdf_url` for B2B Finance) |
| GET | `/admin/finance/settlements/:id` | finance, super_admin | Batch detail: `txnCount`, `feesEtb`, net, unmatched slips | | GET | `/admin/finance/settlements/:id` | finance, super_admin | Batch detail: `txnCount`, `feesEtb`, net, unmatched slips |
| POST | `/admin/finance/settlements/:id/reconcile` | finance, super_admin | Re-run reconciliation | | POST | `/admin/finance/settlements/:id/reconcile` | finance, super_admin | Re-run reconciliation |
| GET | `/admin/organisations/:id` | finance, super_admin | Detail: credit profile + utilization + `orgType` | | GET | `/admin/organisations/:id` | finance, super_admin | Detail: `commercial` + utilization + `orgType` |
| GET | `/admin/organisations/:id/members` | finance, super_admin | HR roster `{ name, dept, plan }` | | GET | `/admin/organisations/:id/members` | finance, super_admin | HR roster `{ name, dept, plan }` |
| GET | `/admin/organisations/:id/doctors` | finance, super_admin | Hospital/clinic doctor roster (empty for other types) | | GET | `/admin/organisations/:id/doctors` | finance, super_admin | Hospital/clinic doctor roster (empty for other types) |
| GET | `/admin/organisations/:id/invoices` | finance, super_admin | `{ id, period, amountEtb, status }` | | GET | `/admin/organisations/:id/invoices` | finance, super_admin | `{ id, period, amountEtb, status }` |
| PATCH | `/admin/organisations/:id/credit` | finance, super_admin | `{ creditLimitEtb }` (review-gated) | | PATCH | `/admin/organisations/:id/commercial` | finance, super_admin | Update commercial terms while active (review-gated) |
`orgType`: `corporate` · `hospital` · `clinic` · `ngo` · `other`. Doctor invitation is only valid for `hospital` / `clinic`. `orgType`: `corporate` · `hospital` · `clinic` · `ngo` · `other`. Doctor invitation is only valid for `hospital` / `clinic`.
**Cross-repo:** B2B portal `POST /company/register` creates `status=pending`. Admin approve activates credit. **Org status (canonical):** `pending_activation` → `active` → `suspended` \| `closed` (plus `rejected` for denied registrations).
UI: `/organisations/:id` (Approve primary; Reject in overflow + credit-section footer; **Add doctor** on hospital/clinic), `/doctors`, `/finance` (tabs: Settlements / Trends / Operations + gateways), `/finance/settlements/:id`, `/finance/gateways` **Activate body sketch:**
```json
{
"commercial": {
"credit_limit": { "amount": "2000000.00", "currency": "ETB" },
"payment_terms_days": 30,
"price_list_id": "pl_corporate_2026",
"contract_start": "2026-04-01",
"contract_end": "2027-03-31"
}
}
```
**Cross-repo:** B2B `POST /v1/org/register` creates `org_status=pending_activation` and emits `org.registration_submitted`. Admin activate emits `org.activated` with commercial payload matching B2B `organisation.md`. (Legacy sketch `POST /company/register` / bare `pending` / `/approve` superseded.)
UI: `/organisations/:id` (Activate primary; Reject in overflow + credit-section footer; **Add doctor** on hospital/clinic), `/doctors`, `/finance` (tabs: Settlements / Trends / Operations + gateways), `/finance/settlements/:id`, `/finance/gateways`
--- ---
@ -630,7 +649,7 @@ or admin `POST /admin/users/:id/avatar`.
## Open questions ## Open questions
1. Money units: ETB major vs cents — keep consistent with Ecom/B2B sheets 1. ~~Money units~~ → **Resolved default:** `Money` decimal-string major ETB (B2B). Close Ecom open decision to match.
2. Driver channel assumption: Telegram bot (flag if SMS/WhatsApp preferred) 2. Driver channel assumption: Telegram bot (flag if SMS/WhatsApp preferred)
3. Checkout entitlement-split: shared component across Ecom/Mobile/B2B (out of Admin UI) 3. Checkout entitlement-split: shared component across Ecom/Mobile/B2B (out of Admin UI)
4. Amharic dates: Gregorian with Amharic month names (current) vs Ethiopian calendar (`am-ET-u-ca-ethiopic`) — year differs (~2018 E.C. for 2026 G.C.); need product decision 4. Amharic dates: Gregorian with Amharic month names (current) vs Ethiopian calendar (`am-ET-u-ca-ethiopic`) — year differs (~2018 E.C. for 2026 G.C.); need product decision

View File

@ -54,7 +54,10 @@ const STATUS_MAP: Record<StatusDomain, Record<string, StatusEntry>> = {
}, },
organisation: { organisation: {
pending: { label: 'Pending', variant: 'warning' }, pending: { label: 'Pending', variant: 'warning' },
pending_activation: { label: 'Pending activation', variant: 'warning' },
active: { label: 'Active', variant: 'success' }, active: { label: 'Active', variant: 'success' },
suspended: { label: 'Suspended', variant: 'warning' },
closed: { label: 'Closed', variant: 'muted' },
rejected: { label: 'Rejected', variant: 'danger' }, rejected: { label: 'Rejected', variant: 'danger' },
admin: { label: 'Added by admin', variant: 'muted' }, admin: { label: 'Added by admin', variant: 'muted' },
self_register: { label: 'Self-registered', variant: 'secondary' }, self_register: { label: 'Self-registered', variant: 'secondary' },

View File

@ -5,6 +5,16 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
} }
export function moneyToNumber(money: { amount: string } | null | undefined): number {
if (!money?.amount) return 0
return Number(money.amount) || 0
}
export function etb(amount: number | string): { amount: string; currency: 'ETB' } {
const n = typeof amount === 'string' ? Number(amount) || 0 : amount
return { amount: n.toFixed(2), currency: 'ETB' }
}
export function formatEtb(amount: number) { export function formatEtb(amount: number) {
return new Intl.NumberFormat('en-ET', { return new Intl.NumberFormat('en-ET', {
style: 'currency', style: 'currency',

View File

@ -579,6 +579,16 @@ export let organisations: Organisation[] = [
businessLicense: 'BL-AA/48291/2014', businessLicense: 'BL-AA/48291/2014',
commercialRegistration: 'CR/015842/2014', commercialRegistration: 'CR/015842/2014',
status: 'active', status: 'active',
commercial: {
credit_limit: { amount: '500000.00', currency: 'ETB' },
credit_used: { amount: '124000.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: '2026-05-02T09:40:00Z',
activated_by: 'adm_finance',
},
creditLimitEtb: 500000, creditLimitEtb: 500000,
usedEtb: 124000, usedEtb: 124000,
billingContact: 'finance@horizon.et', billingContact: 'finance@horizon.et',
@ -595,7 +605,8 @@ export let organisations: Organisation[] = [
vatNumber: '', vatNumber: '',
businessLicense: 'BL-AA/99102/2025', businessLicense: 'BL-AA/99102/2025',
commercialRegistration: '', commercialRegistration: '',
status: 'pending', status: 'pending_activation',
commercial: null,
creditLimitEtb: 100000, creditLimitEtb: 100000,
usedEtb: 0, usedEtb: 0,
billingContact: 'hr@greenngo.et', billingContact: 'hr@greenngo.et',
@ -611,6 +622,16 @@ export let organisations: Organisation[] = [
businessLicense: 'BL-AA/12044/2007', businessLicense: 'BL-AA/12044/2007',
commercialRegistration: 'CR/008812/2007', commercialRegistration: 'CR/008812/2007',
status: 'active', status: 'active',
commercial: {
credit_limit: { amount: '750000.00', currency: 'ETB' },
credit_used: { amount: '186000.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: '2026-03-14T10:15:00Z',
activated_by: 'adm_finance',
},
creditLimitEtb: 750000, creditLimitEtb: 750000,
usedEtb: 186000, usedEtb: 186000,
billingContact: 'pharmacy@sphmmc.et', billingContact: 'pharmacy@sphmmc.et',
@ -628,6 +649,16 @@ export let organisations: Organisation[] = [
businessLicense: 'BL-AA/00418/1999', businessLicense: 'BL-AA/00418/1999',
commercialRegistration: 'CR/001102/1999', commercialRegistration: 'CR/001102/1999',
status: 'active', status: 'active',
commercial: {
credit_limit: { amount: '900000.00', currency: 'ETB' },
credit_used: { amount: '412000.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: '2026-02-22T08:50:00Z',
activated_by: 'adm_finance',
},
creditLimitEtb: 900000, creditLimitEtb: 900000,
usedEtb: 412000, usedEtb: 412000,
billingContact: 'procurement@tash.et', billingContact: 'procurement@tash.et',
@ -1002,7 +1033,7 @@ export const auditLog: AuditEvent[] = [
ip: '196.189.44.77', ip: '196.189.44.77',
userAgent: CHROME_WIN, userAgent: CHROME_WIN,
outcome: 'success', outcome: 'success',
before: { status: 'pending', creditLimitEtb: 250000 }, before: { status: 'pending_activation', creditLimitEtb: 250000 },
after: { status: 'active', creditLimitEtb: 500000 }, after: { status: 'active', creditLimitEtb: 500000 },
}, },
{ {

View File

@ -204,7 +204,7 @@ export function DashboardPage() {
const awaitingPickup = openOrders.filter((o) => o.fulfillment === 'pickup') const awaitingPickup = openOrders.filter((o) => o.fulfillment === 'pickup')
const openDispatch = openOrders.filter((o) => o.fulfillment === 'delivery' && o.paid) const openDispatch = openOrders.filter((o) => o.fulfillment === 'delivery' && o.paid)
const pendingOrgs = organisations.filter((o) => o.status === 'pending') const pendingOrgs = organisations.filter((o) => o.status === 'pending_activation')
const creditUsed = organisations.reduce((sum, o) => sum + o.usedEtb, 0) const creditUsed = organisations.reduce((sum, o) => sum + o.usedEtb, 0)
const creditLimit = organisations.reduce((sum, o) => sum + o.creditLimitEtb, 0) const creditLimit = organisations.reduce((sum, o) => sum + o.creditLimitEtb, 0)

View File

@ -5,7 +5,7 @@ import { CreatePageLayout, createFieldFull, createFieldWide, createFormGrid } fr
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils' import { cn, etb } from '@/lib/utils'
import { organisations } from '@/mocks/data' import { organisations } from '@/mocks/data'
import { SelectNative } from '@/components/ui/select-native' import { SelectNative } from '@/components/ui/select-native'
import type { Organisation, OrganisationType } from '@/types' import type { Organisation, OrganisationType } from '@/types'
@ -31,6 +31,9 @@ export function CreateOrganisationPage() {
const onSubmit = (e: FormEvent) => { const onSubmit = (e: FormEvent) => {
e.preventDefault() e.preventDefault()
const at = new Date().toISOString()
const limit = Number(form.creditLimitEtb) || 0
const termsDays = Number(String(form.paymentTerms).replace(/\D/g, '')) || 30
const org: Organisation = { const org: Organisation = {
id: `org-${Date.now()}`, id: `org-${Date.now()}`,
name: form.name.trim(), name: form.name.trim(),
@ -40,14 +43,26 @@ export function CreateOrganisationPage() {
businessLicense: form.businessLicense.trim() || undefined, businessLicense: form.businessLicense.trim() || undefined,
commercialRegistration: form.commercialRegistration.trim() || undefined, commercialRegistration: form.commercialRegistration.trim() || undefined,
status: 'active', status: 'active',
creditLimitEtb: Number(form.creditLimitEtb) || 0, commercial: {
credit_limit: etb(limit),
credit_used: etb(0),
payment_terms_days: termsDays,
price_list_id: 'pl_corporate_2026',
contract_start: at.slice(0, 10),
contract_end: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10),
activated_at: at,
activated_by: 'adm_finance',
},
creditLimitEtb: limit,
usedEtb: 0, usedEtb: 0,
billingContact: form.billingContact.trim(), billingContact: form.billingContact.trim(),
source: 'admin', source: 'admin',
createdAt: new Date().toISOString(), createdAt: at,
reviewedBy: 'Admin create',
reviewedAt: at,
} }
organisations.unshift(org) organisations.unshift(org)
toast.success('Organisation created — credit active') toast.success('Organisation created — active with commercial terms')
navigate('/organisations') navigate('/organisations')
} }

View File

@ -19,7 +19,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { orgInvoices, orgMembers, organisations, doctors } from '@/mocks/data' import { orgInvoices, orgMembers, organisations, doctors } from '@/mocks/data'
import { orgSpendSeries } from '@/mocks/charts' import { orgSpendSeries } from '@/mocks/charts'
import { useAuthStore } from '@/zustand/authStore' import { useAuthStore } from '@/zustand/authStore'
import { formatDate, formatDateTime, formatEtb } from '@/lib/utils' import { etb, formatDate, formatDateTime, formatEtb } from '@/lib/utils'
import type { Organisation } from '@/types' import type { Organisation } from '@/types'
export function OrganisationDetailPage() { export function OrganisationDetailPage() {
@ -41,24 +41,52 @@ export function OrganisationDetailPage() {
const members = orgMembers[org.id] ?? [] const members = orgMembers[org.id] ?? []
const invoices = orgInvoices[org.id] ?? [] const invoices = orgInvoices[org.id] ?? []
const util = org.creditLimitEtb ? Math.round((org.usedEtb / org.creditLimitEtb) * 100) : 0 const util = org.creditLimitEtb ? Math.round((org.usedEtb / org.creditLimitEtb) * 100) : 0
const isPending = org.status === 'pending' const isPending = org.status === 'pending_activation'
const isClinicalOrg = org.orgType === 'hospital' || org.orgType === 'clinic' const isClinicalOrg = org.orgType === 'hospital' || org.orgType === 'clinic'
const orgDoctors = doctors.filter((d) => d.orgId === org.id) const orgDoctors = doctors.filter((d) => d.orgId === org.id)
const setStatus = (status: Organisation['status'], message: string) => { const applyOrgPatch = (patch: Partial<Organisation>, message: string) => {
const patch = {
status,
reviewedBy: user?.name ?? 'Finance',
reviewedAt: new Date().toISOString(),
}
setOrg((prev) => (prev ? { ...prev, ...patch } : prev)) setOrg((prev) => (prev ? { ...prev, ...patch } : prev))
const target = organisations.find((o) => o.id === org.id) const target = organisations.find((o) => o.id === org.id)
if (target) Object.assign(target, patch) if (target) Object.assign(target, patch)
toast.success(message) toast.success(message)
} }
const approve = () => setStatus('active', 'Organisation approved') const activate = () => {
const reject = () => setStatus('rejected', 'Organisation rejected') const at = new Date().toISOString()
const reviewedBy = user?.name ?? 'Finance'
const commercial = {
credit_limit: org.commercial?.credit_limit ?? etb(org.creditLimitEtb || 100000),
credit_used: etb(0),
payment_terms_days: org.commercial?.payment_terms_days ?? 30,
price_list_id: org.commercial?.price_list_id ?? 'pl_corporate_2026',
contract_start: at.slice(0, 10),
contract_end: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10),
activated_at: at,
activated_by: user?.id ?? 'adm_finance',
}
applyOrgPatch(
{
status: 'active',
commercial,
creditLimitEtb: Number(commercial.credit_limit.amount),
usedEtb: 0,
reviewedBy,
reviewedAt: at,
},
'Organisation activated — commercial terms set (org.activated)',
)
}
const reject = () =>
applyOrgPatch(
{
status: 'rejected',
commercial: null,
reviewedBy: user?.name ?? 'Finance',
reviewedAt: new Date().toISOString(),
},
'Organisation registration rejected',
)
const adjustCredit = () => toast.message('Credit limit change submitted for review') const adjustCredit = () => toast.message('Credit limit change submitted for review')
const overflow: RowAction[] = [ const overflow: RowAction[] = [
@ -127,7 +155,7 @@ export function OrganisationDetailPage() {
label: org.status === 'rejected' ? 'Application rejected' : 'Credit account active', label: org.status === 'rejected' ? 'Application rejected' : 'Credit account active',
state: org.status === 'active' ? 'done' : org.status === 'rejected' ? 'failed' : 'upcoming', state: org.status === 'active' ? 'done' : org.status === 'rejected' ? 'failed' : 'upcoming',
actor: org.status === 'active' ? `Limit ${formatEtb(org.creditLimitEtb)}` : undefined, actor: org.status === 'active' ? `Limit ${formatEtb(org.creditLimitEtb)}` : undefined,
at: org.status === 'pending' ? undefined : org.reviewedAt, at: org.status === 'pending_activation' ? undefined : org.reviewedAt,
}, },
], ],
[org, isPending], [org, isPending],
@ -157,9 +185,9 @@ export function OrganisationDetailPage() {
</Button> </Button>
) : null} ) : null}
{isPending ? ( {isPending ? (
<Button className="h-10 gap-1.5" onClick={approve}> <Button className="h-10 gap-1.5" onClick={activate}>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
Approve credit account Activate credit account
</Button> </Button>
) : ( ) : (
<Button variant="outline" className="h-10 gap-1.5" onClick={adjustCredit}> <Button variant="outline" className="h-10 gap-1.5" onClick={adjustCredit}>
@ -265,9 +293,9 @@ export function OrganisationDetailPage() {
<X className="h-4 w-4" /> <X className="h-4 w-4" />
Reject Reject
</Button> </Button>
<Button type="button" className="h-10 gap-1.5" onClick={approve}> <Button type="button" className="h-10 gap-1.5" onClick={activate}>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
Approve credit Activate credit
</Button> </Button>
</> </>
) : ( ) : (

View File

@ -14,7 +14,7 @@ import { TableToolbar, type TableFilter } from '@/components/table/TableToolbar'
import { RowActions, type RowAction } from '@/components/table/RowActions' import { RowActions, type RowAction } from '@/components/table/RowActions'
import { organisations as seed } from '@/mocks/data' import { organisations as seed } from '@/mocks/data'
import { useAuthStore } from '@/zustand/authStore' import { useAuthStore } from '@/zustand/authStore'
import { formatEtb } from '@/lib/utils' import { etb, formatEtb } from '@/lib/utils'
import type { Organisation } from '@/types' import type { Organisation } from '@/types'
export function OrganisationsPage() { export function OrganisationsPage() {
@ -30,7 +30,7 @@ export function OrganisationsPage() {
const active = orgs.filter((o) => o.status === 'active') const active = orgs.filter((o) => o.status === 'active')
return { return {
total: orgs.length, total: orgs.length,
pending: orgs.filter((o) => o.status === 'pending').length, pending: orgs.filter((o) => o.status === 'pending_activation').length,
active: active.length, active: active.length,
creditExposure: active.reduce((sum, o) => sum + o.usedEtb, 0), creditExposure: active.reduce((sum, o) => sum + o.usedEtb, 0),
creditLimit: active.reduce((sum, o) => sum + o.creditLimitEtb, 0), creditLimit: active.reduce((sum, o) => sum + o.creditLimitEtb, 0),
@ -59,39 +59,90 @@ export function OrganisationsPage() {
return list return list
}, [orgs, q, status, source, orgType]) }, [orgs, q, status, source, orgType])
const approve = (id: string, next: 'active' | 'rejected') => { /** Mirrors POST /admin/organisations/:id/activate | reject — emits org.activated on success. */
const patch = { const activateOrReject = (id: string, next: 'active' | 'rejected') => {
status: next, const at = new Date().toISOString()
reviewedBy: user?.name ?? 'Finance', const reviewedBy = user?.name ?? 'Finance'
reviewedAt: new Date().toISOString(), setOrgs((prev) =>
} prev.map((o) => {
setOrgs((prev) => prev.map((o) => (o.id === id ? { ...o, ...patch } : o))) if (o.id !== id) return o
if (next === 'rejected') {
return { ...o, status: 'rejected', commercial: null, reviewedBy, reviewedAt: at }
}
const commercial = {
credit_limit: o.commercial?.credit_limit ?? etb(o.creditLimitEtb || 100000),
credit_used: etb(0),
payment_terms_days: o.commercial?.payment_terms_days ?? 30,
price_list_id: o.commercial?.price_list_id ?? 'pl_corporate_2026',
contract_start: at.slice(0, 10),
contract_end: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10),
activated_at: at,
activated_by: user?.id ?? 'adm_finance',
}
return {
...o,
status: 'active' as const,
commercial,
creditLimitEtb: Number(commercial.credit_limit.amount),
usedEtb: 0,
reviewedBy,
reviewedAt: at,
}
}),
)
const target = seed.find((o) => o.id === id) const target = seed.find((o) => o.id === id)
if (target) Object.assign(target, patch) if (target) {
toast.success(next === 'active' ? 'Organisation approved — credit active' : 'Organisation rejected') if (next === 'rejected') {
Object.assign(target, { status: 'rejected', commercial: null, reviewedBy, reviewedAt: at })
} else {
const commercial = {
credit_limit: target.commercial?.credit_limit ?? etb(target.creditLimitEtb || 100000),
credit_used: etb(0),
payment_terms_days: 30,
price_list_id: 'pl_corporate_2026',
contract_start: at.slice(0, 10),
contract_end: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10),
activated_at: at,
activated_by: user?.id ?? 'adm_finance',
}
Object.assign(target, {
status: 'active',
commercial,
creditLimitEtb: Number(commercial.credit_limit.amount),
usedEtb: 0,
reviewedBy,
reviewedAt: at,
})
}
}
toast.success(
next === 'active'
? 'Organisation activated — commercial terms set (org.activated)'
: 'Organisation registration rejected',
)
} }
const rowActions = (o: Organisation): { primary: RowAction[]; items: RowAction[] } => ({ const rowActions = (o: Organisation): { primary: RowAction[]; items: RowAction[] } => ({
primary: [ primary: [
{ label: 'View', icon: <Eye className="h-3.5 w-3.5" />, to: `/organisations/${o.id}` }, { label: 'View', icon: <Eye className="h-3.5 w-3.5" />, to: `/organisations/${o.id}` },
...(o.status === 'pending' ...(o.status === 'pending_activation'
? [ ? [
{ {
label: t('common.approve'), label: t('common.approve'),
icon: <Check className="h-3.5 w-3.5" />, icon: <Check className="h-3.5 w-3.5" />,
onClick: () => approve(o.id, 'active'), onClick: () => activateOrReject(o.id, 'active'),
}, },
] ]
: []), : []),
], ],
items: items:
o.status === 'pending' o.status === 'pending_activation'
? [ ? [
{ {
label: t('common.reject'), label: t('common.reject'),
icon: <X className="h-3.5 w-3.5" />, icon: <X className="h-3.5 w-3.5" />,
variant: 'destructive' as const, variant: 'destructive' as const,
onClick: () => approve(o.id, 'rejected'), onClick: () => activateOrReject(o.id, 'rejected'),
}, },
] ]
: [], : [],
@ -105,8 +156,9 @@ export function OrganisationsPage() {
onChange: setStatus, onChange: setStatus,
options: [ options: [
{ value: 'all', label: 'All statuses' }, { value: 'all', label: 'All statuses' },
{ value: 'pending', label: 'Pending' }, { value: 'pending_activation', label: 'Pending activation' },
{ value: 'active', label: 'Active' }, { value: 'active', label: 'Active' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'rejected', label: 'Rejected' }, { value: 'rejected', label: 'Rejected' },
], ],
}, },
@ -156,7 +208,7 @@ export function OrganisationsPage() {
type="button" type="button"
variant="outline" variant="outline"
className="h-9 shrink-0 bg-background" className="h-9 shrink-0 bg-background"
onClick={() => setStatus('pending')} onClick={() => setStatus('pending_activation')}
> >
Review pending Review pending
</Button> </Button>

View File

@ -159,6 +159,27 @@ export interface Doctor {
avatarUrl?: string avatarUrl?: string
} }
/** Platform Money — decimal major ETB (aligned with B2B). */
export interface Money {
amount: string
currency: 'ETB'
}
/** Admin-written commercial terms — matches B2B `Organisation.commercial`. */
export interface CommercialTerms {
credit_limit: Money
credit_used: Money
payment_terms_days: number
price_list_id?: string
contract_start?: string
contract_end?: string
activated_at?: string
activated_by?: string
}
/** Canonical org lifecycle (B2B + Admin). Prefer over bare `pending`. */
export type OrgStatus = 'pending_activation' | 'active' | 'suspended' | 'closed' | 'rejected'
export interface Organisation { export interface Organisation {
id: string id: string
name: string name: string
@ -172,7 +193,16 @@ export interface Organisation {
businessLicense?: string businessLicense?: string
/** Commercial registration number (Ministry of Trade). */ /** Commercial registration number (Ministry of Trade). */
commercialRegistration?: string commercialRegistration?: string
status: 'pending' | 'active' | 'rejected' status: OrgStatus
/**
* Platform commercial terms. Null while `pending_activation`.
* Set on Admin create (active) or `POST .../activate`.
*/
commercial: CommercialTerms | null
/**
* Demo UI convenience (major ETB). Prefer `commercial.credit_*` for API wiring;
* keep in sync when activating / adjusting credit.
*/
creditLimitEtb: number creditLimitEtb: number
usedEtb: number usedEtb: number
billingContact: string billingContact: string