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 | Super Admin team/inbox/audit/settings + FAQ bilingual |
|
||||||
| 2026-08-06 | Data migration import jobs |
|
| 2026-08-06 | Data migration import jobs |
|
||||||
| 2026-08-06 | Deployed Admin SPA: https://gishen-admin.vercel.app |
|
| 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
|
## Conventions
|
||||||
|
|
||||||
|
|
@ -74,7 +75,8 @@ Mock key: `authStore.login` → `/auth/staff/login`
|
||||||
|
|
||||||
| Method | Path | Roles | Description |
|
| 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/confirm` | pharmacist, super_admin | Confirm stock |
|
||||||
| POST | `/orders/:id/assign-rider` | pharmacist, operations, super_admin | `{ riderId }` |
|
| POST | `/orders/:id/assign-rider` | pharmacist, operations, super_admin | `{ riderId }` |
|
||||||
| PATCH | `/orders/:id/status` | pharmacist, operations, super_admin | Status lifecycle |
|
| PATCH | `/orders/:id/status` | pharmacist, operations, super_admin | Status lifecycle |
|
||||||
|
|
@ -87,7 +89,8 @@ Mock: `mocks/data.orders`
|
||||||
|
|
||||||
| Method | Path | Roles | Description |
|
| 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 | `/admin/prescriptions/:id/verify` | pharmacist, super_admin | `{ status: approved\|queried\|rejected, qtyAdjustments?, substitute?, notes? }` |
|
||||||
| POST | `/pharmacy/prescriptions/:id/dispense` | pharmacist | `{ batch, quantity }` stamped pharmacistId + time |
|
| POST | `/pharmacy/prescriptions/:id/dispense` | pharmacist | `{ batch, quantity }` stamped pharmacistId + time |
|
||||||
| GET | `/pharmacy/prescriptions/controlled-report` | pharmacist, super_admin | Controlled-substance report |
|
| GET | `/pharmacy/prescriptions/controlled-report` | pharmacist, super_admin | Controlled-substance report |
|
||||||
|
|
@ -99,7 +102,8 @@ Mock: `mocks/data.orders`
|
||||||
|
|
||||||
| Method | Path | Roles | Description |
|
| 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 | `/integrations/erp/sync-status` | stock_manager, super_admin | Last sync |
|
||||||
| GET | `/admin/stock/discrepancies` | stock_manager, super_admin | Platform vs ERP |
|
| GET | `/admin/stock/discrepancies` | stock_manager, super_admin | Platform vs ERP |
|
||||||
| POST | `/admin/stock/discrepancies/:id/resolve` | stock_manager, super_admin | One-click resolve |
|
| POST | `/admin/stock/discrepancies/:id/resolve` | stock_manager, super_admin | One-click resolve |
|
||||||
|
|
@ -186,7 +190,7 @@ Fields: `title_en`, `title_am`, `body_en`, `body_am`, `category`, `published`.
|
||||||
|
|
||||||
| Method | Path | Roles | Description |
|
| 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/inbox/threads` | super_admin | Chatwoot/Telegram/WhatsApp attached to customer |
|
||||||
| GET | `/admin/audit` | super_admin | Audit log |
|
| GET | `/admin/audit` | super_admin | Audit log |
|
||||||
| GET/PUT | `/admin/settings` | super_admin | Retention, access controls |
|
| 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 { LoginPage } from '@/pages/auth/LoginPage'
|
||||||
import { DashboardPage } from '@/pages/dashboard/DashboardPage'
|
import { DashboardPage } from '@/pages/dashboard/DashboardPage'
|
||||||
import { OrdersPage } from '@/pages/orders/OrdersPage'
|
import { OrdersPage } from '@/pages/orders/OrdersPage'
|
||||||
|
import { CreateOrderPage } from '@/pages/orders/CreateOrderPage'
|
||||||
import { PrescriptionsPage } from '@/pages/prescriptions/PrescriptionsPage'
|
import { PrescriptionsPage } from '@/pages/prescriptions/PrescriptionsPage'
|
||||||
|
import { CreatePrescriptionPage } from '@/pages/prescriptions/CreatePrescriptionPage'
|
||||||
import { StockPage } from '@/pages/stock/StockPage'
|
import { StockPage } from '@/pages/stock/StockPage'
|
||||||
|
import { CreateStockPage } from '@/pages/stock/CreateStockPage'
|
||||||
import { ProcurementPage } from '@/pages/procurement/ProcurementPage'
|
import { ProcurementPage } from '@/pages/procurement/ProcurementPage'
|
||||||
|
import { CreateProcurementRequestPage } from '@/pages/procurement/CreateProcurementRequestPage'
|
||||||
import { FinancePage } from '@/pages/finance/FinancePage'
|
import { FinancePage } from '@/pages/finance/FinancePage'
|
||||||
import { OrganisationsPage } from '@/pages/organisations/OrganisationsPage'
|
import { OrganisationsPage } from '@/pages/organisations/OrganisationsPage'
|
||||||
|
import { CreateOrganisationPage } from '@/pages/organisations/CreateOrganisationPage'
|
||||||
import { MarketingPage } from '@/pages/marketing/MarketingPage'
|
import { MarketingPage } from '@/pages/marketing/MarketingPage'
|
||||||
import { CrmPage } from '@/pages/marketing/CrmPage'
|
import { CrmPage } from '@/pages/marketing/CrmPage'
|
||||||
import { LoyaltyPage } from '@/pages/marketing/LoyaltyPage'
|
import { LoyaltyPage } from '@/pages/marketing/LoyaltyPage'
|
||||||
import { CampaignsPage } from '@/pages/marketing/CampaignsPage'
|
import { CampaignsPage } from '@/pages/marketing/CampaignsPage'
|
||||||
import { DispatchPage } from '@/pages/dispatch/DispatchPage'
|
import { DispatchPage } from '@/pages/dispatch/DispatchPage'
|
||||||
import { TeamPage } from '@/pages/team/TeamPage'
|
import { TeamPage } from '@/pages/team/TeamPage'
|
||||||
|
import { CreateTeamMemberPage } from '@/pages/team/CreateTeamMemberPage'
|
||||||
import { InboxPage } from '@/pages/inbox/InboxPage'
|
import { InboxPage } from '@/pages/inbox/InboxPage'
|
||||||
import { AuditPage } from '@/pages/audit/AuditPage'
|
import { AuditPage } from '@/pages/audit/AuditPage'
|
||||||
import { FaqPage } from '@/pages/faq/FaqPage'
|
import { FaqPage } from '@/pages/faq/FaqPage'
|
||||||
|
|
@ -36,25 +42,19 @@ export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route
|
<Route element={<AppLayout />}>
|
||||||
element={
|
<Route index element={<Guard module="dashboard"><DashboardPage /></Guard>} />
|
||||||
<AppLayout />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route
|
|
||||||
index
|
|
||||||
element={
|
|
||||||
<Guard module="dashboard">
|
|
||||||
<DashboardPage />
|
|
||||||
</Guard>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="orders" element={<Guard module="orders"><OrdersPage /></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" 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" 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" 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="finance" element={<Guard module="finance"><FinancePage /></Guard>} />
|
||||||
<Route path="organisations" element={<Guard module="organisations"><OrganisationsPage /></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="marketing" element={<Guard module="marketing"><MarketingPage /></Guard>} />
|
||||||
<Route path="crm" element={<Guard module="crm"><CrmPage /></Guard>} />
|
<Route path="crm" element={<Guard module="crm"><CrmPage /></Guard>} />
|
||||||
<Route path="loyalty" element={<Guard module="loyalty"><LoyaltyPage /></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="migrations" element={<Guard module="migrations"><MigrationsPage /></Guard>} />
|
||||||
<Route path="faq" element={<Guard module="faq"><FaqPage /></Guard>} />
|
<Route path="faq" element={<Guard module="faq"><FaqPage /></Guard>} />
|
||||||
<Route path="team" element={<Guard module="team"><TeamPage /></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="inbox" element={<Guard module="inbox"><InboxPage /></Guard>} />
|
||||||
<Route path="audit" element={<Guard module="audit"><AuditPage /></Guard>} />
|
<Route path="audit" element={<Guard module="audit"><AuditPage /></Guard>} />
|
||||||
<Route path="settings" element={<Guard module="settings"><SettingsPage /></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 { useTranslation } from 'react-i18next'
|
||||||
import { PageShell } from '@/layouts/PageShell'
|
import { PageShell } from '@/layouts/PageShell'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
|
||||||
import { customers } from '@/mocks/data'
|
import { customers } from '@/mocks/data'
|
||||||
|
|
||||||
export function CrmPage() {
|
export function CrmPage() {
|
||||||
const { t } = useTranslation()
|
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 (
|
return (
|
||||||
<PageShell title={t('crm.title')} subtitle={t('crm.subtitle')}>
|
<PageShell title={t('crm.title')} subtitle={t('crm.subtitle')}>
|
||||||
<Card>
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="p-4">
|
<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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Phone</TableHead>
|
<TableHead>Phone</TableHead>
|
||||||
<TableHead>Tier</TableHead>
|
<TableHead>Tier</TableHead>
|
||||||
|
|
@ -22,7 +84,14 @@ export function CrmPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{customers.map((c) => (
|
{filtered.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
|
||||||
|
{t('common.noResults')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
filtered.map((c) => (
|
||||||
<TableRow key={c.id}>
|
<TableRow key={c.id}>
|
||||||
<TableCell className="font-medium">{c.name}</TableCell>
|
<TableCell className="font-medium">{c.name}</TableCell>
|
||||||
<TableCell>{c.phone}</TableCell>
|
<TableCell>{c.phone}</TableCell>
|
||||||
|
|
@ -32,7 +101,8 @@ export function CrmPage() {
|
||||||
<TableCell>{c.points}</TableCell>
|
<TableCell>{c.points}</TableCell>
|
||||||
<TableCell>{c.branch}</TableCell>
|
<TableCell>{c.branch}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</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 { useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { CheckCircle2, Plus, Truck, XCircle } from 'lucide-react'
|
||||||
import { PageShell } from '@/layouts/PageShell'
|
import { PageShell } from '@/layouts/PageShell'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { SelectNative } from '@/components/ui/select-native'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { useAuthStore } from '@/zustand/authStore'
|
import { useAuthStore } from '@/zustand/authStore'
|
||||||
import { branches, orders as seedOrders, riders } from '@/mocks/data'
|
import { branches, orders as seedOrders, riders } from '@/mocks/data'
|
||||||
import { formatEtb } from '@/lib/utils'
|
import { formatEtb } from '@/lib/utils'
|
||||||
|
|
@ -18,65 +29,166 @@ export function OrdersPage() {
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const [rows, setRows] = useState<Order[]>([...seedOrders])
|
const [rows, setRows] = useState<Order[]>([...seedOrders])
|
||||||
const [q, setQ] = useState('')
|
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(() => {
|
const filtered = useMemo(() => {
|
||||||
let list = rows
|
let list = rows
|
||||||
if (user?.role === 'pharmacist' && user.branchId) {
|
if (user?.role === 'pharmacist' && user.branchId) {
|
||||||
list = list.filter((o) => o.branchId === 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
|
return list
|
||||||
}, [rows, user, q])
|
}, [rows, user, q, status, fulfillment, branchFilter, channel])
|
||||||
|
|
||||||
const assign = (orderId: string, riderId: string) => {
|
const filtersActive =
|
||||||
setRows((prev) =>
|
status !== 'all' || fulfillment !== 'all' || branchFilter !== 'all' || channel !== 'all' || !!q
|
||||||
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 confirm = (orderId: string) => {
|
const confirm = (orderId: string) => {
|
||||||
setRows((prev) => prev.map((o) => (o.id === orderId ? { ...o, status: 'confirmed' } : o)))
|
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')
|
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 (
|
return (
|
||||||
<PageShell title={t('orders.title')} subtitle={t('orders.subtitle')}>
|
<PageShell title={t('orders.title')} subtitle={t('orders.subtitle')}>
|
||||||
<Card>
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="space-y-4 p-4">
|
<TableToolbar
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
search={q}
|
||||||
<Input
|
onSearchChange={setQ}
|
||||||
className="max-w-sm"
|
searchPlaceholder="Search ID or customer…"
|
||||||
placeholder={t('common.search')}
|
resultCount={filtered.length}
|
||||||
value={q}
|
filters={
|
||||||
onChange={(e) => setQ(e.target.value)}
|
<>
|
||||||
|
<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' },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<Badge variant="secondary">
|
<FilterSelect
|
||||||
{user?.branchName ?? t('common.allBranches')} · {filtered.length}
|
label="Fulfillment"
|
||||||
</Badge>
|
value={fulfillment}
|
||||||
</div>
|
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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead>ID</TableHead>
|
<TableHead>ID</TableHead>
|
||||||
<TableHead>Customer</TableHead>
|
<TableHead>Customer</TableHead>
|
||||||
<TableHead>{t('common.branch')}</TableHead>
|
<TableHead>{t('common.branch')}</TableHead>
|
||||||
<TableHead>Fulfillment</TableHead>
|
<TableHead>Fulfillment</TableHead>
|
||||||
<TableHead>{t('common.status')}</TableHead>
|
<TableHead>{t('common.status')}</TableHead>
|
||||||
<TableHead>Total</TableHead>
|
<TableHead>Total</TableHead>
|
||||||
<TableHead>{t('common.actions')}</TableHead>
|
<TableHead className="text-right">{t('common.actions')}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.map((o) => (
|
{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}>
|
<TableRow key={o.id}>
|
||||||
<TableCell className="font-medium">{o.id}</TableCell>
|
<TableCell className="font-medium">{o.id}</TableCell>
|
||||||
<TableCell>{o.customerName}</TableCell>
|
<TableCell>{o.customerName}</TableCell>
|
||||||
|
|
@ -85,35 +197,91 @@ export function OrdersPage() {
|
||||||
<Badge variant="outline">{o.fulfillment}</Badge>
|
<Badge variant="outline">{o.fulfillment}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={o.status === 'out_for_delivery' ? 'warning' : 'success'}>{o.status}</Badge>
|
<Badge
|
||||||
|
variant={
|
||||||
|
o.status === 'cancelled'
|
||||||
|
? 'danger'
|
||||||
|
: o.status === 'out_for_delivery'
|
||||||
|
? 'warning'
|
||||||
|
: 'success'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{o.status}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{formatEtb(o.totalEtb)}</TableCell>
|
<TableCell>{formatEtb(o.totalEtb)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex min-w-[200px] flex-col gap-2">
|
<RowActions
|
||||||
<Button size="sm" variant="outline" onClick={() => confirm(o.id)}>
|
primary={[
|
||||||
{t('common.confirm')}
|
{
|
||||||
</Button>
|
label: 'Confirm',
|
||||||
{o.fulfillment === 'delivery' && (
|
icon: <CheckCircle2 className="h-3.5 w-3.5" />,
|
||||||
<SelectNative
|
onClick: () => confirm(o.id),
|
||||||
value={o.assignedRiderId ?? ''}
|
disabled: o.status === 'confirmed' || o.status === 'cancelled',
|
||||||
onChange={(e) => assign(o.id, e.target.value)}
|
},
|
||||||
>
|
]}
|
||||||
<option value="">{t('orders.assignRider')}</option>
|
items={[
|
||||||
{riders.map((r) => (
|
...(o.fulfillment === 'delivery'
|
||||||
<option key={r.id} value={r.id}>
|
? [
|
||||||
{r.name}
|
{
|
||||||
</option>
|
label: t('orders.assignRider'),
|
||||||
))}
|
icon: <Truck className="h-3.5 w-3.5" />,
|
||||||
</SelectNative>
|
onClick: () => {
|
||||||
)}
|
setAssignTarget(o)
|
||||||
</div>
|
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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</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 { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { Check, Plus, X } from 'lucide-react'
|
||||||
import { PageShell } from '@/layouts/PageShell'
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar'
|
||||||
import { Label } from '@/components/ui/label'
|
import { RowActions } from '@/components/table/RowActions'
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { organisations as seed } from '@/mocks/data'
|
import { organisations as seed } from '@/mocks/data'
|
||||||
import { formatEtb } from '@/lib/utils'
|
import { formatEtb } from '@/lib/utils'
|
||||||
import type { Organisation } from '@/types'
|
import type { Organisation } from '@/types'
|
||||||
|
|
@ -23,128 +17,106 @@ import type { Organisation } from '@/types'
|
||||||
export function OrganisationsPage() {
|
export function OrganisationsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [orgs, setOrgs] = useState<Organisation[]>([...seed])
|
const [orgs, setOrgs] = useState<Organisation[]>([...seed])
|
||||||
const [open, setOpen] = useState(false)
|
const [q, setQ] = useState('')
|
||||||
const [form, setForm] = useState({ name: '', tin: '', creditLimitEtb: '100000', billingContact: '' })
|
const [status, setStatus] = useState('all')
|
||||||
|
const [source, setSource] = useState('all')
|
||||||
|
|
||||||
const approve = (id: string, status: 'active' | 'rejected') => {
|
const filtered = useMemo(() => {
|
||||||
setOrgs((prev) => prev.map((o) => (o.id === id ? { ...o, status } : o)))
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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)
|
const target = seed.find((o) => o.id === id)
|
||||||
if (target) target.status = status
|
if (target) target.status = next
|
||||||
toast.success(status === 'active' ? 'Organisation approved — credit active' : 'Organisation rejected')
|
toast.success(next === 'active' ? 'Organisation approved — credit active' : 'Organisation rejected')
|
||||||
}
|
}
|
||||||
|
|
||||||
const createOrg = (e: FormEvent) => {
|
const pendingCount = orgs.filter((o) => o.status === 'pending').length
|
||||||
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(),
|
|
||||||
}
|
|
||||||
setOrgs((prev) => [org, ...prev])
|
|
||||||
seed.unshift(org)
|
|
||||||
setOpen(false)
|
|
||||||
toast.success('Organisation created from Admin')
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = orgs.filter((o) => o.status === 'pending')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell
|
<PageShell title={t('organisations.title')} subtitle={t('organisations.subtitle')}>
|
||||||
title={t('organisations.title')}
|
{pendingCount > 0 ? (
|
||||||
subtitle={t('organisations.subtitle')}
|
<div className="rounded-2xl border border-orange/30 bg-orange/5 px-4 py-3 text-sm">
|
||||||
actions={
|
<strong>{pendingCount}</strong> self-registration{pendingCount > 1 ? 's' : ''} awaiting approval.
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
Filter by status <em>Pending</em> to review.
|
||||||
<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>
|
||||||
<div className="space-y-1">
|
) : null}
|
||||||
<Label>TIN</Label>
|
<Card className="overflow-hidden">
|
||||||
<Input required value={form.tin} onChange={(e) => setForm({ ...form, tin: e.target.value })} />
|
<TableToolbar
|
||||||
</div>
|
search={q}
|
||||||
<div className="space-y-1">
|
onSearchChange={setQ}
|
||||||
<Label>Credit limit (ETB)</Label>
|
searchPlaceholder="Search name, TIN, contact…"
|
||||||
<Input
|
resultCount={filtered.length}
|
||||||
required
|
filters={
|
||||||
value={form.creditLimitEtb}
|
<>
|
||||||
onChange={(e) => setForm({ ...form, creditLimitEtb: e.target.value })}
|
<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' },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
<FilterSelect
|
||||||
<div className="space-y-1">
|
label="Source"
|
||||||
<Label>Billing contact</Label>
|
value={source}
|
||||||
<Input
|
onChange={setSource}
|
||||||
required
|
options={[
|
||||||
value={form.billingContact}
|
{ value: 'all', label: 'All sources' },
|
||||||
onChange={(e) => setForm({ ...form, billingContact: e.target.value })}
|
{ value: 'admin', label: 'Admin created' },
|
||||||
|
{ value: 'self_register', label: 'Self-register' },
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
<ClearFiltersButton
|
||||||
<Button type="submit" className="w-full">
|
visible={status !== 'all' || source !== 'all' || !!q}
|
||||||
{t('common.create')}
|
onClick={() => {
|
||||||
</Button>
|
setQ('')
|
||||||
</form>
|
setStatus('all')
|
||||||
</DialogContent>
|
setSource('all')
|
||||||
</Dialog>
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
>
|
actions={
|
||||||
{pending.length > 0 && (
|
<Button asChild className="h-10 gap-2 shadow-none">
|
||||||
<Card className="border-orange/40">
|
<Link to="/organisations/new">
|
||||||
<CardHeader>
|
<Plus className="h-4 w-4" />
|
||||||
<CardTitle>
|
{t('organisations.createOrg')}
|
||||||
{t('organisations.pending')} ({pending.length})
|
</Link>
|
||||||
</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>
|
||||||
<Button size="sm" variant="destructive" onClick={() => approve(o.id, 'rejected')}>
|
}
|
||||||
{t('common.reject')}
|
/>
|
||||||
</Button>
|
<CardContent className="p-0">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>TIN</TableHead>
|
<TableHead>TIN</TableHead>
|
||||||
<TableHead>Source</TableHead>
|
<TableHead>Source</TableHead>
|
||||||
<TableHead>Limit</TableHead>
|
<TableHead>Limit</TableHead>
|
||||||
<TableHead>Used</TableHead>
|
<TableHead>Used</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">{t('common.actions')}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{orgs.map((o) => (
|
{filtered.map((o) => (
|
||||||
<TableRow key={o.id}>
|
<TableRow key={o.id}>
|
||||||
<TableCell className="font-medium">{o.name}</TableCell>
|
<TableCell className="font-medium">{o.name}</TableCell>
|
||||||
<TableCell>{o.tin}</TableCell>
|
<TableCell>{o.tin}</TableCell>
|
||||||
|
|
@ -162,6 +134,29 @@ export function OrganisationsPage() {
|
||||||
{o.status}
|
{o.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</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 { useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { Check, HelpCircle, Plus, X } from 'lucide-react'
|
||||||
import { PageShell } from '@/layouts/PageShell'
|
import { PageShell } from '@/layouts/PageShell'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { useAuthStore } from '@/zustand/authStore'
|
||||||
import { branches, prescriptions as seed } from '@/mocks/data'
|
import { branches, prescriptions as seed } from '@/mocks/data'
|
||||||
import type { Prescription } from '@/types'
|
import type { Prescription } from '@/types'
|
||||||
|
|
@ -14,39 +18,127 @@ export function PrescriptionsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const [rows, setRows] = useState<Prescription[]>([...seed])
|
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(() => {
|
const filtered = useMemo(() => {
|
||||||
|
let list = rows
|
||||||
if (user?.role === 'pharmacist' && user.branchId) {
|
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
|
if (status !== 'all') list = list.filter((p) => p.status === status)
|
||||||
}, [rows, user])
|
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']) => {
|
const filtersActive =
|
||||||
setRows((prev) => prev.map((p) => (p.id === id ? { ...p, status } : p)))
|
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)
|
const target = seed.find((p) => p.id === id)
|
||||||
if (target) target.status = status
|
if (target) target.status = next
|
||||||
toast.success(`Prescription ${status}`)
|
toast.success(`Prescription ${next}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title={t('prescriptions.title')} subtitle={t('prescriptions.subtitle')}>
|
<PageShell title={t('prescriptions.title')} subtitle={t('prescriptions.subtitle')}>
|
||||||
<Card>
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="p-4">
|
<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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead>ID</TableHead>
|
<TableHead>ID</TableHead>
|
||||||
<TableHead>Customer</TableHead>
|
<TableHead>Customer</TableHead>
|
||||||
<TableHead>Prescriber</TableHead>
|
<TableHead>Prescriber</TableHead>
|
||||||
<TableHead>Items</TableHead>
|
<TableHead>Items</TableHead>
|
||||||
<TableHead>{t('common.branch')}</TableHead>
|
<TableHead>{t('common.branch')}</TableHead>
|
||||||
<TableHead>{t('common.status')}</TableHead>
|
<TableHead>{t('common.status')}</TableHead>
|
||||||
<TableHead>{t('common.actions')}</TableHead>
|
<TableHead className="text-right">{t('common.actions')}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filtered.map((p) => (
|
{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}>
|
<TableRow key={p.id}>
|
||||||
<TableCell className="font-medium">{p.id}</TableCell>
|
<TableCell className="font-medium">{p.id}</TableCell>
|
||||||
<TableCell>{p.customerName}</TableCell>
|
<TableCell>{p.customerName}</TableCell>
|
||||||
|
|
@ -69,27 +161,43 @@ export function PrescriptionsPage() {
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
p.status === 'approved' ? 'success' : p.status === 'rejected' ? 'danger' : 'warning'
|
p.status === 'approved'
|
||||||
|
? 'success'
|
||||||
|
: p.status === 'rejected'
|
||||||
|
? 'danger'
|
||||||
|
: 'warning'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{p.status}
|
{p.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap gap-1">
|
<RowActions
|
||||||
<Button size="sm" onClick={() => act(p.id, 'approved')}>
|
primary={[
|
||||||
{t('prescriptions.approve')}
|
{
|
||||||
</Button>
|
label: t('prescriptions.approve'),
|
||||||
<Button size="sm" variant="outline" onClick={() => act(p.id, 'queried')}>
|
icon: <Check className="h-3.5 w-3.5" />,
|
||||||
{t('prescriptions.query')}
|
onClick: () => act(p.id, 'approved'),
|
||||||
</Button>
|
},
|
||||||
<Button size="sm" variant="destructive" onClick={() => act(p.id, 'rejected')}>
|
]}
|
||||||
{t('prescriptions.reject')}
|
items={[
|
||||||
</Button>
|
{
|
||||||
</div>
|
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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</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 { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { PackagePlus, Plus } from 'lucide-react'
|
||||||
import { PageShell } from '@/layouts/PageShell'
|
import { PageShell } from '@/layouts/PageShell'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
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'
|
import { stockRows } from '@/mocks/data'
|
||||||
|
|
||||||
const recommendations = [
|
const recommendations = [
|
||||||
|
|
@ -15,35 +20,102 @@ const recommendations = [
|
||||||
|
|
||||||
export function ProcurementPage() {
|
export function ProcurementPage() {
|
||||||
const { t } = useTranslation()
|
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 (
|
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">
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
<Card>
|
<Card className="overflow-hidden">
|
||||||
<CardHeader>
|
<TableToolbar
|
||||||
<CardTitle>Dead-stock & fast movers</CardTitle>
|
search={q}
|
||||||
</CardHeader>
|
onSearchChange={setQ}
|
||||||
<CardContent className="p-4 pt-0">
|
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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead>SKU</TableHead>
|
<TableHead>SKU</TableHead>
|
||||||
<TableHead>Qty</TableHead>
|
<TableHead>Qty</TableHead>
|
||||||
<TableHead>Signal</TableHead>
|
<TableHead>Signal</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{stockRows.map((r) => (
|
{filteredStock.map((r) => {
|
||||||
|
const sig = r.qty < 20 ? 'reorder' : r.qty > 150 ? 'slow' : 'healthy'
|
||||||
|
return (
|
||||||
<TableRow key={r.sku + r.branchId}>
|
<TableRow key={r.sku + r.branchId}>
|
||||||
<TableCell>{r.sku}</TableCell>
|
<TableCell className="font-medium">{r.sku}</TableCell>
|
||||||
<TableCell>{r.qty}</TableCell>
|
<TableCell>{r.qty}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant={r.qty < 20 ? 'warning' : r.qty > 150 ? 'muted' : 'success'}>
|
<Badge
|
||||||
{r.qty < 20 ? 'reorder' : r.qty > 150 ? 'slow' : 'healthy'}
|
variant={sig === 'reorder' ? 'warning' : sig === 'slow' ? 'muted' : 'success'}
|
||||||
|
>
|
||||||
|
{sig}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -62,6 +134,7 @@ export function ProcurementPage() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="h-9"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
toast.success(
|
toast.success(
|
||||||
r.suggestion === 'transfer'
|
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,22 +1,48 @@
|
||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { Plus, RefreshCw } from 'lucide-react'
|
||||||
import { PageShell } from '@/layouts/PageShell'
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
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 { branches, stockRows } from '@/mocks/data'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
export function StockPage() {
|
export function StockPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [entry, setEntry] = useState('')
|
const [entry, setEntry] = useState('')
|
||||||
const [rows, setRows] = useState(stockRows)
|
const [rows, setRows] = useState(stockRows)
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [branchFilter, setBranchFilter] = useState('all')
|
||||||
|
const [alertFilter, setAlertFilter] = useState('all')
|
||||||
|
|
||||||
const duplicates = entry.length > 2
|
const filtered = useMemo(() => {
|
||||||
? rows.filter((r) => r.name.toLowerCase().includes(entry.toLowerCase()) || r.sku.toLowerCase().includes(entry.toLowerCase()))
|
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) => {
|
const resolve = (sku: string) => {
|
||||||
|
|
@ -34,11 +60,56 @@ export function StockPage() {
|
||||||
<TabsTrigger value="transfers">Transfers</TabsTrigger>
|
<TabsTrigger value="transfers">Transfers</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="inventory">
|
<TabsContent value="inventory">
|
||||||
<Card>
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="p-4">
|
<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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead>SKU</TableHead>
|
<TableHead>SKU</TableHead>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Branch</TableHead>
|
<TableHead>Branch</TableHead>
|
||||||
|
|
@ -46,10 +117,11 @@ export function StockPage() {
|
||||||
<TableHead>Batch</TableHead>
|
<TableHead>Batch</TableHead>
|
||||||
<TableHead>Expiry</TableHead>
|
<TableHead>Expiry</TableHead>
|
||||||
<TableHead>Alert</TableHead>
|
<TableHead>Alert</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{rows.map((r) => (
|
{filtered.map((r) => (
|
||||||
<TableRow key={`${r.sku}-${r.branchId}`}>
|
<TableRow key={`${r.sku}-${r.branchId}`}>
|
||||||
<TableCell className="font-medium">{r.sku}</TableCell>
|
<TableCell className="font-medium">{r.sku}</TableCell>
|
||||||
<TableCell>{r.name}</TableCell>
|
<TableCell>{r.name}</TableCell>
|
||||||
|
|
@ -58,7 +130,26 @@ export function StockPage() {
|
||||||
<TableCell>{r.batch}</TableCell>
|
<TableCell>{r.batch}</TableCell>
|
||||||
<TableCell>{r.expiry}</TableCell>
|
<TableCell>{r.expiry}</TableCell>
|
||||||
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|
@ -69,10 +160,7 @@ export function StockPage() {
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="erp">
|
<TabsContent value="erp">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="space-y-3 p-4">
|
||||||
<CardTitle>Platform vs ERP discrepancies</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 p-4 pt-0">
|
|
||||||
{rows
|
{rows
|
||||||
.filter((r) => r.qty !== r.erpQty)
|
.filter((r) => r.qty !== r.erpQty)
|
||||||
.map((r) => (
|
.map((r) => (
|
||||||
|
|
@ -83,7 +171,8 @@ export function StockPage() {
|
||||||
Platform {r.qty} · ERP {r.erpQty}
|
Platform {r.qty} · ERP {r.erpQty}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
Resolve to ERP
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -96,10 +185,7 @@ export function StockPage() {
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="entry">
|
<TabsContent value="entry">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="space-y-3 p-5">
|
||||||
<CardTitle>Duplicate detection while typing</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Name, barcode, or SKU…"
|
placeholder="Name, barcode, or SKU…"
|
||||||
value={entry}
|
value={entry}
|
||||||
|
|
@ -117,9 +203,10 @@ export function StockPage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="space-y-3 p-5">
|
<CardContent className="space-y-3 p-5">
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
|
className="h-10"
|
||||||
onClick={() => toast.success('Transfer draft created (mock)')}
|
onClick={() => toast.success('Transfer draft created (mock)')}
|
||||||
>
|
>
|
||||||
Create transfer suggestion
|
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 { useTranslation } from 'react-i18next'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
import { PageShell } from '@/layouts/PageShell'
|
import { PageShell } from '@/layouts/PageShell'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 { 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() {
|
export function TeamPage() {
|
||||||
const { t } = useTranslation()
|
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 (
|
return (
|
||||||
<PageShell title={t('team.title')} subtitle={t('team.subtitle')}>
|
<PageShell title={t('team.title')} subtitle={t('team.subtitle')}>
|
||||||
<Card>
|
<Card className="overflow-hidden">
|
||||||
<CardContent className="p-4">
|
<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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead>Email</TableHead>
|
||||||
<TableHead>Role</TableHead>
|
<TableHead>Role</TableHead>
|
||||||
|
|
@ -21,12 +98,12 @@ export function TeamPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{demoAccounts.map((a) => (
|
{filtered.map((a) => (
|
||||||
<TableRow key={a.email}>
|
<TableRow key={a.email}>
|
||||||
<TableCell className="font-medium">{a.name}</TableCell>
|
<TableCell className="font-medium">{a.name}</TableCell>
|
||||||
<TableCell>{a.email}</TableCell>
|
<TableCell>{a.email}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge>{a.role}</Badge>
|
<Badge>{a.role.replaceAll('_', ' ')}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{a.branchName ?? '—'}</TableCell>
|
<TableCell>{a.branchName ?? '—'}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
|
||||||
Reference in New Issue
Block a user