Add multi-provider staff login and Rx dosing schedules.
Google, phone OTP, and Telegram join email auth; prescription lines carry frequency, intervalHours, and times, with backend spec updated for this batch. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
dd471c5909
commit
e9a8e0ffc2
|
|
@ -1,2 +1,10 @@
|
||||||
VITE_USE_MOCKS=true
|
VITE_USE_MOCKS=true
|
||||||
VITE_API_BASE_URL=
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
|
# Auth providers (placeholders — do not commit real secrets)
|
||||||
|
# Google OAuth Web client ID for Admin SPA (Google Identity Services)
|
||||||
|
VITE_GOOGLE_CLIENT_ID=
|
||||||
|
# Telegram Login Widget bot username (without @)
|
||||||
|
VITE_TELEGRAM_BOT_USERNAME=
|
||||||
|
# Optional: Twilio / AfricasTalking / other OTP provider keys belong on the backend only
|
||||||
|
# VITE_PUBLIC_* client flags may reference provider names but never API secrets
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@
|
||||||
| 2026-08-06 | New `ModuleKey`: `pos` (pharmacist, stock_manager, operations, super_admin). Items gated by existing `stock` |
|
| 2026-08-06 | New `ModuleKey`: `pos` (pharmacist, stock_manager, operations, super_admin). Items gated by existing `stock` |
|
||||||
| 2026-08-07 | **Doctor** actor: hospital-affiliated clinicians; Admin roster under organisations; create Rx & orders; guest patients |
|
| 2026-08-07 | **Doctor** actor: hospital-affiliated clinicians; Admin roster under organisations; create Rx & orders; guest patients |
|
||||||
| 2026-08-07 | **Guest / unauthenticated orders**: `customerType` registered\|guest; staff/doctor authenticated, end customer optional |
|
| 2026-08-07 | **Guest / unauthenticated orders**: `customerType` registered\|guest; staff/doctor authenticated, end customer optional |
|
||||||
|
| 2026-08-07 | Rx medication schedule: `frequency`, `intervalHours`, `times[]` on prescription line items; Admin create/detail + `dosingSchedule` helpers |
|
||||||
|
| 2026-08-07 | Catalogue **multi-UOM**: `uoms[]` with `conversionFactor` into stock base; `toStockQty` / `fromStockQty` |
|
||||||
|
| 2026-08-07 | Catalogue **storefront / mobile PDP** fields + Admin detail preview (`storefrontPdpPreview`) |
|
||||||
|
| 2026-08-07 | Auth providers: Google, Email, Phone (OTP), Telegram — staff login UI + endpoint sketches |
|
||||||
|
| 2026-08-07 | **Push batch:** multi-provider staff login + Rx dosing schedule (Admin UI/mocks) + spec alignment for doctor/guest orders, multi-UOM, storefront PDP; open questions 8–17 |
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|
@ -68,15 +73,30 @@ Doctors **cannot** manage platform catalogue, team RBAC, finance settlements, or
|
||||||
|
|
||||||
## Auth & me
|
## Auth & me
|
||||||
|
|
||||||
|
Staff Admin authenticates with **Bearer JWT**. Login supports multiple identity providers; all paths mint (or exchange into) the same staff access/refresh pair and `GET /auth/me` principal.
|
||||||
|
|
||||||
| Method | Path | Roles | Description |
|
| Method | Path | Roles | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| POST | `/auth/staff/login` | — | Email/password → tokens + user |
|
| POST | `/auth/staff/login` | — | Email/password → tokens + user |
|
||||||
|
| POST | `/auth/staff/oauth/google` | — | Exchange Google ID token (GIS / OAuth) → staff tokens + user |
|
||||||
|
| POST | `/auth/staff/phone/start` | — | `{ phone }` → start OTP (rate-limited); `{ challengeId, expiresIn }` |
|
||||||
|
| POST | `/auth/staff/phone/verify` | — | `{ challengeId, code }` → tokens + user when phone is linked to a staff account |
|
||||||
|
| POST | `/auth/staff/telegram` | — | Telegram Login Widget payload (hash-verified) → tokens + user |
|
||||||
| POST | `/auth/refresh` | refresh | Rotate access |
|
| POST | `/auth/refresh` | refresh | Rotate access |
|
||||||
| POST | `/auth/logout` | staff | Revoke |
|
| POST | `/auth/logout` | staff | Revoke |
|
||||||
| GET | `/auth/me` | staff | Current principal, roles, branchId, avatarUrl |
|
| GET | `/auth/me` | staff | Current principal, roles, branchId, avatarUrl, `authProvider` |
|
||||||
| PATCH | `/users/me` | staff | `{ preferredLocale?: "en"\|"am", name?, phone?, avatarUrl? }` |
|
| PATCH | `/users/me` | staff | `{ preferredLocale?: "en"\|"am", name?, phone?, avatarUrl? }` |
|
||||||
| POST | `/users/me/avatar` | staff | Multipart image upload → `{ avatarUrl }` |
|
| POST | `/users/me/avatar` | staff | Multipart image upload → `{ avatarUrl }` |
|
||||||
|
|
||||||
|
### Auth providers
|
||||||
|
|
||||||
|
| Provider | Flow | Staff Admin | Doctor / external |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **Email** | `POST /auth/staff/login` with password (magic-link optional later — not in Admin mock) | Primary for pharmacy staff | Doctor uses `POST /auth/doctor/login` (email/invite); same IdP may be shared later with `actor=doctor` |
|
||||||
|
| **Google** | Browser Google Identity Services → ID token → `POST /auth/staff/oauth/google` | Allowed when staff email is in Google Workspace / allow-listed domain | TBD for doctors (hospital Google vs separate realm) |
|
||||||
|
| **Phone (OTP)** | `phone/start` → SMS/WhatsApp OTP → `phone/verify` | Staff accounts with verified `phone` on user profile | Useful for guest order claim / patient flows (out of Admin staff session); doctor phone login TBD |
|
||||||
|
| **Telegram** | [Telegram Login Widget](https://core.telegram.org/widgets/login) on Admin SPA → server verifies `hash` with bot token → `POST /auth/staff/telegram` | Optional for staff who linked Telegram id; also used for **rider** dispatch identity (see Dispatch) | Not primary for doctor clinical auth |
|
||||||
|
|
||||||
**Login response sketch:**
|
**Login response sketch:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -90,13 +110,33 @@ Doctors **cannot** manage platform catalogue, team RBAC, finance settlements, or
|
||||||
"role": "pharmacist",
|
"role": "pharmacist",
|
||||||
"branchId": "uuid",
|
"branchId": "uuid",
|
||||||
"preferredLocale": "en",
|
"preferredLocale": "en",
|
||||||
"avatarUrl": "https://cdn.example/avatars/hana.jpg"
|
"avatarUrl": "https://cdn.example/avatars/hana.jpg",
|
||||||
|
"authProvider": "email"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Mock key: `authStore.login` → `/auth/staff/login`
|
`authProvider` values: `email` | `google` | `phone` | `telegram`.
|
||||||
UI: `/profile` (every authenticated role; not permission-gated), topbar “My profile”
|
|
||||||
|
**Google body sketch:** `{ "idToken": "..." }`
|
||||||
|
**Phone start:** `{ "phone": "+251911000000" }`
|
||||||
|
**Phone verify:** `{ "challengeId": "uuid", "code": "123456" }`
|
||||||
|
**Telegram body:** widget fields (`id`, `first_name`, `username`, `auth_date`, `hash`, …) as returned by the Login Widget.
|
||||||
|
|
||||||
|
Mock keys:
|
||||||
|
- `authStore.login` → `/auth/staff/login`
|
||||||
|
- `authStore.loginWithProvider('google'|'phone'|'telegram')` → oauth / phone verify / telegram (Admin SPA mock signs in as demo pharmacist unless overridden)
|
||||||
|
|
||||||
|
UI: `/login` (provider buttons + email form + phone OTP step + Telegram CTA + demo picker), `/profile` (every authenticated role; not permission-gated), topbar “My profile”
|
||||||
|
|
||||||
|
Env placeholders:
|
||||||
|
|
||||||
|
| Side | Vars |
|
||||||
|
| --- | --- |
|
||||||
|
| Client (Admin SPA / `.env.example`) | `VITE_GOOGLE_CLIENT_ID`, `VITE_TELEGRAM_BOT_USERNAME` — empty = mock provider UX |
|
||||||
|
| Server only (never ship to Vite) | Google token audience / Workspace allow-list; `TELEGRAM_BOT_TOKEN` (Login Widget hash verify); SMS/OTP gateway credentials (AfricasTalking / Twilio / …) |
|
||||||
|
|
||||||
|
Typed on client as `ImportMetaEnv` in `src/vite-env.d.ts`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -159,10 +199,68 @@ UI: `/orders/new` (Guest customer vs Registered customer toggle), `/orders/:id`
|
||||||
| POST | `/admin/prescriptions/:id/verify` | pharmacist, super_admin | `{ status: approved\|queried\|rejected, qtyAdjustments?, substitute?, notes? }` |
|
| POST | `/admin/prescriptions/:id/verify` | pharmacist, super_admin | `{ status: approved\|queried\|rejected, qtyAdjustments?, substitute?, notes? }` |
|
||||||
| POST | `/pharmacy/prescriptions/:id/dispense` | pharmacist | `{ batch, quantity }` stamped pharmacistId + time |
|
| POST | `/pharmacy/prescriptions/:id/dispense` | pharmacist | `{ batch, quantity }` stamped pharmacistId + time |
|
||||||
| GET | `/pharmacy/prescriptions/controlled-report` | pharmacist, super_admin | Controlled-substance report |
|
| GET | `/pharmacy/prescriptions/controlled-report` | pharmacist, super_admin | Controlled-substance report |
|
||||||
| GET | `/pharmacy/prescriptions/:id` | pharmacist, super_admin, doctor (own) | Detail: items, controlled flags, review trail, linked doctor |
|
| GET | `/pharmacy/prescriptions/:id` | pharmacist, super_admin, doctor (own) | Detail: items (incl. dosing schedule), controlled flags, review trail, linked doctor |
|
||||||
| GET | `/pharmacy/refills` | pharmacist, super_admin | Days-of-supply / script expiry oversight |
|
| GET | `/pharmacy/refills` | pharmacist, super_admin | Days-of-supply / script expiry oversight |
|
||||||
|
|
||||||
UI: `/prescriptions/new` (doctor select), `/prescriptions/:id` — Approve is the sole primary header action; Reject / Query live in an overflow menu and as an inline footer on the verification section.
|
UI: `/prescriptions/new` (doctor select + per-line dosing schedule), `/prescriptions/:id` — Approve is the sole primary header action; Reject / Query live in an overflow menu and as an inline footer on the verification section. Medications table shows interval/frequency and dose times.
|
||||||
|
|
||||||
|
### Prescription medication line items (schedule)
|
||||||
|
|
||||||
|
Each `items[]` entry on create / detail carries dosing instructions used by pharmacist counsel and **mobile dose reminders**. Clock `times` use the same shape as mobile `Reminder.times` (`HH:mm` strings, 24h). Backend should persist the pharmacist’s final `times` list (and `intervalHours` for QXH) so the patient app can seed reminders without re-deriving.
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `name` | string | Medicine label / strength as entered |
|
||||||
|
| `qty` | number | Dispense quantity |
|
||||||
|
| `controlled?` | boolean | Controlled-substance flag |
|
||||||
|
| `frequency?` | `QD` \| `BID` \| `TID` \| `QID` \| `QXH` \| `custom` | Once / 2× / 3× / 4× daily, every N hours, or custom clock list |
|
||||||
|
| `intervalHours?` | number | **Required** when `frequency === "QXH"` (hours between doses, ≥ 1) |
|
||||||
|
| `times?` | string[] | Clock times `HH:mm` (24h), e.g. `["08:00","14:00","20:00"]`. Admin intake requires ≥ 1 time |
|
||||||
|
|
||||||
|
**Frequency → default suggested times** (Admin `defaultTimesForFrequency`; pharmacist may add/remove slots):
|
||||||
|
|
||||||
|
| Frequency | Default `times` |
|
||||||
|
| --- | --- |
|
||||||
|
| `QD` | `08:00` |
|
||||||
|
| `BID` | `08:00`, `20:00` |
|
||||||
|
| `TID` | `08:00`, `14:00`, `20:00` |
|
||||||
|
| `QID` | `08:00`, `12:00`, `16:00`, `20:00` |
|
||||||
|
| `QXH` | Generated from `intervalHours` starting 08:00 until midnight (default interval 8h if omitted in UI) |
|
||||||
|
| `custom` | Seed `08:00`; pharmacist edits freely |
|
||||||
|
|
||||||
|
**Create body sketch:** `POST /pharmacy/prescriptions` / `POST /doctor/prescriptions`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customerName": "Guest patient · no account",
|
||||||
|
"branchId": "uuid",
|
||||||
|
"doctorId": "uuid",
|
||||||
|
"orgId": "uuid",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Amoxicillin 500mg",
|
||||||
|
"qty": 21,
|
||||||
|
"controlled": false,
|
||||||
|
"frequency": "TID",
|
||||||
|
"times": ["08:00", "14:00", "20:00"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Paracetamol 500mg",
|
||||||
|
"qty": 20,
|
||||||
|
"frequency": "QXH",
|
||||||
|
"intervalHours": 6,
|
||||||
|
"times": ["08:00", "14:00", "20:00"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rx header also stores denormalised `prescriber` (doctor display name). Patient on Admin intake may be a **guest name** (no CRM `customerId` required).
|
||||||
|
|
||||||
|
Helper (Admin): `src/lib/dosingSchedule.ts` — `DOSING_FREQUENCY_OPTIONS`, `defaultTimesForFrequency`, `formatFrequencyLabel`, `formatDoseTimes`.
|
||||||
|
|
||||||
|
Mock: `mocks/data.prescriptions` · Types: `PrescriptionItem`, `DosingFrequency`
|
||||||
|
UI: create sets schedule per line; detail medications table shows frequency label + dose times.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -219,18 +317,54 @@ Master product identity. **Stock rows reference an Item** via `itemId`; batch/lo
|
||||||
|
|
||||||
1. Core identification — SKU (immutable), generic/INN, brand, manufacturer, country, EFDA number, barcodes[]
|
1. Core identification — SKU (immutable), generic/INN, brand, manufacturer, country, EFDA number, barcodes[]
|
||||||
2. Classification — product type (`OTC`\|`Rx`\|`Controlled`\|`Medical device`\|`Beauty`\|`Wellness`), therapeutic class, activeIngredients[], dosage form, route, controlled schedule
|
2. Classification — product type (`OTC`\|`Rx`\|`Controlled`\|`Medical device`\|`Beauty`\|`Wellness`), therapeutic class, activeIngredients[], dosage form, route, controlled schedule
|
||||||
3. Packaging — pack size, base UoM, sell-by vs pack unit, parent/variant links
|
3. Packaging — pack size, `baseUnit` (stock UOM), `sellByUnit`, `packUnit`, **`uoms[]` multi-UOM table**, parent/variant links
|
||||||
4. Pricing — cost (ERP-owned), selling price (+ owner ERP\|Platform), currency ETB, VAT, promo eligibility (**auto-off for Rx/Controlled**, no override), B2B price-list ref
|
4. Pricing — cost (ERP-owned), selling price (+ owner ERP\|Platform), currency ETB, VAT, promo eligibility (**auto-off for Rx/Controlled**, no override), B2B price-list ref, optional `compareAtPriceEtb`
|
||||||
5. Regulatory — Rx-required, EFDA status, controlled flag+schedule, advertising-restriction (auto from type), age restriction
|
5. Regulatory — Rx-required, EFDA status, controlled flag+schedule, advertising-restriction (auto from type), age restriction
|
||||||
6. Content — descriptions, dosage guidance, side effects, warnings, storage, similar alternatives[] (display text only)
|
6. Content — short description, dosage guidance, side effects (free text + structured mild/severe), warnings, storage (+ structured `storageNotes[]`), `directions[]`, `pharmacistTip`, `useCase`, similar alternatives[]
|
||||||
7. Inventory defaults — default reorder point, expiry-alert days (on-hand/reserved/incoming are derived from stock)
|
7. Storefront / mobile PDP — fields below; Admin detail shows a live preview shaped for Gishen mobile
|
||||||
8. Media — images[], thumbnail, merchandising badges (**disabled for Rx/Controlled**), SEO slug/title/description
|
8. Inventory defaults — default reorder point, expiry-alert days (on-hand/reserved/incoming are derived from stock)
|
||||||
9. Dedupe — `matchKey`, confidence, mergeCandidateId, mergeStatus, approver, timestamps
|
9. Media — images[], thumbnail, merchandising badges (**disabled for Rx/Controlled**), SEO slug/title/description
|
||||||
10. Ownership & audit — per field-group source of truth, last ERP sync + error, created/modified by/at
|
10. Dedupe — `matchKey`, confidence, mergeCandidateId, mergeStatus, approver, timestamps
|
||||||
11. Relationships — B2B-eligible category tag; loyalty earn/redeem eligibility (**auto-excluded for Rx/Controlled**)
|
11. Ownership & audit — per field-group source of truth, last ERP sync + error, created/modified by/at
|
||||||
|
12. Relationships — B2B-eligible category tag; loyalty earn/redeem eligibility (**auto-excluded for Rx/Controlled**)
|
||||||
|
|
||||||
Mock: `mocks/catalog.ts` (`catalogItems`, `itemChangeHistory`, …)
|
### Multi-UOM (ERPNext-style)
|
||||||
UI: `/items`, `/items/new`, `/items/:id`
|
|
||||||
|
Warehouse quantity is **always** counted in the stock / base UOM (`baseUnit`). Additional purchase/sales units convert via `conversionFactor` = how many **stock units** equal one of that UOM. The stock UOM row always has `conversionFactor: 1`.
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `uoms[]` | `ItemUom[]` | Allowed units for the item |
|
||||||
|
| `uoms[].uom` | string | e.g. `tablet`, `strip`, `box` |
|
||||||
|
| `uoms[].conversionFactor` | number | Stock qty per 1 of this UOM |
|
||||||
|
| `uoms[].isStockUom?` | boolean | Exactly one true; matches `baseUnit` |
|
||||||
|
| `uoms[].isPurchaseUom?` | boolean | Used on PO / receive |
|
||||||
|
| `uoms[].isSalesUom?` | boolean | Used on sales / POS lines |
|
||||||
|
|
||||||
|
**Helpers (Admin mock / shared):** `resolveItemUoms` (falls back to `buildDefaultUoms` from pack labels when `uoms` empty), `toStockQty(qty, fromUom, item)`, `fromStockQty(stockQty, toUom, item)`, `stockUomOf(item)`.
|
||||||
|
|
||||||
|
**API expectation:** `POST`/`PATCH` `/admin/catalog/items` accept `uoms`; stock receive / POS / order lines may submit qty in a sales/purchase UOM — server converts with `toStockQty` before decrementing inventory. Reject unknown UOM labels.
|
||||||
|
|
||||||
|
Example: `1 strip = 10 tablet` → selling 2 strips deducts `20` stock units.
|
||||||
|
|
||||||
|
### Storefront / mobile PDP fields
|
||||||
|
|
||||||
|
Aligned with Gishen mobile product detail (`product/[id]`). Admin create/edit captures these; detail page previews via `storefrontPdpPreview(item)`.
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
| --- | --- |
|
||||||
|
| `storefrontUnit?` | Shelf / pack label shown as mobile `product.unit` (e.g. `10 capsules/strip`); falls back to `packSize` / `sellByUnit` |
|
||||||
|
| `useCase?` | Chip above name (e.g. Pain & Fever); falls back to therapeutic class |
|
||||||
|
| `compareAtPriceEtb?` | Strike-through compare-at price |
|
||||||
|
| `sideEffectsMild?` / `sideEffectsSevere?` | Structured lists; mild falls back to parsing free-text `sideEffects` |
|
||||||
|
| `directions?` | `{ title, body }[]` stepper; falls back from `dosageGuidance` + `warnings` |
|
||||||
|
| `storageNotes?` | `{ title, body }[]` cards; falls back from `storageInstructions` |
|
||||||
|
| `pharmacistTip?` | Callout; falls back to `warnings` |
|
||||||
|
|
||||||
|
Preview mapping also exposes name, brand, slug, sku, price, images, Rx flag, badge, form, generic, drug class, ingredient strengths.
|
||||||
|
|
||||||
|
Mock: `mocks/catalog.ts` (`catalogItems`, `itemChangeHistory`, `ItemUom`, …)
|
||||||
|
UI: `/items`, `/items/new` (multi-UOM editor + PDP fields), `/items/:id` (UOM table + Storefront / Mobile PDP section)
|
||||||
Stock create: `POST /pharmacy/inventory` prefers `{ itemId, branchId, batch, … }` over free-typed product fields. UI deep-link `?item=`
|
Stock create: `POST /pharmacy/inventory` prefers `{ itemId, branchId, batch, … }` over free-typed product fields. UI deep-link `?item=`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -240,7 +374,7 @@ Stock create: `POST /pharmacy/inventory` prefers `{ itemId, branchId, batch, …
|
||||||
| Method | Path | Roles | Description |
|
| Method | Path | Roles | Description |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| GET | `/pharmacy/inventory` | stock_manager, pharmacist (read), procurement (read), super_admin | Per-branch stock; filters: `branchId`, `q`, `alert=low\|mismatch` |
|
| GET | `/pharmacy/inventory` | stock_manager, pharmacist (read), procurement (read), super_admin | Per-branch stock; filters: `branchId`, `q`, `alert=low\|mismatch` |
|
||||||
| POST | `/pharmacy/inventory` | stock_manager, super_admin | Receive stock against catalogue item: `{ itemId, branchId, batch, manufacturedAt?, expiresAt, qty, reorderPoint? }` — must not invent a new Item |
|
| POST | `/pharmacy/inventory` | stock_manager, super_admin | Receive stock against catalogue item: `{ itemId, branchId, batch, manufacturedAt?, expiresAt, qty, uom?, reorderPoint? }` — `qty` may be in a purchase UOM; server converts via item `uoms` → stock base. Must not invent a new Item |
|
||||||
| GET | `/integrations/erp/sync-status` | stock_manager, super_admin | Last sync |
|
| GET | `/integrations/erp/sync-status` | stock_manager, super_admin | Last sync |
|
||||||
| GET | `/admin/stock/discrepancies` | stock_manager, super_admin | Platform vs ERP |
|
| GET | `/admin/stock/discrepancies` | stock_manager, super_admin | Platform vs ERP |
|
||||||
| POST | `/admin/stock/discrepancies/:id/resolve` | stock_manager, super_admin | One-click resolve |
|
| POST | `/admin/stock/discrepancies/:id/resolve` | stock_manager, super_admin | One-click resolve |
|
||||||
|
|
@ -473,8 +607,9 @@ Palette results are permission-filtered with the same `roleModules` map the
|
||||||
route guards use, so no backend change is needed — but any new module key added
|
route guards use, so no backend change is needed — but any new module key added
|
||||||
server-side must also exist in `ModuleKey` and `roleModules`.
|
server-side must also exist in `ModuleKey` and `roleModules`.
|
||||||
|
|
||||||
Current SPA destinations of note: `/pos`, `/items`, `/profile` (palette + topbar;
|
Current SPA destinations of note: `/pos`, `/items`, `/doctors` (orgs module —
|
||||||
not a gated module), Payment Gateways `/finance/gateways` (palette-only).
|
hospital/clinic roster), `/profile` (palette + topbar; not a gated module),
|
||||||
|
Payment Gateways `/finance/gateways` (palette-only).
|
||||||
|
|
||||||
Entity detail routes (`orders/:id`, `stock/:sku`, `items/:id`, …) are
|
Entity detail routes (`orders/:id`, `stock/:sku`, `items/:id`, …) are
|
||||||
deliberately not registered: they require a record id. Register the list page
|
deliberately not registered: they require a record id. Register the list page
|
||||||
|
|
@ -502,4 +637,10 @@ or admin `POST /admin/users/:id/avatar`.
|
||||||
8. Doctor identity provider: dedicated doctor realm vs B2B portal users with `role=doctor` under hospital orgs
|
8. Doctor identity provider: dedicated doctor realm vs B2B portal users with `role=doctor` under hospital orgs
|
||||||
9. Guest order claim: can a later registered customer attach historical guest orders by phone OTP? Retention / masking of guest PII?
|
9. Guest order claim: can a later registered customer attach historical guest orders by phone OTP? Retention / masking of guest PII?
|
||||||
10. Guest order ceilings: max ETB / controlled-item rules without registered identity — confirm with compliance
|
10. Guest order ceilings: max ETB / controlled-item rules without registered identity — confirm with compliance
|
||||||
11. Multi-hospital doctors: allow one clinician affiliation to multiple hospital orgs?
|
11. Multi-hospital doctors: allow one clinician affiliation to multiple hospital orgs?
|
||||||
|
12. Multi-UOM source of truth: Platform-editable vs ERP-synced conversion factors — which field-group ownership wins on conflict?
|
||||||
|
13. Mobile reminder sync: push dose `times` on Rx approve vs on first dispense / patient claim?
|
||||||
|
14. Google OAuth: production Web client ID + allowed staff email domains / Workspace org for Admin
|
||||||
|
15. Telegram Login Widget: production bot username and whether staff must pre-link Telegram id in Admin before first login
|
||||||
|
16. OTP provider for staff phone login: AfricasTalking vs Twilio vs local SMS gateway; shared with guest-order claim OTP?
|
||||||
|
17. Should doctor login reuse Google / phone / Telegram providers or stay email+invite only?
|
||||||
|
|
|
||||||
56
src/lib/dosingSchedule.ts
Normal file
56
src/lib/dosingSchedule.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { DosingFrequency } from '@/types'
|
||||||
|
|
||||||
|
/** Common frequency options for pharmacist Rx intake. */
|
||||||
|
export const DOSING_FREQUENCY_OPTIONS: {
|
||||||
|
value: DosingFrequency
|
||||||
|
label: string
|
||||||
|
short: string
|
||||||
|
}[] = [
|
||||||
|
{ value: 'QD', label: 'Once daily (QD)', short: 'Once daily' },
|
||||||
|
{ value: 'BID', label: 'Twice daily (BID)', short: 'Twice daily' },
|
||||||
|
{ value: 'TID', label: 'Three times daily (TID)', short: '3× daily' },
|
||||||
|
{ value: 'QID', label: 'Four times daily (QID)', short: '4× daily' },
|
||||||
|
{ value: 'QXH', label: 'Every N hours', short: 'Every N hours' },
|
||||||
|
{ value: 'custom', label: 'Custom times', short: 'Custom' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_TIMES: Record<Exclude<DosingFrequency, 'QXH' | 'custom'>, string[]> = {
|
||||||
|
QD: ['08:00'],
|
||||||
|
BID: ['08:00', '20:00'],
|
||||||
|
TID: ['08:00', '14:00', '20:00'],
|
||||||
|
QID: ['08:00', '12:00', '16:00', '20:00'],
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sensible clock times when the pharmacist picks a common frequency. */
|
||||||
|
export function defaultTimesForFrequency(
|
||||||
|
frequency: DosingFrequency,
|
||||||
|
intervalHours?: number,
|
||||||
|
): string[] {
|
||||||
|
if (frequency === 'custom') return ['08:00']
|
||||||
|
if (frequency === 'QXH') {
|
||||||
|
const hours = intervalHours && intervalHours > 0 ? intervalHours : 8
|
||||||
|
const slots: string[] = []
|
||||||
|
for (let h = 8; h < 24 && slots.length < Math.floor(24 / hours); h += hours) {
|
||||||
|
slots.push(`${String(h).padStart(2, '0')}:00`)
|
||||||
|
}
|
||||||
|
return slots.length ? slots : ['08:00']
|
||||||
|
}
|
||||||
|
return [...DEFAULT_TIMES[frequency]]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFrequencyLabel(
|
||||||
|
frequency?: DosingFrequency,
|
||||||
|
intervalHours?: number,
|
||||||
|
): string {
|
||||||
|
if (!frequency) return '—'
|
||||||
|
if (frequency === 'QXH') {
|
||||||
|
const n = intervalHours && intervalHours > 0 ? intervalHours : undefined
|
||||||
|
return n ? `Every ${n} hours` : 'Every N hours'
|
||||||
|
}
|
||||||
|
return DOSING_FREQUENCY_OPTIONS.find((o) => o.value === frequency)?.short ?? frequency
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDoseTimes(times?: string[]): string {
|
||||||
|
if (!times?.length) return '—'
|
||||||
|
return times.join(' · ')
|
||||||
|
}
|
||||||
|
|
@ -7,17 +7,54 @@
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "ግባ",
|
"login": "ግባ",
|
||||||
"email": "ኢሜይል",
|
"email": "ኢሜይል",
|
||||||
|
"phone": "ስልክ",
|
||||||
"password": "የይለፍ ቃል",
|
"password": "የይለፍ ቃል",
|
||||||
"welcome": "እንኳን ደህና መጡ",
|
"welcome": "እንኳን ደህና መጡ",
|
||||||
"subtitle": "የሙከራ ሚና ይምረጡ ወይም በሰራተኛ መለያ ይግቡ።",
|
"subtitle": "በGoogle፣ ኢሜይል፣ ስልክ ወይም ቴሌግራም ይግቡ — ወይም የሙከራ ሚና ይምረጡ።",
|
||||||
"demoHint": "ለሁሉም መለያዎች የይለፍ ቃል፡ demo123",
|
"demoHint": "ለሁሉም መለያዎች የይለፍ ቃል፡ demo123",
|
||||||
"demoAccounts": "የሙከራ መለያዎች",
|
"demoAccounts": "የሙከራ መለያዎች",
|
||||||
|
"demoPickHint": "ፎርሙን ለመሙላት እና በቀጥታ ለመግባት የሙከራ ተጠቃሚ ይምረጡ።",
|
||||||
|
"chooseTestUser": "የሙከራ ተጠቃሚ ይምረጡ",
|
||||||
|
"signingInAs": "እንደ {{email}} በመግባት ላይ…",
|
||||||
"signedIn": "ገብተዋል",
|
"signedIn": "ገብተዋል",
|
||||||
"signedOut": "ወጥተዋል",
|
"signedOut": "ወጥተዋል",
|
||||||
|
"continueGoogle": "በGoogle ቀጥል",
|
||||||
|
"continueTelegram": "በ{{bot}} ቀጥል",
|
||||||
|
"connecting": "በመገናኘት ላይ…",
|
||||||
|
"googleMockHint": "የሙከራ Google መግባት (ለእውነተኛ OAuth VITE_GOOGLE_CLIENT_ID ያዘጋጁ)።",
|
||||||
|
"telegramMockHint": "የሙከራ Telegram Login Widget (VITE_TELEGRAM_BOT_USERNAME ያዘጋጁ)።",
|
||||||
|
"telegramWidgetHint": "የTelegram Login Widget ዘይቤ — bot @{{bot}}።",
|
||||||
|
"orContinueWith": "ወይም በዚህ ይቀጥሉ",
|
||||||
|
"credentialTabs": "ኢሜይል ወይም ስልክ መግባት",
|
||||||
|
"forgotPassword": "የይለፍ ቃል ረሱ?",
|
||||||
|
"resetTitle": "የይለፍ ቃል መቀየር",
|
||||||
|
"resetBody": "ይህ ኮንሶል በሙከራ ውሂብ ላይ ይሰራል፤ የማስተካከያ ኢሜይል የለም። አዲስ የይለፍ ቃል ለማስቀመጥ ሱፐር አድሚንን ጠይቁ።",
|
||||||
|
"rememberMe": "በዚህ መሣሪያ ላይ አስታውሰኝ",
|
||||||
|
"signingIn": "በመግባት ላይ…",
|
||||||
|
"showPassword": "የይለፍ ቃል አሳይ",
|
||||||
|
"hidePassword": "የይለፍ ቃል ደብቅ",
|
||||||
|
"phoneHint": "የኢትዮጵያ ሞባይል ቁጥሮች ይሻላሉ (+251…)። የሙከራ OTP፡ 123456።",
|
||||||
|
"sendCode": "የማረጋገጫ ኮድ ላክ",
|
||||||
|
"sendingCode": "ኮድ በመላክ ላይ…",
|
||||||
|
"otp": "የማረጋገጫ ኮድ",
|
||||||
|
"otpSent": "ኮድ ተልኳል",
|
||||||
|
"otpSentHint": "የሙከራ SMS፡ ለመቀጠል {{code}} ያስገቡ።",
|
||||||
|
"otpHint": "ወደ {{phone}} ተልኳል። የሙከራ ኮድ {{code}} ይጠቀሙ።",
|
||||||
|
"changePhone": "ሌላ ቁጥር ይጠቀሙ",
|
||||||
|
"verifyAndSignIn": "አረጋግጥ እና ግባ",
|
||||||
|
"providerSignedIn": "በ{{provider}}",
|
||||||
|
"providers": {
|
||||||
|
"email": "ኢሜይል",
|
||||||
|
"google": "Google",
|
||||||
|
"phone": "ስልክ",
|
||||||
|
"telegram": "ቴሌግራም"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"invalidCredentials": "የተሳሳተ ኢሜይል ወይም የይለፍ ቃል",
|
"invalidCredentials": "የተሳሳተ ኢሜይል ወይም የይለፍ ቃል",
|
||||||
"emailRequired": "የኢሜይል አድራሻዎን ያስገቡ",
|
"emailRequired": "የኢሜይል አድራሻዎን ያስገቡ",
|
||||||
"passwordRequired": "የይለፍ ቃልዎን ያስገቡ"
|
"passwordRequired": "የይለፍ ቃልዎን ያስገቡ",
|
||||||
|
"phoneInvalid": "ትክክለኛ ስልክ ቁጥር ያስገቡ",
|
||||||
|
"otpInvalid": "ትክክል ያልሆነ ኮድ። የሙከራ OTP፡ 123456።"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,54 @@
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Sign in",
|
"login": "Sign in",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
|
"phone": "Phone",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"welcome": "Welcome back",
|
"welcome": "Welcome back",
|
||||||
"subtitle": "Choose a demo role or sign in with your staff account.",
|
"subtitle": "Sign in with Google, email, phone, or Telegram — or pick a demo role.",
|
||||||
"demoHint": "Demo password for all accounts: demo123",
|
"demoHint": "Demo password for all accounts: demo123",
|
||||||
"demoAccounts": "Demo accounts",
|
"demoAccounts": "Demo accounts",
|
||||||
|
"demoPickHint": "Pick a test user to fill the form and sign in straight away.",
|
||||||
|
"chooseTestUser": "Choose a test user",
|
||||||
|
"signingInAs": "Signing in as {{email}}…",
|
||||||
"signedIn": "Signed in",
|
"signedIn": "Signed in",
|
||||||
"signedOut": "Signed out",
|
"signedOut": "Signed out",
|
||||||
|
"continueGoogle": "Continue with Google",
|
||||||
|
"continueTelegram": "Continue with {{bot}}",
|
||||||
|
"connecting": "Connecting…",
|
||||||
|
"googleMockHint": "Mock Google sign-in (set VITE_GOOGLE_CLIENT_ID for real OAuth).",
|
||||||
|
"telegramMockHint": "Mock Telegram Login Widget (set VITE_TELEGRAM_BOT_USERNAME).",
|
||||||
|
"telegramWidgetHint": "Telegram Login Widget style — bot @{{bot}}.",
|
||||||
|
"orContinueWith": "or continue with",
|
||||||
|
"credentialTabs": "Email or phone sign-in",
|
||||||
|
"forgotPassword": "Forgot password?",
|
||||||
|
"resetTitle": "Password reset",
|
||||||
|
"resetBody": "This console runs on demo data, so there is no reset email. Ask a super admin to set a new password.",
|
||||||
|
"rememberMe": "Remember me on this device",
|
||||||
|
"signingIn": "Signing in…",
|
||||||
|
"showPassword": "Show password",
|
||||||
|
"hidePassword": "Hide password",
|
||||||
|
"phoneHint": "Ethiopian mobile numbers work best (+251…). Demo OTP is 123456.",
|
||||||
|
"sendCode": "Send verification code",
|
||||||
|
"sendingCode": "Sending code…",
|
||||||
|
"otp": "Verification code",
|
||||||
|
"otpSent": "Code sent",
|
||||||
|
"otpSentHint": "Demo SMS: enter {{code}} to continue.",
|
||||||
|
"otpHint": "Sent to {{phone}}. Use demo code {{code}}.",
|
||||||
|
"changePhone": "Use a different number",
|
||||||
|
"verifyAndSignIn": "Verify and sign in",
|
||||||
|
"providerSignedIn": "Via {{provider}}",
|
||||||
|
"providers": {
|
||||||
|
"email": "Email",
|
||||||
|
"google": "Google",
|
||||||
|
"phone": "Phone",
|
||||||
|
"telegram": "Telegram"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"invalidCredentials": "Invalid credentials",
|
"invalidCredentials": "Invalid credentials",
|
||||||
"emailRequired": "Enter your email address",
|
"emailRequired": "Enter your email address",
|
||||||
"passwordRequired": "Enter your password"
|
"passwordRequired": "Enter your password",
|
||||||
|
"phoneInvalid": "Enter a valid phone number",
|
||||||
|
"otpInvalid": "Incorrect code. Demo OTP is 123456."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,19 @@ export let prescriptions: Prescription[] = [
|
||||||
branchId: 'br-bole',
|
branchId: 'br-bole',
|
||||||
status: 'under_review',
|
status: 'under_review',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Amoxicillin 500mg', qty: 21 },
|
{
|
||||||
{ name: 'Paracetamol 500mg', qty: 10 },
|
name: 'Amoxicillin 500mg',
|
||||||
|
qty: 21,
|
||||||
|
frequency: 'TID',
|
||||||
|
times: ['08:00', '14:00', '20:00'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Paracetamol 500mg',
|
||||||
|
qty: 10,
|
||||||
|
frequency: 'QXH',
|
||||||
|
intervalHours: 6,
|
||||||
|
times: ['08:00', '14:00', '20:00'],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
submittedAt: '2026-08-06T08:10:00Z',
|
submittedAt: '2026-08-06T08:10:00Z',
|
||||||
prescriber: 'Dr. Selam',
|
prescriber: 'Dr. Selam',
|
||||||
|
|
@ -42,7 +53,15 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Marta Hailu',
|
customerName: 'Marta Hailu',
|
||||||
branchId: 'br-bole',
|
branchId: 'br-bole',
|
||||||
status: 'submitted',
|
status: 'submitted',
|
||||||
items: [{ name: 'Metformin 850mg', qty: 60, controlled: false }],
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Metformin 850mg',
|
||||||
|
qty: 60,
|
||||||
|
controlled: false,
|
||||||
|
frequency: 'BID',
|
||||||
|
times: ['08:00', '20:00'],
|
||||||
|
},
|
||||||
|
],
|
||||||
submittedAt: '2026-08-06T09:20:00Z',
|
submittedAt: '2026-08-06T09:20:00Z',
|
||||||
prescriber: 'Dr. Yohannes',
|
prescriber: 'Dr. Yohannes',
|
||||||
},
|
},
|
||||||
|
|
@ -51,7 +70,15 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Tigist Alemu',
|
customerName: 'Tigist Alemu',
|
||||||
branchId: 'br-cmc',
|
branchId: 'br-cmc',
|
||||||
status: 'under_review',
|
status: 'under_review',
|
||||||
items: [{ name: 'Tramadol 50mg', qty: 20, controlled: true }],
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Tramadol 50mg',
|
||||||
|
qty: 20,
|
||||||
|
controlled: true,
|
||||||
|
frequency: 'QID',
|
||||||
|
times: ['08:00', '12:00', '16:00', '20:00'],
|
||||||
|
},
|
||||||
|
],
|
||||||
submittedAt: '2026-08-06T07:45:00Z',
|
submittedAt: '2026-08-06T07:45:00Z',
|
||||||
prescriber: 'Dr. Helen',
|
prescriber: 'Dr. Helen',
|
||||||
reviewStartedAt: '2026-08-06T08:05:00Z',
|
reviewStartedAt: '2026-08-06T08:05:00Z',
|
||||||
|
|
@ -61,7 +88,14 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Dawit Lemma',
|
customerName: 'Dawit Lemma',
|
||||||
branchId: 'br-piassa',
|
branchId: 'br-piassa',
|
||||||
status: 'submitted',
|
status: 'submitted',
|
||||||
items: [{ name: 'Salbutamol inhaler', qty: 2 }],
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Salbutamol inhaler',
|
||||||
|
qty: 2,
|
||||||
|
frequency: 'custom',
|
||||||
|
times: ['08:00', '14:00', '21:00'],
|
||||||
|
},
|
||||||
|
],
|
||||||
submittedAt: '2026-08-05T16:30:00Z',
|
submittedAt: '2026-08-05T16:30:00Z',
|
||||||
prescriber: 'Dr. Kalkidan',
|
prescriber: 'Dr. Kalkidan',
|
||||||
},
|
},
|
||||||
|
|
@ -70,7 +104,7 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Hiwot Bekele',
|
customerName: 'Hiwot Bekele',
|
||||||
branchId: 'br-cmc',
|
branchId: 'br-cmc',
|
||||||
status: 'queried',
|
status: 'queried',
|
||||||
items: [{ name: 'Amlodipine 5mg', qty: 30 }],
|
items: [{ name: 'Amlodipine 5mg', qty: 30, frequency: 'QD', times: ['08:00'] }],
|
||||||
submittedAt: '2026-08-05T11:15:00Z',
|
submittedAt: '2026-08-05T11:15:00Z',
|
||||||
prescriber: 'Dr. Yohannes',
|
prescriber: 'Dr. Yohannes',
|
||||||
reviewStartedAt: '2026-08-05T11:40:00Z',
|
reviewStartedAt: '2026-08-05T11:40:00Z',
|
||||||
|
|
@ -83,7 +117,7 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Netsanet Desta',
|
customerName: 'Netsanet Desta',
|
||||||
branchId: 'br-bole',
|
branchId: 'br-bole',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
items: [{ name: 'Cetirizine 10mg', qty: 20 }],
|
items: [{ name: 'Cetirizine 10mg', qty: 20, frequency: 'QD', times: ['21:00'] }],
|
||||||
submittedAt: '2026-08-04T09:10:00Z',
|
submittedAt: '2026-08-04T09:10:00Z',
|
||||||
prescriber: 'Dr. Selam',
|
prescriber: 'Dr. Selam',
|
||||||
reviewStartedAt: '2026-08-04T09:25:00Z',
|
reviewStartedAt: '2026-08-04T09:25:00Z',
|
||||||
|
|
@ -96,8 +130,8 @@ export let prescriptions: Prescription[] = [
|
||||||
branchId: 'br-bole',
|
branchId: 'br-bole',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Metformin 850mg', qty: 60 },
|
{ name: 'Metformin 850mg', qty: 60, frequency: 'BID', times: ['08:00', '20:00'] },
|
||||||
{ name: 'Amlodipine 5mg', qty: 30 },
|
{ name: 'Amlodipine 5mg', qty: 30, frequency: 'QD', times: ['08:00'] },
|
||||||
],
|
],
|
||||||
submittedAt: '2026-08-03T14:05:00Z',
|
submittedAt: '2026-08-03T14:05:00Z',
|
||||||
prescriber: 'Dr. Helen',
|
prescriber: 'Dr. Helen',
|
||||||
|
|
@ -110,7 +144,15 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Bekele Tesfaye',
|
customerName: 'Bekele Tesfaye',
|
||||||
branchId: 'br-cmc',
|
branchId: 'br-cmc',
|
||||||
status: 'rejected',
|
status: 'rejected',
|
||||||
items: [{ name: 'Diazepam 5mg', qty: 14, controlled: true }],
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Diazepam 5mg',
|
||||||
|
qty: 14,
|
||||||
|
controlled: true,
|
||||||
|
frequency: 'BID',
|
||||||
|
times: ['08:00', '20:00'],
|
||||||
|
},
|
||||||
|
],
|
||||||
submittedAt: '2026-08-02T10:40:00Z',
|
submittedAt: '2026-08-02T10:40:00Z',
|
||||||
prescriber: 'Dr. Unknown',
|
prescriber: 'Dr. Unknown',
|
||||||
reviewStartedAt: '2026-08-02T11:00:00Z',
|
reviewStartedAt: '2026-08-02T11:00:00Z',
|
||||||
|
|
@ -123,7 +165,7 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Yeshi Lemma',
|
customerName: 'Yeshi Lemma',
|
||||||
branchId: 'br-bole',
|
branchId: 'br-bole',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
items: [{ name: 'Ferrous sulphate 200mg', qty: 30 }],
|
items: [{ name: 'Ferrous sulphate 200mg', qty: 30, frequency: 'QD', times: ['08:00'] }],
|
||||||
submittedAt: '2026-08-01T08:20:00Z',
|
submittedAt: '2026-08-01T08:20:00Z',
|
||||||
prescriber: 'Dr. Selam',
|
prescriber: 'Dr. Selam',
|
||||||
reviewStartedAt: '2026-08-01T08:35:00Z',
|
reviewStartedAt: '2026-08-01T08:35:00Z',
|
||||||
|
|
@ -135,7 +177,7 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Sofia Ahmed',
|
customerName: 'Sofia Ahmed',
|
||||||
branchId: 'br-bole',
|
branchId: 'br-bole',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
items: [{ name: 'Azithromycin 250mg', qty: 6 }],
|
items: [{ name: 'Azithromycin 250mg', qty: 6, frequency: 'QD', times: ['08:00'] }],
|
||||||
submittedAt: '2026-07-31T13:45:00Z',
|
submittedAt: '2026-07-31T13:45:00Z',
|
||||||
prescriber: 'Dr. Yohannes',
|
prescriber: 'Dr. Yohannes',
|
||||||
reviewStartedAt: '2026-07-31T14:00:00Z',
|
reviewStartedAt: '2026-07-31T14:00:00Z',
|
||||||
|
|
@ -147,7 +189,14 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Genet Abera',
|
customerName: 'Genet Abera',
|
||||||
branchId: 'br-piassa',
|
branchId: 'br-piassa',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
items: [{ name: 'Hydrocortisone cream 1%', qty: 2 }],
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Hydrocortisone cream 1%',
|
||||||
|
qty: 2,
|
||||||
|
frequency: 'BID',
|
||||||
|
times: ['08:00', '20:00'],
|
||||||
|
},
|
||||||
|
],
|
||||||
submittedAt: '2026-07-30T11:05:00Z',
|
submittedAt: '2026-07-30T11:05:00Z',
|
||||||
prescriber: 'Dr. Kalkidan',
|
prescriber: 'Dr. Kalkidan',
|
||||||
reviewStartedAt: '2026-07-30T11:20:00Z',
|
reviewStartedAt: '2026-07-30T11:20:00Z',
|
||||||
|
|
@ -159,7 +208,15 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Solomon Wolde',
|
customerName: 'Solomon Wolde',
|
||||||
branchId: 'br-cmc',
|
branchId: 'br-cmc',
|
||||||
status: 'queried',
|
status: 'queried',
|
||||||
items: [{ name: 'Ibuprofen 400mg', qty: 30 }],
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Ibuprofen 400mg',
|
||||||
|
qty: 30,
|
||||||
|
frequency: 'QXH',
|
||||||
|
intervalHours: 8,
|
||||||
|
times: ['08:00', '16:00'],
|
||||||
|
},
|
||||||
|
],
|
||||||
submittedAt: '2026-07-29T15:20:00Z',
|
submittedAt: '2026-07-29T15:20:00Z',
|
||||||
prescriber: 'Dr. Helen',
|
prescriber: 'Dr. Helen',
|
||||||
reviewStartedAt: '2026-07-29T15:35:00Z',
|
reviewStartedAt: '2026-07-29T15:35:00Z',
|
||||||
|
|
@ -172,7 +229,14 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Rahel Tadesse',
|
customerName: 'Rahel Tadesse',
|
||||||
branchId: 'br-bole',
|
branchId: 'br-bole',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
items: [{ name: 'Amoxicillin 500mg', qty: 21 }],
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Amoxicillin 500mg',
|
||||||
|
qty: 21,
|
||||||
|
frequency: 'TID',
|
||||||
|
times: ['08:00', '14:00', '20:00'],
|
||||||
|
},
|
||||||
|
],
|
||||||
submittedAt: '2026-07-28T09:30:00Z',
|
submittedAt: '2026-07-28T09:30:00Z',
|
||||||
prescriber: 'Dr. Selam',
|
prescriber: 'Dr. Selam',
|
||||||
reviewStartedAt: '2026-07-28T09:45:00Z',
|
reviewStartedAt: '2026-07-28T09:45:00Z',
|
||||||
|
|
@ -184,7 +248,14 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Meseret Kassa',
|
customerName: 'Meseret Kassa',
|
||||||
branchId: 'br-cmc',
|
branchId: 'br-cmc',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
items: [{ name: 'Oral rehydration salts', qty: 10 }],
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Oral rehydration salts',
|
||||||
|
qty: 10,
|
||||||
|
frequency: 'custom',
|
||||||
|
times: ['08:00', '12:00', '16:00', '20:00'],
|
||||||
|
},
|
||||||
|
],
|
||||||
submittedAt: '2026-07-26T16:10:00Z',
|
submittedAt: '2026-07-26T16:10:00Z',
|
||||||
prescriber: 'Dr. Yohannes',
|
prescriber: 'Dr. Yohannes',
|
||||||
reviewStartedAt: '2026-07-26T16:25:00Z',
|
reviewStartedAt: '2026-07-26T16:25:00Z',
|
||||||
|
|
@ -196,7 +267,15 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Tesfaye Negash',
|
customerName: 'Tesfaye Negash',
|
||||||
branchId: 'br-bole',
|
branchId: 'br-bole',
|
||||||
status: 'rejected',
|
status: 'rejected',
|
||||||
items: [{ name: 'Tramadol 50mg', qty: 40, controlled: true }],
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Tramadol 50mg',
|
||||||
|
qty: 40,
|
||||||
|
controlled: true,
|
||||||
|
frequency: 'QID',
|
||||||
|
times: ['08:00', '12:00', '16:00', '20:00'],
|
||||||
|
},
|
||||||
|
],
|
||||||
submittedAt: '2026-07-24T12:00:00Z',
|
submittedAt: '2026-07-24T12:00:00Z',
|
||||||
prescriber: 'Dr. Helen',
|
prescriber: 'Dr. Helen',
|
||||||
reviewStartedAt: '2026-07-24T12:15:00Z',
|
reviewStartedAt: '2026-07-24T12:15:00Z',
|
||||||
|
|
@ -209,7 +288,15 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Kalkidan Berhe',
|
customerName: 'Kalkidan Berhe',
|
||||||
branchId: 'br-piassa',
|
branchId: 'br-piassa',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
items: [{ name: 'Paracetamol 500mg', qty: 20 }],
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Paracetamol 500mg',
|
||||||
|
qty: 20,
|
||||||
|
frequency: 'QXH',
|
||||||
|
intervalHours: 6,
|
||||||
|
times: ['06:00', '12:00', '18:00'],
|
||||||
|
},
|
||||||
|
],
|
||||||
submittedAt: '2026-07-22T10:15:00Z',
|
submittedAt: '2026-07-22T10:15:00Z',
|
||||||
prescriber: 'Dr. Kalkidan',
|
prescriber: 'Dr. Kalkidan',
|
||||||
reviewStartedAt: '2026-07-22T10:30:00Z',
|
reviewStartedAt: '2026-07-22T10:30:00Z',
|
||||||
|
|
@ -222,8 +309,8 @@ export let prescriptions: Prescription[] = [
|
||||||
branchId: 'br-bole',
|
branchId: 'br-bole',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Vitamin C 1000mg', qty: 2 },
|
{ name: 'Vitamin C 1000mg', qty: 2, frequency: 'QD', times: ['08:00'] },
|
||||||
{ name: 'Ferrous sulphate 200mg', qty: 30 },
|
{ name: 'Ferrous sulphate 200mg', qty: 30, frequency: 'QD', times: ['08:00'] },
|
||||||
],
|
],
|
||||||
submittedAt: '2026-07-20T14:40:00Z',
|
submittedAt: '2026-07-20T14:40:00Z',
|
||||||
prescriber: 'Dr. Selam',
|
prescriber: 'Dr. Selam',
|
||||||
|
|
@ -236,7 +323,7 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Fikru Assefa',
|
customerName: 'Fikru Assefa',
|
||||||
branchId: 'br-cmc',
|
branchId: 'br-cmc',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
items: [{ name: 'Metformin 850mg', qty: 60 }],
|
items: [{ name: 'Metformin 850mg', qty: 60, frequency: 'BID', times: ['08:00', '20:00'] }],
|
||||||
submittedAt: '2026-07-17T09:05:00Z',
|
submittedAt: '2026-07-17T09:05:00Z',
|
||||||
prescriber: 'Dr. Yohannes',
|
prescriber: 'Dr. Yohannes',
|
||||||
reviewStartedAt: '2026-07-17T09:20:00Z',
|
reviewStartedAt: '2026-07-17T09:20:00Z',
|
||||||
|
|
@ -248,7 +335,14 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Mulugeta Fikadu',
|
customerName: 'Mulugeta Fikadu',
|
||||||
branchId: 'br-bole',
|
branchId: 'br-bole',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
items: [{ name: 'Salbutamol inhaler', qty: 1 }],
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Salbutamol inhaler',
|
||||||
|
qty: 1,
|
||||||
|
frequency: 'custom',
|
||||||
|
times: ['08:00', '14:00', '21:00'],
|
||||||
|
},
|
||||||
|
],
|
||||||
submittedAt: '2026-07-14T17:25:00Z',
|
submittedAt: '2026-07-14T17:25:00Z',
|
||||||
prescriber: 'Dr. Helen',
|
prescriber: 'Dr. Helen',
|
||||||
reviewStartedAt: '2026-07-14T17:40:00Z',
|
reviewStartedAt: '2026-07-14T17:40:00Z',
|
||||||
|
|
@ -260,7 +354,7 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Selam Getahun',
|
customerName: 'Selam Getahun',
|
||||||
branchId: 'br-piassa',
|
branchId: 'br-piassa',
|
||||||
status: 'rejected',
|
status: 'rejected',
|
||||||
items: [{ name: 'Azithromycin 250mg', qty: 12 }],
|
items: [{ name: 'Azithromycin 250mg', qty: 12, frequency: 'QD', times: ['08:00'] }],
|
||||||
submittedAt: '2026-07-11T11:50:00Z',
|
submittedAt: '2026-07-11T11:50:00Z',
|
||||||
prescriber: 'Dr. Kalkidan',
|
prescriber: 'Dr. Kalkidan',
|
||||||
reviewStartedAt: '2026-07-11T12:05:00Z',
|
reviewStartedAt: '2026-07-11T12:05:00Z',
|
||||||
|
|
@ -273,7 +367,7 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Zenebech Haile',
|
customerName: 'Zenebech Haile',
|
||||||
branchId: 'br-cmc',
|
branchId: 'br-cmc',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
items: [{ name: 'Cetirizine 10mg', qty: 30 }],
|
items: [{ name: 'Cetirizine 10mg', qty: 30, frequency: 'QD', times: ['21:00'] }],
|
||||||
submittedAt: '2026-07-09T08:45:00Z',
|
submittedAt: '2026-07-09T08:45:00Z',
|
||||||
prescriber: 'Dr. Yohannes',
|
prescriber: 'Dr. Yohannes',
|
||||||
reviewStartedAt: '2026-07-09T09:00:00Z',
|
reviewStartedAt: '2026-07-09T09:00:00Z',
|
||||||
|
|
@ -285,7 +379,14 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Abebe Kebede',
|
customerName: 'Abebe Kebede',
|
||||||
branchId: 'br-bole',
|
branchId: 'br-bole',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
items: [{ name: 'Amoxicillin 500mg', qty: 21 }],
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Amoxicillin 500mg',
|
||||||
|
qty: 21,
|
||||||
|
frequency: 'TID',
|
||||||
|
times: ['08:00', '14:00', '20:00'],
|
||||||
|
},
|
||||||
|
],
|
||||||
submittedAt: '2026-08-06T07:20:00Z',
|
submittedAt: '2026-08-06T07:20:00Z',
|
||||||
prescriber: 'Dr. Selam',
|
prescriber: 'Dr. Selam',
|
||||||
reviewStartedAt: '2026-08-06T07:35:00Z',
|
reviewStartedAt: '2026-08-06T07:35:00Z',
|
||||||
|
|
@ -297,7 +398,15 @@ export let prescriptions: Prescription[] = [
|
||||||
customerName: 'Marta Hailu',
|
customerName: 'Marta Hailu',
|
||||||
branchId: 'br-bole',
|
branchId: 'br-bole',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
items: [{ name: 'Tramadol 50mg', qty: 20, controlled: true }],
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Tramadol 50mg',
|
||||||
|
qty: 20,
|
||||||
|
controlled: true,
|
||||||
|
frequency: 'QID',
|
||||||
|
times: ['08:00', '12:00', '16:00', '20:00'],
|
||||||
|
},
|
||||||
|
],
|
||||||
submittedAt: '2026-08-05T15:10:00Z',
|
submittedAt: '2026-08-05T15:10:00Z',
|
||||||
prescriber: 'Dr. Helen',
|
prescriber: 'Dr. Helen',
|
||||||
reviewStartedAt: '2026-08-05T15:25:00Z',
|
reviewStartedAt: '2026-08-05T15:25:00Z',
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,16 @@ import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
ArrowLeft,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
|
Mail,
|
||||||
|
Phone,
|
||||||
|
Send,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Truck,
|
Truck,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
@ -27,9 +31,12 @@ import {
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { useAuthStore } from '@/zustand/authStore'
|
import { useAuthStore } from '@/zustand/authStore'
|
||||||
import { demoAccounts } from '@/config/permissions'
|
import { demoAccounts } from '@/config/permissions'
|
||||||
import type { StaffRole } from '@/types'
|
import type { AuthProvider, StaffRole } from '@/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const REMEMBER_KEY = 'gishen_remember_email'
|
const REMEMBER_KEY = 'gishen_remember_email'
|
||||||
|
/** Demo OTP accepted for any phone (mock SMS provider). */
|
||||||
|
const MOCK_OTP = '123456'
|
||||||
|
|
||||||
const ROLE_ORDER: StaffRole[] = [
|
const ROLE_ORDER: StaffRole[] = [
|
||||||
'super_admin',
|
'super_admin',
|
||||||
|
|
@ -47,21 +54,80 @@ const VALUE_POINTS = [
|
||||||
{ icon: Lock, text: 'Role-scoped access so each team only sees its own work' },
|
{ icon: Lock, text: 'Role-scoped access so each team only sees its own work' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
type CredentialMode = 'email' | 'phone'
|
||||||
|
type PhoneStep = 'enter' | 'verify'
|
||||||
|
|
||||||
|
function GoogleMark({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
fill="#4285F4"
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#34A853"
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#FBBC05"
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill="#EA4335"
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TelegramMark({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" fill="currentColor">
|
||||||
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePhone(value: string) {
|
||||||
|
return value.replace(/[\s\-()]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyPhone(value: string) {
|
||||||
|
const digits = normalizePhone(value).replace(/^\+/, '')
|
||||||
|
return /^\d{9,15}$/.test(digits)
|
||||||
|
}
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const login = useAuthStore((s) => s.login)
|
const login = useAuthStore((s) => s.login)
|
||||||
|
const loginWithProvider = useAuthStore((s) => s.loginWithProvider)
|
||||||
const token = useAuthStore((s) => s.token) || localStorage.getItem('access_token')
|
const token = useAuthStore((s) => s.token) || localStorage.getItem('access_token')
|
||||||
|
|
||||||
|
const [credentialMode, setCredentialMode] = useState<CredentialMode>('email')
|
||||||
const [email, setEmail] = useState(() => localStorage.getItem(REMEMBER_KEY) ?? '')
|
const [email, setEmail] = useState(() => localStorage.getItem(REMEMBER_KEY) ?? '')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [remember, setRemember] = useState(() => Boolean(localStorage.getItem(REMEMBER_KEY)))
|
const [remember, setRemember] = useState(() => Boolean(localStorage.getItem(REMEMBER_KEY)))
|
||||||
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string }>({})
|
const [fieldErrors, setFieldErrors] = useState<{
|
||||||
|
email?: string
|
||||||
|
password?: string
|
||||||
|
phone?: string
|
||||||
|
otp?: string
|
||||||
|
}>({})
|
||||||
const [formError, setFormError] = useState<string | null>(null)
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [providerBusy, setProviderBusy] = useState<Exclude<AuthProvider, 'email'> | null>(null)
|
||||||
const [demoEmail, setDemoEmail] = useState<string | null>(null)
|
const [demoEmail, setDemoEmail] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [phone, setPhone] = useState('')
|
||||||
|
const [phoneStep, setPhoneStep] = useState<PhoneStep>('enter')
|
||||||
|
const [otp, setOtp] = useState('')
|
||||||
|
const [otpSending, setOtpSending] = useState(false)
|
||||||
|
|
||||||
|
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined
|
||||||
|
const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID as string | undefined
|
||||||
|
|
||||||
const accountsByRole = useMemo(
|
const accountsByRole = useMemo(
|
||||||
() =>
|
() =>
|
||||||
ROLE_ORDER.map((role) => ({
|
ROLE_ORDER.map((role) => ({
|
||||||
|
|
@ -74,21 +140,39 @@ export function LoginPage() {
|
||||||
|
|
||||||
if (token) return <Navigate to="/" replace />
|
if (token) return <Navigate to="/" replace />
|
||||||
|
|
||||||
const signIn = (nextEmail: string, nextPassword: string) => {
|
const finishSignIn = (nextEmail: string) => {
|
||||||
|
if (remember) localStorage.setItem(REMEMBER_KEY, nextEmail)
|
||||||
|
else localStorage.removeItem(REMEMBER_KEY)
|
||||||
|
toast.success(t('auth.signedIn'))
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const signInEmail = (nextEmail: string, nextPassword: string) => {
|
||||||
const result = login(nextEmail, nextPassword)
|
const result = login(nextEmail, nextPassword)
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setFormError(result.error ?? t('auth.error.invalidCredentials'))
|
setFormError(result.error ?? t('auth.error.invalidCredentials'))
|
||||||
toast.error(result.error ?? t('auth.error.invalidCredentials'))
|
toast.error(result.error ?? t('auth.error.invalidCredentials'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (remember) localStorage.setItem(REMEMBER_KEY, nextEmail)
|
finishSignIn(nextEmail)
|
||||||
else localStorage.removeItem(REMEMBER_KEY)
|
return true
|
||||||
toast.success(t('auth.signedIn'))
|
}
|
||||||
|
|
||||||
|
const signInProvider = (provider: Exclude<AuthProvider, 'email'>) => {
|
||||||
|
const result = loginWithProvider(provider)
|
||||||
|
if (!result.ok) {
|
||||||
|
setFormError(result.error ?? t('auth.error.invalidCredentials'))
|
||||||
|
toast.error(result.error ?? t('auth.error.invalidCredentials'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
toast.success(t('auth.signedIn'), {
|
||||||
|
description: t('auth.providerSignedIn', { provider: t(`auth.providers.${provider}`) }),
|
||||||
|
})
|
||||||
navigate('/')
|
navigate('/')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSubmit = (e: FormEvent) => {
|
const onSubmitEmail = (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const errors: { email?: string; password?: string } = {}
|
const errors: { email?: string; password?: string } = {}
|
||||||
if (!email.trim()) errors.email = t('auth.error.emailRequired')
|
if (!email.trim()) errors.email = t('auth.error.emailRequired')
|
||||||
|
|
@ -99,24 +183,67 @@ export function LoginPage() {
|
||||||
setFormError(null)
|
setFormError(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const ok = signIn(email.trim(), password)
|
const ok = signInEmail(email.trim(), password)
|
||||||
if (!ok) setLoading(false)
|
if (!ok) setLoading(false)
|
||||||
}, 450)
|
}, 450)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onSendOtp = () => {
|
||||||
|
if (!isLikelyPhone(phone)) {
|
||||||
|
setFieldErrors((prev) => ({ ...prev, phone: t('auth.error.phoneInvalid') }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFieldErrors((prev) => ({ ...prev, phone: undefined, otp: undefined }))
|
||||||
|
setFormError(null)
|
||||||
|
setOtpSending(true)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setOtpSending(false)
|
||||||
|
setPhoneStep('verify')
|
||||||
|
setOtp('')
|
||||||
|
toast.message(t('auth.otpSent'), {
|
||||||
|
description: t('auth.otpSentHint', { code: MOCK_OTP }),
|
||||||
|
})
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVerifyOtp = (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (otp.trim() !== MOCK_OTP) {
|
||||||
|
setFieldErrors((prev) => ({ ...prev, otp: t('auth.error.otpInvalid') }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFieldErrors((prev) => ({ ...prev, otp: undefined }))
|
||||||
|
setFormError(null)
|
||||||
|
setLoading(true)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const ok = signInProvider('phone')
|
||||||
|
if (!ok) setLoading(false)
|
||||||
|
}, 450)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onProviderClick = (provider: 'google' | 'telegram') => {
|
||||||
|
setFormError(null)
|
||||||
|
setProviderBusy(provider)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const ok = signInProvider(provider)
|
||||||
|
if (!ok) setProviderBusy(null)
|
||||||
|
}, 700)
|
||||||
|
}
|
||||||
|
|
||||||
const onPickDemoAccount = (account: (typeof demoAccounts)[number]) => {
|
const onPickDemoAccount = (account: (typeof demoAccounts)[number]) => {
|
||||||
setEmail(account.email)
|
setEmail(account.email)
|
||||||
setPassword(account.password)
|
setPassword(account.password)
|
||||||
|
setCredentialMode('email')
|
||||||
setFieldErrors({})
|
setFieldErrors({})
|
||||||
setFormError(null)
|
setFormError(null)
|
||||||
setDemoEmail(account.email)
|
setDemoEmail(account.email)
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const ok = signIn(account.email, account.password)
|
const ok = signInEmail(account.email, account.password)
|
||||||
if (!ok) setDemoEmail(null)
|
if (!ok) setDemoEmail(null)
|
||||||
}, 450)
|
}, 450)
|
||||||
}
|
}
|
||||||
|
|
||||||
const busy = loading || demoEmail !== null
|
const busy = loading || demoEmail !== null || providerBusy !== null || otpSending
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-screen bg-cream lg:grid-cols-[1.05fr_1fr]">
|
<div className="grid min-h-screen bg-cream lg:grid-cols-[1.05fr_1fr]">
|
||||||
|
|
@ -155,7 +282,7 @@ export function LoginPage() {
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="flex items-center justify-center px-5 py-10 sm:px-8">
|
<main className="flex items-center justify-center px-5 py-10 sm:px-8">
|
||||||
<div className="w-full max-w-[26rem] space-y-6">
|
<div className="w-full max-w-[26rem] space-y-5">
|
||||||
<div className="flex items-center gap-3 lg:hidden">
|
<div className="flex items-center gap-3 lg:hidden">
|
||||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-brand-500 font-display text-lg font-bold text-forest">
|
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-brand-500 font-display text-lg font-bold text-forest">
|
||||||
G
|
G
|
||||||
|
|
@ -179,95 +306,267 @@ export function LoginPage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<form onSubmit={onSubmit} noValidate className="space-y-4 rounded-2xl border bg-white p-6 shadow-soft">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<Button
|
||||||
<Label htmlFor="email">{t('auth.email')}</Label>
|
type="button"
|
||||||
<Input
|
variant="outline"
|
||||||
id="email"
|
disabled={busy}
|
||||||
name="email"
|
className="w-full justify-center gap-2.5 bg-white"
|
||||||
type="email"
|
onClick={() => onProviderClick('google')}
|
||||||
autoComplete="email"
|
>
|
||||||
placeholder="you@gishen.et"
|
{providerBusy === 'google' ? (
|
||||||
value={email}
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||||
aria-invalid={Boolean(fieldErrors.email)}
|
) : (
|
||||||
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
|
<GoogleMark className="h-4 w-4" />
|
||||||
onChange={(e) => {
|
)}
|
||||||
setEmail(e.target.value)
|
{providerBusy === 'google' ? t('auth.connecting') : t('auth.continueGoogle')}
|
||||||
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
|
|
||||||
}}
|
|
||||||
className={fieldErrors.email ? 'border-red-400 focus-visible:ring-red-400' : undefined}
|
|
||||||
/>
|
|
||||||
{fieldErrors.email ? (
|
|
||||||
<p id="email-error" className="text-xs text-red-600">
|
|
||||||
{fieldErrors.email}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-sm text-xs font-medium text-brand-700 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
onClick={() =>
|
|
||||||
toast.message('Password reset', {
|
|
||||||
description:
|
|
||||||
'This console runs on demo data, so there is no reset email. Ask a super admin to set a new password.',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Forgot password?
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
autoComplete="current-password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={password}
|
|
||||||
aria-invalid={Boolean(fieldErrors.password)}
|
|
||||||
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPassword(e.target.value)
|
|
||||||
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
|
|
||||||
}}
|
|
||||||
className={`pr-11 ${fieldErrors.password ? 'border-red-400 focus-visible:ring-red-400' : ''}`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword((prev) => !prev)}
|
|
||||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
|
||||||
aria-pressed={showPassword}
|
|
||||||
className="absolute right-2 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{fieldErrors.password ? (
|
|
||||||
<p id="password-error" className="text-xs text-red-600">
|
|
||||||
{fieldErrors.password}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex w-fit cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={remember}
|
|
||||||
onChange={(e) => setRemember(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded border-input accent-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
/>
|
|
||||||
Remember me on this device
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={busy}>
|
|
||||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : null}
|
|
||||||
{loading ? 'Signing in…' : t('auth.login')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
{!googleClientId ? (
|
||||||
|
<p className="text-center text-[11px] text-muted-foreground">{t('auth.googleMockHint')}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
className="w-full justify-center gap-2.5 bg-[#229ED9] text-white hover:bg-[#1b8bc0]"
|
||||||
|
onClick={() => onProviderClick('telegram')}
|
||||||
|
>
|
||||||
|
{providerBusy === 'telegram' ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<TelegramMark className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{providerBusy === 'telegram'
|
||||||
|
? t('auth.connecting')
|
||||||
|
: t('auth.continueTelegram', {
|
||||||
|
bot: botUsername ? `@${botUsername}` : 'Telegram',
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-[11px] text-muted-foreground">
|
||||||
|
{botUsername ? t('auth.telegramWidgetHint', { bot: botUsername }) : t('auth.telegramMockHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
<span>{t('auth.orContinueWith')}</span>
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-label={t('auth.credentialTabs')}
|
||||||
|
className="grid grid-cols-2 rounded-xl border bg-white p-1 shadow-soft"
|
||||||
|
>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ id: 'email' as const, icon: Mail, label: t('auth.email') },
|
||||||
|
{ id: 'phone' as const, icon: Phone, label: t('auth.phone') },
|
||||||
|
] as const
|
||||||
|
).map(({ id, icon: Icon, label }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={credentialMode === id}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => {
|
||||||
|
setCredentialMode(id)
|
||||||
|
setFormError(null)
|
||||||
|
setFieldErrors({})
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
credentialMode === id
|
||||||
|
? 'bg-forest text-white shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:bg-grayScale-100 hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{credentialMode === 'email' ? (
|
||||||
|
<form onSubmit={onSubmitEmail} noValidate className="space-y-4 rounded-2xl border bg-white p-6 shadow-soft">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">{t('auth.email')}</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="you@gishen.et"
|
||||||
|
value={email}
|
||||||
|
aria-invalid={Boolean(fieldErrors.email)}
|
||||||
|
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value)
|
||||||
|
if (fieldErrors.email) setFieldErrors((prev) => ({ ...prev, email: undefined }))
|
||||||
|
}}
|
||||||
|
className={fieldErrors.email ? 'border-red-400 focus-visible:ring-red-400' : undefined}
|
||||||
|
/>
|
||||||
|
{fieldErrors.email ? (
|
||||||
|
<p id="email-error" className="text-xs text-red-600">
|
||||||
|
{fieldErrors.email}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-sm text-xs font-medium text-brand-700 underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
onClick={() =>
|
||||||
|
toast.message(t('auth.resetTitle'), {
|
||||||
|
description: t('auth.resetBody'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('auth.forgotPassword')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
aria-invalid={Boolean(fieldErrors.password)}
|
||||||
|
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value)
|
||||||
|
if (fieldErrors.password) setFieldErrors((prev) => ({ ...prev, password: undefined }))
|
||||||
|
}}
|
||||||
|
className={`pr-11 ${fieldErrors.password ? 'border-red-400 focus-visible:ring-red-400' : ''}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
|
aria-label={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
|
||||||
|
aria-pressed={showPassword}
|
||||||
|
className="absolute right-2 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-lg text-grayScale-400 transition-colors hover:bg-grayScale-100 hover:text-grayScale-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{fieldErrors.password ? (
|
||||||
|
<p id="password-error" className="text-xs text-red-600">
|
||||||
|
{fieldErrors.password}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex w-fit cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={remember}
|
||||||
|
onChange={(e) => setRemember(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-input accent-brand-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
{t('auth.rememberMe')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={busy}>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : null}
|
||||||
|
{loading ? t('auth.signingIn') : t('auth.login')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 rounded-2xl border bg-white p-6 shadow-soft">
|
||||||
|
{phoneStep === 'enter' ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="phone">{t('auth.phone')}</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
name="phone"
|
||||||
|
type="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
placeholder="+251 9XX XXX XXX"
|
||||||
|
value={phone}
|
||||||
|
aria-invalid={Boolean(fieldErrors.phone)}
|
||||||
|
aria-describedby={fieldErrors.phone ? 'phone-error' : 'phone-hint'}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPhone(e.target.value)
|
||||||
|
if (fieldErrors.phone) setFieldErrors((prev) => ({ ...prev, phone: undefined }))
|
||||||
|
}}
|
||||||
|
className={fieldErrors.phone ? 'border-red-400 focus-visible:ring-red-400' : undefined}
|
||||||
|
/>
|
||||||
|
{fieldErrors.phone ? (
|
||||||
|
<p id="phone-error" className="text-xs text-red-600">
|
||||||
|
{fieldErrors.phone}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p id="phone-hint" className="text-xs text-muted-foreground">
|
||||||
|
{t('auth.phoneHint')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button type="button" className="w-full" disabled={busy} onClick={onSendOtp}>
|
||||||
|
{otpSending ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : <Send className="h-4 w-4" />}
|
||||||
|
{otpSending ? t('auth.sendingCode') : t('auth.sendCode')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={onVerifyOtp} className="space-y-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs font-medium text-brand-700 underline-offset-2 hover:underline"
|
||||||
|
onClick={() => {
|
||||||
|
setPhoneStep('enter')
|
||||||
|
setOtp('')
|
||||||
|
setFieldErrors((prev) => ({ ...prev, otp: undefined }))
|
||||||
|
}}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{t('auth.changePhone')}
|
||||||
|
</button>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="otp">{t('auth.otp')}</Label>
|
||||||
|
<Input
|
||||||
|
id="otp"
|
||||||
|
name="otp"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
placeholder="123456"
|
||||||
|
maxLength={6}
|
||||||
|
value={otp}
|
||||||
|
aria-invalid={Boolean(fieldErrors.otp)}
|
||||||
|
aria-describedby={fieldErrors.otp ? 'otp-error' : 'otp-hint'}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))
|
||||||
|
if (fieldErrors.otp) setFieldErrors((prev) => ({ ...prev, otp: undefined }))
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'tracking-[0.35em] font-mono text-center text-lg',
|
||||||
|
fieldErrors.otp ? 'border-red-400 focus-visible:ring-red-400' : undefined,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.otp ? (
|
||||||
|
<p id="otp-error" className="text-xs text-red-600">
|
||||||
|
{fieldErrors.otp}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p id="otp-hint" className="text-xs text-muted-foreground">
|
||||||
|
{t('auth.otpHint', { phone: normalizePhone(phone), code: MOCK_OTP })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={busy || otp.length < 6}>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" /> : null}
|
||||||
|
{loading ? t('auth.signingIn') : t('auth.verifyAndSignIn')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<section
|
<section
|
||||||
aria-labelledby="demo-heading"
|
aria-labelledby="demo-heading"
|
||||||
|
|
@ -285,7 +584,7 @@ export function LoginPage() {
|
||||||
<Badge variant="warning">Demo only</Badge>
|
<Badge variant="warning">Demo only</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||||
Pick a test user to fill the form and sign in straight away. {t('auth.demoHint')}
|
{t('auth.demoPickHint')} {t('auth.demoHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -303,7 +602,7 @@ export function LoginPage() {
|
||||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden="true" />
|
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden="true" />
|
||||||
) : null}
|
) : null}
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{demoEmail ? `Signing in as ${demoEmail}…` : 'Choose a test user'}
|
{demoEmail ? t('auth.signingInAs', { email: demoEmail }) : t('auth.chooseTestUser')}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useMemo, useState, type FormEvent } from 'react'
|
import { useMemo, useState, type FormEvent } from 'react'
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
CreatePageLayout,
|
CreatePageLayout,
|
||||||
createFieldFull,
|
createFieldFull,
|
||||||
|
|
@ -13,8 +14,12 @@ import { Label } from '@/components/ui/label'
|
||||||
import { SelectNative } from '@/components/ui/select-native'
|
import { SelectNative } from '@/components/ui/select-native'
|
||||||
import { useAuthStore } from '@/zustand/authStore'
|
import { useAuthStore } from '@/zustand/authStore'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
DOSING_FREQUENCY_OPTIONS,
|
||||||
|
defaultTimesForFrequency,
|
||||||
|
} from '@/lib/dosingSchedule'
|
||||||
import { branches, doctors, prescriptions } from '@/mocks/data'
|
import { branches, doctors, prescriptions } from '@/mocks/data'
|
||||||
import type { Prescription } from '@/types'
|
import type { DosingFrequency, Prescription } from '@/types'
|
||||||
|
|
||||||
export function CreatePrescriptionPage() {
|
export function CreatePrescriptionPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -30,16 +35,71 @@ export function CreatePrescriptionPage() {
|
||||||
itemName: '',
|
itemName: '',
|
||||||
qty: '1',
|
qty: '1',
|
||||||
controlled: false,
|
controlled: false,
|
||||||
|
frequency: 'BID' as DosingFrequency,
|
||||||
|
intervalHours: '8',
|
||||||
|
times: defaultTimesForFrequency('BID'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedDoctor = activeDoctors.find((d) => d.id === form.doctorId)
|
const selectedDoctor = activeDoctors.find((d) => d.id === form.doctorId)
|
||||||
|
|
||||||
|
const setFrequency = (frequency: DosingFrequency) => {
|
||||||
|
const intervalHours = Number(form.intervalHours) || 8
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
frequency,
|
||||||
|
times: defaultTimesForFrequency(frequency, intervalHours),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setIntervalHours = (value: string) => {
|
||||||
|
const hours = Number(value) || 0
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
intervalHours: value,
|
||||||
|
times:
|
||||||
|
prev.frequency === 'QXH' && hours > 0
|
||||||
|
? defaultTimesForFrequency('QXH', hours)
|
||||||
|
: prev.times,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTime = (index: number, value: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
times: prev.times.map((t, i) => (i === index ? value : t)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTime = () => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
times: [...prev.times, '12:00'],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTime = (index: number) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
times: prev.times.length <= 1 ? prev.times : prev.times.filter((_, i) => i !== index),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmit = (e: FormEvent) => {
|
const onSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!selectedDoctor) {
|
if (!selectedDoctor) {
|
||||||
toast.error('Select a registered doctor as the prescriber')
|
toast.error('Select a registered doctor as the prescriber')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const cleanedTimes = form.times.map((t) => t.trim()).filter(Boolean)
|
||||||
|
if (cleanedTimes.length === 0) {
|
||||||
|
toast.error('Add at least one dose time')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const intervalHours = Number(form.intervalHours) || undefined
|
||||||
|
if (form.frequency === 'QXH' && (!intervalHours || intervalHours < 1)) {
|
||||||
|
toast.error('Enter hours between doses')
|
||||||
|
return
|
||||||
|
}
|
||||||
const rx: Prescription = {
|
const rx: Prescription = {
|
||||||
id: `rx-${Date.now().toString().slice(-6)}`,
|
id: `rx-${Date.now().toString().slice(-6)}`,
|
||||||
customerName: form.customerName,
|
customerName: form.customerName,
|
||||||
|
|
@ -50,6 +110,9 @@ export function CreatePrescriptionPage() {
|
||||||
name: form.itemName,
|
name: form.itemName,
|
||||||
qty: Number(form.qty) || 1,
|
qty: Number(form.qty) || 1,
|
||||||
controlled: form.controlled,
|
controlled: form.controlled,
|
||||||
|
frequency: form.frequency,
|
||||||
|
...(form.frequency === 'QXH' ? { intervalHours } : {}),
|
||||||
|
times: cleanedTimes,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
submittedAt: new Date().toISOString(),
|
submittedAt: new Date().toISOString(),
|
||||||
|
|
@ -154,6 +217,71 @@ export function CreatePrescriptionPage() {
|
||||||
onChange={(e) => setForm({ ...form, qty: e.target.value })}
|
onChange={(e) => setForm({ ...form, qty: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Dosing interval</Label>
|
||||||
|
<SelectNative
|
||||||
|
required
|
||||||
|
value={form.frequency}
|
||||||
|
onChange={(e) => setFrequency(e.target.value as DosingFrequency)}
|
||||||
|
>
|
||||||
|
{DOSING_FREQUENCY_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</SelectNative>
|
||||||
|
</div>
|
||||||
|
{form.frequency === 'QXH' ? (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Every N hours</Label>
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={24}
|
||||||
|
value={form.intervalHours}
|
||||||
|
onChange={(e) => setIntervalHours(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={cn('space-y-3', createFieldFull)}>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<Label>Dose times</Label>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
Clock times when this medication should be taken (24h)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5" onClick={addTime}>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Add time
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{form.times.map((time, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
required
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => updateTime(index, e.target.value)}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 shrink-0 text-muted-foreground hover:text-destructive"
|
||||||
|
disabled={form.times.length <= 1}
|
||||||
|
onClick={() => removeTime(index)}
|
||||||
|
aria-label={`Remove dose time ${time}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label className={cn('flex items-center gap-2 text-sm', createFieldFull)}>
|
<label className={cn('flex items-center gap-2 text-sm', createFieldFull)}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { StatusBadge, statusLabel } from '@/components/ui/status-badge'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { branches, doctors, prescriptions } from '@/mocks/data'
|
import { branches, doctors, prescriptions } from '@/mocks/data'
|
||||||
import { useAuthStore } from '@/zustand/authStore'
|
import { useAuthStore } from '@/zustand/authStore'
|
||||||
|
import { formatDoseTimes, formatFrequencyLabel } from '@/lib/dosingSchedule'
|
||||||
import { formatDateTime } from '@/lib/utils'
|
import { formatDateTime } from '@/lib/utils'
|
||||||
import type { Prescription } from '@/types'
|
import type { Prescription } from '@/types'
|
||||||
|
|
||||||
|
|
@ -303,12 +304,17 @@ export function PrescriptionDetailPage() {
|
||||||
/>
|
/>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
<DetailSection title="Medications" description="Verify strength, qty, and controlled flags">
|
<DetailSection
|
||||||
|
title="Medications"
|
||||||
|
description="Verify strength, qty, dosing schedule, and controlled flags"
|
||||||
|
>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead>Medication</TableHead>
|
<TableHead>Medication</TableHead>
|
||||||
<TableHead>Qty</TableHead>
|
<TableHead>Qty</TableHead>
|
||||||
|
<TableHead>Interval</TableHead>
|
||||||
|
<TableHead>Dose times</TableHead>
|
||||||
<TableHead>Flags</TableHead>
|
<TableHead>Flags</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -318,7 +324,25 @@ export function PrescriptionDetailPage() {
|
||||||
<TableCell className="font-medium">{item.name}</TableCell>
|
<TableCell className="font-medium">{item.name}</TableCell>
|
||||||
<TableCell>{item.qty}</TableCell>
|
<TableCell>{item.qty}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{item.controlled ? <Badge variant="danger">Controlled</Badge> : <Badge variant="secondary">OTC/Rx</Badge>}
|
{formatFrequencyLabel(item.frequency, item.intervalHours)}
|
||||||
|
{item.frequency ? (
|
||||||
|
<span className="mt-0.5 block font-mono text-[11px] text-muted-foreground">
|
||||||
|
{item.frequency}
|
||||||
|
{item.frequency === 'QXH' && item.intervalHours
|
||||||
|
? ` · ${item.intervalHours}h`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{formatDoseTimes(item.times)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.controlled ? (
|
||||||
|
<Badge variant="danger">Controlled</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">OTC/Rx</Badge>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ export type ModuleKey =
|
||||||
| 'migrations'
|
| 'migrations'
|
||||||
| 'analytics'
|
| 'analytics'
|
||||||
|
|
||||||
|
/** How the current session was established (mock or real IdP). */
|
||||||
|
export type AuthProvider = 'email' | 'google' | 'phone' | 'telegram'
|
||||||
|
|
||||||
export interface StaffUser {
|
export interface StaffUser {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -39,6 +42,8 @@ export interface StaffUser {
|
||||||
preferredLocale?: 'en' | 'am'
|
preferredLocale?: 'en' | 'am'
|
||||||
/** Profile photo URL when the account has one. */
|
/** Profile photo URL when the account has one. */
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
/** Provider used for this session (email, Google, phone OTP, Telegram). */
|
||||||
|
authProvider?: AuthProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Branch {
|
export interface Branch {
|
||||||
|
|
@ -50,12 +55,34 @@ export interface Branch {
|
||||||
|
|
||||||
export type OrganisationType = 'corporate' | 'hospital' | 'clinic' | 'ngo' | 'other'
|
export type OrganisationType = 'corporate' | 'hospital' | 'clinic' | 'ngo' | 'other'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How often a prescribed medication should be taken.
|
||||||
|
* Aligns with common sig codes; `QXH` uses `intervalHours`, `custom` relies on `times`.
|
||||||
|
*/
|
||||||
|
export type DosingFrequency = 'QD' | 'BID' | 'TID' | 'QID' | 'QXH' | 'custom'
|
||||||
|
|
||||||
|
/** Single medication line on a prescription (Rx item / sig). */
|
||||||
|
export interface PrescriptionItem {
|
||||||
|
name: string
|
||||||
|
qty: number
|
||||||
|
controlled?: boolean
|
||||||
|
/** Dosing frequency (BID, TID, every N hours, etc.). */
|
||||||
|
frequency?: DosingFrequency
|
||||||
|
/** Hours between doses when `frequency === 'QXH'`. */
|
||||||
|
intervalHours?: number
|
||||||
|
/**
|
||||||
|
* Clock times (HH:mm, 24h) when the dose should be taken.
|
||||||
|
* Same shape as mobile `Reminder.times` for patient notifications.
|
||||||
|
*/
|
||||||
|
times?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface Prescription {
|
export interface Prescription {
|
||||||
id: string
|
id: string
|
||||||
customerName: string
|
customerName: string
|
||||||
branchId: string
|
branchId: string
|
||||||
status: 'submitted' | 'under_review' | 'approved' | 'queried' | 'rejected'
|
status: 'submitted' | 'under_review' | 'approved' | 'queried' | 'rejected'
|
||||||
items: { name: string; qty: number; controlled?: boolean }[]
|
items: PrescriptionItem[]
|
||||||
submittedAt: string
|
submittedAt: string
|
||||||
/** Display name of the prescribing clinician (denormalised). */
|
/** Display name of the prescribing clinician (denormalised). */
|
||||||
prescriber?: string
|
prescriber?: string
|
||||||
|
|
|
||||||
4
src/vite-env.d.ts
vendored
4
src/vite-env.d.ts
vendored
|
|
@ -3,6 +3,10 @@
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_USE_MOCKS?: string
|
readonly VITE_USE_MOCKS?: string
|
||||||
readonly VITE_API_BASE_URL?: string
|
readonly VITE_API_BASE_URL?: string
|
||||||
|
/** Google OAuth Web client ID (GIS). Empty = mock Google sign-in. */
|
||||||
|
readonly VITE_GOOGLE_CLIENT_ID?: string
|
||||||
|
/** Telegram Login Widget bot username without @. Empty = mock widget. */
|
||||||
|
readonly VITE_TELEGRAM_BOT_USERNAME?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,46 @@
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
import type { StaffUser } from '@/types'
|
import type { AuthProvider, StaffUser } from '@/types'
|
||||||
import { demoAccounts } from '@/config/permissions'
|
import { demoAccounts } from '@/config/permissions'
|
||||||
import i18n from '@/i18n/config'
|
import i18n from '@/i18n/config'
|
||||||
|
|
||||||
|
const DEFAULT_PROVIDER_DEMO_EMAIL = 'pharmacist@gishen.et'
|
||||||
|
|
||||||
|
function staffFromDemo(
|
||||||
|
account: (typeof demoAccounts)[number],
|
||||||
|
authProvider: AuthProvider,
|
||||||
|
): StaffUser {
|
||||||
|
return {
|
||||||
|
id: account.email,
|
||||||
|
name: account.name,
|
||||||
|
email: account.email,
|
||||||
|
role: account.role,
|
||||||
|
branchId: account.branchId,
|
||||||
|
branchName: account.branchName,
|
||||||
|
avatarUrl: account.avatarUrl,
|
||||||
|
preferredLocale: (localStorage.getItem('gishen_locale') as 'en' | 'am') || 'en',
|
||||||
|
authProvider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSession(user: StaffUser) {
|
||||||
|
localStorage.setItem('access_token', 'demo-token')
|
||||||
|
return { user, token: 'demo-token' as const }
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: StaffUser | null
|
user: StaffUser | null
|
||||||
token: string | null
|
token: string | null
|
||||||
|
/** Email + password against demoAccounts. */
|
||||||
login: (email: string, password: string) => { ok: boolean; error?: string }
|
login: (email: string, password: string) => { ok: boolean; error?: string }
|
||||||
|
/**
|
||||||
|
* Mock IdP / OTP / Telegram widget success.
|
||||||
|
* Resolves a demo staff user (default pharmacist unless `email` is passed).
|
||||||
|
*/
|
||||||
|
loginWithProvider: (
|
||||||
|
provider: Exclude<AuthProvider, 'email'>,
|
||||||
|
opts?: { email?: string },
|
||||||
|
) => { ok: boolean; error?: string }
|
||||||
loginAsDemo: (email: string) => void
|
loginAsDemo: (email: string) => void
|
||||||
updateUser: (patch: Partial<StaffUser>) => void
|
updateUser: (patch: Partial<StaffUser>) => void
|
||||||
logout: () => void
|
logout: () => void
|
||||||
|
|
@ -21,34 +54,20 @@ export const useAuthStore = create<AuthState>()(
|
||||||
login: (email, password) => {
|
login: (email, password) => {
|
||||||
const account = demoAccounts.find((a) => a.email === email && a.password === password)
|
const account = demoAccounts.find((a) => a.email === email && a.password === password)
|
||||||
if (!account) return { ok: false, error: i18n.t('auth.error.invalidCredentials') }
|
if (!account) return { ok: false, error: i18n.t('auth.error.invalidCredentials') }
|
||||||
const user: StaffUser = {
|
set(persistSession(staffFromDemo(account, 'email')))
|
||||||
id: account.email,
|
return { ok: true }
|
||||||
name: account.name,
|
},
|
||||||
email: account.email,
|
loginWithProvider: (provider, opts) => {
|
||||||
role: account.role,
|
const email = opts?.email ?? DEFAULT_PROVIDER_DEMO_EMAIL
|
||||||
branchId: account.branchId,
|
const account = demoAccounts.find((a) => a.email === email)
|
||||||
branchName: account.branchName,
|
if (!account) return { ok: false, error: i18n.t('auth.error.invalidCredentials') }
|
||||||
avatarUrl: account.avatarUrl,
|
set(persistSession(staffFromDemo(account, provider)))
|
||||||
preferredLocale: (localStorage.getItem('gishen_locale') as 'en' | 'am') || 'en',
|
|
||||||
}
|
|
||||||
localStorage.setItem('access_token', 'demo-token')
|
|
||||||
set({ user, token: 'demo-token' })
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
loginAsDemo: (email) => {
|
loginAsDemo: (email) => {
|
||||||
const account = demoAccounts.find((a) => a.email === email)
|
const account = demoAccounts.find((a) => a.email === email)
|
||||||
if (!account) return
|
if (!account) return
|
||||||
const user: StaffUser = {
|
set(persistSession(staffFromDemo(account, 'email')))
|
||||||
id: account.email,
|
|
||||||
name: account.name,
|
|
||||||
email: account.email,
|
|
||||||
role: account.role,
|
|
||||||
branchId: account.branchId,
|
|
||||||
branchName: account.branchName,
|
|
||||||
avatarUrl: account.avatarUrl,
|
|
||||||
}
|
|
||||||
localStorage.setItem('access_token', 'demo-token')
|
|
||||||
set({ user, token: 'demo-token' })
|
|
||||||
},
|
},
|
||||||
updateUser: (patch) => {
|
updateUser: (patch) => {
|
||||||
set((state) => (state.user ? { user: { ...state.user, ...patch } } : {}))
|
set((state) => (state.user ? { user: { ...state.user, ...patch } } : {}))
|
||||||
|
|
|
||||||
Reference in New Issue
Block a user