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:
parent
728a31060f
commit
f2aff8c377
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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>} />
|
||||
|
|
|
|||
81
src/components/table/RowActions.tsx
Normal file
81
src/components/table/RowActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
src/components/table/TableToolbar.tsx
Normal file
105
src/components/table/TableToolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
src/layouts/CreatePageLayout.tsx
Normal file
48
src/layouts/CreatePageLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
152
src/pages/orders/CreateOrderPage.tsx
Normal file
152
src/pages/orders/CreateOrderPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
93
src/pages/organisations/CreateOrganisationPage.tsx
Normal file
93
src/pages/organisations/CreateOrganisationPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
123
src/pages/prescriptions/CreatePrescriptionPage.tsx
Normal file
123
src/pages/prescriptions/CreatePrescriptionPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
86
src/pages/procurement/CreateProcurementRequestPage.tsx
Normal file
86
src/pages/procurement/CreateProcurementRequestPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
102
src/pages/stock/CreateStockPage.tsx
Normal file
102
src/pages/stock/CreateStockPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
102
src/pages/team/CreateTeamMemberPage.tsx
Normal file
102
src/pages/team/CreateTeamMemberPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Reference in New Issue
Block a user