Improve list pages with filters, row actions, and create flows

Shared TableToolbar and RowActions; add create pages for orders, Rx, orgs, stock, team, and procurement.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kirukib 2026-08-06 19:09:41 +03:00
parent 728a31060f
commit f2aff8c377
18 changed files with 1782 additions and 307 deletions

View File

@ -20,6 +20,7 @@
| 2026-08-06 | Super Admin team/inbox/audit/settings + FAQ bilingual |
| 2026-08-06 | Data migration import jobs |
| 2026-08-06 | Deployed Admin SPA: https://gishen-admin.vercel.app |
| 2026-08-06 | List UX: filters + create pages for orders, Rx, orgs, stock, team, procurement; `POST` create sketches below |
## Conventions
@ -74,7 +75,8 @@ Mock key: `authStore.login` → `/auth/staff/login`
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET | `/pharmacy/orders` | pharmacist, operations, super_admin | `?branchId=` required for pharmacist |
| GET | `/pharmacy/orders` | pharmacist, operations, super_admin | `?branchId=` required for pharmacist; filters: `status`, `fulfillment`, `channel`, `q` |
| POST | `/pharmacy/orders` | pharmacist, operations, super_admin | Staff-created order (call centre / walk-in) |
| POST | `/orders/:id/confirm` | pharmacist, super_admin | Confirm stock |
| POST | `/orders/:id/assign-rider` | pharmacist, operations, super_admin | `{ riderId }` |
| PATCH | `/orders/:id/status` | pharmacist, operations, super_admin | Status lifecycle |
@ -87,7 +89,8 @@ Mock: `mocks/data.orders`
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET | `/pharmacy/prescriptions` | pharmacist, super_admin | Branch-scoped queue |
| GET | `/pharmacy/prescriptions` | pharmacist, super_admin | Branch-scoped queue; filters: `status`, `controlled`, `q` |
| POST | `/pharmacy/prescriptions` | pharmacist, super_admin | Staff Rx intake |
| 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 |
@ -99,7 +102,8 @@ Mock: `mocks/data.orders`
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET | `/pharmacy/inventory` | stock_manager, pharmacist (read), procurement (read), super_admin | Per-branch stock |
| 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 | Add stock line |
| 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 |
@ -186,7 +190,7 @@ Fields: `title_en`, `title_am`, `body_en`, `body_am`, `category`, `published`.
| Method | Path | Roles | Description |
| --- | --- | --- | --- |
| GET/POST | `/admin/users` | super_admin | Staff + role + branch |
| GET/POST | `/admin/users` | super_admin | Staff + role + branch; list supports `role`, `branchScoped`, `q` |
| GET | `/admin/inbox/threads` | super_admin | Chatwoot/Telegram/WhatsApp attached to customer |
| GET | `/admin/audit` | super_admin | Audit log |
| GET/PUT | `/admin/settings` | super_admin | Retention, access controls |

View File

