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:
parent
40771434d1
commit
8616c03645
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
if (o.id !== id) return o
|
||||||
|
if (next === 'rejected') {
|
||||||
|
return { ...o, status: 'rejected', commercial: null, reviewedBy, reviewedAt: at }
|
||||||
}
|
}
|
||||||
setOrgs((prev) => prev.map((o) => (o.id === id ? { ...o, ...patch } : o)))
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Reference in New Issue
Block a user