From f2aff8c377bc5b647f8fc7a2e9cdbe9c2c924df4 Mon Sep 17 00:00:00 2001 From: kirukib Date: Thu, 6 Aug 2026 19:09:41 +0300 Subject: [PATCH] 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 --- docs/admin-backend-spec.md | 12 +- src/app/AppRoutes.tsx | 27 +- src/components/table/RowActions.tsx | 81 +++++ src/components/table/TableToolbar.tsx | 105 +++++++ src/layouts/CreatePageLayout.tsx | 48 +++ src/pages/marketing/CrmPage.tsx | 94 +++++- src/pages/orders/CreateOrderPage.tsx | 152 +++++++++ src/pages/orders/OrdersPage.tsx | 296 ++++++++++++++---- .../organisations/CreateOrganisationPage.tsx | 93 ++++++ src/pages/organisations/OrganisationsPage.tsx | 229 +++++++------- .../prescriptions/CreatePrescriptionPage.tsx | 123 ++++++++ src/pages/prescriptions/PrescriptionsPage.tsx | 214 +++++++++---- .../CreateProcurementRequestPage.tsx | 86 +++++ src/pages/procurement/ProcurementPage.tsx | 109 +++++-- src/pages/stock/CreateStockPage.tsx | 102 ++++++ src/pages/stock/StockPage.tsx | 129 ++++++-- src/pages/team/CreateTeamMemberPage.tsx | 102 ++++++ src/pages/team/TeamPage.tsx | 87 ++++- 18 files changed, 1782 insertions(+), 307 deletions(-) create mode 100644 src/components/table/RowActions.tsx create mode 100644 src/components/table/TableToolbar.tsx create mode 100644 src/layouts/CreatePageLayout.tsx create mode 100644 src/pages/orders/CreateOrderPage.tsx create mode 100644 src/pages/organisations/CreateOrganisationPage.tsx create mode 100644 src/pages/prescriptions/CreatePrescriptionPage.tsx create mode 100644 src/pages/procurement/CreateProcurementRequestPage.tsx create mode 100644 src/pages/stock/CreateStockPage.tsx create mode 100644 src/pages/team/CreateTeamMemberPage.tsx diff --git a/docs/admin-backend-spec.md b/docs/admin-backend-spec.md index 118d22a..f9678a6 100644 --- a/docs/admin-backend-spec.md +++ b/docs/admin-backend-spec.md @@ -20,6 +20,7 @@ | 2026-08-06 | Super Admin team/inbox/audit/settings + FAQ bilingual | | 2026-08-06 | Data migration import jobs | | 2026-08-06 | Deployed Admin SPA: https://gishen-admin.vercel.app | +| 2026-08-06 | List UX: filters + create pages for orders, Rx, orgs, stock, team, procurement; `POST` create sketches below | ## Conventions @@ -74,7 +75,8 @@ Mock key: `authStore.login` → `/auth/staff/login` | Method | Path | Roles | Description | | --- | --- | --- | --- | -| GET | `/pharmacy/orders` | pharmacist, operations, super_admin | `?branchId=` required for pharmacist | +| GET | `/pharmacy/orders` | pharmacist, operations, super_admin | `?branchId=` required for pharmacist; filters: `status`, `fulfillment`, `channel`, `q` | +| POST | `/pharmacy/orders` | pharmacist, operations, super_admin | Staff-created order (call centre / walk-in) | | POST | `/orders/:id/confirm` | pharmacist, super_admin | Confirm stock | | POST | `/orders/:id/assign-rider` | pharmacist, operations, super_admin | `{ riderId }` | | PATCH | `/orders/:id/status` | pharmacist, operations, super_admin | Status lifecycle | @@ -87,7 +89,8 @@ Mock: `mocks/data.orders` | Method | Path | Roles | Description | | --- | --- | --- | --- | -| GET | `/pharmacy/prescriptions` | pharmacist, super_admin | Branch-scoped queue | +| GET | `/pharmacy/prescriptions` | pharmacist, super_admin | Branch-scoped queue; filters: `status`, `controlled`, `q` | +| POST | `/pharmacy/prescriptions` | pharmacist, super_admin | Staff Rx intake | | POST | `/admin/prescriptions/:id/verify` | pharmacist, super_admin | `{ status: approved\|queried\|rejected, qtyAdjustments?, substitute?, notes? }` | | POST | `/pharmacy/prescriptions/:id/dispense` | pharmacist | `{ batch, quantity }` stamped pharmacistId + time | | GET | `/pharmacy/prescriptions/controlled-report` | pharmacist, super_admin | Controlled-substance report | @@ -99,7 +102,8 @@ Mock: `mocks/data.orders` | Method | Path | Roles | Description | | --- | --- | --- | --- | -| GET | `/pharmacy/inventory` | stock_manager, pharmacist (read), procurement (read), super_admin | Per-branch stock | +| GET | `/pharmacy/inventory` | stock_manager, pharmacist (read), procurement (read), super_admin | Per-branch stock; filters: `branchId`, `q`, `alert=low\|mismatch` | +| POST | `/pharmacy/inventory` | stock_manager, super_admin | Add stock line | | GET | `/integrations/erp/sync-status` | stock_manager, super_admin | Last sync | | GET | `/admin/stock/discrepancies` | stock_manager, super_admin | Platform vs ERP | | POST | `/admin/stock/discrepancies/:id/resolve` | stock_manager, super_admin | One-click resolve | @@ -186,7 +190,7 @@ Fields: `title_en`, `title_am`, `body_en`, `body_am`, `category`, `published`. | Method | Path | Roles | Description | | --- | --- | --- | --- | -| GET/POST | `/admin/users` | super_admin | Staff + role + branch | +| GET/POST | `/admin/users` | super_admin | Staff + role + branch; list supports `role`, `branchScoped`, `q` | | GET | `/admin/inbox/threads` | super_admin | Chatwoot/Telegram/WhatsApp attached to customer | | GET | `/admin/audit` | super_admin | Audit log | | GET/PUT | `/admin/settings` | super_admin | Retention, access controls | diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx index f45936f..d65e28c 100644 --- a/src/app/AppRoutes.tsx +++ b/src/app/AppRoutes.tsx @@ -4,17 +4,23 @@ import { AppLayout } from '@/layouts/AppLayout' import { LoginPage } from '@/pages/auth/LoginPage' import { DashboardPage } from '@/pages/dashboard/DashboardPage' import { OrdersPage } from '@/pages/orders/OrdersPage' +import { CreateOrderPage } from '@/pages/orders/CreateOrderPage' import { PrescriptionsPage } from '@/pages/prescriptions/PrescriptionsPage' +import { CreatePrescriptionPage } from '@/pages/prescriptions/CreatePrescriptionPage' import { StockPage } from '@/pages/stock/StockPage' +import { CreateStockPage } from '@/pages/stock/CreateStockPage' import { ProcurementPage } from '@/pages/procurement/ProcurementPage' +import { CreateProcurementRequestPage } from '@/pages/procurement/CreateProcurementRequestPage' import { FinancePage } from '@/pages/finance/FinancePage' import { OrganisationsPage } from '@/pages/organisations/OrganisationsPage' +import { CreateOrganisationPage } from '@/pages/organisations/CreateOrganisationPage' import { MarketingPage } from '@/pages/marketing/MarketingPage' import { CrmPage } from '@/pages/marketing/CrmPage' import { LoyaltyPage } from '@/pages/marketing/LoyaltyPage' import { CampaignsPage } from '@/pages/marketing/CampaignsPage' import { DispatchPage } from '@/pages/dispatch/DispatchPage' import { TeamPage } from '@/pages/team/TeamPage' +import { CreateTeamMemberPage } from '@/pages/team/CreateTeamMemberPage' import { InboxPage } from '@/pages/inbox/InboxPage' import { AuditPage } from '@/pages/audit/AuditPage' import { FaqPage } from '@/pages/faq/FaqPage' @@ -36,25 +42,19 @@ export function AppRoutes() { return ( } /> - - } - > - - - - } - /> + }> + } /> } /> + } /> } /> + } /> } /> + } /> } /> + } /> } /> } /> + } /> } /> } /> } /> @@ -64,6 +64,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/table/RowActions.tsx b/src/components/table/RowActions.tsx new file mode 100644 index 0000000..49f924c --- /dev/null +++ b/src/components/table/RowActions.tsx @@ -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 ( +
+ {primary?.map((action) => ( + + ))} + {overflow.length > 0 ? ( + + + + + + {overflow.map((action) => ( + + {action.icon} + {action.label} + + ))} + + + ) : null} +
+ ) +} diff --git a/src/components/table/TableToolbar.tsx b/src/components/table/TableToolbar.tsx new file mode 100644 index 0000000..ef72856 --- /dev/null +++ b/src/components/table/TableToolbar.tsx @@ -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 ( +
+
+
+ {onSearchChange ? ( +
+ + onSearchChange(e.target.value)} + /> + {search ? ( + + ) : null} +
+ ) : null} + {filters ?
{filters}
: null} +
+
+ {typeof resultCount === 'number' ? ( + + {resultCount} results + + ) : null} + {actions} +
+
+
+ ) +} + +export function FilterSelect({ + label, + value, + onChange, + options, + className, +}: { + label: string + value: string + onChange: (value: string) => void + options: { value: string; label: string }[] + className?: string +}) { + return ( +
+ {label} + onChange(e.target.value)} + aria-label={label} + > + {options.map((o) => ( + + ))} + +
+ ) +} + +export function ClearFiltersButton({ onClick, visible }: { onClick: () => void; visible: boolean }) { + if (!visible) return null + return ( + + ) +} diff --git a/src/layouts/CreatePageLayout.tsx b/src/layouts/CreatePageLayout.tsx new file mode 100644 index 0000000..2f18f42 --- /dev/null +++ b/src/layouts/CreatePageLayout.tsx @@ -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 ( +
+
+ +
+

{title}

+ {subtitle ?

{subtitle}

: null} +
+ + + Details + {subtitle ? {subtitle} : null} + + {children} + + {footer ?
{footer}
: null} +
+ ) +} diff --git a/src/pages/marketing/CrmPage.tsx b/src/pages/marketing/CrmPage.tsx index f1866f5..f02afec 100644 --- a/src/pages/marketing/CrmPage.tsx +++ b/src/pages/marketing/CrmPage.tsx @@ -1,19 +1,81 @@ +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { PageShell } from '@/layouts/PageShell' import { Card, CardContent } from '@/components/ui/card' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Badge } from '@/components/ui/badge' +import { ClearFiltersButton, FilterSelect, TableToolbar } from '@/components/table/TableToolbar' import { customers } from '@/mocks/data' export function CrmPage() { const { t } = useTranslation() + const [q, setQ] = useState('') + const [tier, setTier] = useState('all') + const [branch, setBranch] = useState('all') + + const filtered = useMemo(() => { + return customers.filter((c) => { + if (tier !== 'all' && c.tier !== tier) return false + if (branch !== 'all' && c.branch !== branch) return false + if (q) { + const needle = q.toLowerCase() + if ( + !c.name.toLowerCase().includes(needle) && + !c.phone.includes(needle) + ) { + return false + } + } + return true + }) + }, [q, tier, branch]) + return ( - - + + + + + { + setQ('') + setTier('all') + setBranch('all') + }} + /> + + } + /> + - + Name Phone Tier @@ -22,17 +84,25 @@ export function CrmPage() { - {customers.map((c) => ( - - {c.name} - {c.phone} - - {c.tier} + {filtered.length === 0 ? ( + + + {t('common.noResults')} - {c.points} - {c.branch} - ))} + ) : ( + filtered.map((c) => ( + + {c.name} + {c.phone} + + {c.tier} + + {c.points} + {c.branch} + + )) + )}
diff --git a/src/pages/orders/CreateOrderPage.tsx b/src/pages/orders/CreateOrderPage.tsx new file mode 100644 index 0000000..1cd123e --- /dev/null +++ b/src/pages/orders/CreateOrderPage.tsx @@ -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 ( + + + + + } + > +
+
+ + setForm({ ...form, customerName: e.target.value })} + /> +
+
+ + setForm({ ...form, phone: e.target.value })} + /> +
+
+ + setForm({ ...form, totalEtb: e.target.value })} + /> +
+
+ + setForm({ ...form, branchId: e.target.value })} + disabled={user?.role === 'pharmacist'} + > + {branches.map((b) => ( + + ))} + +
+
+ + + setForm({ ...form, fulfillment: e.target.value as 'pickup' | 'delivery' }) + } + > + + + +
+
+ + setForm({ ...form, channel: e.target.value })} + > + + + + + +
+ {form.fulfillment === 'delivery' ? ( +
+ + setForm({ ...form, address: e.target.value })} + /> +
+ ) : null} +
+ +