new updates
This commit is contained in:
@@ -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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user