new updates

This commit is contained in:
2026-09-17 14:22:45 +05:30
committed by ImanThiyanga
parent e1f19fe413
commit 8c3d62223c
261 changed files with 102091 additions and 549 deletions
@@ -38,6 +38,7 @@ import {
PackageCheck,
PackageSearch,
PackageX,
Percent,
PlayCircle,
PieChart,
Receipt,
@@ -96,7 +97,9 @@ const navItems: {
children: [
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
{ title: "Brand/UOM", code: "products.brand", href: "/dashboard/products/brands", icon: Tag, altCodes: ["products.uom"] },
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
{ title: "Tax Rates", code: "products.tax-rates", href: "/dashboard/products/tax-rates", icon: Percent },
],
},
{
+143 -49
View File
@@ -1,15 +1,19 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { ArrowLeft, Bell, Building2, LogOut, Settings, User } from "lucide-react"
import { ArrowLeft, Bell, Boxes, Building2, FileText, LogOut, Receipt, Search, Settings, User } from "lucide-react"
import { authApi } from "@/lib/api/auth"
import { searchApi } from "@/lib/api/search"
import { notificationsApi } from "@/lib/api/notifications"
import { clearStoredUser, displayName, getStoredUser } from "@/lib/auth-session"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { AuthUser } from "@/types/auth"
import { Branch } from "@/types/hrm"
import { NotificationItem } from "@/types/notification-feed"
import { GlobalSearchResult, GlobalSearchResultType } from "@/types/search"
import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { ThemeToggle } from "@/components/theme-toggle"
@@ -125,44 +129,24 @@ function titleFromPath(pathname: string) {
return segment.charAt(0).toUpperCase() + segment.slice(1)
}
interface Notification {
id: string
title: string
description: string
time: string
unread: boolean
const SEARCH_TYPE_META: Record<GlobalSearchResultType, { label: string; icon: typeof Boxes; href: (id: number) => string }> = {
Item: { label: "Items", icon: Boxes, href: (id) => `/dashboard/products/${id}` },
PurchaseOrder: { label: "Purchase Orders", icon: FileText, href: (id) => `/dashboard/procurement/purchase-orders/${id}` },
Invoice: { label: "Invoices", icon: Receipt, href: (id) => `/dashboard/sales/invoices/${id}` },
Employee: { label: "Employees", icon: User, href: (id) => `/dashboard/hrm/employees/${id}` },
}
const initialNotifications: Notification[] = [
{
id: "1",
title: "New order received",
description: "Order #3210 placed by John Martinez",
time: "2m ago",
unread: true,
},
{
id: "2",
title: "Low stock alert",
description: "SKU-2291 has dropped below its reorder threshold",
time: "1h ago",
unread: true,
},
{
id: "3",
title: "Payment failed",
description: "Order #3207 payment was declined",
time: "3h ago",
unread: false,
},
{
id: "4",
title: "New customer",
description: "Grace Kim just created an account",
time: "1d ago",
unread: false,
},
]
/** Relative time for a notification timestamp: "2m ago", "3h ago", "1d ago". */
function relativeTime(iso: string): string {
const diffMs = Date.now() - new Date(iso).getTime()
const minutes = Math.floor(diffMs / 60000)
if (minutes < 1) return "just now"
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
export function Header() {
const pathname = usePathname() || "/"
@@ -171,8 +155,24 @@ export function Header() {
const showBackButton = pathname !== "/dashboard"
const { isAdmin, branchName, activeBranchId, activeBranchName, setActiveBranch, branchingEnabled } = useAuth()
const branchLabel = isAdmin ? (activeBranchName ?? "All Branches") : branchName
const [notifications, setNotifications] = useState(initialNotifications)
const unreadCount = notifications.filter((n) => n.unread).length
const [notifications, setNotifications] = useState<NotificationItem[]>([])
const [unreadCount, setUnreadCount] = useState(0)
const loadNotifications = () =>
notificationsApi
.getFeed()
.then((feed) => {
setNotifications(feed.notifications)
setUnreadCount(feed.unreadCount)
})
.catch(() => {
setNotifications([])
setUnreadCount(0)
})
useEffect(() => {
loadNotifications()
}, [])
const [branches, setBranches] = useState<Branch[]>([])
useEffect(() => {
@@ -197,8 +197,53 @@ export function Header() {
setUser(getStoredUser())
}, [])
const markAllAsRead = () =>
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
// Global search bar: jump to an Item, PO, Invoice or Employee by number or name.
const [searchQuery, setSearchQuery] = useState("")
const [searchResults, setSearchResults] = useState<GlobalSearchResult[]>([])
const [searchOpen, setSearchOpen] = useState(false)
const searchBoxRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const term = searchQuery.trim()
if (term.length < 2) return
const handle = setTimeout(() => {
searchApi
.search(term)
.then((r) => setSearchResults(r))
.catch(() => setSearchResults([]))
}, 200)
return () => clearTimeout(handle)
}, [searchQuery])
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (searchBoxRef.current && !searchBoxRef.current.contains(e.target as Node)) {
setSearchOpen(false)
}
}
document.addEventListener("mousedown", onClickOutside)
return () => document.removeEventListener("mousedown", onClickOutside)
}, [])
function goToSearchResult(result: GlobalSearchResult) {
setSearchOpen(false)
setSearchQuery("")
setSearchResults([])
router.push(SEARCH_TYPE_META[result.type].href(result.id))
}
const searchGrouped = (["Item", "PurchaseOrder", "Invoice", "Employee"] as GlobalSearchResultType[])
.map((type) => ({ type, items: searchResults.filter((r) => r.type === type) }))
.filter((g) => g.items.length > 0)
const markAllAsRead = () => {
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })))
setUnreadCount(0)
notificationsApi.markAllRead().catch(() => {
// Best-effort — a failed mark-all-read is corrected on the next fetch.
})
}
async function handleLogout() {
try {
@@ -264,9 +309,58 @@ export function Header() {
)
))}
<div ref={searchBoxRef} className="relative hidden w-56 shrink-0 sm:block lg:w-72">
<Search className="pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setSearchOpen(true)
}}
onFocus={() => setSearchOpen(true)}
placeholder="Search item, PO, invoice, employee…"
className="h-9 w-full rounded-full border border-transparent bg-muted pr-3 pl-9 text-xs text-foreground outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
/>
{searchOpen && searchQuery.trim().length >= 2 && (
<div className="absolute top-full right-0 z-50 mt-2 max-h-96 w-80 overflow-y-auto rounded-xl bg-popover py-1.5 text-sm shadow-md ring-1 ring-foreground/10">
{searchResults.length === 0 ? (
<p className="p-4 text-center text-sm text-muted-foreground">No matches found.</p>
) : (
searchGrouped.map((group) => {
const meta = SEARCH_TYPE_META[group.type]
const Icon = meta.icon
return (
<div key={group.type}>
<p className="px-4 pt-1 pb-1 text-xs font-medium text-muted-foreground">{meta.label}</p>
{group.items.map((item) => (
<button
key={`${item.type}-${item.id}`}
type="button"
onClick={() => goToSearchResult(item)}
className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-primary/10"
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">{item.title}</p>
<p className="truncate text-xs text-muted-foreground">
{item.number}
{item.subtitle ? ` · ${item.subtitle}` : ""}
</p>
</div>
</button>
))}
</div>
)
})
)}
</div>
)}
</div>
<ThemeToggle />
<Popover>
<Popover onOpenChange={(open) => open && loadNotifications()}>
<PopoverTrigger
render={
<button
@@ -306,24 +400,24 @@ export function Header() {
) : (
notifications.map((notification) => (
<div
key={notification.id}
key={notification.notificationId}
className={cn(
"flex gap-3 border-b border-foreground/10 px-4 py-3 last:border-0",
notification.unread && "bg-primary/10"
!notification.isRead && "bg-primary/10"
)}
>
<span
className={cn(
"mt-1.5 size-2 shrink-0 rounded-full",
notification.unread ? "bg-primary" : "bg-transparent"
!notification.isRead ? "bg-primary" : "bg-transparent"
)}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{notification.title}</p>
<p className="truncate text-sm text-muted-foreground">
{notification.description}
{notification.message}
</p>
<p className="mt-1 text-xs text-muted-foreground">{notification.time}</p>
<p className="mt-1 text-xs text-muted-foreground">{relativeTime(notification.createdAt)}</p>
</div>
</div>
))
@@ -349,7 +443,7 @@ export function Header() {
{user ? displayName(user) : "Signed in"}
</p>
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
{branchLabel && (
{branchingEnabled && branchLabel && (
<p className="mt-1 flex items-center gap-1.5 text-sm font-normal text-muted-foreground">
<Building2 className="size-3.5" />
{branchLabel}
@@ -56,6 +56,12 @@ interface AuthContextValue {
* app should be hidden entirely, not merely restricted by role.
*/
branchingEnabled: boolean
/**
* Independent sub-toggle for Warehouses only (Settings → Branch Configuration). False
* hides Warehouse's branch picker/badge even while `branchingEnabled` stays true for
* everything else. Always false when `branchingEnabled` itself is false.
*/
warehouseBranchingEnabled: boolean
/** True until the first `/auth/me` response lands. */
loading: boolean
}
@@ -71,6 +77,7 @@ const AuthContext = createContext<AuthContextValue>({
activeBranchName: null,
setActiveBranch: () => {},
branchingEnabled: true,
warehouseBranchingEnabled: true,
loading: true,
})
@@ -88,7 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
.catch(() => {
// Unauthenticated/unreachable — fall back to "no permissions" rather than
// crash the shell; proxy.ts already redirects unauthenticated users to /login.
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true })
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true, warehouseBranchingEnabled: true })
})
return () => {
cancelled = true
@@ -121,6 +128,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
activeBranchName: activeBranch?.name ?? null,
setActiveBranch,
branchingEnabled: me?.branchingEnabled ?? true,
warehouseBranchingEnabled: me?.warehouseBranchingEnabled ?? true,
loading: me === null,
}}
>
@@ -0,0 +1,209 @@
"use client"
// Step 2 of the role-permission flow: after nav items/sub-items are picked in
// RolePermissionTree, this lists exactly those selected items — grouped under their
// parent module, main item first then its sub-items — and lets an admin toggle which
// mutating actions (View/Create/Edit/Delete/Approve) the role gets within each —
// ERPCore's RolePermission.Actions bitmask (Domain/Enums/RolePermissionAction.cs). Every
// action pill starts plain (nothing granted yet) for a freshly-checked section — an
// admin has to deliberately click each action to grant it, rather than everything
// showing pre-granted and requiring clicks to take access away. A granted action turns
// the same blue as a checked pill on the Sidebar permissions step, for one consistent
// "selected" look across both steps.
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
import { ALL_ROLE_PERMISSION_ACTIONS, NavItem } from "@/types/rbac"
interface SelectedRow {
id: number
kind: "nav" | "subnav"
label: string
}
interface SelectedGroup {
navItemId: number
navLabel: string
/** The nav item's own row, when it's a leaf (no children) and was itself selected. */
own: SelectedRow | null
children: SelectedRow[]
}
interface Props {
tree: NavItem[]
selectedNavItemIds: Set<number>
selectedSubNavItemIds: Set<number>
navItemActions: Record<number, number>
subNavItemActions: Record<number, number>
onActionsChange: (navItemActions: Record<number, number>, subNavItemActions: Record<number, number>) => void
}
// Same "selected" style as the sub-item pills on the Sidebar permissions step
// (RolePermissionTree's child chips) — one color for every granted action, not one
// color per action type.
const ACTIVE_CLASS_NAME =
"border-transparent bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-500/20 dark:text-blue-300 dark:hover:bg-blue-500/30"
const ACTION_FLAGS: { bit: number; label: string }[] = [
{ bit: 1, label: "View" },
{ bit: 2, label: "Create" },
{ bit: 4, label: "Edit" },
{ bit: 8, label: "Delete" },
{ bit: 16, label: "Approve" },
]
function actionsFor(id: number, actions: Record<number, number>): number {
return actions[id] ?? 0
}
/**
* Fills in an explicit 0 for every selected id missing from the map before a save, so
* an id the admin never clicked an action for is actually saved as "no actions" — what
* the plain/unselected pills showed — instead of falling through to the backend's
* RolePermission.Actions default of All for any id the request omits.
*/
export function fillUntouchedActions(ids: Iterable<number>, actions: Record<number, number>): Record<number, number> {
const filled = { ...actions }
for (const id of ids) if (!(id in filled)) filled[id] = 0
return filled
}
function buildSelectedGroups(tree: NavItem[], navIds: Set<number>, subNavIds: Set<number>): SelectedGroup[] {
const groups: SelectedGroup[] = []
for (const item of tree) {
const own: SelectedRow | null =
item.children.length === 0 && navIds.has(item.navItemId) ? { id: item.navItemId, kind: "nav", label: item.label } : null
const children: SelectedRow[] = item.children
.filter((c) => subNavIds.has(c.subNavItemId))
.map((c) => ({ id: c.subNavItemId, kind: "subnav" as const, label: c.label }))
if (own || children.length > 0) groups.push({ navItemId: item.navItemId, navLabel: item.label, own, children })
}
return groups
}
/** Same "select all" convenience as the parent-item checkbox on the Sidebar permissions
* step — sits in front of the section's label, not amongst the action pills, and
* grants/clears every action in one click. */
function SelectAllCheckbox({ actions, onToggleAll, small }: { actions: number; onToggleAll: (checked: boolean) => void; small?: boolean }) {
const allChecked = actions === ALL_ROLE_PERMISSION_ACTIONS
const someChecked = actions !== 0 && !allChecked
return (
<Checkbox
checked={allChecked}
indeterminate={someChecked}
onCheckedChange={(checked) => onToggleAll(!!checked)}
className={cn(
"border-2 border-muted-foreground/50 data-checked:border-blue-600! data-checked:bg-blue-600! data-checked:text-white!",
small ? "size-4.5 [&_svg]:size-3.5" : "size-5 [&_svg]:size-4"
)}
/>
)
}
function ActionRow({ actions, onToggle }: { actions: number; onToggle: (bit: number) => void }) {
return (
<div className="flex flex-wrap items-center gap-2">
{ACTION_FLAGS.map(({ bit, label }) => {
const checked = (actions & bit) === bit
return (
<button
key={bit}
type="button"
aria-pressed={checked}
onClick={() => onToggle(bit)}
className={cn(
"rounded-full border px-3 py-1 text-sm font-medium transition-colors",
checked ? ACTIVE_CLASS_NAME : "border-input bg-white text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground"
)}
>
{label}
</button>
)
})}
</div>
)
}
export function RolePermissionActions({
tree,
selectedNavItemIds,
selectedSubNavItemIds,
navItemActions,
subNavItemActions,
onActionsChange,
}: Props) {
const groups = buildSelectedGroups(tree, selectedNavItemIds, selectedSubNavItemIds)
function toggleNav(id: number, bit: number) {
const current = actionsFor(id, navItemActions)
onActionsChange({ ...navItemActions, [id]: current ^ bit }, subNavItemActions)
}
function toggleSubNav(id: number, bit: number) {
const current = actionsFor(id, subNavItemActions)
onActionsChange(navItemActions, { ...subNavItemActions, [id]: current ^ bit })
}
function toggleAllNav(id: number, checked: boolean) {
onActionsChange({ ...navItemActions, [id]: checked ? ALL_ROLE_PERMISSION_ACTIONS : 0 }, subNavItemActions)
}
function toggleAllSubNav(id: number, checked: boolean) {
onActionsChange(navItemActions, { ...subNavItemActions, [id]: checked ? ALL_ROLE_PERMISSION_ACTIONS : 0 })
}
if (groups.length === 0) {
return (
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
No sections selected — go back and pick at least one.
</div>
)
}
return (
<div className="overflow-hidden rounded-lg border">
<div className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 border-b bg-muted/40 px-4 py-2 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
<span>Section</span>
<span>Actions</span>
</div>
{groups.map((group) => (
<div key={group.navItemId} className="border-b last:border-b-0">
{group.own ? (
<div className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 bg-white px-4 py-3">
<label className="flex items-center gap-2.5">
<SelectAllCheckbox
actions={actionsFor(group.own.id, navItemActions)}
onToggleAll={(checked) => toggleAllNav(group.own!.id, checked)}
/>
<span className="text-sm font-semibold">{group.own.label}</span>
</label>
<ActionRow actions={actionsFor(group.own.id, navItemActions)} onToggle={(bit) => toggleNav(group.own!.id, bit)} />
</div>
) : (
group.children.length > 0 && (
<div className="bg-muted/20 px-4 py-1.5 text-sm font-semibold text-foreground">{group.navLabel}</div>
)
)}
{group.children.map((child) => (
<div key={child.id} className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 bg-white px-4 py-3 pl-8">
<label className="flex items-center gap-2.5">
<span className="text-muted-foreground/40">└</span>
<SelectAllCheckbox
actions={actionsFor(child.id, subNavItemActions)}
onToggleAll={(checked) => toggleAllSubNav(child.id, checked)}
small
/>
<span className="text-sm text-muted-foreground">{child.label}</span>
</label>
<ActionRow actions={actionsFor(child.id, subNavItemActions)} onToggle={(bit) => toggleSubNav(child.id, bit)} />
</div>
))}
</div>
))}
</div>
)
}
@@ -0,0 +1,82 @@
"use client"
// Reusable capture box for USB/Bluetooth barcode & RFID scanners. Those devices are plain HID
// keyboards: they "type" the scanned code into whatever input is focused, then send Enter — so
// there is no camera/getUserMedia code here, just a text input that reacts to Enter (and to
// losing focus with content still in it, in case a scanner is configured without a trailing
// Enter) and clears itself after each read. It stays auto-focused so the next scan always lands
// here rather than wherever the cursor last was in a large form.
import { useEffect, useRef, useState } from "react"
import { ScanBarcode } from "lucide-react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export interface ScanInputProps {
/** Fired once per scan with the trimmed code. The input is already cleared by the time this runs. */
onScan: (code: string) => void
label?: string
/** Helper text under the input, e.g. "Scan item or lot barcode". */
hint?: string
placeholder?: string
disabled?: boolean
className?: string
}
export function ScanInput({
onScan,
label = "Scan",
hint = "Scan item or lot barcode",
placeholder = "Scan or type a barcode, then press Enter",
disabled = false,
className,
}: ScanInputProps) {
const [value, setValue] = useState("")
const inputRef = useRef<HTMLInputElement>(null)
// Refocus whenever the field becomes usable again (e.g. after being briefly disabled while a
// lookup for the previous scan was in flight) so a scanner's next read always lands here.
useEffect(() => {
if (!disabled) inputRef.current?.focus()
}, [disabled])
function commit() {
const code = value.trim()
setValue("")
if (!code) return
onScan(code)
}
return (
<div className={cn("flex flex-col gap-2", className)}>
<Label className="flex items-center gap-1.5 text-base">
<ScanBarcode className="size-4 text-muted-foreground" />
{label}
</Label>
<Input
ref={inputRef}
value={value}
disabled={disabled}
placeholder={placeholder}
autoComplete="off"
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return
// A scanner's Enter is just another keystroke to the browser — stop it from also
// submitting an enclosing <form> or activating a nearby default button.
e.preventDefault()
commit()
}}
onBlur={() => {
// Guards a scanner configured without a trailing Enter — but only fires for content
// that's actually there, so a plain click-away with an empty box does nothing.
if (value.trim()) commit()
}}
className="h-11 text-base"
/>
{hint && <p className="text-sm text-muted-foreground">{hint}</p>}
</div>
)
}
@@ -20,10 +20,19 @@ type Props = {
data: number[]
backgroundColor?: string | string[]
}>
/** Container height in px — defaults to the original 260 so existing callers are unaffected. */
height?: number
/** Fraction of each category's slot a bar occupies (Chart.js default 0.9) — raise for thicker bars. */
barPercentage?: number
/** Fraction of the axis each category slot occupies (Chart.js default 0.8) — raise to shrink gaps between categories. */
categoryPercentage?: number
}
export default function BarChart({ labels, datasets }: Props) {
const data = { labels, datasets }
export default function BarChart({ labels, datasets, height = 260, barPercentage, categoryPercentage }: Props) {
const data = {
labels,
datasets: datasets.map((d) => ({ ...d, barPercentage, categoryPercentage })),
}
const options = {
responsive: true,
@@ -33,7 +42,7 @@ export default function BarChart({ labels, datasets }: Props) {
}
return (
<div style={{ width: "100%", minHeight: 260 }}>
<div style={{ width: "100%", height }}>
<Bar data={data} options={options} />
</div>
)
@@ -0,0 +1,92 @@
"use client"
import * as React from "react"
import { Search, SlidersHorizontal, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
interface FilterBarProps {
search: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
searchAriaLabel?: string
/** Rendered to the left of the search box — status tabs/pills, typically. */
children?: React.ReactNode
/** Content shown inside the "Advanced" popover. Omit to hide the Advanced button entirely. */
advanced?: React.ReactNode
/** Shows a dot on the Advanced button when any advanced filter is set. */
advancedActive?: boolean
/** Shown as a "Clear advanced filters" action inside the popover when provided. */
onClearAdvanced?: () => void
className?: string
}
/**
* Standard list-page filter bar: search box + optional left-side content (status
* tabs/pills) + an optional "Advanced" popover for filters that don't fit inline
* (customer, warehouse, department, date range, ...). One shape, reused across
* Sales, HRM, and Warehouses list pages instead of each rolling its own.
*/
export function FilterBar({
search, onSearchChange, searchPlaceholder = "Search…", searchAriaLabel,
children, advanced, advancedActive, onClearAdvanced, className,
}: FilterBarProps) {
return (
<div className={cn("flex flex-col gap-3 lg:flex-row lg:items-center", className)}>
{children}
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<div className="relative w-full lg:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={searchPlaceholder}
className="h-10 pl-9 pr-9"
aria-label={searchAriaLabel ?? searchPlaceholder}
/>
{search && (
<button
type="button"
onClick={() => onSearchChange("")}
aria-label="Clear search"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
)}
</div>
{advanced && (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="relative shrink-0">
<SlidersHorizontal className="size-4" />
Advanced
{advancedActive && (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-primary" aria-hidden="true" />
)}
</Button>
}
/>
<PopoverContent align="end" className="w-72 p-4">
<div className="flex flex-col gap-3">
{advanced}
{onClearAdvanced && (
<Button variant="ghost" size="sm" onClick={onClearAdvanced} className="self-start">
Clear advanced filters
</Button>
)}
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
)
}
@@ -0,0 +1,28 @@
import { type LucideIcon } from "lucide-react"
import { StatCard } from "@/components/ui/stat-card"
import { Skeleton } from "@/components/ui/skeleton"
export interface KpiTileSpec {
label: string
value: number
icon?: LucideIcon
}
/**
* A small row of KPI tiles for a list page — reuses the same GET /dashboard/stats
* aggregate the Dashboard home page renders (see hooks/use-dashboard-stats.ts), just
* picking out the one or two counts relevant to this page. Pass `null` while stats are
* still loading to render skeleton placeholders instead of a layout jump.
*/
export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
return (
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
{tiles
? tiles.map((tile) => (
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} />
))
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-[90px] rounded-xl" />)}
</div>
)
}