From 8616c0364566a597ee3c1f8bc7cc069c3c14fec1 Mon Sep 17 00:00:00 2001 From: Kirubel-Kibru-Yaltopia Date: Sat, 8 Aug 2026 00:29:44 +0300 Subject: [PATCH] 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 --- docs/GISHEN-MASTER-SPEC.md | 20 +++-- docs/admin-backend-spec.md | 41 ++++++--- src/components/ui/status-badge.tsx | 3 + src/lib/utils.ts | 10 +++ src/mocks/data.ts | 35 +++++++- src/pages/dashboard/DashboardPage.tsx | 2 +- .../organisations/CreateOrganisationPage.tsx | 23 ++++- .../organisations/OrganisationDetailPage.tsx | 58 +++++++++---- src/pages/organisations/OrganisationsPage.tsx | 86 +++++++++++++++---- src/types/index.ts | 32 ++++++- 10 files changed, 250 insertions(+), 60 deletions(-) diff --git a/docs/GISHEN-MASTER-SPEC.md b/docs/GISHEN-MASTER-SPEC.md index 042bc2f..d0e9639 100644 --- a/docs/GISHEN-MASTER-SPEC.md +++ b/docs/GISHEN-MASTER-SPEC.md @@ -6,8 +6,8 @@ | --- | --- | | **Home repo (this file)** | `Gishen-Admin` | | **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** | -| **Last reconciled** | 2026-08-07 | +| **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-08 | --- @@ -15,6 +15,7 @@ | 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`. | --- @@ -39,10 +40,10 @@ Update these dates whenever you reconcile against a sibling. | 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 | -| **Ecom** | `Gishen-Ecom/` | ✅ | [`docs/backend.md`](../../Gishen-Ecom/docs/backend.md) | 2026-08-07 | -| **B2B** | `Gishen-B2B/` | ✅ | [`docs/backend/OVERVIEW.md`](../../Gishen-B2B/docs/backend/OVERVIEW.md) + coordination | 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-08 | | **Dispatch Bot** | `Gishen-Dispatch-Bot/` | ✅ (related) | `docs/BACKEND_SPEC.md` | 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 | | `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 @@ -202,13 +203,14 @@ Env (Admin SPA): `VITE_GOOGLE_CLIENT_ID`, `VITE_TELEGRAM_BOT_USERNAME` (empty = ## 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 | | --- | --- | --- | -| API base host | Admin/Mob/Ecom use `api.gishen.example`; B2B OVERVIEW uses `api.gishenpharmacy.org` | Pick one production host | -| Money | Admin demos major ETB; Ecom sheet says cents optional | Platform-wide decision (Admin open Q1) | +| API base host | Some sheets still use `api.gishen.example`; B2B + Admin alignment prefer `api.gishenpharmacy.org` | Pick one production host | +| 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 | +| 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 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 | diff --git a/docs/admin-backend-spec.md b/docs/admin-backend-spec.md index 393c865..72e60f0 100644 --- a/docs/admin-backend-spec.md +++ b/docs/admin-backend-spec.md @@ -12,6 +12,7 @@ | 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-06 | Scaffold: conventions, roles, auth, empty module sections | | 2026-08-06 | Shell + RBAC + preferredLocale | @@ -53,7 +54,7 @@ | --- | --- | | Content type | `application/json` (multipart for imports / uploads) | | 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 } }` | | 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 | | 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` | -| POST | `/admin/organisations` | finance, super_admin | **Create org from Admin** (active); body includes `orgType` | -| GET | `/admin/organisations/pending` | finance, super_admin | Self-register queue | -| POST | `/admin/organisations/:id/approve` | finance, super_admin | Activate credit | -| POST | `/admin/organisations/:id/reject` | finance, super_admin | Reject | -| GET | `/admin/organisations/:id/statements` | finance, super_admin | Consolidated invoices | +| 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 (`status=pending_activation`) | +| GET | `/admin/organisations/registration-requests` | finance, super_admin | CRM leads from `org.registration_requested` | +| POST | `/admin/organisations/:id/activate` | finance, super_admin | Body: `commercial` (`credit_limit` Money, payment terms, price list, contract dates). Emits `org.activated` | +| 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 | | 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/doctors` | finance, super_admin | Hospital/clinic doctor roster (empty for other types) | | 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`. -**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 -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) 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 diff --git a/src/components/ui/status-badge.tsx b/src/components/ui/status-badge.tsx index 5b64d55..96048fa 100644 --- a/src/components/ui/status-badge.tsx +++ b/src/components/ui/status-badge.tsx @@ -54,7 +54,10 @@ const STATUS_MAP: Record> = { }, organisation: { pending: { label: 'Pending', variant: 'warning' }, + pending_activation: { label: 'Pending activation', variant: 'warning' }, active: { label: 'Active', variant: 'success' }, + suspended: { label: 'Suspended', variant: 'warning' }, + closed: { label: 'Closed', variant: 'muted' }, rejected: { label: 'Rejected', variant: 'danger' }, admin: { label: 'Added by admin', variant: 'muted' }, self_register: { label: 'Self-registered', variant: 'secondary' }, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index dee2137..c5731b9 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,6 +5,16 @@ export function cn(...inputs: ClassValue[]) { 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) { return new Intl.NumberFormat('en-ET', { style: 'currency', diff --git a/src/mocks/data.ts b/src/mocks/data.ts index f6973f1..e386fcd 100644 --- a/src/mocks/data.ts +++ b/src/mocks/data.ts @@ -579,6 +579,16 @@ export let organisations: Organisation[] = [ businessLicense: 'BL-AA/48291/2014', commercialRegistration: 'CR/015842/2014', 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, usedEtb: 124000, billingContact: 'finance@horizon.et', @@ -595,7 +605,8 @@ export let organisations: Organisation[] = [ vatNumber: '', businessLicense: 'BL-AA/99102/2025', commercialRegistration: '', - status: 'pending', + status: 'pending_activation', + commercial: null, creditLimitEtb: 100000, usedEtb: 0, billingContact: 'hr@greenngo.et', @@ -611,6 +622,16 @@ export let organisations: Organisation[] = [ businessLicense: 'BL-AA/12044/2007', commercialRegistration: 'CR/008812/2007', 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, usedEtb: 186000, billingContact: 'pharmacy@sphmmc.et', @@ -628,6 +649,16 @@ export let organisations: Organisation[] = [ businessLicense: 'BL-AA/00418/1999', commercialRegistration: 'CR/001102/1999', 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, usedEtb: 412000, billingContact: 'procurement@tash.et', @@ -1002,7 +1033,7 @@ export const auditLog: AuditEvent[] = [ ip: '196.189.44.77', userAgent: CHROME_WIN, outcome: 'success', - before: { status: 'pending', creditLimitEtb: 250000 }, + before: { status: 'pending_activation', creditLimitEtb: 250000 }, after: { status: 'active', creditLimitEtb: 500000 }, }, { diff --git a/src/pages/dashboard/DashboardPage.tsx b/src/pages/dashboard/DashboardPage.tsx index 9ff6ec1..a65ace0 100644 --- a/src/pages/dashboard/DashboardPage.tsx +++ b/src/pages/dashboard/DashboardPage.tsx @@ -204,7 +204,7 @@ export function DashboardPage() { const awaitingPickup = openOrders.filter((o) => o.fulfillment === 'pickup') 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 creditLimit = organisations.reduce((sum, o) => sum + o.creditLimitEtb, 0) diff --git a/src/pages/organisations/CreateOrganisationPage.tsx b/src/pages/organisations/CreateOrganisationPage.tsx index b4003b8..18f33af 100644 --- a/src/pages/organisations/CreateOrganisationPage.tsx +++ b/src/pages/organisations/CreateOrganisationPage.tsx @@ -5,7 +5,7 @@ import { CreatePageLayout, createFieldFull, createFieldWide, createFormGrid } fr import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { cn } from '@/lib/utils' +import { cn, etb } from '@/lib/utils' import { organisations } from '@/mocks/data' import { SelectNative } from '@/components/ui/select-native' import type { Organisation, OrganisationType } from '@/types' @@ -31,6 +31,9 @@ export function CreateOrganisationPage() { const onSubmit = (e: FormEvent) => { 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 = { id: `org-${Date.now()}`, name: form.name.trim(), @@ -40,14 +43,26 @@ export function CreateOrganisationPage() { businessLicense: form.businessLicense.trim() || undefined, commercialRegistration: form.commercialRegistration.trim() || undefined, 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, billingContact: form.billingContact.trim(), source: 'admin', - createdAt: new Date().toISOString(), + createdAt: at, + reviewedBy: 'Admin create', + reviewedAt: at, } organisations.unshift(org) - toast.success('Organisation created — credit active') + toast.success('Organisation created — active with commercial terms') navigate('/organisations') } diff --git a/src/pages/organisations/OrganisationDetailPage.tsx b/src/pages/organisations/OrganisationDetailPage.tsx index fa73a8f..c9096b1 100644 --- a/src/pages/organisations/OrganisationDetailPage.tsx +++ b/src/pages/organisations/OrganisationDetailPage.tsx @@ -19,7 +19,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { orgInvoices, orgMembers, organisations, doctors } from '@/mocks/data' import { orgSpendSeries } from '@/mocks/charts' 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' export function OrganisationDetailPage() { @@ -41,24 +41,52 @@ export function OrganisationDetailPage() { const members = orgMembers[org.id] ?? [] const invoices = orgInvoices[org.id] ?? [] 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 orgDoctors = doctors.filter((d) => d.orgId === org.id) - const setStatus = (status: Organisation['status'], message: string) => { - const patch = { - status, - reviewedBy: user?.name ?? 'Finance', - reviewedAt: new Date().toISOString(), - } + const applyOrgPatch = (patch: Partial, message: string) => { setOrg((prev) => (prev ? { ...prev, ...patch } : prev)) const target = organisations.find((o) => o.id === org.id) if (target) Object.assign(target, patch) toast.success(message) } - const approve = () => setStatus('active', 'Organisation approved') - const reject = () => setStatus('rejected', 'Organisation rejected') + const activate = () => { + 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 overflow: RowAction[] = [ @@ -127,7 +155,7 @@ export function OrganisationDetailPage() { label: org.status === 'rejected' ? 'Application rejected' : 'Credit account active', state: org.status === 'active' ? 'done' : org.status === 'rejected' ? 'failed' : 'upcoming', 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], @@ -157,9 +185,9 @@ export function OrganisationDetailPage() { ) : null} {isPending ? ( - ) : ( - ) : ( diff --git a/src/pages/organisations/OrganisationsPage.tsx b/src/pages/organisations/OrganisationsPage.tsx index 54f1dc0..22ca2d1 100644 --- a/src/pages/organisations/OrganisationsPage.tsx +++ b/src/pages/organisations/OrganisationsPage.tsx @@ -14,7 +14,7 @@ import { TableToolbar, type TableFilter } from '@/components/table/TableToolbar' import { RowActions, type RowAction } from '@/components/table/RowActions' import { organisations as seed } from '@/mocks/data' import { useAuthStore } from '@/zustand/authStore' -import { formatEtb } from '@/lib/utils' +import { etb, formatEtb } from '@/lib/utils' import type { Organisation } from '@/types' export function OrganisationsPage() { @@ -30,7 +30,7 @@ export function OrganisationsPage() { const active = orgs.filter((o) => o.status === 'active') return { total: orgs.length, - pending: orgs.filter((o) => o.status === 'pending').length, + pending: orgs.filter((o) => o.status === 'pending_activation').length, active: active.length, creditExposure: active.reduce((sum, o) => sum + o.usedEtb, 0), creditLimit: active.reduce((sum, o) => sum + o.creditLimitEtb, 0), @@ -59,39 +59,90 @@ export function OrganisationsPage() { return list }, [orgs, q, status, source, orgType]) - const approve = (id: string, next: 'active' | 'rejected') => { - const patch = { - status: next, - reviewedBy: user?.name ?? 'Finance', - reviewedAt: new Date().toISOString(), - } - setOrgs((prev) => prev.map((o) => (o.id === id ? { ...o, ...patch } : o))) + /** Mirrors POST /admin/organisations/:id/activate | reject — emits org.activated on success. */ + const activateOrReject = (id: string, next: 'active' | 'rejected') => { + const at = new Date().toISOString() + const reviewedBy = user?.name ?? 'Finance' + setOrgs((prev) => + prev.map((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) - if (target) Object.assign(target, patch) - toast.success(next === 'active' ? 'Organisation approved — credit active' : 'Organisation rejected') + if (target) { + 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[] } => ({ primary: [ { label: 'View', icon: , to: `/organisations/${o.id}` }, - ...(o.status === 'pending' + ...(o.status === 'pending_activation' ? [ { label: t('common.approve'), icon: , - onClick: () => approve(o.id, 'active'), + onClick: () => activateOrReject(o.id, 'active'), }, ] : []), ], items: - o.status === 'pending' + o.status === 'pending_activation' ? [ { label: t('common.reject'), icon: , variant: 'destructive' as const, - onClick: () => approve(o.id, 'rejected'), + onClick: () => activateOrReject(o.id, 'rejected'), }, ] : [], @@ -105,8 +156,9 @@ export function OrganisationsPage() { onChange: setStatus, options: [ { value: 'all', label: 'All statuses' }, - { value: 'pending', label: 'Pending' }, + { value: 'pending_activation', label: 'Pending activation' }, { value: 'active', label: 'Active' }, + { value: 'suspended', label: 'Suspended' }, { value: 'rejected', label: 'Rejected' }, ], }, @@ -156,7 +208,7 @@ export function OrganisationsPage() { type="button" variant="outline" className="h-9 shrink-0 bg-background" - onClick={() => setStatus('pending')} + onClick={() => setStatus('pending_activation')} > Review pending diff --git a/src/types/index.ts b/src/types/index.ts index 278d50c..820f7ed 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -159,6 +159,27 @@ export interface Doctor { 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 { id: string name: string @@ -172,7 +193,16 @@ export interface Organisation { businessLicense?: string /** Commercial registration number (Ministry of Trade). */ 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 usedEtb: number billingContact: string