@ -4,17 +4,23 @@ import { AppLayout } from '@/layouts/AppLayout'
import { LoginPage } from '@/pages/auth/LoginPage'
import { DashboardPage } from '@/pages/dashboard/DashboardPage'
import { OrdersPage } from '@/pages/orders/OrdersPage'
import { CreateOrderPage } from '@/pages/orders/CreateOrderPage'
import { PrescriptionsPage } from '@/pages/prescriptions/PrescriptionsPage'
import { CreatePrescriptionPage } from '@/pages/prescriptions/CreatePrescriptionPage'
import { StockPage } from '@/pages/stock/StockPage'
import { CreateStockPage } from '@/pages/stock/CreateStockPage'
import { ProcurementPage } from '@/pages/procurement/ProcurementPage'
import { CreateProcurementRequestPage } from '@/pages/procurement/CreateProcurementRequestPage'
import { FinancePage } from '@/pages/finance/FinancePage'
import { OrganisationsPage } from '@/pages/organisations/OrganisationsPage'
import { CreateOrganisationPage } from '@/pages/organisations/CreateOrganisationPage'
import { MarketingPage } from '@/pages/marketing/MarketingPage'
import { CrmPage } from '@/pages/marketing/CrmPage'
import { LoyaltyPage } from '@/pages/marketing/LoyaltyPage'
import { CampaignsPage } from '@/pages/marketing/CampaignsPage'
import { DispatchPage } from '@/pages/dispatch/DispatchPage'
import { TeamPage } from '@/pages/team/TeamPage'
import { CreateTeamMemberPage } from '@/pages/team/CreateTeamMemberPage'
import { InboxPage } from '@/pages/inbox/InboxPage'
import { AuditPage } from '@/pages/audit/AuditPage'
import { FaqPage } from '@/pages/faq/FaqPage'
@ -36,25 +42,19 @@ export function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
element={
<AppLayout />
}
>
<Route
index
element={
<Guard module="dashboard">
<DashboardPage />
</Guard>
}
/>
<Route element={<AppLayout />}>
<Route index element={<Guard module="dashboard"><DashboardPage /></Guard>} />
<Route path="orders" element={<Guard module="orders"><OrdersPage /></Guard>} />
<Route path="orders/new" element={<Guard module="orders"><CreateOrderPage /></Guard>} />
<Route path="prescriptions" element={<Guard module="prescriptions"><PrescriptionsPage /></Guard>} />
<Route path="prescriptions/new" element={<Guard module="prescriptions"><CreatePrescriptionPage /></Guard>} />
<Route path="stock" element={<Guard module="stock"><StockPage /></Guard>} />
<Route path="stock/new" element={<Guard module="stock"><CreateStockPage /></Guard>} />
<Route path="procurement" element={<Guard module="procurement"><ProcurementPage /></Guard>} />
<Route path="procurement/new" element={<Guard module="procurement"><CreateProcurementRequestPage /></Guard>} />
<Route path="finance" element={<Guard module="finance"><FinancePage /></Guard>} />
<Route path="organisations" element={<Guard module="organisations"><OrganisationsPage /></Guard>} />
<Route path="organisations/new" element={<Guard module="organisations"><CreateOrganisationPage /></Guard>} />
<Route path="marketing" element={<Guard module="marketing"><MarketingPage /></Guard>} />
<Route path="crm" element={<Guard module="crm"><CrmPage /></Guard>} />
<Route path="loyalty" element={<Guard module="loyalty"><LoyaltyPage /></Guard>} />
@ -64,6 +64,7 @@ export function AppRoutes() {
<Route path="migrations" element={<Guard module="migrations"><MigrationsPage /></Guard>} />
<Route path="faq" element={<Guard module="faq"><FaqPage /></Guard>} />
<Route path="team" element={<Guard module="team"><TeamPage /></Guard>} />
<Route path="team/new" element={<Guard module="team"><CreateTeamMemberPage /></Guard>} />
<Route path="inbox" element={<Guard module="inbox"><InboxPage /></Guard>} />
<Route path="audit" element={<Guard module="audit"><AuditPage /></Guard>} />
<Route path="settings" element={<Guard module="settings"><SettingsPage /></Guard>} />

View File

@ -0,0 +1,81 @@
import type { ReactNode } from 'react'
import { MoreHorizontal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
export type RowAction = {
label: string
onClick: () => void
icon?: ReactNode
variant?: 'default' | 'destructive'
disabled?: boolean
}
/** Compact primary icon + overflow menu for table rows */
export function RowActions({
primary,
items,
className,
}: {
primary?: RowAction[]
items?: RowAction[]
className?: string
}) {
const overflow = items ?? []
return (
<div className={cn('flex items-center justify-end gap-1', className)}>
{primary?.map((action) => (
<Button
key={action.label}
type="button"
size="sm"
variant={action.variant === 'destructive' ? 'destructive' : 'outline'}
className="h-9 gap-1.5 px-3 shadow-none"
disabled={action.disabled}
onClick={action.onClick}
>
{action.icon}
<span className="hidden xl:inline">{action.label}</span>
<span className="xl:hidden">{action.icon ? null : action.label}</span>
</Button>
))}
{overflow.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="h-9 w-9 shrink-0 rounded-lg border border-transparent hover:border-border hover:bg-muted"
aria-label="More actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[11rem]">
{overflow.map((action) => (
<DropdownMenuItem
key={action.label}
disabled={action.disabled}
className={cn(
'gap-2',
action.variant === 'destructive' && 'text-destructive focus:text-destructive',
)}
onClick={action.onClick}
>
{action.icon}
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
)
}

View File

@ -0,0 +1,105 @@
import type { ReactNode } from 'react'
import { Search, X } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { SelectNative } from '@/components/ui/select-native'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
export function TableToolbar({
search,
onSearchChange,
searchPlaceholder = 'Search…',
filters,
actions,
resultCount,
className,
}: {
search?: string
onSearchChange?: (value: string) => void
searchPlaceholder?: string
filters?: ReactNode
actions?: ReactNode
resultCount?: number
className?: string
}) {
return (
<div className={cn('space-y-3 border-b bg-muted/30 px-4 py-4', className)}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-3 sm:flex-row sm:items-center">
{onSearchChange ? (
<div className="relative w-full max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-10 pl-9 pr-9"
placeholder={searchPlaceholder}
value={search ?? ''}
onChange={(e) => onSearchChange(e.target.value)}
/>
{search ? (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() => onSearchChange('')}
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
) : null}
{filters ? <div className="flex flex-wrap items-center gap-2">{filters}</div> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{typeof resultCount === 'number' ? (
<Badge variant="secondary" className="h-8 px-3 font-medium">
{resultCount} results
</Badge>
) : null}
{actions}
</div>
</div>
</div>
)
}
export function FilterSelect({
label,
value,
onChange,
options,
className,
}: {
label: string
value: string
onChange: (value: string) => void
options: { value: string; label: string }[]
className?: string
}) {
return (
<div className={cn('flex items-center gap-1.5', className)}>
<span className="hidden text-xs font-medium text-muted-foreground sm:inline">{label}</span>
<SelectNative
className="h-10 w-auto min-w-[9rem] bg-background"
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={label}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</SelectNative>
</div>
)
}
export function ClearFiltersButton({ onClick, visible }: { onClick: () => void; visible: boolean }) {
if (!visible) return null
return (
<Button type="button" variant="ghost" size="sm" className="h-10 text-muted-foreground" onClick={onClick}>
Clear filters
</Button>
)
}

View File

@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
export function CreatePageLayout({
title,
subtitle,
backTo,
backLabel = 'Back to list',
children,
footer,
className,
}: {
title: string
subtitle?: string
backTo: string
backLabel?: string
children: ReactNode
footer?: ReactNode
className?: string
}) {
return (
<div className={cn('mx-auto max-w-3xl space-y-5', className)}>
<div>
<Button asChild variant="ghost" size="sm" className="-ml-2 mb-3 text-muted-foreground">
<Link to={backTo}>
<ArrowLeft className="h-4 w-4" />
{backLabel}
</Link>
</Button>
<div className="mb-2 h-1 w-12 rounded-full bg-gradient-to-r from-brand-500 to-orange" />
<h1 className="font-display text-2xl font-bold tracking-tight text-forest">{title}</h1>
{subtitle ? <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p> : null}
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Details</CardTitle>
{subtitle ? <CardDescription className="sr-only">{subtitle}</CardDescription> : null}
</CardHeader>
<CardContent className="space-y-5">{children}</CardContent>
</Card>
{footer ? <div className="flex flex-wrap justify-end gap-2">{footer}</div> : null}
</div>
)
}

View File

@ -1,19 +1,81 @@
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PageShell } from '@/layouts/PageShell'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
import { customers } from '@/mocks/data'
export function CrmPage() {
const { t } = useTranslation()
const [q, setQ] = useState('')
const [tier, setTier] = useState('all')
const [branch, setBranch] = useState('all')
const filtered = useMemo(() => {
return customers.filter((c) => {
if (tier !== 'all' && c.tier !== tier) return false
if (branch !== 'all' && c.branch !== branch) return false
if (q) {
const needle = q.toLowerCase()
if (
!c.name.toLowerCase().includes(needle) &&
!c.phone.includes(needle)
) {
return false
}
}
return true
})
}, [q, tier, branch])
return (
<PageShell title={t('crm.title')} subtitle={t('crm.subtitle')}>
<Card>
<CardContent className="p-4">
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search name or phone…"
resultCount={filtered.length}
filters={
<>
<FilterSelect
label="Tier"
value={tier}
onChange={setTier}
options={[
{ value: 'all', label: 'All tiers' },
{ value: 'Bronze', label: 'Bronze' },
{ value: 'Silver', label: 'Silver' },
{ value: 'Gold', label: 'Gold' },
]}
/>
<FilterSelect
label="Branch"
value={branch}
onChange={setBranch}
options={[
{ value: 'all', label: 'All catchments' },
{ value: 'Bole', label: 'Bole' },
{ value: 'CMC', label: 'CMC' },
]}
/>
<ClearFiltersButton
visible={tier !== 'all' || branch !== 'all' || !!q}
onClick={() => {
setQ('')
setTier('all')
setBranch('all')
}}
/>
</>
}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Tier</TableHead>
@ -22,17 +84,25 @@ export function CrmPage() {
</TableRow>
</TableHeader>
<TableBody>
{customers.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell>{c.phone}</TableCell>
<TableCell>
<Badge>{c.tier}</Badge>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
<TableCell>{c.points}</TableCell>
<TableCell>{c.branch}</TableCell>
</TableRow>
))}
) : (
filtered.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell>{c.phone}</TableCell>
<TableCell>
<Badge>{c.tier}</Badge>
</TableCell>
<TableCell>{c.points}</TableCell>
<TableCell>{c.branch}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>

View File

@ -0,0 +1,152 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { CreatePageLayout } from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { Textarea } from '@/components/ui/textarea'
import { useAuthStore } from '@/zustand/authStore'
import { branches, orders } from '@/mocks/data'
import type { Order } from '@/types'
export function CreateOrderPage() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const [form, setForm] = useState({
customerName: '',
phone: '',
branchId: user?.branchId ?? branches[0]?.id ?? '',
fulfillment: 'pickup' as 'pickup' | 'delivery',
channel: 'web',
totalEtb: '',
notes: '',
address: '',
})
const onSubmit = (e: FormEvent) => {
e.preventDefault()
const order: Order = {
id: `ord-${Date.now().toString().slice(-6)}`,
customerName: form.customerName || `Guest · ${form.phone}`,
branchId: form.branchId,
fulfillment: form.fulfillment,
status: 'confirmed',
totalEtb: Number(form.totalEtb) || 0,
channel: form.channel,
address: form.fulfillment === 'delivery' ? form.address : undefined,
zone: branches.find((b) => b.id === form.branchId)?.zone,
createdAt: new Date().toISOString(),
paid: false,
rxApproved: false,
}
orders.unshift(order)
toast.success('Staff order created')
navigate('/orders')
}
return (
<CreatePageLayout
title="Create order"
subtitle="Staff-entered order for call centre / walk-in"
backTo="/orders"
footer={
<>
<Button type="button" variant="outline" onClick={() => navigate('/orders')}>
Cancel
</Button>
<Button type="submit" form="create-order-form">
Create order
</Button>
</>
}
>
<form id="create-order-form" className="grid gap-4 sm:grid-cols-2" onSubmit={onSubmit}>
<div className="space-y-1.5 sm:col-span-2">
<Label>Customer name</Label>
<Input
required
value={form.customerName}
onChange={(e) => setForm({ ...form, customerName: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Phone</Label>
<Input
required
placeholder="+2519…"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Total (ETB)</Label>
<Input
required
type="number"
min={0}
value={form.totalEtb}
onChange={(e) => setForm({ ...form, totalEtb: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Branch</Label>
<SelectNative
value={form.branchId}
onChange={(e) => setForm({ ...form, branchId: e.target.value })}
disabled={user?.role === 'pharmacist'}
>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Fulfillment</Label>
<SelectNative
value={form.fulfillment}
onChange={(e) =>
setForm({ ...form, fulfillment: e.target.value as 'pickup' | 'delivery' })
}
>
<option value="pickup">Pickup</option>
<option value="delivery">Delivery</option>
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Channel</Label>
<SelectNative
value={form.channel}
onChange={(e) => setForm({ ...form, channel: e.target.value })}
>
<option value="web">Web / call</option>
<option value="mobile">Mobile</option>
<option value="telegram">Telegram</option>
<option value="b2b">B2B</option>
</SelectNative>
</div>
{form.fulfillment === 'delivery' ? (
<div className="space-y-1.5 sm:col-span-2">
<Label>Delivery address</Label>
<Input
required
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
/>
</div>
) : null}
<div className="space-y-1.5 sm:col-span-2">
<Label>Notes</Label>
<Textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Optional staff notes"
/>
</div>
</form>
</CreatePageLayout>
)
}

View File

@ -1,13 +1,24 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { CheckCircle2, Plus, Truck, XCircle } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { Input } from '@/components/ui/input'
import { useAuthStore } from '@/zustand/authStore'
import { branches, orders as seedOrders, riders } from '@/mocks/data'
import { formatEtb } from '@/lib/utils'
@ -18,102 +29,259 @@ export function OrdersPage() {
const user = useAuthStore((s) => s.user)
const [rows, setRows] = useState<Order[]>([...seedOrders])
const [q, setQ] = useState('')
const [status, setStatus] = useState('all')
const [fulfillment, setFulfillment] = useState('all')
const [branchFilter, setBranchFilter] = useState('all')
const [channel, setChannel] = useState('all')
const [assignTarget, setAssignTarget] = useState<Order | null>(null)
const [riderId, setRiderId] = useState('')
const canPickBranch = user?.role !== 'pharmacist'
const filtered = useMemo(() => {
let list = rows
if (user?.role === 'pharmacist' && user.branchId) {
list = list.filter((o) => o.branchId === user.branchId)
} else if (branchFilter !== 'all') {
list = list.filter((o) => o.branchId === branchFilter)
}
if (status !== 'all') list = list.filter((o) => o.status === status)
if (fulfillment !== 'all') list = list.filter((o) => o.fulfillment === fulfillment)
if (channel !== 'all') list = list.filter((o) => o.channel === channel)
if (q) {
const needle = q.toLowerCase()
list = list.filter(
(o) => o.customerName.toLowerCase().includes(needle) || o.id.toLowerCase().includes(needle),
)
}
if (q) list = list.filter((o) => o.customerName.toLowerCase().includes(q.toLowerCase()) || o.id.includes(q))
return list
}, [rows, user, q])
}, [rows, user, q, status, fulfillment, branchFilter, channel])
const assign = (orderId: string, riderId: string) => {
setRows((prev) =>
prev.map((o) =>
o.id === orderId
? { ...o, assignedRiderId: riderId || undefined, status: riderId ? 'out_for_delivery' : o.status }
: o,
),
)
Object.assign(
seedOrders.find((o) => o.id === orderId) ?? {},
{ assignedRiderId: riderId || undefined, status: riderId ? 'out_for_delivery' : undefined },
)
toast.success(riderId ? 'Delivery person assigned' : 'Assignment cleared')
}
const filtersActive =
status !== 'all' || fulfillment !== 'all' || branchFilter !== 'all' || channel !== 'all' || !!q
const confirm = (orderId: string) => {
setRows((prev) => prev.map((o) => (o.id === orderId ? { ...o, status: 'confirmed' } : o)))
const target = seedOrders.find((o) => o.id === orderId)
if (target) target.status = 'confirmed'
toast.success('Order confirmed')
}
const cancel = (orderId: string) => {
setRows((prev) => prev.map((o) => (o.id === orderId ? { ...o, status: 'cancelled' } : o)))
toast.message('Order cancelled')
}
const saveAssign = () => {
if (!assignTarget || !riderId) return
setRows((prev) =>
prev.map((o) =>
o.id === assignTarget.id
? { ...o, assignedRiderId: riderId, status: 'out_for_delivery' }
: o,
),
)
const target = seedOrders.find((o) => o.id === assignTarget.id)
if (target) {
target.assignedRiderId = riderId
target.status = 'out_for_delivery'
}
toast.success('Delivery person assigned')
setAssignTarget(null)
setRiderId('')
}
return (
<PageShell title={t('orders.title')} subtitle={t('orders.subtitle')}>
<Card>
<CardContent className="space-y-4 p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Input
className="max-w-sm"
placeholder={t('common.search')}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<Badge variant="secondary">
{user?.branchName ?? t('common.allBranches')} · {filtered.length}
</Badge>
</div>
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search ID or customer…"
resultCount={filtered.length}
filters={
<>
<FilterSelect
label="Status"
value={status}
onChange={setStatus}
options={[
{ value: 'all', label: 'All statuses' },
{ value: 'confirmed', label: 'Confirmed' },
{ value: 'picking', label: 'Picking' },
{ value: 'out_for_delivery', label: 'Out for delivery' },
{ value: 'cancelled', label: 'Cancelled' },
]}
/>
<FilterSelect
label="Fulfillment"
value={fulfillment}
onChange={setFulfillment}
options={[
{ value: 'all', label: 'All types' },
{ value: 'delivery', label: 'Delivery' },
{ value: 'pickup', label: 'Pickup' },
]}
/>
{canPickBranch ? (
<FilterSelect
label="Branch"
value={branchFilter}
onChange={setBranchFilter}
options={[
{ value: 'all', label: t('common.allBranches') },
...branches.map((b) => ({ value: b.id, label: b.name })),
]}
/>
) : null}
<FilterSelect
label="Channel"
value={channel}
onChange={setChannel}
options={[
{ value: 'all', label: 'All channels' },
{ value: 'web', label: 'Web' },
{ value: 'mobile', label: 'Mobile' },
{ value: 'telegram', label: 'Telegram' },
{ value: 'b2b', label: 'B2B' },
]}
/>
<ClearFiltersButton
visible={filtersActive}
onClick={() => {
setQ('')
setStatus('all')
setFulfillment('all')
setBranchFilter('all')
setChannel('all')
}}
/>
</>
}
actions={
<Button asChild className="h-10 gap-2 shadow-none">
<Link to="/orders/new">
<Plus className="h-4 w-4" />
New order
</Link>
</Button>
}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableRow className="hover:bg-transparent">
<TableHead>ID</TableHead>
<TableHead>Customer</TableHead>
<TableHead>{t('common.branch')}</TableHead>
<TableHead>Fulfillment</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead>Total</TableHead>
<TableHead>{t('common.actions')}</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((o) => (
<TableRow key={o.id}>
<TableCell className="font-medium">{o.id}</TableCell>
<TableCell>{o.customerName}</TableCell>
<TableCell>{branches.find((b) => b.id === o.branchId)?.name}</TableCell>
<TableCell>
<Badge variant="outline">{o.fulfillment}</Badge>
</TableCell>
<TableCell>
<Badge variant={o.status === 'out_for_delivery' ? 'warning' : 'success'}>{o.status}</Badge>
</TableCell>
<TableCell>{formatEtb(o.totalEtb)}</TableCell>
<TableCell>
<div className="flex min-w-[200px] flex-col gap-2">
<Button size="sm" variant="outline" onClick={() => confirm(o.id)}>
{t('common.confirm')}
</Button>
{o.fulfillment === 'delivery' && (
<SelectNative
value={o.assignedRiderId ?? ''}
onChange={(e) => assign(o.id, e.target.value)}
>
<option value="">{t('orders.assignRider')}</option>
{riders.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</SelectNative>
)}
</div>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
))}
) : (
filtered.map((o) => (
<TableRow key={o.id}>
<TableCell className="font-medium">{o.id}</TableCell>
<TableCell>{o.customerName}</TableCell>
<TableCell>{branches.find((b) => b.id === o.branchId)?.name}</TableCell>
<TableCell>
<Badge variant="outline">{o.fulfillment}</Badge>
</TableCell>
<TableCell>
<Badge
variant={
o.status === 'cancelled'
? 'danger'
: o.status === 'out_for_delivery'
? 'warning'
: 'success'
}
>
{o.status}
</Badge>
</TableCell>
<TableCell>{formatEtb(o.totalEtb)}</TableCell>
<TableCell>
<RowActions
primary={[
{
label: 'Confirm',
icon: <CheckCircle2 className="h-3.5 w-3.5" />,
onClick: () => confirm(o.id),
disabled: o.status === 'confirmed' || o.status === 'cancelled',
},
]}
items={[
...(o.fulfillment === 'delivery'
? [
{
label: t('orders.assignRider'),
icon: <Truck className="h-3.5 w-3.5" />,
onClick: () => {
setAssignTarget(o)
setRiderId(o.assignedRiderId ?? '')
},
},
]
: []),
{
label: 'Cancel',
icon: <XCircle className="h-3.5 w-3.5" />,
variant: 'destructive' as const,
onClick: () => cancel(o.id),
disabled: o.status === 'cancelled',
},
]}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={!!assignTarget} onOpenChange={(open) => !open && setAssignTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('orders.assignRider')}</DialogTitle>
<DialogDescription>
{assignTarget?.id} · {assignTarget?.customerName}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label>Delivery person</Label>
<SelectNative value={riderId} onChange={(e) => setRiderId(e.target.value)}>
<option value="">Select rider…</option>
{riders.map((r) => (
<option key={r.id} value={r.id}>
{r.name} · {r.zone}
</option>
))}
</SelectNative>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setAssignTarget(null)}>
{t('common.cancel')}
</Button>
<Button disabled={!riderId} onClick={saveAssign}>
{t('common.assign')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</PageShell>
)
}

View File

@ -0,0 +1,93 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { CreatePageLayout } from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { organisations } from '@/mocks/data'
import type { Organisation } from '@/types'
export function CreateOrganisationPage() {
const navigate = useNavigate()
const [form, setForm] = useState({
name: '',
tin: '',
creditLimitEtb: '100000',
billingContact: '',
paymentTerms: 'Net 30',
})
const onSubmit = (e: FormEvent) => {
e.preventDefault()
const org: Organisation = {
id: `org-${Date.now()}`,
name: form.name,
tin: form.tin,
status: 'active',
creditLimitEtb: Number(form.creditLimitEtb) || 0,
usedEtb: 0,
billingContact: form.billingContact,
source: 'admin',
createdAt: new Date().toISOString(),
}
organisations.unshift(org)
toast.success('Organisation created — credit active')
navigate('/organisations')
}
return (
<CreatePageLayout
title="Create organisation"
subtitle="Admin-created orgs become active immediately"
backTo="/organisations"
footer={
<>
<Button type="button" variant="outline" onClick={() => navigate('/organisations')}>
Cancel
</Button>
<Button type="submit" form="create-org-form">
Create organisation
</Button>
</>
}
>
<form id="create-org-form" className="grid gap-4 sm:grid-cols-2" onSubmit={onSubmit}>
<div className="space-y-1.5 sm:col-span-2">
<Label>Organisation name</Label>
<Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>TIN</Label>
<Input required value={form.tin} onChange={(e) => setForm({ ...form, tin: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>Credit limit (ETB)</Label>
<Input
required
type="number"
min={0}
value={form.creditLimitEtb}
onChange={(e) => setForm({ ...form, creditLimitEtb: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Billing contact</Label>
<Input
required
type="email"
value={form.billingContact}
onChange={(e) => setForm({ ...form, billingContact: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Payment terms</Label>
<Input
value={form.paymentTerms}
onChange={(e) => setForm({ ...form, paymentTerms: e.target.value })}
/>
</div>
</form>
</CreatePageLayout>
)
}

View File

@ -1,21 +1,15 @@
import { useState, type FormEvent } from 'react'
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Check, Plus, X } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { organisations as seed } from '@/mocks/data'
import { formatEtb } from '@/lib/utils'
import type { Organisation } from '@/types'
@ -23,128 +17,106 @@ import type { Organisation } from '@/types'
export function OrganisationsPage() {
const { t } = useTranslation()
const [orgs, setOrgs] = useState<Organisation[]>([...seed])
const [open, setOpen] = useState(false)
const [form, setForm] = useState({ name: '', tin: '', creditLimitEtb: '100000', billingContact: '' })
const [q, setQ] = useState('')
const [status, setStatus] = useState('all')
const [source, setSource] = useState('all')
const approve = (id: string, status: 'active' | 'rejected') => {
setOrgs((prev) => prev.map((o) => (o.id === id ? { ...o, status } : o)))
const target = seed.find((o) => o.id === id)
if (target) target.status = status
toast.success(status === 'active' ? 'Organisation approved — credit active' : 'Organisation rejected')
}
const createOrg = (e: FormEvent) => {
e.preventDefault()
const org: Organisation = {
id: `org-${Date.now()}`,
name: form.name,
tin: form.tin,
status: 'active',
creditLimitEtb: Number(form.creditLimitEtb) || 0,
usedEtb: 0,
billingContact: form.billingContact,
source: 'admin',
createdAt: new Date().toISOString(),
const filtered = useMemo(() => {
let list = orgs
if (status !== 'all') list = list.filter((o) => o.status === status)
if (source !== 'all') list = list.filter((o) => o.source === source)
if (q) {
const needle = q.toLowerCase()
list = list.filter(
(o) =>
o.name.toLowerCase().includes(needle) ||
o.tin.includes(needle) ||
o.billingContact.toLowerCase().includes(needle),
)
}
setOrgs((prev) => [org, ...prev])
seed.unshift(org)
setOpen(false)
toast.success('Organisation created from Admin')
return list
}, [orgs, q, status, source])
const approve = (id: string, next: 'active' | 'rejected') => {
setOrgs((prev) => prev.map((o) => (o.id === id ? { ...o, status: next } : o)))
const target = seed.find((o) => o.id === id)
if (target) target.status = next
toast.success(next === 'active' ? 'Organisation approved — credit active' : 'Organisation rejected')
}
const pending = orgs.filter((o) => o.status === 'pending')
const pendingCount = orgs.filter((o) => o.status === 'pending').length
return (
<PageShell
title={t('organisations.title')}
subtitle={t('organisations.subtitle')}
actions={
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>{t('organisations.createOrg')}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('organisations.createOrg')}</DialogTitle>
<DialogDescription>Admin-created orgs become active immediately.</DialogDescription>
</DialogHeader>
<form className="space-y-3" onSubmit={createOrg}>
<div className="space-y-1">
<Label>Name</Label>
<Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-1">
<Label>TIN</Label>
<Input required value={form.tin} onChange={(e) => setForm({ ...form, tin: e.target.value })} />
</div>
<div className="space-y-1">
<Label>Credit limit (ETB)</Label>
<Input
required
value={form.creditLimitEtb}
onChange={(e) => setForm({ ...form, creditLimitEtb: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label>Billing contact</Label>
<Input
required
value={form.billingContact}
onChange={(e) => setForm({ ...form, billingContact: e.target.value })}
/>
</div>
<Button type="submit" className="w-full">
{t('common.create')}
</Button>
</form>
</DialogContent>
</Dialog>
}
>
{pending.length > 0 && (
<Card className="border-orange/40">
<CardHeader>
<CardTitle>
{t('organisations.pending')} ({pending.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
{pending.map((o) => (
<div key={o.id} className="flex flex-col gap-2 rounded-xl border p-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-medium">{o.name}</p>
<p className="text-xs text-muted-foreground">
Self-register · TIN {o.tin} · {o.billingContact}
</p>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={() => approve(o.id, 'active')}>
{t('common.approve')}
</Button>
<Button size="sm" variant="destructive" onClick={() => approve(o.id, 'rejected')}>
{t('common.reject')}
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
<Card>
<CardContent className="p-4">
<PageShell title={t('organisations.title')} subtitle={t('organisations.subtitle')}>
{pendingCount > 0 ? (
<div className="rounded-2xl border border-orange/30 bg-orange/5 px-4 py-3 text-sm">
<strong>{pendingCount}</strong> self-registration{pendingCount > 1 ? 's' : ''} awaiting approval.
Filter by status <em>Pending</em> to review.
</div>
) : null}
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search name, TIN, contact…"
resultCount={filtered.length}
filters={
<>
<FilterSelect
label="Status"
value={status}
onChange={setStatus}
options={[
{ value: 'all', label: 'All statuses' },
{ value: 'pending', label: 'Pending' },
{ value: 'active', label: 'Active' },
{ value: 'rejected', label: 'Rejected' },
]}
/>
<FilterSelect
label="Source"
value={source}
onChange={setSource}
options={[
{ value: 'all', label: 'All sources' },
{ value: 'admin', label: 'Admin created' },
{ value: 'self_register', label: 'Self-register' },
]}
/>
<ClearFiltersButton
visible={status !== 'all' || source !== 'all' || !!q}
onClick={() => {
setQ('')
setStatus('all')
setSource('all')
}}
/>
</>
}
actions={
<Button asChild className="h-10 gap-2 shadow-none">
<Link to="/organisations/new">
<Plus className="h-4 w-4" />
{t('organisations.createOrg')}
</Link>
</Button>
}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>TIN</TableHead>
<TableHead>Source</TableHead>
<TableHead>Limit</TableHead>
<TableHead>Used</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orgs.map((o) => (
{filtered.map((o) => (
<TableRow key={o.id}>
<TableCell className="font-medium">{o.name}</TableCell>
<TableCell>{o.tin}</TableCell>
@ -162,6 +134,29 @@ export function OrganisationsPage() {
{o.status}
</Badge>
</TableCell>
<TableCell>
{o.status === 'pending' ? (
<RowActions
primary={[
{
label: t('common.approve'),
icon: <Check className="h-3.5 w-3.5" />,
onClick: () => approve(o.id, 'active'),
},
]}
items={[
{
label: t('common.reject'),
icon: <X className="h-3.5 w-3.5" />,
variant: 'destructive',
onClick: () => approve(o.id, 'rejected'),
},
]}
/>
) : (
<span className="block pr-3 text-right text-xs text-muted-foreground">—</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>

View File

@ -0,0 +1,123 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { CreatePageLayout } from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { useAuthStore } from '@/zustand/authStore'
import { branches, prescriptions } from '@/mocks/data'
import type { Prescription } from '@/types'
export function CreatePrescriptionPage() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const [form, setForm] = useState({
customerName: '',
prescriber: '',
branchId: user?.branchId ?? branches[0]?.id ?? '',
itemName: '',
qty: '1',
controlled: false,
})
const onSubmit = (e: FormEvent) => {
e.preventDefault()
const rx: Prescription = {
id: `rx-${Date.now().toString().slice(-6)}`,
customerName: form.customerName,
branchId: form.branchId,
status: 'submitted',
items: [
{
name: form.itemName,
qty: Number(form.qty) || 1,
controlled: form.controlled,
},
],
submittedAt: new Date().toISOString(),
prescriber: form.prescriber,
}
prescriptions.unshift(rx)
toast.success('Prescription intake created')
navigate('/prescriptions')
}
return (
<CreatePageLayout
title="New prescription intake"
subtitle="Staff-entered Rx for walk-in / phone photo"
backTo="/prescriptions"
footer={
<>
<Button type="button" variant="outline" onClick={() => navigate('/prescriptions')}>
Cancel
</Button>
<Button type="submit" form="create-rx-form">
Submit to queue
</Button>
</>
}
>
<form id="create-rx-form" className="grid gap-4 sm:grid-cols-2" onSubmit={onSubmit}>
<div className="space-y-1.5 sm:col-span-2">
<Label>Customer name</Label>
<Input
required
value={form.customerName}
onChange={(e) => setForm({ ...form, customerName: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Prescriber</Label>
<Input
required
value={form.prescriber}
onChange={(e) => setForm({ ...form, prescriber: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Branch</Label>
<SelectNative
value={form.branchId}
onChange={(e) => setForm({ ...form, branchId: e.target.value })}
disabled={user?.role === 'pharmacist'}
>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Medicine</Label>
<Input
required
value={form.itemName}
onChange={(e) => setForm({ ...form, itemName: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Quantity</Label>
<Input
required
type="number"
min={1}
value={form.qty}
onChange={(e) => setForm({ ...form, qty: e.target.value })}
/>
</div>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input
type="checkbox"
checked={form.controlled}
onChange={(e) => setForm({ ...form, controlled: e.target.checked })}
/>
Controlled substance
</label>
</form>
</CreatePageLayout>
)
}

View File

@ -1,11 +1,15 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Check, HelpCircle, Plus, X } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { useAuthStore } from '@/zustand/authStore'
import { branches, prescriptions as seed } from '@/mocks/data'
import type { Prescription } from '@/types'
@ -14,82 +18,186 @@ export function PrescriptionsPage() {
const { t } = useTranslation()
const user = useAuthStore((s) => s.user)
const [rows, setRows] = useState<Prescription[]>([...seed])
const [q, setQ] = useState('')
const [status, setStatus] = useState('all')
const [branchFilter, setBranchFilter] = useState('all')
const [controlledOnly, setControlledOnly] = useState('all')
const filtered = useMemo(() => {
let list = rows
if (user?.role === 'pharmacist' && user.branchId) {
return rows.filter((p) => p.branchId === user.branchId)
list = list.filter((p) => p.branchId === user.branchId)
} else if (branchFilter !== 'all') {
list = list.filter((p) => p.branchId === branchFilter)
}
return rows
}, [rows, user])
if (status !== 'all') list = list.filter((p) => p.status === status)
if (controlledOnly === 'yes') list = list.filter((p) => p.items.some((i) => i.controlled))
if (q) {
const needle = q.toLowerCase()
list = list.filter(
(p) =>
p.customerName.toLowerCase().includes(needle) ||
p.id.toLowerCase().includes(needle) ||
(p.prescriber ?? '').toLowerCase().includes(needle),
)
}
return list
}, [rows, user, q, status, branchFilter, controlledOnly])
const act = (id: string, status: Prescription['status']) => {
setRows((prev) => prev.map((p) => (p.id === id ? { ...p, status } : p)))
const filtersActive =
status !== 'all' || branchFilter !== 'all' || controlledOnly !== 'all' || !!q
const act = (id: string, next: Prescription['status']) => {
setRows((prev) => prev.map((p) => (p.id === id ? { ...p, status: next } : p)))
const target = seed.find((p) => p.id === id)
if (target) target.status = status
toast.success(`Prescription ${status}`)
if (target) target.status = next
toast.success(`Prescription ${next}`)
}
return (
<PageShell title={t('prescriptions.title')} subtitle={t('prescriptions.subtitle')}>
<Card>
<CardContent className="p-4">
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search Rx, customer, prescriber…"
resultCount={filtered.length}
filters={
<>
<FilterSelect
label="Status"
value={status}
onChange={setStatus}
options={[
{ value: 'all', label: 'All statuses' },
{ value: 'submitted', label: 'Submitted' },
{ value: 'under_review', label: 'Under review' },
{ value: 'approved', label: 'Approved' },
{ value: 'queried', label: 'Queried' },
{ value: 'rejected', label: 'Rejected' },
]}
/>
{user?.role !== 'pharmacist' ? (
<FilterSelect
label="Branch"
value={branchFilter}
onChange={setBranchFilter}
options={[
{ value: 'all', label: t('common.allBranches') },
...branches.map((b) => ({ value: b.id, label: b.name })),
]}
/>
) : null}
<FilterSelect
label="Controlled"
value={controlledOnly}
onChange={setControlledOnly}
options={[
{ value: 'all', label: 'All items' },
{ value: 'yes', label: 'Has controlled' },
]}
/>
<ClearFiltersButton
visible={filtersActive}
onClick={() => {
setQ('')
setStatus('all')
setBranchFilter('all')
setControlledOnly('all')
}}
/>
</>
}
actions={
<Button asChild className="h-10 gap-2 shadow-none">
<Link to="/prescriptions/new">
<Plus className="h-4 w-4" />
New Rx intake
</Link>
</Button>
}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableRow className="hover:bg-transparent">
<TableHead>ID</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Prescriber</TableHead>
<TableHead>Items</TableHead>
<TableHead>{t('common.branch')}</TableHead>
<TableHead>{t('common.status')}</TableHead>
<TableHead>{t('common.actions')}</TableHead>
<TableHead className="text-right">{t('common.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.id}</TableCell>
<TableCell>{p.customerName}</TableCell>
<TableCell>{p.prescriber}</TableCell>
<TableCell>
<div className="space-y-1">
{p.items.map((i) => (
<div key={i.name} className="text-xs">
{i.name} × {i.qty}
{i.controlled ? (
<Badge variant="danger" className="ml-1">
controlled
</Badge>
) : null}
</div>
))}
</div>
</TableCell>
<TableCell>{branches.find((b) => b.id === p.branchId)?.name}</TableCell>
<TableCell>
<Badge
variant={
p.status === 'approved' ? 'success' : p.status === 'rejected' ? 'danger' : 'warning'
}
>
{p.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
<Button size="sm" onClick={() => act(p.id, 'approved')}>
{t('prescriptions.approve')}
</Button>
<Button size="sm" variant="outline" onClick={() => act(p.id, 'queried')}>
{t('prescriptions.query')}
</Button>
<Button size="sm" variant="destructive" onClick={() => act(p.id, 'rejected')}>
{t('prescriptions.reject')}
</Button>
</div>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
{t('common.noResults')}
</TableCell>
</TableRow>
))}
) : (
filtered.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.id}</TableCell>
<TableCell>{p.customerName}</TableCell>
<TableCell>{p.prescriber}</TableCell>
<TableCell>
<div className="space-y-1">
{p.items.map((i) => (
<div key={i.name} className="text-xs">
{i.name} × {i.qty}
{i.controlled ? (
<Badge variant="danger" className="ml-1">
controlled
</Badge>
) : null}
</div>
))}
</div>
</TableCell>
<TableCell>{branches.find((b) => b.id === p.branchId)?.name}</TableCell>
<TableCell>
<Badge
variant={
p.status === 'approved'
? 'success'
: p.status === 'rejected'
? 'danger'
: 'warning'
}
>
{p.status}
</Badge>
</TableCell>
<TableCell>
<RowActions
primary={[
{
label: t('prescriptions.approve'),
icon: <Check className="h-3.5 w-3.5" />,
onClick: () => act(p.id, 'approved'),
},
]}
items={[
{
label: t('prescriptions.query'),
icon: <HelpCircle className="h-3.5 w-3.5" />,
onClick: () => act(p.id, 'queried'),
},
{
label: t('prescriptions.reject'),
icon: <X className="h-3.5 w-3.5" />,
variant: 'destructive',
onClick: () => act(p.id, 'rejected'),
},
]}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>

View File

@ -0,0 +1,86 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { CreatePageLayout } from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { Textarea } from '@/components/ui/textarea'
import { stockRows } from '@/mocks/data'
export function CreateProcurementRequestPage() {
const navigate = useNavigate()
const [form, setForm] = useState({
sku: stockRows[0]?.sku ?? '',
qty: '50',
type: 'reorder',
notes: '',
})
const onSubmit = (e: FormEvent) => {
e.preventDefault()
toast.success(
form.type === 'transfer'
? `Transfer request for ${form.sku} sent to Stock Manager`
: `Reorder request for ${form.qty} × ${form.sku} created`,
)
navigate('/procurement')
}
return (
<CreatePageLayout
title="New procurement request"
subtitle="Reorder PO draft or inter-branch transfer request"
backTo="/procurement"
footer={
<>
<Button type="button" variant="outline" onClick={() => navigate('/procurement')}>
Cancel
</Button>
<Button type="submit" form="create-proc-form">
Submit request
</Button>
</>
}
>
<form id="create-proc-form" className="grid gap-4 sm:grid-cols-2" onSubmit={onSubmit}>
<div className="space-y-1.5">
<Label>SKU</Label>
<SelectNative value={form.sku} onChange={(e) => setForm({ ...form, sku: e.target.value })}>
{stockRows.map((r) => (
<option key={r.sku + r.branchId} value={r.sku}>
{r.sku} — {r.name}
</option>
))}
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Request type</Label>
<SelectNative value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })}>
<option value="reorder">Reorder / PO</option>
<option value="transfer">Inter-branch transfer</option>
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Quantity</Label>
<Input
required
type="number"
min={1}
value={form.qty}
onChange={(e) => setForm({ ...form, qty: e.target.value })}
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label>Notes</Label>
<Textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Supplier preference, urgency…"
/>
</div>
</form>
</CreatePageLayout>
)
}

View File

@ -1,10 +1,15 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { PackagePlus, Plus } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { stockRows } from '@/mocks/data'
const recommendations = [
@ -15,35 +20,102 @@ const recommendations = [
export function ProcurementPage() {
const { t } = useTranslation()
const [q, setQ] = useState('')
const [signal, setSignal] = useState('all')
const filteredStock = useMemo(() => {
return stockRows.filter((r) => {
const sig = r.qty < 20 ? 'reorder' : r.qty > 150 ? 'slow' : 'healthy'
if (signal !== 'all' && sig !== signal) return false
if (q) {
const needle = q.toLowerCase()
if (!r.sku.toLowerCase().includes(needle) && !r.name.toLowerCase().includes(needle)) return false
}
return true
})
}, [q, signal])
return (
<PageShell title={t('procurement.title')} subtitle={t('procurement.subtitle')}>
<PageShell
title={t('procurement.title')}
subtitle={t('procurement.subtitle')}
actions={
<Button asChild className="h-10 gap-2 shadow-none">
<Link to="/procurement/new">
<Plus className="h-4 w-4" />
New reorder request
</Link>
</Button>
}
>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Dead-stock & fast movers</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search SKU…"
resultCount={filteredStock.length}
filters={
<>
<FilterSelect
label="Signal"
value={signal}
onChange={setSignal}
options={[
{ value: 'all', label: 'All signals' },
{ value: 'reorder', label: 'Reorder' },
{ value: 'slow', label: 'Slow / dead' },
{ value: 'healthy', label: 'Healthy' },
]}
/>
<ClearFiltersButton
visible={signal !== 'all' || !!q}
onClick={() => {
setQ('')
setSignal('all')
}}
/>
</>
}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableRow className="hover:bg-transparent">
<TableHead>SKU</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Signal</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stockRows.map((r) => (
<TableRow key={r.sku + r.branchId}>
<TableCell>{r.sku}</TableCell>
<TableCell>{r.qty}</TableCell>
<TableCell>
<Badge variant={r.qty < 20 ? 'warning' : r.qty > 150 ? 'muted' : 'success'}>
{r.qty < 20 ? 'reorder' : r.qty > 150 ? 'slow' : 'healthy'}
</Badge>
</TableCell>
</TableRow>
))}
{filteredStock.map((r) => {
const sig = r.qty < 20 ? 'reorder' : r.qty > 150 ? 'slow' : 'healthy'
return (
<TableRow key={r.sku + r.branchId}>
<TableCell className="font-medium">{r.sku}</TableCell>
<TableCell>{r.qty}</TableCell>
<TableCell>
<Badge
variant={sig === 'reorder' ? 'warning' : sig === 'slow' ? 'muted' : 'success'}
>
{sig}
</Badge>
</TableCell>
<TableCell>
<RowActions
primary={[
{
label: 'Request',
icon: <PackagePlus className="h-3.5 w-3.5" />,
onClick: () => toast.success(`Reorder draft for ${r.sku}`),
},
]}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
@ -62,6 +134,7 @@ export function ProcurementPage() {
<Button
size="sm"
variant="outline"
className="h-9"
onClick={() =>
toast.success(
r.suggestion === 'transfer'

View File

@ -0,0 +1,102 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { CreatePageLayout } from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { branches, stockRows } from '@/mocks/data'
export function CreateStockPage() {
const navigate = useNavigate()
const [form, setForm] = useState({
sku: '',
name: '',
branchId: branches[0]?.id ?? '',
qty: '',
batch: '',
expiry: '',
})
const onSubmit = (e: FormEvent) => {
e.preventDefault()
const qty = Number(form.qty) || 0
stockRows.unshift({
sku: form.sku,
name: form.name,
branchId: form.branchId,
qty,
batch: form.batch,
expiry: form.expiry,
erpQty: qty,
})
toast.success('Stock line added')
navigate('/stock')
}
return (
<CreatePageLayout
title="Add stock line"
subtitle="Manual inventory entry for a branch"
backTo="/stock"
footer={
<>
<Button type="button" variant="outline" onClick={() => navigate('/stock')}>
Cancel
</Button>
<Button type="submit" form="create-stock-form">
Save stock line
</Button>
</>
}
>
<form id="create-stock-form" className="grid gap-4 sm:grid-cols-2" onSubmit={onSubmit}>
<div className="space-y-1.5">
<Label>SKU</Label>
<Input required value={form.sku} onChange={(e) => setForm({ ...form, sku: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>Branch</Label>
<SelectNative
value={form.branchId}
onChange={(e) => setForm({ ...form, branchId: e.target.value })}
>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</SelectNative>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label>Product name</Label>
<Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-1.5">
<Label>Quantity</Label>
<Input
required
type="number"
min={0}
value={form.qty}
onChange={(e) => setForm({ ...form, qty: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Batch</Label>
<Input required value={form.batch} onChange={(e) => setForm({ ...form, batch: e.target.value })} />
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label>Expiry (YYYY-MM)</Label>
<Input
required
placeholder="2027-01"
value={form.expiry}
onChange={(e) => setForm({ ...form, expiry: e.target.value })}
/>
</div>
</form>
</CreatePageLayout>
)
}

View File

@ -1,23 +1,49 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Plus, RefreshCw } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
import { RowActions } from '@/components/table/RowActions'
import { branches, stockRows } from '@/mocks/data'
import { Input } from '@/components/ui/input'
export function StockPage() {
const { t } = useTranslation()
const [entry, setEntry] = useState('')
const [rows, setRows] = useState(stockRows)
const [q, setQ] = useState('')
const [branchFilter, setBranchFilter] = useState('all')
const [alertFilter, setAlertFilter] = useState('all')
const duplicates = entry.length > 2
? rows.filter((r) => r.name.toLowerCase().includes(entry.toLowerCase()) || r.sku.toLowerCase().includes(entry.toLowerCase()))
: []
const filtered = useMemo(() => {
let list = rows
if (branchFilter !== 'all') list = list.filter((r) => r.branchId === branchFilter)
if (alertFilter === 'low') list = list.filter((r) => r.qty < 20)
if (alertFilter === 'mismatch') list = list.filter((r) => r.qty !== r.erpQty)
if (q) {
const needle = q.toLowerCase()
list = list.filter(
(r) => r.sku.toLowerCase().includes(needle) || r.name.toLowerCase().includes(needle),
)
}
return list
}, [rows, q, branchFilter, alertFilter])
const duplicates =
entry.length > 2
? rows.filter(
(r) =>
r.name.toLowerCase().includes(entry.toLowerCase()) ||
r.sku.toLowerCase().includes(entry.toLowerCase()),
)
: []
const resolve = (sku: string) => {
setRows((prev) => prev.map((r) => (r.sku === sku ? { ...r, qty: r.erpQty } : r)))
@ -34,11 +60,56 @@ export function StockPage() {
<TabsTrigger value="transfers">Transfers</TabsTrigger>
</TabsList>
<TabsContent value="inventory">
<Card>
<CardContent className="p-4">
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search SKU or name…"
resultCount={filtered.length}
filters={
<>
<FilterSelect
label="Branch"
value={branchFilter}
onChange={setBranchFilter}
options={[
{ value: 'all', label: t('common.allBranches') },
...branches.map((b) => ({ value: b.id, label: b.name })),
]}
/>
<FilterSelect
label="Alert"
value={alertFilter}
onChange={setAlertFilter}
options={[
{ value: 'all', label: 'All stock' },
{ value: 'low', label: 'Low stock' },
{ value: 'mismatch', label: 'ERP mismatch' },
]}
/>
<ClearFiltersButton
visible={branchFilter !== 'all' || alertFilter !== 'all' || !!q}
onClick={() => {
setQ('')
setBranchFilter('all')
setAlertFilter('all')
}}
/>
</>
}
actions={
<Button asChild className="h-10 gap-2 shadow-none">
<Link to="/stock/new">
<Plus className="h-4 w-4" />
Add stock line
</Link>
</Button>
}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableRow className="hover:bg-transparent">
<TableHead>SKU</TableHead>
<TableHead>Name</TableHead>
<TableHead>Branch</TableHead>
@ -46,10 +117,11 @@ export function StockPage() {
<TableHead>Batch</TableHead>
<TableHead>Expiry</TableHead>
<TableHead>Alert</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
{filtered.map((r) => (
<TableRow key={`${r.sku}-${r.branchId}`}>
<TableCell className="font-medium">{r.sku}</TableCell>
<TableCell>{r.name}</TableCell>
@ -58,7 +130,26 @@ export function StockPage() {
<TableCell>{r.batch}</TableCell>
<TableCell>{r.expiry}</TableCell>
<TableCell>
{r.qty < 20 ? <Badge variant="warning">low stock</Badge> : <Badge variant="success">ok</Badge>}
{r.qty < 20 ? (
<Badge variant="warning">low stock</Badge>
) : (
<Badge variant="success">ok</Badge>
)}
</TableCell>
<TableCell>
<RowActions
primary={
r.qty !== r.erpQty
? [
{
label: 'Sync ERP',
icon: <RefreshCw className="h-3.5 w-3.5" />,
onClick: () => resolve(r.sku),
},
]
: undefined
}
/>
</TableCell>
</TableRow>
))}
@ -69,10 +160,7 @@ export function StockPage() {
</TabsContent>
<TabsContent value="erp">
<Card>
<CardHeader>
<CardTitle>Platform vs ERP discrepancies</CardTitle>
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
<CardContent className="space-y-3 p-4">
{rows
.filter((r) => r.qty !== r.erpQty)
.map((r) => (
@ -83,7 +171,8 @@ export function StockPage() {
Platform {r.qty} · ERP {r.erpQty}
</p>
</div>
<Button size="sm" onClick={() => resolve(r.sku)}>
<Button size="sm" className="h-9 gap-1.5" onClick={() => resolve(r.sku)}>
<RefreshCw className="h-3.5 w-3.5" />
Resolve to ERP
</Button>
</div>
@ -96,10 +185,7 @@ export function StockPage() {
</TabsContent>
<TabsContent value="entry">
<Card>
<CardHeader>
<CardTitle>Duplicate detection while typing</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<CardContent className="space-y-3 p-5">
<Input
placeholder="Name, barcode, or SKU…"
value={entry}
@ -117,9 +203,10 @@ export function StockPage() {
<Card>
<CardContent className="space-y-3 p-5">
<p className="text-sm text-muted-foreground">
Stock Manager owns inter-branch transfers. Suggest move of Vitamin C from Bole → CMC (fast mover).
Stock Manager owns inter-branch transfers. Suggest move of Vitamin C from Bole → CMC.
</p>
<Button
className="h-10"
onClick={() => toast.success('Transfer draft created (mock)')}
>
Create transfer suggestion

View File

@ -0,0 +1,102 @@
import { useState, type FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { CreatePageLayout } from '@/layouts/CreatePageLayout'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SelectNative } from '@/components/ui/select-native'
import { demoAccounts } from '@/config/permissions'
import { branches } from '@/mocks/data'
import type { StaffRole } from '@/types'
export function CreateTeamMemberPage() {
const navigate = useNavigate()
const [form, setForm] = useState({
name: '',
email: '',
role: 'pharmacist' as StaffRole,
branchId: '',
})
const needsBranch = form.role === 'pharmacist'
const onSubmit = (e: FormEvent) => {
e.preventDefault()
const branch = branches.find((b) => b.id === form.branchId)
demoAccounts.push({
email: form.email,
password: 'demo123',
role: form.role,
name: form.name,
branchId: needsBranch ? form.branchId || undefined : undefined,
branchName: needsBranch ? branch?.name : undefined,
})
toast.success('Staff member added (demo login password: demo123)')
navigate('/team')
}
return (
<CreatePageLayout
title="Add staff member"
subtitle="Assign a role and optional branch scope"
backTo="/team"
footer={
<>
<Button type="button" variant="outline" onClick={() => navigate('/team')}>
Cancel
</Button>
<Button type="submit" form="create-team-form">
Add staff
</Button>
</>
}
>
<form id="create-team-form" className="grid gap-4 sm:grid-cols-2" onSubmit={onSubmit}>
<div className="space-y-1.5 sm:col-span-2">
<Label>Full name</Label>
<Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label>Email</Label>
<Input
required
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label>Role</Label>
<SelectNative
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value as StaffRole })}
>
<option value="pharmacist">Pharmacist</option>
<option value="stock_manager">Stock manager</option>
<option value="procurement">Procurement</option>
<option value="finance">Finance</option>
<option value="marketing_manager">Marketing manager</option>
<option value="operations">Operations</option>
<option value="super_admin">Super admin</option>
</SelectNative>
</div>
<div className="space-y-1.5">
<Label>Branch {needsBranch ? '(required)' : '(optional)'}</Label>
<SelectNative
value={form.branchId}
onChange={(e) => setForm({ ...form, branchId: e.target.value })}
required={needsBranch}
>
<option value="">None</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</SelectNative>
</div>
</form>
</CreatePageLayout>
)
}

View File

@ -1,19 +1,96 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Plus } from 'lucide-react'
import { PageShell } from '@/layouts/PageShell'
import { Card, CardContent } from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
import { demoAccounts } from '@/config/permissions'
import type { StaffRole } from '@/types'
const roles: { value: string; label: string }[] = [
{ value: 'all', label: 'All roles' },
{ value: 'pharmacist', label: 'Pharmacist' },
{ value: 'stock_manager', label: 'Stock manager' },
{ value: 'procurement', label: 'Procurement' },
{ value: 'finance', label: 'Finance' },
{ value: 'marketing_manager', label: 'Marketing' },
{ value: 'operations', label: 'Operations' },
{ value: 'super_admin', label: 'Super admin' },
]
export function TeamPage() {
const { t } = useTranslation()
const [q, setQ] = useState('')
const [role, setRole] = useState('all')
const [branchOnly, setBranchOnly] = useState('all')
const filtered = useMemo(() => {
return demoAccounts.filter((a) => {
if (role !== 'all' && a.role !== (role as StaffRole)) return false
if (branchOnly === 'yes' && !a.branchId) return false
if (branchOnly === 'no' && a.branchId) return false
if (q) {
const needle = q.toLowerCase()
if (
!a.name.toLowerCase().includes(needle) &&
!a.email.toLowerCase().includes(needle) &&
!a.role.includes(needle)
) {
return false
}
}
return true
})
}, [q, role, branchOnly])
return (
<PageShell title={t('team.title')} subtitle={t('team.subtitle')}>
<Card>
<CardContent className="p-4">
<Card className="overflow-hidden">
<TableToolbar
search={q}
onSearchChange={setQ}
searchPlaceholder="Search name or email…"
resultCount={filtered.length}
filters={
<>
<FilterSelect label="Role" value={role} onChange={setRole} options={roles} />
<FilterSelect
label="Branch scope"
value={branchOnly}
onChange={setBranchOnly}
options={[
{ value: 'all', label: 'All staff' },
{ value: 'yes', label: 'Branch-scoped' },
{ value: 'no', label: 'Platform-wide' },
]}
/>
<ClearFiltersButton
visible={role !== 'all' || branchOnly !== 'all' || !!q}
onClick={() => {
setQ('')
setRole('all')
setBranchOnly('all')
}}
/>
</>
}
actions={
<Button asChild className="h-10 gap-2 shadow-none">
<Link to="/team/new">
<Plus className="h-4 w-4" />
Add staff
</Link>
</Button>
}
/>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableRow className="hover:bg-transparent">
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
@ -21,12 +98,12 @@ export function TeamPage() {
</TableRow>
</TableHeader>
<TableBody>
{demoAccounts.map((a) => (
{filtered.map((a) => (
<TableRow key={a.email}>
<TableCell className="font-medium">{a.name}</TableCell>
<TableCell>{a.email}</TableCell>
<TableCell>
<Badge>{a.role}</Badge>
<Badge>{a.role.replaceAll('_', ' ')}</Badge>
</TableCell>
<TableCell>{a.branchName ?? '—'}</TableCell>
</TableRow>