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_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-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 | 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
|
||||
|
||||
|
|
@ -68,15 +73,30 @@ Doctors **cannot** manage platform catalogue, team RBAC, finance settlements, or
|
|||
|
||||
## 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 |
|
||||
| --- | --- | --- | --- |
|
||||
| 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/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? }` |
|
||||
| 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:**
|
||||
|
||||
```json
|
||||
|
|
@ -90,13 +110,33 @@ Doctors **cannot** manage platform catalogue, team RBAC, finance settlements, or
|
|||
"role": "pharmacist",
|
||||
"branchId": "uuid",
|
||||
"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`
|
||||
UI: `/profile` (every authenticated role; not permission-gated), topbar “My profile”
|
||||
`authProvider` values: `email` | `google` | `phone` | `telegram`.
|
||||
|
||||
**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 | `/pharmacy/prescriptions/:id/dispense` | pharmacist | `{ batch, quantity }` stamped pharmacistId + time |
|
||||
| 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 |
|
||||
|
||||
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[]
|
||||
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
|
||||
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
|
||||
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, optional `compareAtPriceEtb`
|
||||
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)
|
||||
7. Inventory defaults — default reorder point, expiry-alert days (on-hand/reserved/incoming are derived from stock)
|
||||
8. Media — images[], thumbnail, merchandising badges (**disabled for Rx/Controlled**), SEO slug/title/description
|
||||
9. Dedupe — `matchKey`, confidence, mergeCandidateId, mergeStatus, approver, timestamps
|
||||
10. Ownership & audit — per field-group source of truth, last ERP sync + error, created/modified by/at
|
||||
11. Relationships — B2B-eligible category tag; loyalty earn/redeem eligibility (**auto-excluded for Rx/Controlled**)
|
||||
6. Content — short description, dosage guidance, side effects (free text + structured mild/severe), warnings, storage (+ structured `storageNotes[]`), `directions[]`, `pharmacistTip`, `useCase`, similar alternatives[]
|
||||
7. Storefront / mobile PDP — fields below; Admin detail shows a live preview shaped for Gishen mobile
|
||||
8. Inventory defaults — default reorder point, expiry-alert days (on-hand/reserved/incoming are derived from stock)
|
||||
9. Media — images[], thumbnail, merchandising badges (**disabled for Rx/Controlled**), SEO slug/title/description
|
||||
10. Dedupe — `matchKey`, confidence, mergeCandidateId, mergeStatus, approver, timestamps
|
||||
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`, …)
|
||||
UI: `/items`, `/items/new`, `/items/:id`
|
||||
### Multi-UOM (ERPNext-style)
|
||||
|
||||
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=`
|
||||
|
||||
---
|
||||
|
|
@ -240,7 +374,7 @@ Stock create: `POST /pharmacy/inventory` prefers `{ itemId, branchId, batch, …
|
|||
| Method | Path | Roles | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| 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 | `/admin/stock/discrepancies` | stock_manager, super_admin | Platform vs ERP |
|
||||
| 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
|
||||
server-side must also exist in `ModuleKey` and `roleModules`.
|
||||
|
||||
Current SPA destinations of note: `/pos`, `/items`, `/profile` (palette + topbar;
|
||||
not a gated module), Payment Gateways `/finance/gateways` (palette-only).
|
||||
Current SPA destinations of note: `/pos`, `/items`, `/doctors` (orgs module —
|
||||
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
|
||||
deliberately not registered: they require a record id. Register the list page
|
||||
|
|
@ -503,3 +638,9 @@ or admin `POST /admin/users/:id/avatar`.
|
|||
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
|
||||
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": {
|
||||
"login": "ግባ",
|
||||
"email": "ኢሜይል",
|
||||
"phone": "ስልክ",
|
||||
"password": "የይለፍ ቃል",
|
||||
"welcome": "እንኳን ደህና መጡ",
|
||||
"subtitle": "የሙከራ ሚና ይምረጡ ወይም በሰራተኛ መለያ ይግቡ።",
|
||||
"subtitle": "በGoogle፣ ኢሜይል፣ ስልክ ወይም ቴሌግራም ይግቡ — ወይም የሙከራ ሚና ይምረጡ።",
|
||||
"demoHint": "ለሁሉም መለያዎች የይለፍ ቃል፡ demo123",
|
||||
"demoAccounts": "የሙከራ መለያዎች",
|
||||
"demoPickHint": "ፎርሙን ለመሙላት እና በቀጥታ ለመግባት የሙከራ ተጠቃሚ ይምረጡ።",
|
||||
"chooseTestUser": "የሙከራ ተጠቃሚ ይምረጡ",
|
||||
"signingInAs": "እንደ {{email}} በመግባት ላይ…",
|
||||
"signedIn": "ገብተዋል",
|
||||
"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": {
|
||||
"invalidCredentials": "የተሳሳተ ኢሜይል ወይም የይለፍ ቃል",
|
||||
"emailRequired": "የኢሜይል አድራሻዎን ያስገቡ",
|
||||
"passwordRequired": "የይለፍ ቃልዎን ያስገቡ"
|
||||
"passwordRequired": "የይለፍ ቃልዎን ያስገቡ",
|
||||
"phoneInvalid": "ትክክለኛ ስልክ ቁጥር ያስገቡ",
|
||||
"otpInvalid": "ትክክል ያልሆነ ኮድ። የሙከራ OTP፡ 123456።"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
|
|
|
|||
|
|
@ -7,17 +7,54 @@
|
|||
"auth": {
|
||||
"login": "Sign in",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"password": "Password",
|
||||
"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",
|
||||
"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",
|
||||
"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": {
|
||||
"invalidCredentials": "Invalid credentials",
|
||||
"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": {
|
||||
|
|
|
|||
|
|
@ -31,8 +31,19 @@ export let prescriptions: Prescription[] = [
|
|||
branchId: 'br-bole',
|
||||
status: 'under_review',
|
||||
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',
|
||||
prescriber: 'Dr. Selam',
|
||||
|
|
@ -42,7 +53,15 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Marta Hailu',
|
||||
branchId: 'br-bole',
|
||||
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',
|
||||
prescriber: 'Dr. Yohannes',
|
||||
},
|
||||
|
|
@ -51,7 +70,15 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Tigist Alemu',
|
||||
branchId: 'br-cmc',
|
||||
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',
|
||||
prescriber: 'Dr. Helen',
|
||||
reviewStartedAt: '2026-08-06T08:05:00Z',
|
||||
|
|
@ -61,7 +88,14 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Dawit Lemma',
|
||||
branchId: 'br-piassa',
|
||||
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',
|
||||
prescriber: 'Dr. Kalkidan',
|
||||
},
|
||||
|
|
@ -70,7 +104,7 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Hiwot Bekele',
|
||||
branchId: 'br-cmc',
|
||||
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',
|
||||
prescriber: 'Dr. Yohannes',
|
||||
reviewStartedAt: '2026-08-05T11:40:00Z',
|
||||
|
|
@ -83,7 +117,7 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Netsanet Desta',
|
||||
branchId: 'br-bole',
|
||||
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',
|
||||
prescriber: 'Dr. Selam',
|
||||
reviewStartedAt: '2026-08-04T09:25:00Z',
|
||||
|
|
@ -96,8 +130,8 @@ export let prescriptions: Prescription[] = [
|
|||
branchId: 'br-bole',
|
||||
status: 'approved',
|
||||
items: [
|
||||
{ name: 'Metformin 850mg', qty: 60 },
|
||||
{ name: 'Amlodipine 5mg', qty: 30 },
|
||||
{ name: 'Metformin 850mg', qty: 60, frequency: 'BID', times: ['08:00', '20:00'] },
|
||||
{ name: 'Amlodipine 5mg', qty: 30, frequency: 'QD', times: ['08:00'] },
|
||||
],
|
||||
submittedAt: '2026-08-03T14:05:00Z',
|
||||
prescriber: 'Dr. Helen',
|
||||
|
|
@ -110,7 +144,15 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Bekele Tesfaye',
|
||||
branchId: 'br-cmc',
|
||||
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',
|
||||
prescriber: 'Dr. Unknown',
|
||||
reviewStartedAt: '2026-08-02T11:00:00Z',
|
||||
|
|
@ -123,7 +165,7 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Yeshi Lemma',
|
||||
branchId: 'br-bole',
|
||||
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',
|
||||
prescriber: 'Dr. Selam',
|
||||
reviewStartedAt: '2026-08-01T08:35:00Z',
|
||||
|
|
@ -135,7 +177,7 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Sofia Ahmed',
|
||||
branchId: 'br-bole',
|
||||
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',
|
||||
prescriber: 'Dr. Yohannes',
|
||||
reviewStartedAt: '2026-07-31T14:00:00Z',
|
||||
|
|
@ -147,7 +189,14 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Genet Abera',
|
||||
branchId: 'br-piassa',
|
||||
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',
|
||||
prescriber: 'Dr. Kalkidan',
|
||||
reviewStartedAt: '2026-07-30T11:20:00Z',
|
||||
|
|
@ -159,7 +208,15 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Solomon Wolde',
|
||||
branchId: 'br-cmc',
|
||||
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',
|
||||
prescriber: 'Dr. Helen',
|
||||
reviewStartedAt: '2026-07-29T15:35:00Z',
|
||||
|
|
@ -172,7 +229,14 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Rahel Tadesse',
|
||||
branchId: 'br-bole',
|
||||
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',
|
||||
prescriber: 'Dr. Selam',
|
||||
reviewStartedAt: '2026-07-28T09:45:00Z',
|
||||
|
|
@ -184,7 +248,14 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Meseret Kassa',
|
||||
branchId: 'br-cmc',
|
||||
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',
|
||||
prescriber: 'Dr. Yohannes',
|
||||
reviewStartedAt: '2026-07-26T16:25:00Z',
|
||||
|
|
@ -196,7 +267,15 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Tesfaye Negash',
|
||||
branchId: 'br-bole',
|
||||
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',
|
||||
prescriber: 'Dr. Helen',
|
||||
reviewStartedAt: '2026-07-24T12:15:00Z',
|
||||
|
|
@ -209,7 +288,15 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Kalkidan Berhe',
|
||||
branchId: 'br-piassa',
|
||||
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',
|
||||
prescriber: 'Dr. Kalkidan',
|
||||
reviewStartedAt: '2026-07-22T10:30:00Z',
|
||||
|
|
@ -222,8 +309,8 @@ export let prescriptions: Prescription[] = [
|
|||
branchId: 'br-bole',
|
||||
status: 'approved',
|
||||
items: [
|
||||
{ name: 'Vitamin C 1000mg', qty: 2 },
|
||||
{ name: 'Ferrous sulphate 200mg', qty: 30 },
|
||||
{ name: 'Vitamin C 1000mg', qty: 2, frequency: 'QD', times: ['08:00'] },
|
||||
{ name: 'Ferrous sulphate 200mg', qty: 30, frequency: 'QD', times: ['08:00'] },
|
||||
],
|
||||
submittedAt: '2026-07-20T14:40:00Z',
|
||||
prescriber: 'Dr. Selam',
|
||||
|
|
@ -236,7 +323,7 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Fikru Assefa',
|
||||
branchId: 'br-cmc',
|
||||
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',
|
||||
prescriber: 'Dr. Yohannes',
|
||||
reviewStartedAt: '2026-07-17T09:20:00Z',
|
||||
|
|
@ -248,7 +335,14 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Mulugeta Fikadu',
|
||||
branchId: 'br-bole',
|
||||
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',
|
||||
prescriber: 'Dr. Helen',
|
||||
reviewStartedAt: '2026-07-14T17:40:00Z',
|
||||
|
|
@ -260,7 +354,7 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Selam Getahun',
|
||||
branchId: 'br-piassa',
|
||||
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',
|
||||
prescriber: 'Dr. Kalkidan',
|
||||
reviewStartedAt: '2026-07-11T12:05:00Z',
|
||||
|
|
@ -273,7 +367,7 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Zenebech Haile',
|
||||
branchId: 'br-cmc',
|
||||
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',
|
||||
prescriber: 'Dr. Yohannes',
|
||||
reviewStartedAt: '2026-07-09T09:00:00Z',
|
||||
|
|
@ -285,7 +379,14 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Abebe Kebede',
|
||||
branchId: 'br-bole',
|
||||
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',
|
||||
prescriber: 'Dr. Selam',
|
||||
reviewStartedAt: '2026-08-06T07:35:00Z',
|
||||
|
|
@ -297,7 +398,15 @@ export let prescriptions: Prescription[] = [
|
|||
customerName: 'Marta Hailu',
|
||||
branchId: 'br-bole',
|
||||
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',
|
||||
prescriber: 'Dr. Helen',
|
||||
reviewStartedAt: '2026-08-05T15:25:00Z',
|
||||
|
|
|
|||
|
|
@ -4,12 +4,16 @@ import { useTranslation } from 'react-i18next'
|
|||
import { toast } from 'sonner'
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FlaskConical,
|
||||
Loader2,
|
||||
Lock,
|
||||
Mail,
|
||||
Phone,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
Truck,
|
||||
} from 'lucide-react'
|
||||
|
|
@ -27,9 +31,12 @@ import {
|
|||
} from '@/components/ui/dropdown-menu'
|
||||
import { useAuthStore } from '@/zustand/authStore'
|
||||
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'
|
||||
/** Demo OTP accepted for any phone (mock SMS provider). */
|
||||
const MOCK_OTP = '123456'
|
||||
|
||||
const ROLE_ORDER: StaffRole[] = [
|
||||
'super_admin',
|
||||
|
|
@ -47,21 +54,80 @@ const VALUE_POINTS = [
|
|||
{ 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() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const loginWithProvider = useAuthStore((s) => s.loginWithProvider)
|
||||
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 [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
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 [loading, setLoading] = useState(false)
|
||||
const [providerBusy, setProviderBusy] = useState<Exclude<AuthProvider, 'email'> | 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(
|
||||
() =>
|
||||
ROLE_ORDER.map((role) => ({
|
||||
|
|
@ -74,21 +140,39 @@ export function LoginPage() {
|
|||
|
||||
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)
|
||||
if (!result.ok) {
|
||||
setFormError(result.error ?? t('auth.error.invalidCredentials'))
|
||||
toast.error(result.error ?? t('auth.error.invalidCredentials'))
|
||||
return false
|
||||
}
|
||||
if (remember) localStorage.setItem(REMEMBER_KEY, nextEmail)
|
||||
else localStorage.removeItem(REMEMBER_KEY)
|
||||
toast.success(t('auth.signedIn'))
|
||||
finishSignIn(nextEmail)
|
||||
return true
|
||||
}
|
||||
|
||||
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('/')
|
||||
return true
|
||||
}
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
const onSubmitEmail = (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
const errors: { email?: string; password?: string } = {}
|
||||
if (!email.trim()) errors.email = t('auth.error.emailRequired')
|
||||
|
|
@ -99,24 +183,67 @@ export function LoginPage() {
|
|||
setFormError(null)
|
||||
setLoading(true)
|
||||
window.setTimeout(() => {
|
||||
const ok = signIn(email.trim(), password)
|
||||
const ok = signInEmail(email.trim(), password)
|
||||
if (!ok) setLoading(false)
|
||||
}, 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]) => {
|
||||
setEmail(account.email)
|
||||
setPassword(account.password)
|
||||
setCredentialMode('email')
|
||||
setFieldErrors({})
|
||||
setFormError(null)
|
||||
setDemoEmail(account.email)
|
||||
window.setTimeout(() => {
|
||||
const ok = signIn(account.email, account.password)
|
||||
const ok = signInEmail(account.email, account.password)
|
||||
if (!ok) setDemoEmail(null)
|
||||
}, 450)
|
||||
}
|
||||
|
||||
const busy = loading || demoEmail !== null
|
||||
const busy = loading || demoEmail !== null || providerBusy !== null || otpSending
|
||||
|
||||
return (
|
||||
<div className="grid min-h-screen bg-cream lg:grid-cols-[1.05fr_1fr]">
|
||||
|
|
@ -155,7 +282,7 @@ export function LoginPage() {
|
|||
</aside>
|
||||
|
||||
<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 h-11 w-11 items-center justify-center rounded-2xl bg-brand-500 font-display text-lg font-bold text-forest">
|
||||
G
|
||||
|
|
@ -179,95 +306,267 @@ export function LoginPage() {
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={onSubmit} 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('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')}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
className="w-full justify-center gap-2.5 bg-white"
|
||||
onClick={() => onProviderClick('google')}
|
||||
>
|
||||
{providerBusy === 'google' ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
) : (
|
||||
<GoogleMark className="h-4 w-4" />
|
||||
)}
|
||||
{providerBusy === 'google' ? t('auth.connecting') : t('auth.continueGoogle')}
|
||||
</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
|
||||
aria-labelledby="demo-heading"
|
||||
|
|
@ -285,7 +584,7 @@ export function LoginPage() {
|
|||
<Badge variant="warning">Demo only</Badge>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -303,7 +602,7 @@ export function LoginPage() {
|
|||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden="true" />
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{demoEmail ? `Signing in as ${demoEmail}…` : 'Choose a test user'}
|
||||
{demoEmail ? t('auth.signingInAs', { email: demoEmail }) : t('auth.chooseTestUser')}
|
||||
</span>
|
||||
</span>
|
||||
<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 { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
CreatePageLayout,
|
||||
createFieldFull,
|
||||
|
|
@ -13,8 +14,12 @@ import { Label } from '@/components/ui/label'
|
|||
import { SelectNative } from '@/components/ui/select-native'
|
||||
import { useAuthStore } from '@/zustand/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
DOSING_FREQUENCY_OPTIONS,
|
||||
defaultTimesForFrequency,
|
||||
} from '@/lib/dosingSchedule'
|
||||
import { branches, doctors, prescriptions } from '@/mocks/data'
|
||||
import type { Prescription } from '@/types'
|
||||
import type { DosingFrequency, Prescription } from '@/types'
|
||||
|
||||
export function CreatePrescriptionPage() {
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -30,16 +35,71 @@ export function CreatePrescriptionPage() {
|
|||
itemName: '',
|
||||
qty: '1',
|
||||
controlled: false,
|
||||
frequency: 'BID' as DosingFrequency,
|
||||
intervalHours: '8',
|
||||
times: defaultTimesForFrequency('BID'),
|
||||
})
|
||||
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
if (!selectedDoctor) {
|
||||
toast.error('Select a registered doctor as the prescriber')
|
||||
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 = {
|
||||
id: `rx-${Date.now().toString().slice(-6)}`,
|
||||
customerName: form.customerName,
|
||||
|
|
@ -50,6 +110,9 @@ export function CreatePrescriptionPage() {
|
|||
name: form.itemName,
|
||||
qty: Number(form.qty) || 1,
|
||||
controlled: form.controlled,
|
||||
frequency: form.frequency,
|
||||
...(form.frequency === 'QXH' ? { intervalHours } : {}),
|
||||
times: cleanedTimes,
|
||||
},
|
||||
],
|
||||
submittedAt: new Date().toISOString(),
|
||||
|
|
@ -154,6 +217,71 @@ export function CreatePrescriptionPage() {
|
|||
onChange={(e) => setForm({ ...form, qty: e.target.value })}
|
||||
/>
|
||||
</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)}>
|
||||
<input
|
||||
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 { branches, doctors, prescriptions } from '@/mocks/data'
|
||||
import { useAuthStore } from '@/zustand/authStore'
|
||||
import { formatDoseTimes, formatFrequencyLabel } from '@/lib/dosingSchedule'
|
||||
import { formatDateTime } from '@/lib/utils'
|
||||
import type { Prescription } from '@/types'
|
||||
|
||||
|
|
@ -303,12 +304,17 @@ export function PrescriptionDetailPage() {
|
|||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Medications" description="Verify strength, qty, and controlled flags">
|
||||
<DetailSection
|
||||
title="Medications"
|
||||
description="Verify strength, qty, dosing schedule, and controlled flags"
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Medication</TableHead>
|
||||
<TableHead>Qty</TableHead>
|
||||
<TableHead>Interval</TableHead>
|
||||
<TableHead>Dose times</TableHead>
|
||||
<TableHead>Flags</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -318,7 +324,25 @@ export function PrescriptionDetailPage() {
|
|||
<TableCell className="font-medium">{item.name}</TableCell>
|
||||
<TableCell>{item.qty}</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>
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ export type ModuleKey =
|
|||
| 'migrations'
|
||||
| 'analytics'
|
||||
|
||||
/** How the current session was established (mock or real IdP). */
|
||||
export type AuthProvider = 'email' | 'google' | 'phone' | 'telegram'
|
||||
|
||||
export interface StaffUser {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -39,6 +42,8 @@ export interface StaffUser {
|
|||
preferredLocale?: 'en' | 'am'
|
||||
/** Profile photo URL when the account has one. */
|
||||
avatarUrl?: string
|
||||
/** Provider used for this session (email, Google, phone OTP, Telegram). */
|
||||
authProvider?: AuthProvider
|
||||
}
|
||||
|
||||
export interface Branch {
|
||||
|
|
@ -50,12 +55,34 @@ export interface Branch {
|
|||
|
||||
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 {
|
||||
id: string
|
||||
customerName: string
|
||||
branchId: string
|
||||
status: 'submitted' | 'under_review' | 'approved' | 'queried' | 'rejected'
|
||||
items: { name: string; qty: number; controlled?: boolean }[]
|
||||
items: PrescriptionItem[]
|
||||
submittedAt: string
|
||||
/** Display name of the prescribing clinician (denormalised). */
|
||||
prescriber?: string
|
||||
|
|
|
|||
4
src/vite-env.d.ts
vendored
4
src/vite-env.d.ts
vendored
|
|
@ -3,6 +3,10 @@
|
|||
interface ImportMetaEnv {
|
||||
readonly VITE_USE_MOCKS?: 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 {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,46 @@
|
|||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { StaffUser } from '@/types'
|
||||
import type { AuthProvider, StaffUser } from '@/types'
|
||||
import { demoAccounts } from '@/config/permissions'
|
||||
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 {
|
||||
user: StaffUser | null
|
||||
token: string | null
|
||||
/** Email + password against demoAccounts. */
|
||||
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
|
||||
updateUser: (patch: Partial<StaffUser>) => void
|
||||
logout: () => void
|
||||
|
|
@ -21,34 +54,20 @@ export const useAuthStore = create<AuthState>()(
|
|||
login: (email, password) => {
|
||||
const account = demoAccounts.find((a) => a.email === email && a.password === password)
|
||||
if (!account) return { ok: false, error: i18n.t('auth.error.invalidCredentials') }
|
||||
const user: StaffUser = {
|
||||
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',
|
||||
}
|
||||
localStorage.setItem('access_token', 'demo-token')
|
||||
set({ user, token: 'demo-token' })
|
||||
set(persistSession(staffFromDemo(account, 'email')))
|
||||
return { ok: true }
|
||||
},
|
||||
loginWithProvider: (provider, opts) => {
|
||||
const email = opts?.email ?? DEFAULT_PROVIDER_DEMO_EMAIL
|
||||
const account = demoAccounts.find((a) => a.email === email)
|
||||
if (!account) return { ok: false, error: i18n.t('auth.error.invalidCredentials') }
|
||||
set(persistSession(staffFromDemo(account, provider)))
|
||||
return { ok: true }
|
||||
},
|
||||
loginAsDemo: (email) => {
|
||||
const account = demoAccounts.find((a) => a.email === email)
|
||||
if (!account) return
|
||||
const user: StaffUser = {
|
||||
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' })
|
||||
set(persistSession(staffFromDemo(account, 'email')))
|
||||
},
|
||||
updateUser: (patch) => {
|
||||
set((state) => (state.user ? { user: { ...state.user, ...patch } } : {}))
|
||||
|
|
|
|||
Reference in New Issue
Block a user