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
@@ -0,0 +1,387 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import {
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock,
HelpCircle,
Inbox,
Plus,
Search,
X,
} from "lucide-react"
import { supportTicketsApi } from "@/lib/api/support-tickets"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { SupportTicketSummary } from "@/types/support"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Skeleton } from "@/components/ui/skeleton"
import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
const statusStyles: Record<string, string> = {
Open: "border-transparent bg-primary/10 text-primary",
InProgress: "border-transparent bg-warning/10 text-warning",
Resolved: "border-success/40 text-success",
}
const statusLabels: Record<string, string> = {
Open: "Open",
InProgress: "In Progress",
Resolved: "Resolved",
}
export default function HelpPage() {
const { isAdmin } = useAuth()
const [tickets, setTickets] = useState<SupportTicketSummary[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(5)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [open, setOpen] = useState(false)
const [subject, setSubject] = useState("")
const [message, setMessage] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
function load() {
supportTicketsApi
.list()
.then(setTickets)
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
useEffect(() => {
const t = setTimeout(() => {
setPage(1)
setDebouncedSearch(search.trim())
}, 300)
return () => clearTimeout(t)
}, [search])
const stats = useMemo(() => {
if (!tickets) return null
return {
total: tickets.length,
open: tickets.filter((t) => t.status === "Open").length,
inProgress: tickets.filter((t) => t.status === "InProgress").length,
resolved: tickets.filter((t) => t.status === "Resolved").length,
}
}, [tickets])
const filtered = useMemo(() => {
if (!tickets) return []
const term = debouncedSearch.toLowerCase()
if (!term) return tickets
return tickets.filter(
(t) => t.subject.toLowerCase().includes(term) || t.createdByName.toLowerCase().includes(term)
)
}, [tickets, debouncedSearch])
const totalItems = filtered.length
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
const pageItems = filtered.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize)
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!subject.trim()) nextErrors.subject = "Subject is required"
if (!message.trim()) nextErrors.message = "Please describe your issue"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
await supportTicketsApi.create({ subject, message })
toast.success("Ticket submitted", "Support will get back to you here.")
setOpen(false)
setSubject("")
setMessage("")
setErrors({})
load()
} catch (err) {
toast.error("Could not submit ticket", errorMessage(err))
} finally {
setSubmitting(false)
}
}
const loading = !tickets && !error
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Help &amp; Support</h1>
{tickets && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{tickets.length}
</span>
)}
</div>
<p className="text-base text-muted-foreground">
{isAdmin
? "All support tickets submitted by users across the system."
: "Raise a ticket and track its status here."}
</p>
</div>
{!isAdmin && (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<Button size="lg">
<Plus className="size-5" />
New Ticket
</Button>
}
/>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>New support ticket</DialogTitle>
<DialogDescription>Describe the issue and an admin will follow up here.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.subject}>
<FieldLabel htmlFor="ticket-subject">Subject</FieldLabel>
<Input
id="ticket-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Can't confirm a GRN"
aria-invalid={!!errors.subject}
/>
<FieldError errors={[errors.subject ? { message: errors.subject } : undefined]} />
</Field>
<Field data-invalid={!!errors.message}>
<FieldLabel htmlFor="ticket-message">Message</FieldLabel>
<Textarea
id="ticket-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="What were you trying to do, and what happened?"
rows={5}
aria-invalid={!!errors.message}
/>
<FieldError errors={[errors.message ? { message: errors.message } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleCreate} disabled={submitting}>
{submitting ? "Submitting…" : "Submit"}
</Button>
</div>
</DialogContent>
</Dialog>
)}
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{stats ? (
<>
<StatCard label="Total tickets" value={stats.total} icon={HelpCircle} />
<StatCard label="Open" value={stats.open} icon={Inbox} />
<StatCard label="In Progress" value={stats.inProgress} icon={Clock} />
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} />
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20 w-full rounded-xl" />)
)}
</div>
<div className="relative w-full sm:max-w-xs">
<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) => setSearch(e.target.value)}
placeholder="Search by subject or submitter…"
className="h-10 pl-9 pr-9"
aria-label="Search tickets"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
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>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && loading && (
<div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
</div>
))}
</div>
)}
{!error && tickets !== null && pageItems.length === 0 && debouncedSearch && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Search className="size-10 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">No tickets match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different subject or name.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
</Button>
</div>
)}
{!error && tickets !== null && tickets.length === 0 && !debouncedSearch && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<HelpCircle className="size-12 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">
{isAdmin ? "No support tickets yet" : "You haven't submitted any tickets yet"}
</p>
{!isAdmin && <p className="text-sm text-muted-foreground">Click &quot;New Ticket&quot; to raise your first one.</p>}
</div>
</div>
)}
{!error && tickets !== null && tickets.length > 0 && pageItems.length > 0 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-3 text-sm">Subject</TableHead>
{isAdmin && <TableHead className="h-11 w-40 px-3 text-sm">Submitted By</TableHead>}
<TableHead className="h-11 w-32 px-3 text-sm">Status</TableHead>
<TableHead className="h-11 w-24 px-3 text-sm">Messages</TableHead>
<TableHead className="h-11 w-40 px-3 text-sm">Last Updated</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageItems.map((t) => (
<TableRow key={t.ticketId} className="group align-top hover:bg-muted/20">
<TableCell className="px-3 py-4">
<Link
href={`/dashboard/help/tickets/${t.ticketId}`}
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
>
{t.subject}
</Link>
</TableCell>
{isAdmin && <TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.createdByName}</TableCell>}
<TableCell className="px-3 py-4">
<Badge variant="outline" className={cn("h-5 w-fit justify-center bg-white px-2 text-xs", statusStyles[t.status])}>
{statusLabels[t.status]}
</Badge>
</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">{t.messageCount}</TableCell>
<TableCell className="px-3 py-4 text-sm text-muted-foreground">
{new Date(t.updatedAt ?? t.createdAt).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>–
<span className="font-medium text-foreground">{Math.min(page * pageSize, totalItems)}</span>{" "}
of <span className="font-medium text-foreground">{totalItems}</span> tickets
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,174 @@
"use client"
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { ArrowLeft, HelpCircle } from "lucide-react"
import { supportTicketsApi } from "@/lib/api/support-tickets"
import { errorMessage } from "@/lib/error-map"
import { SupportTicket, TicketStatus } from "@/types/support"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
const statusStyles: Record<TicketStatus, string> = {
Open: "bg-primary/10 text-primary",
InProgress: "bg-warning/10 text-warning",
Resolved: "bg-success/10 text-success",
}
const statusLabels: Record<TicketStatus, string> = {
Open: "Open",
InProgress: "In Progress",
Resolved: "Resolved",
}
export default function SupportTicketDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const ticketId = Number(params.id)
const { isAdmin } = useAuth()
const [ticket, setTicket] = useState<SupportTicket | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [reply, setReply] = useState("")
const [sending, setSending] = useState(false)
const [updatingStatus, setUpdatingStatus] = useState(false)
function load() {
supportTicketsApi
.get(ticketId)
.then((res) => { setTicket(res.data); setEtag(res.etag) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [ticketId])
async function handleReply() {
if (!reply.trim() || !etag) return
setSending(true)
try {
const res = await supportTicketsApi.addMessage(ticketId, { message: reply }, etag)
setTicket(res.data)
setEtag(res.etag)
setReply("")
} catch (err) {
toast.error("Could not send reply", errorMessage(err))
load()
} finally {
setSending(false)
}
}
async function handleStatusChange(status: TicketStatus) {
if (!etag) return
setUpdatingStatus(true)
try {
const res = await supportTicketsApi.updateStatus(ticketId, { status }, etag)
setTicket(res.data)
setEtag(res.etag)
toast.success("Status updated", statusLabels[status])
} catch (err) {
toast.error("Could not update status", errorMessage(err))
load()
} finally {
setUpdatingStatus(false)
}
}
if (error) {
return (
<div className="flex flex-col gap-6">
<Button variant="ghost" size="sm" className="w-fit" onClick={() => router.push("/dashboard/help")}>
<ArrowLeft className="size-4" />
Back to Help
</Button>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
</div>
)
}
if (!ticket) {
return (
<div className="flex flex-col gap-3">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
)
}
return (
<div className="flex flex-col gap-6">
<Button variant="ghost" size="sm" className="w-fit" onClick={() => router.push("/dashboard/help")}>
<ArrowLeft className="size-4" />
Back to Help
</Button>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
<HelpCircle className="size-4" />
Ticket #{ticket.ticketId}
</div>
<h1 className="text-2xl font-bold text-foreground">{ticket.subject}</h1>
<p className="text-base text-muted-foreground">Submitted by {ticket.createdByName}</p>
</div>
{isAdmin ? (
<Select value={ticket.status} onValueChange={(v) => handleStatusChange(v as TicketStatus)} disabled={updatingStatus}>
<SelectTrigger className="w-44"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Open">Open</SelectItem>
<SelectItem value="InProgress">In Progress</SelectItem>
<SelectItem value="Resolved">Resolved</SelectItem>
</SelectContent>
</Select>
) : (
<Badge variant="outline" className={`h-6 w-fit justify-center border-transparent px-2.5 text-sm ${statusStyles[ticket.status]}`}>
{statusLabels[ticket.status]}
</Badge>
)}
</div>
<div className="flex flex-col gap-4">
{ticket.messages.map((m) => (
<div
key={m.messageId}
className={`rounded-2xl border p-4 ${m.isFromAdmin ? "ml-8 bg-primary/5" : "mr-8 bg-card"}`}
>
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-foreground">
{m.senderName} {m.isFromAdmin && <span className="text-primary">(Support)</span>}
</span>
<span className="text-xs text-muted-foreground">{new Date(m.createdAt).toLocaleString()}</span>
</div>
<p className="whitespace-pre-wrap text-sm leading-6 text-foreground">{m.message}</p>
</div>
))}
</div>
{ticket.status !== "Resolved" || isAdmin ? (
<div className="flex flex-col gap-3 rounded-2xl border bg-card p-5">
<Textarea
value={reply}
onChange={(e) => setReply(e.target.value)}
placeholder={isAdmin ? "Reply to this ticket…" : "Add more details…"}
rows={3}
/>
<Button className="w-fit" onClick={handleReply} disabled={sending || !reply.trim()}>
{sending ? "Sending…" : "Send"}
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">This ticket is resolved.</p>
)}
</div>
)
}
@@ -2,18 +2,18 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon, X } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, Link2, Pencil, Plus, Search, Trash2, UserCheck, UserMinus, UserX, Users as UsersIcon } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { departmentsApi, designationsApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { EmployeeListItem } from "@/types/hrm"
import { Department, Designation, EmployeeListItem, EmployeeStatus } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
@@ -24,6 +24,8 @@ import { StatCard } from "@/components/ui/stat-card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
const EMPLOYEE_STATUSES: EmployeeStatus[] = ["Active", "Suspended", "Resigned", "Terminated", "Retired"]
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]
export default function EmployeesPage() {
@@ -37,16 +39,33 @@ export default function EmployeesPage() {
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const [userAccountChoiceFor, setUserAccountChoiceFor] = useState<EmployeeListItem | null>(null)
const [stats, setStats] = useState<{ total: number; active: number; terminated: number } | null>(null)
const [statusFilter, setStatusFilter] = useState<EmployeeStatus | undefined>(undefined)
const [departmentId, setDepartmentId] = useState<number | undefined>(undefined)
const [designationId, setDesignationId] = useState<number | undefined>(undefined)
const [departments, setDepartments] = useState<Department[]>([])
const [designations, setDesignations] = useState<Designation[]>([])
useEffect(() => {
departmentsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDepartments(r.items)).catch(() => setDepartments([]))
designationsApi.list({ pageSize: 200, status: "Active" }).then((r) => setDesignations(r.items)).catch(() => setDesignations([]))
}, [])
function load() {
employeesApi
.list({ page, pageSize, q: debouncedSearch || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
.list({
page, pageSize, q: debouncedSearch || undefined,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
status: statusFilter, departmentId, designationId,
})
.then((res) => { setEmployees(res.items); setPagination(res.pagination) })
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled])
useEffect(load, [page, pageSize, debouncedSearch, activeBranchId, branchingEnabled, statusFilter, departmentId, designationId])
useEffect(() => setPage(1), [statusFilter, departmentId, designationId])
// Switching branch changes what "page 1" even means — stay on a page past the
// new filtered result set otherwise.
useEffect(() => setPage(1), [activeBranchId])
@@ -74,11 +93,29 @@ export default function EmployeesPage() {
useEffect(loadStats, [activeBranchId, branchingEnabled])
// Permanent delete — blocked by the backend (409) if the employee has payroll history.
async function handleDelete(e: EmployeeListItem) {
// If the employee has a linked login, the caller must say whether that User account should
// also be permanently deleted (deleteUserAccount) or just deactivated (the default).
async function handleDelete(e: EmployeeListItem, deleteUserAccount: boolean) {
setActionPendingId(e.employeeId)
try {
await employeesApi.remove(e.employeeId)
toast.success("Employee deleted", `${e.fullName} has been permanently removed.`)
const result = await employeesApi.remove(e.employeeId, deleteUserAccount)
if (result.userAccountDeleted) {
toast.success(
"Employee deleted",
result.authHexIdentityPurged
? `${e.fullName} and their login access have been permanently removed. Their email/username is free to reuse.`
: `${e.fullName} and their login access have been permanently removed locally, but their email/username may still be blocked from reuse (AuthHex identity purge failed or is not yet deployed).`
)
} else if (result.userAccountDeactivated) {
toast.success(
"Employee deleted",
deleteUserAccount
? `${e.fullName} removed. Their login has linked records, so it was deactivated instead of deleted.`
: `${e.fullName} removed. Their login was deactivated.`
)
} else {
toast.success("Employee deleted", `${e.fullName} has been permanently removed.`)
}
load()
loadStats()
} catch (err) {
@@ -88,6 +125,14 @@ export default function EmployeesPage() {
}
}
function handleDeleteRequest(e: EmployeeListItem) {
if (e.hasUserLink) {
setUserAccountChoiceFor(e)
} else {
handleDelete(e, false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -122,26 +167,47 @@ export default function EmployeesPage() {
)}
</div>
<div className="relative w-full sm:max-w-xs">
<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) => setSearch(e.target.value)}
placeholder="Search by name or employee code…"
className="h-10 pl-9 pr-9"
aria-label="Search employees"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
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>
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search by name or employee code…"
advancedActive={statusFilter !== undefined || departmentId !== undefined || designationId !== undefined}
onClearAdvanced={() => { setStatusFilter(undefined); setDepartmentId(undefined); setDesignationId(undefined) }}
advanced={
<>
<Field>
<FieldLabel htmlFor="emp-filter-status">Status</FieldLabel>
<Select value={statusFilter ?? "All"} onValueChange={(v) => setStatusFilter(v && v !== "All" ? (v as EmployeeStatus) : undefined)}>
<SelectTrigger id="emp-filter-status"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="All">All statuses</SelectItem>
{EMPLOYEE_STATUSES.map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="emp-filter-department">Department</FieldLabel>
<Select value={departmentId ? String(departmentId) : "All"} onValueChange={(v) => setDepartmentId(v && v !== "All" ? Number(v) : undefined)}>
<SelectTrigger id="emp-filter-department"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="All">All departments</SelectItem>
{departments.map((d) => <SelectItem key={d.departmentId} value={String(d.departmentId)}>{d.name}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="emp-filter-designation">Designation</FieldLabel>
<Select value={designationId ? String(designationId) : "All"} onValueChange={(v) => setDesignationId(v && v !== "All" ? Number(v) : undefined)}>
<SelectTrigger id="emp-filter-designation"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="All">All designations</SelectItem>
{designations.map((d) => <SelectItem key={d.designationId} value={String(d.designationId)}>{d.name}</SelectItem>)}
</SelectContent>
</Select>
</Field>
</>
}
/>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
@@ -286,7 +352,7 @@ export default function EmployeesPage() {
title={`Delete ${e.fullName}?`}
description="This permanently removes the employee and all their HR records (bank details, documents, salary history, loans). This cannot be undone. Blocked if the employee has payroll history."
confirmLabel="Delete"
onConfirm={() => handleDelete(e)}
onConfirm={() => handleDeleteRequest(e)}
/>
</AlertDialog>
</div>
@@ -350,6 +416,23 @@ export default function EmployeesPage() {
</div>
</div>
)}
<AlertDialog
open={!!userAccountChoiceFor}
onOpenChange={(open) => { if (!open) setUserAccountChoiceFor(null) }}
>
{userAccountChoiceFor && (
<AlertDialogContent
variant="warning"
title="This employee has login access"
description={`${userAccountChoiceFor.fullName} has a linked system-user account. Do you also want to permanently delete their login access, or just deactivate it?`}
confirmLabel="Delete User Access"
cancelLabel="Keep, but Deactivate"
onConfirm={() => { handleDelete(userAccountChoiceFor, true); setUserAccountChoiceFor(null) }}
onCancel={() => { handleDelete(userAccountChoiceFor, false); setUserAccountChoiceFor(null) }}
/>
)}
</AlertDialog>
</div>
)
}
@@ -1,7 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { CheckCircle2, ChevronLeft, ChevronRight, Plus, Search, X, XCircle } from "lucide-react"
import { CheckCircle2, ChevronLeft, ChevronRight, Clock, Plus, Search, X, XCircle } from "lucide-react"
import { employeesApi } from "@/lib/api/employees"
import { leaveTypesApi } from "@/lib/api/hrm-masters"
@@ -10,10 +10,12 @@ import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { EmployeeListItem, LeaveRequest, LeaveRequestStatus, LeaveType } from "@/types/hrm"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { KpiRow } from "@/components/ui/kpi-row"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { PaginationFooter } from "@/components/ui/pagination-footer"
@@ -36,6 +38,7 @@ const STATUS_STYLE: Record<LeaveRequestStatus, string> = {
}
export default function LeaveRequestsPage() {
const stats = useDashboardStats()
const [requests, setRequests] = useState<LeaveRequest[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -225,6 +228,8 @@ export default function LeaveRequestsPage() {
</DialogContent>
</Dialog>
<KpiRow tiles={stats ? [{ label: "Pending Leave", value: stats.pendingLeaveRequests, icon: Clock }] : null} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<div className="relative w-full sm:max-w-xs">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
@@ -15,11 +15,15 @@ import {
} from "@/types/hrm"
import { Button } from "@/components/ui/button"
import BarChart from "@/components/ui/bar-chart"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
/** Charts stay legible with a lot of employees only by capping to the top N by primary metric. */
const CHART_ROW_LIMIT = 15
const REPORTS = [
"Attendance Summary",
"Overtime",
@@ -51,6 +55,9 @@ export default function HrReportsPage() {
const [leaveRows, setLeaveRows] = useState<LeaveBalanceReportRow[]>([])
const [expiryRows, setExpiryRows] = useState<DocumentExpiryReportRow[]>([])
const topAttendance = [...attendanceRows].sort((a, b) => b.presentDays - a.presentDays).slice(0, CHART_ROW_LIMIT)
const topPayroll = [...payrollRows].sort((a, b) => b.grossSalary - a.grossSalary).slice(0, CHART_ROW_LIMIT)
async function run() {
setLoading(true)
try {
@@ -108,6 +115,20 @@ export default function HrReportsPage() {
<Button onClick={run} disabled={loading}>{loading ? "Loading…" : "Run"}</Button>
</div>
{report === "Attendance Summary" && topAttendance.length > 0 && (
<div className="rounded-xl border p-5">
<p className="mb-3 text-sm font-medium text-muted-foreground">Present / Absent / Leave days (top {CHART_ROW_LIMIT} by present days)</p>
<BarChart
labels={topAttendance.map((r) => r.employeeName)}
datasets={[
{ label: "Present", data: topAttendance.map((r) => r.presentDays), backgroundColor: "rgba(34,197,94,0.6)" },
{ label: "Absent", data: topAttendance.map((r) => r.absentDays), backgroundColor: "rgba(239,68,68,0.6)" },
{ label: "Leave", data: topAttendance.map((r) => r.leaveDays), backgroundColor: "rgba(234,179,8,0.6)" },
]}
/>
</div>
)}
{report === "Attendance Summary" && (
<Table className="text-base">
<TableHeader><TableRow><TableHead className="h-12 px-3 text-sm">Employee</TableHead><TableHead className="h-12 px-3 text-sm">Dept</TableHead><TableHead className="h-12 px-3 text-sm">Present</TableHead><TableHead className="h-12 px-3 text-sm">Absent</TableHead><TableHead className="h-12 px-3 text-sm">Leave</TableHead><TableHead className="h-12 px-3 text-sm">OT (min)</TableHead><TableHead className="h-12 px-3 text-sm">Late (min)</TableHead></TableRow></TableHeader>
@@ -135,6 +156,20 @@ export default function HrReportsPage() {
</Table>
)}
{report === "Payroll Register" && topPayroll.length > 0 && (
<div className="rounded-xl border p-5">
<p className="mb-3 text-sm font-medium text-muted-foreground">Gross / Deductions / Net (top {CHART_ROW_LIMIT} by gross)</p>
<BarChart
labels={topPayroll.map((r) => r.employeeName)}
datasets={[
{ label: "Gross", data: topPayroll.map((r) => r.grossSalary), backgroundColor: "rgba(59,130,246,0.6)" },
{ label: "Deductions", data: topPayroll.map((r) => r.totalDeductions), backgroundColor: "rgba(239,68,68,0.6)" },
{ label: "Net", data: topPayroll.map((r) => r.netSalary), backgroundColor: "rgba(34,197,94,0.6)" },
]}
/>
</div>
)}
{report === "Payroll Register" && (
<Table className="text-base">
<TableHeader><TableRow><TableHead className="h-12 px-3 text-sm">Employee</TableHead><TableHead className="h-12 px-3 text-sm">Gross</TableHead><TableHead className="h-12 px-3 text-sm">Deductions</TableHead><TableHead className="h-12 px-3 text-sm">Net</TableHead></TableRow></TableHeader>
@@ -11,6 +11,7 @@ import { cn } from "@/lib/utils"
import { BudgetVsActualRow, GlBudget, ReportType } from "@/types/general-ledger"
import { buttonVariants } from "@/components/ui/button"
import BarChart from "@/components/ui/bar-chart"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
@@ -134,6 +135,18 @@ export default function BudgetVsActualPage() {
{!error && budgetId && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader title="Budget vs Actual" subtitle={selectedBudget?.name ?? ""} />
<div className="mt-4 rounded-xl border p-5">
<p className="mb-3 text-sm font-medium text-muted-foreground">Budgeted vs Actual by account</p>
<BarChart
labels={rows.map((r) => r.accountCode)}
datasets={[
{ label: "Budgeted", data: rows.map((r) => r.budgetedAmount), backgroundColor: "rgba(59,130,246,0.6)" },
{ label: "Actual", data: rows.map((r) => r.actualAmount), backgroundColor: "rgba(34,197,94,0.6)" },
]}
/>
</div>
<Table className="mt-4 text-base">
<TableHeader>
<TableRow>
@@ -16,6 +16,7 @@ import {
} from "@/types/general-ledger"
import { buttonVariants } from "@/components/ui/button"
import BarChart from "@/components/ui/bar-chart"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
@@ -141,6 +142,25 @@ export default function CashFlowPage() {
subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`}
/>
<div className="mt-4 rounded-xl border p-5">
<p className="mb-3 text-sm font-medium text-muted-foreground">Net cash by activity</p>
<BarChart
labels={["Operating", "Investing", "Financing", "Net Change"]}
datasets={[
{
label: "Amount",
data: [
report.operatingActivities?.netCashFromOperatingActivities ?? 0,
report.investingActivities?.netCashFromInvestingActivities ?? 0,
report.financingActivities?.netCashFromFinancingActivities ?? 0,
report.netIncreaseDecreaseInCash,
],
backgroundColor: "rgba(59,130,246,0.6)",
},
]}
/>
</div>
<div className="mt-4">
<ReportSubtotal label="Net Earnings" amount={report.operatingActivities?.profitForPeriod ?? 0} />
<ReportSection title="Additions to Cash" lines={buckets.additions} />
@@ -1,12 +1,83 @@
import Link from "next/link"
import {
ClipboardList,
FileText,
Landmark,
PackageX,
ShoppingCart,
} from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
const sections = [
{
title: "Requisitions",
description: "Raise and approve internal purchase requisitions.",
href: "/dashboard/procurement/requisitions",
icon: ClipboardList,
},
{
title: "RFQs",
description: "Request quotes from vendors for a requisition.",
href: "/dashboard/procurement/rfqs",
icon: FileText,
},
{
title: "Purchase Orders",
description: "Issue and track purchase orders to vendors.",
href: "/dashboard/procurement/purchase-orders",
icon: ShoppingCart,
},
{
title: "Purchase Invoices",
description: "Match vendor invoices against GRNs and purchase orders.",
href: "/dashboard/procurement/purchase-invoices",
icon: Landmark,
},
{
title: "Purchase Returns",
description: "Return received stock back to a vendor.",
href: "/dashboard/procurement/purchase-returns",
icon: PackageX,
},
]
export default function ProcurementHubPage() {
return (
<div className="flex flex-col gap-6">
<div>
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
<ShoppingCart className="size-4" />
Procurement
</div>
<h1 className="text-2xl font-bold text-foreground">Procurement</h1>
<p className="text-base text-muted-foreground">
Requisition → RFQ (optional) → Purchase Order → Purchase Return (FR-PROC-01..09).
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{sections.map((section) => {
const Icon = section.icon
return (
<Link
key={section.href}
href={section.href}
className="group rounded-2xl border bg-card p-5 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
>
<div className="mb-4 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Icon className="size-5" />
</div>
<h2 className="text-lg font-semibold text-foreground">{section.title}</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{section.description}</p>
<div className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "mt-4 px-0 text-primary")}>
Open
</div>
</Link>
)
})}
</div>
</div>
)
}
@@ -5,7 +5,10 @@ import Link from "next/link"
import { CheckCircle2, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
@@ -15,6 +18,7 @@ import { toast } from "@/components/ui/toast"
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
import { PaginationMeta } from "@/types/common"
import { Branch } from "@/types/hrm"
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
import { cn } from "@/lib/utils"
@@ -29,7 +33,9 @@ const tabs: { value: StatusFilter; label: string }[] = [
]
export default function PurchaseInvoicesPage() {
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
@@ -58,6 +64,17 @@ export default function PurchaseInvoicesPage() {
.catch((err) => setError(errorMessage(err)))
}, [page, status])
// Admins bypass branch scoping server-side and see every invoice unfiltered — the header's
// branch switcher is the only filter available here, applied client-side since
// /purchase-invoices has no branchId query param (docs/BRANCH_ISOLATION_PLAN.md §Frontend impact).
useEffect(() => {
if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [isAdmin, branchingEnabled])
function branchName(branchId: number) {
return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
}
async function handleConfirm(row: PurchaseInvoiceSummary) {
setConfirmingId(row.purchaseInvoiceId)
try {
@@ -88,10 +105,10 @@ export default function PurchaseInvoicesPage() {
const visibleRows = useMemo(
() =>
rows?.filter((row) =>
`${row.docNo} ${row.vendorInvoiceNo} ${row.vendorSnapshotName}`.toLowerCase().includes(query.toLowerCase())
) ?? [],
[rows, query]
rows
?.filter((row) => `${row.docNo} ${row.vendorInvoiceNo} ${row.vendorSnapshotName}`.toLowerCase().includes(query.toLowerCase()))
.filter((row) => !(isAdmin && branchingEnabled && activeBranchId) || row.branchId === activeBranchId) ?? [],
[rows, query, isAdmin, branchingEnabled, activeBranchId]
)
const hasFilters = status !== "All" || query.length > 0
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
@@ -185,6 +202,7 @@ export default function PurchaseInvoicesPage() {
<TableHead className="h-12 px-4 text-sm text-right">Subtotal</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">VAT</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Total</TableHead>
{isAdmin && branchingEnabled && <TableHead className="h-12 px-4 text-sm">Branch</TableHead>}
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
</TableRow>
@@ -204,6 +222,11 @@ export default function PurchaseInvoicesPage() {
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.vatTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
{isAdmin && branchingEnabled && (
<TableCell className="px-4 py-3.5">
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(row.branchId)}</Badge>
</TableCell>
)}
<TableCell className="px-4 py-3.5">
<div className="flex flex-col items-start gap-1">
<PurchaseInvoiceStatusBadge status={row.status} />
@@ -3,8 +3,9 @@
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, Ban, Check, Plus, Save, Trash2 } from "lucide-react"
import { AlertTriangle, Ban, Check, Plus, Save, Send, Trash2, X } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { useAuth } from "@/components/auth/AuthProvider"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
@@ -18,8 +19,15 @@ import { ItemListItem, Uom, Vendor, Warehouse } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { FieldError } from "@/components/ui/field"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Skeleton } from "@/components/ui/skeleton"
@@ -47,6 +55,7 @@ export default function PurchaseOrderDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const poId = Number(params.id)
const { isAdmin } = useAuth()
const [po, setPo] = useState<PurchaseOrder | null>(null)
const [etag, setEtag] = useState<string | null>(null)
@@ -67,6 +76,10 @@ export default function PurchaseOrderDetailPage() {
const [cancelling, setCancelling] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [deleting, setDeleting] = useState(false)
const [approving, setApproving] = useState(false)
const [showRejectForm, setShowRejectForm] = useState(false)
const [rejectReason, setRejectReason] = useState("")
const [rejecting, setRejecting] = useState(false)
function toDraftLines(order: PurchaseOrder): DraftLine[] {
return order.lines.map((l) => ({
@@ -189,12 +202,48 @@ export default function PurchaseOrderDetailPage() {
const updated = await purchaseOrdersApi.submit(po.poId)
setPo(updated)
setLines(toDraftLines(updated))
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`)
toast.success("Submitted for approval", `${updated.docNo} — now ${updated.status} and locked for editing.`)
} catch (err) {
setSaveError(errorMessage(err))
toast.error("Could not submit purchase order", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function handleApprove() {
if (!po) return
setSaveError(null)
setApproving(true)
try {
const updated = await purchaseOrdersApi.approve(po.poId)
setPo(updated)
setLines(toDraftLines(updated))
toast.success("Purchase order approved", updated.docNo)
} catch (err) {
setSaveError(errorMessage(err))
toast.error("Could not approve purchase order", errorMessage(err))
} finally {
setSubmitting(false)
setApproving(false)
}
}
async function handleReject() {
if (!po) return
setSaveError(null)
setRejecting(true)
try {
const updated = await purchaseOrdersApi.reject(po.poId, { reason: rejectReason.trim() || undefined })
setPo(updated)
setLines(toDraftLines(updated))
setShowRejectForm(false)
setRejectReason("")
toast.success("Purchase order rejected", `${updated.docNo} — back to Draft for revision.`)
} catch (err) {
setSaveError(errorMessage(err))
toast.error("Could not reject purchase order", errorMessage(err))
} finally {
setRejecting(false)
}
}
@@ -277,8 +326,8 @@ export default function PurchaseOrderDetailPage() {
{po.status === "Draft" && (
<>
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
<Check className="size-5" />
{submitting ? "Approving�" : "Approve"}
<Send className="size-5" />
{submitting ? "Submitting…" : "Submit for approval"}
</Button>
<AlertDialog>
<AlertDialogTrigger
@@ -297,6 +346,18 @@ export default function PurchaseOrderDetailPage() {
</AlertDialog>
</>
)}
{po.status === "PendingApproval" && isAdmin && (
<>
<Button variant="success" size="lg" onClick={handleApprove} disabled={approving || rejecting}>
<Check className="size-5" />
{approving ? "Approving…" : "Approve"}
</Button>
<Button variant="destructive" size="lg" onClick={() => setShowRejectForm(true)} disabled={approving || rejecting}>
<X className="size-5" />
Reject
</Button>
</>
)}
{cancellable && !showCancelForm && (
<Button
variant="destructive"
@@ -332,6 +393,33 @@ export default function PurchaseOrderDetailPage() {
</div>
)}
<Dialog open={showRejectForm} onOpenChange={setShowRejectForm}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>Reject {po.docNo}?</DialogTitle>
<DialogDescription>Sends it back to Draft so the requester can revise and resubmit.</DialogDescription>
</DialogHeader>
<Field>
<FieldLabel htmlFor="po-reject-reason">Reason (optional)</FieldLabel>
<Input
id="po-reject-reason"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="e.g. Wrong vendor pricing"
autoFocus
/>
</Field>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setShowRejectForm(false)} disabled={rejecting}>
Cancel
</Button>
<Button variant="destructive" className="w-full sm:w-auto sm:min-w-36" onClick={handleReject} disabled={rejecting}>
{rejecting ? "Rejecting…" : "Confirm reject"}
</Button>
</div>
</DialogContent>
</Dialog>
{conflict && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 p-5 text-base text-warning">
<AlertTriangle className="size-5 shrink-0" />
@@ -0,0 +1,243 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { Check, CheckCircle2, Clock, Eye, ShieldAlert, X } from "lucide-react"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PurchaseOrderSummary } from "@/types/procurement"
import { Vendor } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { PoStatusBadge } from "@/components/procurement/status-badges"
/**
* Approvals Queue — every PO currently PendingApproval, with Approve/Reject actions
* right here instead of having to open each PO individually. Approve/Reject are
* server-gated to Admins who aren't the PO's own creator (FR-PROC-04); this screen
* still shows the queue to non-admins (read-only) so anyone can see what's waiting.
*/
export default function PurchaseOrderApprovalsPage() {
const { isAdmin } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [error, setError] = useState<string | null>(null)
const [approvingId, setApprovingId] = useState<number | null>(null)
const [rejectTarget, setRejectTarget] = useState<PurchaseOrderSummary | null>(null)
const [rejectReason, setRejectReason] = useState("")
const [rejectBusy, setRejectBusy] = useState(false)
function load() {
setError(null)
purchaseOrdersApi
.list({ status: "PendingApproval", pageSize: 200 })
.then((res) => setPos(res.items))
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
useEffect(() => {
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
}, [])
const vendorsById = useMemo(() => new Map(vendors.map((v) => [v.vendorId, v])), [vendors])
async function handleApprove(po: PurchaseOrderSummary) {
setApprovingId(po.poId)
try {
const updated = await purchaseOrdersApi.approve(po.poId)
toast.success("Purchase order approved", updated.docNo)
setPos((prev) => prev?.filter((p) => p.poId !== po.poId) ?? prev)
} catch (err) {
toast.error("Could not approve purchase order", errorMessage(err))
} finally {
setApprovingId(null)
}
}
function openReject(po: PurchaseOrderSummary) {
setRejectTarget(po)
setRejectReason("")
}
async function confirmReject() {
if (!rejectTarget) return
setRejectBusy(true)
try {
const updated = await purchaseOrdersApi.reject(rejectTarget.poId, { reason: rejectReason.trim() || undefined })
toast.success("Purchase order rejected", `${updated.docNo} — back to Draft for revision.`)
setPos((prev) => prev?.filter((p) => p.poId !== rejectTarget.poId) ?? prev)
setRejectTarget(null)
} catch (err) {
toast.error("Could not reject purchase order", errorMessage(err))
} finally {
setRejectBusy(false)
}
}
const loading = pos === null
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Approvals Queue</h1>
{pos && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{pos.length}
</span>
)}
</div>
<p className="text-base text-muted-foreground">Purchase orders awaiting approval (FR-PROC-04).</p>
</div>
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
All purchase orders
</Link>
</div>
{!isAdmin && (
<div className="flex items-center gap-3 rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning">
<ShieldAlert className="size-5 shrink-0" />
You can see the queue, but only an Admin can approve or reject — and not their own PO.
</div>
)}
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{loading && !error && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!loading && !error && pos.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<CheckCircle2 className="size-12 text-success" />
<p className="text-base text-muted-foreground">Nothing is waiting on approval right now.</p>
</div>
)}
{!loading && !error && pos.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead>
<TableHead className="h-12 px-3 text-sm">Submitted</TableHead>
<TableHead className="h-12 w-56 px-3 text-sm">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pos.map((po) => {
const vendor = vendorsById.get(po.vendorId)
return (
<TableRow key={po.poId} className="align-top">
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/purchase-orders/${po.poId}`} className="font-medium text-foreground hover:underline">
{po.docNo}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{vendor?.name ?? vendor?.code ?? `#${po.vendorId}`}</TableCell>
<TableCell className="px-3 py-3.5">
<PoStatusBadge status={po.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
<div className="flex items-center gap-1.5">
<Clock className="size-3.5" />
{new Date(po.createdAt).toLocaleString()}
</div>
</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button
type="button"
size="sm"
variant="success"
disabled={!isAdmin || approvingId === po.poId}
onClick={() => handleApprove(po)}
>
<Check className="size-4" />
{approvingId === po.poId ? "Approving…" : "Approve"}
</Button>
<Button
type="button"
size="sm"
variant="destructive"
disabled={!isAdmin}
onClick={() => openReject(po)}
>
<X className="size-4" />
Reject
</Button>
<Link
href={`/dashboard/procurement/purchase-orders/${po.poId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label="View"
title="View"
>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
<Dialog open={!!rejectTarget} onOpenChange={(v) => { if (!v) setRejectTarget(null) }}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>Reject {rejectTarget?.docNo}?</DialogTitle>
<DialogDescription>Sends it back to Draft so the requester can revise and resubmit.</DialogDescription>
</DialogHeader>
<Field>
<FieldLabel htmlFor="po-reject-reason">Reason (optional)</FieldLabel>
<Input
id="po-reject-reason"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="e.g. Wrong vendor pricing"
autoFocus
/>
</Field>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setRejectTarget(null)} disabled={rejectBusy}>
Cancel
</Button>
<Button variant="destructive" className="w-full sm:w-auto sm:min-w-36" onClick={confirmReject} disabled={rejectBusy}>
{rejectBusy ? "Rejecting…" : "Confirm reject"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
@@ -2,19 +2,25 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { Check, Eye, Pencil, Plus, ShoppingCart, Trash2 } from "lucide-react"
import { ChevronLeft, ChevronRight, Clock, Eye, Pencil, Plus, Send, ShoppingCart, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
import { Vendor } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { useAuth } from "@/components/auth/AuthProvider"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { KpiRow } from "@/components/ui/kpi-row"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -26,8 +32,11 @@ type StatusFilter = PurchaseOrderStatus | "All"
const PAGE_SIZE = 25
export default function PurchaseOrdersListPage() {
const stats = useDashboardStats()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -36,7 +45,7 @@ export default function PurchaseOrdersListPage() {
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [approvingId, setApprovingId] = useState<number | null>(null)
const [submittingId, setSubmittingId] = useState<number | null>(null)
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
@@ -61,10 +70,23 @@ export default function PurchaseOrdersListPage() {
vendorsApi.list({ pageSize: 200 }).then((res) => setVendors(res.items)).catch(() => {})
}, [])
// Admins bypass branch scoping server-side and see every PO unfiltered — the header's
// branch switcher is the only filter available here, applied client-side since
// /purchase-orders has no branchId query param (docs/BRANCH_ISOLATION_PLAN.md §Frontend impact).
useEffect(() => {
if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [isAdmin, branchingEnabled])
function vendorCode(vendorId: number) {
return vendors.find((v) => v.vendorId === vendorId)?.code ?? `#${vendorId}`
}
function branchName(branchId: number) {
return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
}
const visiblePos = isAdmin && branchingEnabled && activeBranchId ? (pos ?? []).filter((po) => po.branchId === activeBranchId) : pos
async function handleDelete(po: PurchaseOrderSummary) {
setDeletingId(po.poId)
try {
@@ -78,16 +100,16 @@ export default function PurchaseOrdersListPage() {
}
}
async function handleApprove(po: PurchaseOrderSummary) {
setApprovingId(po.poId)
async function handleSubmit(po: PurchaseOrderSummary) {
setSubmittingId(po.poId)
try {
const updated = await purchaseOrdersApi.submit(po.poId)
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status}.`)
toast.success("Submitted for approval", `${updated.docNo} — now ${updated.status}.`)
load()
} catch (err) {
toast.error("Could not approve purchase order", errorMessage(err))
toast.error("Could not submit purchase order", errorMessage(err))
} finally {
setApprovingId(null)
setSubmittingId(null)
}
}
@@ -98,14 +120,22 @@ export default function PurchaseOrdersListPage() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Purchase Orders</h1>
<p className="text-base text-muted-foreground">Auto-approved on creation and freely editable while open (FR-PROC-03..05).</p>
<p className="text-base text-muted-foreground">Draft, freely editable; submitting sends it for approval before it&apos;s issued (FR-PROC-03..05).</p>
</div>
<div className="flex flex-wrap gap-2">
<Link href="/dashboard/procurement/purchase-orders/approvals" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
<Clock className="size-5" />
Approvals queue
</Link>
<Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New PO
</Link>
</div>
<Link href="/dashboard/procurement/purchase-orders/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New PO
</Link>
</div>
<KpiRow tiles={stats ? [{ label: "Pending Approval", value: stats.pendingApprovalPurchaseOrders, icon: Clock }] : null} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={searchInput}
@@ -121,6 +151,7 @@ export default function PurchaseOrdersListPage() {
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Draft" className="text-base">Draft</SelectItem>
<SelectItem value="PendingApproval" className="text-base">Pending approval</SelectItem>
<SelectItem value="Approved" className="text-base">Approved</SelectItem>
<SelectItem value="PartiallyReceived" className="text-base">Partially received</SelectItem>
<SelectItem value="FullyReceived" className="text-base">Fully received</SelectItem>
@@ -147,14 +178,14 @@ export default function PurchaseOrdersListPage() {
</div>
)}
{!error && pos !== null && pos.length === 0 && (
{!error && visiblePos !== null && visiblePos.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ShoppingCart className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No purchase orders match your search/filter." : "No purchase orders yet."}</p>
</div>
)}
{!error && pos !== null && pos.length > 0 && (
{!error && visiblePos !== null && visiblePos.length > 0 && (
<>
<Table className="text-base">
<TableHeader>
@@ -163,12 +194,13 @@ export default function PurchaseOrdersListPage() {
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Grand total</TableHead>
{isAdmin && branchingEnabled && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pos.map((po) => {
{visiblePos.map((po) => {
const editable = isPoEditable(po.status)
return (
<TableRow key={po.poId}>
@@ -182,6 +214,11 @@ export default function PurchaseOrdersListPage() {
<PoStatusBadge status={po.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{po.totals.currency} {po.totals.grandTotal.toFixed(2)}</TableCell>
{isAdmin && branchingEnabled && (
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(po.branchId)}</Badge>
</TableCell>
)}
<TableCell className="px-3 py-3.5">{new Date(po.createdAt).toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
@@ -210,21 +247,21 @@ export default function PurchaseOrdersListPage() {
type="button"
variant="ghost"
size="icon"
aria-label="Approve draft"
title="Approve draft"
disabled={approvingId === po.poId || deletingId === po.poId}
aria-label="Submit for approval"
title="Submit for approval"
disabled={submittingId === po.poId || deletingId === po.poId}
className="text-success hover:bg-success/10 hover:text-success"
/>
}
>
<Check className="size-4" />
<Send className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="success"
title={`Approve ${po.docNo}?`}
description="It will be locked for editing once approved."
confirmLabel="Approve"
onConfirm={() => handleApprove(po)}
title={`Submit ${po.docNo} for approval?`}
description="It will be locked for editing while it awaits an Admin's approval."
confirmLabel="Submit"
onConfirm={() => handleSubmit(po)}
/>
</AlertDialog>
<AlertDialog>
@@ -236,7 +273,7 @@ export default function PurchaseOrdersListPage() {
size="icon"
aria-label="Delete draft"
title="Delete draft"
disabled={deletingId === po.poId || approvingId === po.poId}
disabled={deletingId === po.poId || submittingId === po.poId}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
/>
}
@@ -2,14 +2,20 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ClipboardList, Plus } from "lucide-react"
import { ClipboardList, Plus, Send } from "lucide-react"
import { requisitionsApi } from "@/lib/api/requisitions"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { RequisitionStatus, RequisitionSummary } from "@/types/procurement"
import { PaginationMeta } from "@/types/common"
import { Branch } from "@/types/hrm"
import { cn } from "@/lib/utils"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { KpiRow } from "@/components/ui/kpi-row"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -21,7 +27,10 @@ type StatusFilter = RequisitionStatus | "All"
const PAGE_SIZE = 25
export default function RequisitionsListPage() {
const stats = useDashboardStats()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [requisitions, setRequisitions] = useState<RequisitionSummary[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
@@ -29,6 +38,17 @@ export default function RequisitionsListPage() {
useEffect(() => setPage(1), [status])
// Admins bypass branch scoping server-side and see every requisition unfiltered — the
// header's branch switcher is the only filter available here, applied client-side since
// /requisitions has no branchId query param (docs/BRANCH_ISOLATION_PLAN.md §Frontend impact).
useEffect(() => {
if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [isAdmin, branchingEnabled])
function branchName(branchId: number) {
return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
}
function load() {
setError(null)
requisitionsApi
@@ -42,6 +62,9 @@ export default function RequisitionsListPage() {
useEffect(load, [page, status])
const visibleRequisitions =
isAdmin && branchingEnabled && activeBranchId ? (requisitions ?? []).filter((r) => r.branchId === activeBranchId) : requisitions
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -55,6 +78,8 @@ export default function RequisitionsListPage() {
</Link>
</div>
<KpiRow tiles={stats ? [{ label: "Open Requisitions", value: stats.openRequisitions, icon: Send }] : null} />
<div className="flex items-center gap-3">
<Select value={status} onValueChange={(v) => setStatus(v as StatusFilter)}>
<SelectTrigger className="h-12! w-56 text-base">
@@ -80,7 +105,7 @@ export default function RequisitionsListPage() {
</div>
)}
{!error && requisitions !== null && requisitions.length === 0 && (
{!error && visibleRequisitions !== null && visibleRequisitions.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<ClipboardList className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No requisitions yet.</p>
@@ -91,7 +116,7 @@ export default function RequisitionsListPage() {
</div>
)}
{!error && requisitions !== null && requisitions.length > 0 && (
{!error && visibleRequisitions !== null && visibleRequisitions.length > 0 && (
<>
<Table className="text-base">
<TableHeader>
@@ -100,11 +125,12 @@ export default function RequisitionsListPage() {
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Lines</TableHead>
<TableHead className="h-12 px-3 text-sm">Requested by</TableHead>
{isAdmin && branchingEnabled && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requisitions.map((r) => (
{visibleRequisitions.map((r) => (
<TableRow key={r.requisitionId}>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/requisitions/${r.requisitionId}`} className="font-medium text-foreground hover:underline">
@@ -115,7 +141,12 @@ export default function RequisitionsListPage() {
<RequisitionStatusBadge status={r.status} />
</TableCell>
<TableCell className="px-3 py-3.5">{r.lineCount}</TableCell>
<TableCell className="px-3 py-3.5">{r.requestedBy}</TableCell>
<TableCell className="px-3 py-3.5">#{r.requestedBy}</TableCell>
{isAdmin && branchingEnabled && (
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(r.branchId)}</Badge>
</TableCell>
)}
<TableCell className="px-3 py-3.5">{new Date(r.createdAt).toLocaleString()}</TableCell>
</TableRow>
))}
@@ -5,16 +5,25 @@ import Link from "next/link"
import { FileText, Plus } from "lucide-react"
import { rfqsApi } from "@/lib/api/rfqs"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { RfqSummary } from "@/types/procurement"
import { Branch } from "@/types/hrm"
import { cn } from "@/lib/utils"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { useAuth } from "@/components/auth/AuthProvider"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { KpiRow } from "@/components/ui/kpi-row"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { RfqStatusBadge } from "@/components/procurement/status-badges"
export default function RfqsListPage() {
const stats = useDashboardStats()
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [rfqs, setRfqs] = useState<RfqSummary[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [error, setError] = useState<string | null>(null)
// Vendors are no longer fetched here: the "invited vendors" column is gone because that
@@ -26,6 +35,19 @@ export default function RfqsListPage() {
.catch((err) => setError(errorMessage(err)))
}, [])
// Admins bypass branch scoping server-side and see every RFQ unfiltered — the header's
// branch switcher is the only filter available here, applied client-side since /rfqs has
// no branchId query param (docs/BRANCH_ISOLATION_PLAN.md §Frontend impact).
useEffect(() => {
if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [isAdmin, branchingEnabled])
function branchName(branchId: number) {
return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
}
const visibleRfqs = isAdmin && branchingEnabled && activeBranchId ? (rfqs ?? []).filter((r) => r.branchId === activeBranchId) : rfqs
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -39,6 +61,8 @@ export default function RfqsListPage() {
</Link>
</div>
<KpiRow tiles={stats ? [{ label: "Open RFQs", value: stats.openRfqs, icon: FileText }] : null} />
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
@@ -51,7 +75,7 @@ export default function RfqsListPage() {
</div>
)}
{!error && rfqs !== null && rfqs.length === 0 && (
{!error && visibleRfqs !== null && visibleRfqs.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<FileText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No RFQs yet.</p>
@@ -62,7 +86,7 @@ export default function RfqsListPage() {
</div>
)}
{!error && rfqs !== null && rfqs.length > 0 && (
{!error && visibleRfqs !== null && visibleRfqs.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
@@ -72,11 +96,12 @@ export default function RfqsListPage() {
persisted (docs/11 §3.2). Quotations received is the fact that survives. */}
<TableHead className="h-12 px-3 text-sm">Lines</TableHead>
<TableHead className="h-12 px-3 text-sm">Quotations</TableHead>
{isAdmin && branchingEnabled && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rfqs.map((r) => (
{visibleRfqs.map((r) => (
<TableRow key={r.rfqId}>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/procurement/rfqs/${r.rfqId}`} className="font-medium text-foreground hover:underline">
@@ -86,6 +111,11 @@ export default function RfqsListPage() {
<TableCell className="px-3 py-3.5">{r.requisitionId ? `#${r.requisitionId}` : <span className="text-muted-foreground">—</span>}</TableCell>
<TableCell className="px-3 py-3.5">{r.lineCount}</TableCell>
<TableCell className="px-3 py-3.5">{r.quotationCount}</TableCell>
{isAdmin && branchingEnabled && (
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(r.branchId)}</Badge>
</TableCell>
)}
<TableCell className="px-3 py-3.5">
<RfqStatusBadge status={r.status} />
</TableCell>
@@ -8,14 +8,16 @@ import { AlertTriangle, Save } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
import { uomsApi } from "@/lib/api/uoms"
import { taxRatesApi } from "@/lib/api/tax-rates"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { validateItemForm } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Item, MeasureUnit, StockNature, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
import { Item, MeasureUnit, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
const NO_TAX_RATE = "__none__"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
@@ -40,6 +42,7 @@ export default function ItemDetailPage() {
// Basic info form
const [sku, setSku] = useState("")
const [barcode, setBarcode] = useState("")
const [name, setName] = useState("")
// No longer editable here — carried through unchanged so a save doesn't silently clear it.
const [description, setDescription] = useState("")
@@ -57,7 +60,8 @@ export default function ItemDetailPage() {
// Not editable here — carried through unchanged so a save doesn't silently reset it.
const [warranty, setWarranty] = useState<Warranty>("NonWarranty")
const [warrantyPeriodMonths, setWarrantyPeriodMonths] = useState<WarrantyPeriodMonths | null>(null)
const [taxClass, setTaxClass] = useState("")
const [taxRateId, setTaxRateId] = useState<number | null>(null)
const [taxRates, setTaxRates] = useState<TaxRate[]>([])
const [sellingMinPrice, setSellingMinPrice] = useState("")
const [sellingMaxPrice, setSellingMaxPrice] = useState("")
const [sellingFlatPrice, setSellingFlatPrice] = useState("")
@@ -77,6 +81,7 @@ export default function ItemDetailPage() {
function applyItem(data: Item) {
setItem(data)
setSku(data.sku)
setBarcode(data.barcode ?? "")
setName(data.name)
setDescription(data.description ?? "")
setCategoryId(data.categoryId)
@@ -88,7 +93,7 @@ export default function ItemDetailPage() {
setTrackingMode(data.trackingMode)
setWarranty(data.warranty)
setWarrantyPeriodMonths(data.warrantyPeriodMonths)
setTaxClass(data.taxClass ?? "")
setTaxRateId(data.taxRateId)
setSellingMinPrice(data.sellingMinPrice === null ? "" : String(data.sellingMinPrice))
setSellingMaxPrice(data.sellingMaxPrice === null ? "" : String(data.sellingMaxPrice))
setSellingFlatPrice(data.sellingFlatPrice === null ? "" : String(data.sellingFlatPrice))
@@ -111,11 +116,12 @@ export default function ItemDetailPage() {
useEffect(() => {
if (!Number.isFinite(itemId)) return
load()
Promise.all([categoriesApi.list(), uomsApi.list(), warehousesApi.list({ pageSize: 200 })])
.then(([cat, uo, wh]) => {
Promise.all([categoriesApi.list(), uomsApi.list(), warehousesApi.list({ pageSize: 200 }), taxRatesApi.list({ status: "Active", pageSize: 200 })])
.then(([cat, uo, wh, tr]) => {
setCategories(cat.items)
setUoms(uo.items)
setWarehouses(wh.items)
setTaxRates(tr.items)
})
.catch(() => {})
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -133,9 +139,9 @@ export default function ItemDetailPage() {
const result = await itemsApi.update(
item.itemId,
{
sku, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
sku, barcode: barcode.trim() ? barcode.trim() : null, name, description: description || null, categoryId: categoryId as number, subCategoryId, brandId,
baseUomId: baseUomId as number, defaultVendorId, stockNature, trackingMode, warranty, warrantyPeriodMonths,
taxClass: taxClass || null,
taxRateId,
sellingMinPrice: sellingMinPrice.trim() ? Number(sellingMinPrice) : null,
sellingMaxPrice: sellingMaxPrice.trim() ? Number(sellingMaxPrice) : null,
sellingFlatPrice: sellingFlatPrice.trim() ? Number(sellingFlatPrice) : null,
@@ -157,6 +163,7 @@ export default function ItemDetailPage() {
}
const fe = fieldErrors(err)
if (fe?.sku) setErrors((prev) => ({ ...prev, sku: fe.sku }))
if (fe?.barcode) setErrors((prev) => ({ ...prev, barcode: fe.barcode }))
setSaveError(errorMessage(err))
toast.error("Could not save item", errorMessage(err))
} finally {
@@ -250,6 +257,19 @@ export default function ItemDetailPage() {
<Input value={sku} onChange={(e) => setSku(e.target.value)} aria-invalid={!!errors.sku} className="h-12 text-base" disabled={conflict} />
<FieldError errors={[errors.sku ? { message: errors.sku } : undefined]} />
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Barcode (optional)</Label>
<Input
value={barcode}
onChange={(e) => setBarcode(e.target.value)}
placeholder="Scan or type a barcode"
aria-invalid={!!errors.barcode}
className="h-12 text-base"
disabled={conflict}
/>
<FieldError errors={[errors.barcode ? { message: errors.barcode } : undefined]} />
<p className="text-sm text-muted-foreground">Used for scan lookup on GRN, stock count, and sales lines — separate from SKU.</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} aria-invalid={!!errors.name} className="h-12 text-base" disabled={conflict} />
@@ -297,6 +317,29 @@ export default function ItemDetailPage() {
disabled={conflict}
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Tax rate</Label>
<Select<string>
value={taxRateId === null ? NO_TAX_RATE : String(taxRateId)}
onValueChange={(v) => setTaxRateId(!v || v === NO_TAX_RATE ? null : Number(v))}
disabled={conflict}
>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="No default (0%)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_TAX_RATE} className="text-base">No default (0%)</SelectItem>
{taxRates.map((t) => (
<SelectItem key={t.taxRateId} value={String(t.taxRateId)} className="text-base">
{t.name} ({t.percentage}%)
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Sales/purchase lines for this item default to this rate — see Products → Tax Rates.
</p>
</div>
<div className="flex flex-col gap-2">
<Label className="text-base">Category</Label>
<Select<number | null>
@@ -0,0 +1,417 @@
"use client"
import { useEffect, useState } from "react"
import { ArrowUp, ArrowDown, ArrowUpDown, Ban, CheckCircle2, ChevronLeft, ChevronRight, Pencil, Percent, Plus, Search } from "lucide-react"
import { taxRatesApi } from "@/lib/api/tax-rates"
import { errorMessage } from "@/lib/error-map"
import { validateTaxRate } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { TaxRate } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
type SortOrder = "asc" | "desc"
type SortKey = "taxRateId" | "code" | "name" | "percentage" | "status"
type StatusFilter = EntityStatus | "All"
const PAGE_SIZE = 10
export default function TaxRatesPage() {
const [rates, setRates] = useState<TaxRate[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [searchInput, setSearchInput] = useState("")
const [search, setSearch] = useState("")
const [status, setStatus] = useState<StatusFilter>("All")
const [page, setPage] = useState(1)
const [sortKey, setSortKey] = useState<SortKey>("name")
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<TaxRate | null>(null)
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [percentage, setPercentage] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
useEffect(() => {
const timeout = setTimeout(() => setSearch(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => {
setPage(1)
}, [search, status])
function load() {
setError(null)
taxRatesApi
.list({ q: search || undefined, status: status === "All" ? undefined : status, page, pageSize: PAGE_SIZE })
.then((res) => {
setRates(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [search, status, page])
const hasFilters = search.trim().length > 0 || status !== "All"
const sortedRates = rates
? [...rates].sort((a, b) => {
const cmp =
sortKey === "taxRateId"
? a.taxRateId - b.taxRateId
: sortKey === "percentage"
? a.percentage - b.percentage
: sortKey === "status"
? a.status.localeCompare(b.status)
: a[sortKey].localeCompare(b[sortKey])
return sortOrder === "asc" ? cmp : -cmp
})
: null
function toggleSort(key: SortKey) {
if (key === sortKey) {
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
} else {
setSortKey(key)
setSortOrder("asc")
}
}
function openCreateDialog() {
setEditing(null)
setCode("")
setName("")
setPercentage("")
setErrors({})
setOpen(true)
}
function openEditDialog(rate: TaxRate) {
setEditing(rate)
setCode(rate.code)
setName(rate.name)
setPercentage(String(rate.percentage))
setErrors({})
setOpen(true)
}
async function handleSubmit() {
const pct = Number(percentage)
const nextErrors = validateTaxRate(code, name, pct)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
let result
if (editing) {
// The list response carries no ETag, so re-read to get a fresh If-Match token
// rather than guessing one. A concurrent edit surfaces as 412 from the server.
const current = await taxRatesApi.get(editing.taxRateId)
result = await taxRatesApi.update(editing.taxRateId, { code, name, percentage: pct }, current.etag ?? "")
} else {
result = await taxRatesApi.create({ code, name, percentage: pct })
}
toast.success(editing ? "Tax rate updated" : "Tax rate created", `${result.data.name} — ${result.data.percentage}%`)
setOpen(false)
setEditing(null)
setErrors({})
load()
} catch (err) {
setErrors({ code: errorMessage(err) })
toast.error(editing ? "Could not update tax rate" : "Could not create tax rate", errorMessage(err))
} finally {
setSubmitting(false)
}
}
/** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */
async function handleToggleStatus(rate: TaxRate) {
const next = rate.status === "Active" ? "Inactive" : "Active"
setTogglingId(rate.taxRateId)
try {
await taxRatesApi.updateStatus(rate.taxRateId, next)
toast.success(next === "Inactive" ? "Tax rate deactivated" : "Tax rate activated", rate.name)
load()
} catch (err) {
toast.error("Could not update tax rate status", errorMessage(err))
} finally {
setTogglingId(null)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Tax rates</h1>
<p className="text-base text-muted-foreground">
What an item&apos;s Tax Rate actually resolves to on sales and purchase lines.
</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button size="lg" onClick={openCreateDialog}><Plus className="size-5" />Add Tax Rate</Button>} />
<DialogContent className="sm:max-w-sm">
<DialogHeader className="items-center text-center">
<DialogTitle>{editing ? "Edit tax rate" : "New tax rate"}</DialogTitle>
<DialogDescription>A code, a name, and the percentage it applies.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="tax-rate-code">Code</FieldLabel>
<Input id="tax-rate-code" value={code} onChange={(e) => setCode(e.target.value.toUpperCase())} placeholder="STANDARD" aria-invalid={!!errors.code} />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</Field>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="tax-rate-name">Name</FieldLabel>
<Input id="tax-rate-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Standard" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field data-invalid={!!errors.percentage}>
<FieldLabel htmlFor="tax-rate-percentage">Percentage</FieldLabel>
<Input
id="tax-rate-percentage"
type="number"
min={0}
max={100}
step={0.01}
value={percentage}
onChange={(e) => setPercentage(e.target.value)}
placeholder="15"
aria-invalid={!!errors.percentage}
/>
<FieldError errors={[errors.percentage ? { message: errors.percentage } : undefined]} />
</Field>
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setOpen(false)} disabled={submitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? (editing ? "Saving…" : "Creating…") : editing ? "Save" : "Create"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search tax rates…"
className="h-14 w-full pl-11 text-base"
aria-label="Search tax rates"
/>
</div>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-14! w-full sm:w-48 text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="Active" className="text-base">Active</SelectItem>
<SelectItem value="Inactive" className="text-base">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && rates === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && rates !== null && rates.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Percent className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{hasFilters ? "No tax rates match your search/filter." : "No tax rates yet."}
</p>
</div>
)}
{!error && rates !== null && rates.length > 0 && (
<>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="ID" active={sortKey === "taxRateId"} order={sortOrder} onClick={() => toggleSort("taxRateId")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Code" active={sortKey === "code"} order={sortOrder} onClick={() => toggleSort("code")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Name" active={sortKey === "name"} order={sortOrder} onClick={() => toggleSort("name")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Percentage" active={sortKey === "percentage"} order={sortOrder} onClick={() => toggleSort("percentage")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedRates!.map((t) => (
<TableRow key={t.taxRateId}>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{t.taxRateId}</TableCell>
<TableCell className="px-3 py-3.5 font-mono text-sm">{t.code}</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{t.name}</TableCell>
<TableCell className="px-3 py-3.5">{t.percentage}%</TableCell>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"border-transparent",
t.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
)}
>
{t.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${t.name}`}
onClick={() => openEditDialog(t)}
>
<Pencil className="size-4" />
</Button>
{/* Deactivate, not delete: the API has no DELETE for any master
(FR-MD-08) — records referenced by items must survive. */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={t.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${t.status === "Active" ? "Deactivate" : "Activate"} ${t.name}`}
disabled={togglingId === t.taxRateId}
/>
}
>
{t.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<AlertDialogContent
variant={t.status === "Active" ? "destructive" : "success"}
title={`${t.status === "Active" ? "Deactivate" : "Activate"} ${t.name}?`}
description={
t.status === "Active"
? "The rate stays on existing items but cannot be assigned to new ones."
: "The rate becomes selectable again."
}
confirmLabel={t.status === "Active" ? "Deactivate" : "Activate"}
onConfirm={() => handleToggleStatus(t)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}–
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
</div>
)
}
function SortableHeader({
label,
active,
order,
onClick,
}: {
label: string
active: boolean
order: SortOrder
onClick: () => void
}) {
return (
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={onClick}
aria-label={`Sort by ${label}, ${active && order === "asc" ? "descending" : "ascending"}`}
>
{label}
{active ? (
order === "asc" ? (
<ArrowUp className="size-3.5" />
) : (
<ArrowDown className="size-3.5" />
)
) : (
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
)}
</button>
)
}
@@ -5,6 +5,8 @@ import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, ExternalLink, Plus, RefreshCw, ShieldCheck, Trash2 } from "lucide-react"
import { ScanInput } from "@/components/scanning/ScanInput"
import { ApiError } from "@/lib/api-client"
import { grnsApi } from "@/lib/api/grns"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { vendorsApi } from "@/lib/api/vendors"
@@ -81,6 +83,8 @@ interface DraftLine {
holdStatus: HoldStatus
/** One entry per received unit, index-aligned; only meaningful when the item is warranty-tracked. */
warrantyNumbers: string[]
/** Only meaningful when the item is batch-tracked; sent as `batch: { batchNo }` on submit. */
batchNo: string
}
/** Mirror of the server's line arithmetic — display only (docs/20 §3, server stays authoritative). */
@@ -172,6 +176,7 @@ function emptyLine(): DraftLine {
vatPct: "0",
holdStatus: "Available",
warrantyNumbers: [],
batchNo: "",
}
}
@@ -225,6 +230,8 @@ export default function NewGrnPage() {
const [submitError, setSubmitError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [refreshingItems, setRefreshingItems] = useState(false)
const [scanError, setScanError] = useState<string | null>(null)
const [scanning, setScanning] = useState(false)
useEffect(() => {
Promise.all([
@@ -313,6 +320,7 @@ export default function NewGrnPage() {
vatPct: headerVatPct.trim() || "0",
holdStatus: "Available",
warrantyNumbers: [],
batchNo: "",
}
return { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) }
})
@@ -339,6 +347,61 @@ export default function NewGrnPage() {
}
}
// Fast path for a barcode/RFID scan above the lines table: resolves the code to an item (and
// optionally a specific batch), then either bumps an existing line's qty by one unit (same
// item + batch already on the GRN) or appends a new draft line for it — manual entry via the
// Select/inputs below is untouched either way.
async function handleGrnScan(code: string) {
setScanError(null)
setScanning(true)
try {
const result = await itemsApi.scan(code)
const scannedBatchNo = result.matchedBy === "Batch" ? (result.batchNo ?? "") : ""
setLines((prev) => {
const existingIdx = prev.findIndex(
(l) => l.itemId === result.itemId && l.batchNo.trim() === scannedBatchNo.trim()
)
if (existingIdx >= 0) {
const next = [...prev]
const merged: DraftLine = {
...next[existingIdx],
qty: String((Number(next[existingIdx].qty) || 0) + 1),
batchNo: scannedBatchNo || next[existingIdx].batchNo,
}
next[existingIdx] = { ...merged, warrantyNumbers: syncWarrantyNumbers(merged, itemFor(merged.itemId)) }
return next
}
// Fill the first still-blank row (itemId never picked) instead of piling on a new
// one below it — the untouched starter row, or one left empty after its item was
// cleared, would otherwise sit there forever while every scan adds another line.
const blankIdx = prev.findIndex((l) => l.itemId === null)
const draft: DraftLine = {
...emptyLine(),
itemId: result.itemId,
qty: "1",
batchNo: scannedBatchNo,
discountPct: headerDiscountPct.trim() || "0",
vatPct: headerVatPct.trim() || "0",
}
const filled = { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) }
if (blankIdx >= 0) {
const next = [...prev]
next[blankIdx] = { ...filled, key: next[blankIdx].key }
return next
}
return [...prev, filled]
})
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
setScanError(`No item or lot found for "${code}"`)
} else {
setScanError(errorMessage(err))
}
} finally {
setScanning(false)
}
}
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) =>
prev.map((l) => {
@@ -501,6 +564,7 @@ export default function NewGrnPage() {
item?.warranty === "Warranty"
? l.warrantyNumbers.map((s) => s.trim()).filter((s) => s.length > 0)
: null,
batch: item?.trackingMode === "Batch" && l.batchNo.trim() ? { batchNo: l.batchNo.trim() } : null,
}
})
@@ -677,6 +741,17 @@ export default function NewGrnPage() {
</p>
</div>
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-input p-4">
<ScanInput
label="Scan to receive"
hint="Scan an item barcode/SKU to add a line, or a lot barcode to fill/match its batch number."
disabled={scanning}
onScan={handleGrnScan}
className="max-w-md"
/>
{scanError && <FieldError errors={[{ message: scanError }]} />}
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
@@ -766,6 +841,7 @@ export default function NewGrnPage() {
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Batch no</TableHead>
<TableHead className="h-12 w-88 px-3 text-sm">Qty</TableHead>
<TableHead className="h-12 w-77 px-3 text-sm">Unit cost</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Disc %</TableHead>
@@ -831,6 +907,18 @@ export default function NewGrnPage() {
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-3 py-3 align-top">
{item?.trackingMode === "Batch" ? (
<Input
value={line.batchNo}
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
placeholder="Batch no"
className="h-11 text-sm"
/>
) : (
<span className="text-sm text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<Input
type="number"
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { Eye, PackageSearch, Plus, Search } from "lucide-react"
import { ChevronLeft, ChevronRight, Clock, Eye, PackageSearch, Plus, Search } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { errorMessage } from "@/lib/error-map"
@@ -10,8 +10,10 @@ import { GrnStatus, GrnSummary } from "@/types/grn"
import { PaginationMeta } from "@/types/common"
import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { KpiRow } from "@/components/ui/kpi-row"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -32,6 +34,7 @@ const PAGE_SIZE = 25
export default function GrnListPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
const [grns, setGrns] = useState<GrnSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
@@ -90,6 +93,8 @@ export default function GrnListPage() {
</Link>
</div>
<KpiRow tiles={stats ? [{ label: "Pending GRNs", value: stats.pendingGrns, icon: Clock }] : null} />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1 basis-0">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
@@ -173,6 +173,11 @@ export default function SalesDayEndPage() {
<Stat label="Grand total" value={preview.grandTotal} emphasize />
</div>
<div className="grid grid-cols-2 gap-3">
<Stat label="Paid (cash)" value={preview.paidTotal} />
<Stat label="On account (balance)" value={preview.balanceTotal} />
</div>
<InvoiceSummaryPanel invoices={preview.invoices} />
{!preview.alreadyClosed && (
@@ -224,6 +229,11 @@ export default function SalesDayEndPage() {
<Stat label="Grand total" value={report.grandTotal} emphasize />
</div>
<div className="grid grid-cols-2 gap-3">
<Stat label="Paid (cash)" value={report.paidTotal} />
<Stat label="On account (balance)" value={report.balanceTotal} />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-sm font-semibold text-foreground">Item breakdown</h3>
<div className="overflow-x-auto">
@@ -6,7 +6,9 @@ import Link from "next/link"
import { Plus, Trash2 } from "lucide-react"
import { useAuth } from "@/components/auth/AuthProvider"
import { ScanInput } from "@/components/scanning/ScanInput"
import { Button, buttonVariants } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -17,6 +19,7 @@ import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { baseUomLabel } from "@/lib/uom-label"
import { errorMessage } from "@/lib/error-map"
import { ApiError } from "@/lib/api-client"
import { salesApi } from "@/lib/api/sales"
import { bundleApi } from "@/lib/api/bundles"
import { customersApi } from "@/lib/api/customers"
@@ -36,12 +39,21 @@ import { toast } from "@/components/ui/toast"
// component lines added from the same bundle render collapsed into one row, with a single "how
// many bundles" quantity that scales every component's qty (bundleBaseQty holds the per-bundle
// qty from the template so re-scaling on a qty change is exact, not compounding).
// batchId/serialId/batchNo/serialNo are frontend-only display metadata from a barcode/RFID scan
// (see ScanInput below) — the live CreateSalesInvoiceLineRequest DTO has no batch/serial fields
// yet (only CreateSalesSlipLineRequest does), so these are carried for display/traceability on
// this draft line but deliberately NOT sent in submit()'s payload until the invoice line request
// grows the same fields.
type Line = CreateSalesInvoiceLineRequest & {
key: string
bundleGroupId?: string
bundleName?: string
bundleQty?: number
bundleBaseQty?: number
batchId?: number | null
batchNo?: string | null
serialId?: number | null
serialNo?: string | null
}
type DisplayRow =
@@ -79,6 +91,10 @@ const blankLine = (key: string): Line => ({
taxPct: 0,
isFreeIssue: false,
parentLineId: null,
batchId: null,
batchNo: null,
serialId: null,
serialNo: null,
})
const lkr = new Intl.NumberFormat("en-LK", {
@@ -110,6 +126,7 @@ function NewSalesInvoiceContent() {
const [loading, setLoading] = useState(true)
const [submitError, setSubmitError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [scanError, setScanError] = useState<string | null>(null)
useEffect(() => {
Promise.all([
@@ -215,6 +232,59 @@ function NewSalesInvoiceContent() {
updateLine(key, { qty, freeQty: suggestedFreeQty(itemId, qty) })
}
// Fast path for a barcode/RFID scan above the lines table: resolves the code to an item (and
// optionally its batch/serial), then either bumps an existing line for the same item+lot by one
// unit, or appends a new line for it — batchId/serialId ride along on the line for traceability
// (see the Line type note above re: submit() not yet sending them).
async function handleScan(code: string) {
setScanError(null)
try {
const result = await itemsApi.scan(code)
setLines((prev) => {
const idx = prev.findIndex(
(l) =>
!l.bundleGroupId &&
l.itemId === result.itemId &&
(l.batchId ?? null) === (result.batchId ?? null) &&
(l.serialId ?? null) === (result.serialId ?? null)
)
if (idx >= 0) {
const next = [...prev]
const nextQty = Number(next[idx].qty || 0) + 1
next[idx] = { ...next[idx], qty: nextQty, freeQty: suggestedFreeQty(result.itemId, nextQty) }
return next
}
// Fill the first still-blank row (item never picked) instead of appending below it —
// whether it's the lone starter line or one left empty further down the table.
const blankIdx = prev.findIndex((l) => l.itemId === 0 && !l.bundleGroupId)
const newLine: Line = {
...blankLine(blankIdx >= 0 ? prev[blankIdx].key : `line-${Date.now()}`),
itemId: result.itemId,
warehouseId: warehouseId ?? 0,
qty: 1,
unitPrice: getSuggestedUnitPrice(items, result.itemId),
freeQty: suggestedFreeQty(result.itemId, 1),
batchId: result.batchId,
batchNo: result.batchNo,
serialId: result.serialId,
serialNo: result.serialNo,
}
if (blankIdx >= 0) {
const next = [...prev]
next[blankIdx] = newLine
return next
}
return [...prev, newLine]
})
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
setScanError(`No item or lot found for "${code}"`)
} else {
setScanError(errorMessage(err))
}
}
}
// The per-line item search doubles as bundle search: item values are prefixed "item:" and
// bundle template values "bundle:" so one Combobox/handler can tell them apart on select.
const itemOptions = useMemo(
@@ -510,6 +580,16 @@ function NewSalesInvoiceContent() {
</div>
</div>
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-input p-4">
<ScanInput
label="Scan to add"
hint="Scan an item barcode, or a batch/serial lot barcode, to add or bump a line."
onScan={handleScan}
className="max-w-md"
/>
{scanError && <FieldError errors={[{ message: scanError }]} />}
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-base font-semibold text-foreground">Lines</h2>
@@ -607,6 +687,11 @@ function NewSalesInvoiceContent() {
placeholder="Search item or bundle…"
className="h-11 text-sm"
/>
{(line.batchNo || line.serialNo) && (
<p className="mt-1 text-xs text-muted-foreground">
Lot: {line.batchNo ?? line.serialNo}
</p>
)}
</TableCell>
<TableCell className="px-3 py-3 align-top">
<div className="flex h-11 items-center text-sm text-muted-foreground">
@@ -2,14 +2,19 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { CheckCircle2, CircleDollarSign, Eye, Filter, FileText, Plus, Printer } from "lucide-react"
import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, FileText, Plus, Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { FilterBar } from "@/components/ui/filter-bar"
import { Field, FieldLabel } from "@/components/ui/field"
import { KpiRow } from "@/components/ui/kpi-row"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -17,6 +22,8 @@ import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymen
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
import { PaginationMeta } from "@/types/common"
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
import { Customer } from "@/types/customers"
import { Warehouse } from "@/types/master-data"
import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All"
@@ -31,6 +38,7 @@ const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
export default function SalesInvoicesPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
@@ -43,12 +51,22 @@ export default function SalesInvoicesPage() {
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false)
const [customerId, setCustomerId] = useState<number | undefined>(undefined)
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
const [customers, setCustomers] = useState<Customer[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status, activeBranchId])
useEffect(() => setPage(1), [query, status, activeBranchId, customerId, warehouseId])
useEffect(() => {
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
}, [])
useEffect(() => {
setError(null)
@@ -58,13 +76,15 @@ export default function SalesInvoicesPage() {
pageSize: PAGE_SIZE,
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
customerId,
warehouseId,
})
.then((res) => {
setRows(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}, [page, status, activeBranchId, branchingEnabled])
}, [page, status, activeBranchId, branchingEnabled, customerId, warehouseId])
async function handleConfirm(row: SalesInvoiceSummary) {
setConfirmingId(row.salesInvoiceId)
@@ -101,7 +121,7 @@ export default function SalesInvoicesPage() {
) ?? [],
[rows, query]
)
const hasFilters = status !== "All" || query.length > 0
const hasFilters = status !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
@@ -131,8 +151,41 @@ export default function SalesInvoicesPage() {
</div>
</div>
<KpiRow tiles={stats ? [{ label: "Overdue Invoices", value: stats.overdueSalesInvoices, icon: AlertTriangle }] : null} />
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<FilterBar
className="border-b px-4 py-4"
search={searchInput}
onSearchChange={setSearchInput}
searchPlaceholder="Filter by customer or invoice number"
advancedActive={customerId !== undefined || warehouseId !== undefined}
onClearAdvanced={() => { setCustomerId(undefined); setWarehouseId(undefined) }}
advanced={
<>
<Field>
<FieldLabel htmlFor="inv-filter-customer">Customer</FieldLabel>
<Select value={customerId ? String(customerId) : "All"} onValueChange={(v) => setCustomerId(v && v !== "All" ? Number(v) : undefined)}>
<SelectTrigger id="inv-filter-customer"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="All">All customers</SelectItem>
{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="inv-filter-warehouse">Warehouse</FieldLabel>
<Select value={warehouseId ? String(warehouseId) : "All"} onValueChange={(v) => setWarehouseId(v && v !== "All" ? Number(v) : undefined)}>
<SelectTrigger id="inv-filter-warehouse"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="All">All warehouses</SelectItem>
{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} — {w.name}</SelectItem>)}
</SelectContent>
</Select>
</Field>
</>
}
>
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<button
@@ -147,20 +200,7 @@ export default function SalesInvoicesPage() {
</button>
))}
</div>
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Filter by customer or invoice number"
className="h-12 w-full lg:max-w-sm"
/>
<Button variant="outline" size="sm" className="lg:ml-auto">
<Filter className="size-4" />
Advanced
</Button>
</div>
</div>
</FilterBar>
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
@@ -40,7 +40,7 @@ export default function BranchConfigurationPage() {
if (!config) return
setSaving(true)
try {
const res = await branchConfigApi.update({ branchingEnabled: next }, etag ?? "")
const res = await branchConfigApi.update({ branchingEnabled: next, warehouseBranchingEnabled: config.warehouseBranchingEnabled }, etag ?? "")
setConfig(res.data)
setEtag(res.etag)
toast.success("Configuration saved", `Branches ${next ? "enabled" : "disabled"}.`)
@@ -52,6 +52,22 @@ export default function BranchConfigurationPage() {
}
}
async function toggleWarehouses(next: boolean) {
if (!config) return
setSaving(true)
try {
const res = await branchConfigApi.update({ branchingEnabled: config.branchingEnabled, warehouseBranchingEnabled: next }, etag ?? "")
setConfig(res.data)
setEtag(res.etag)
toast.success("Configuration saved", `Warehouse branches ${next ? "enabled" : "disabled"}.`)
} catch (err) {
toast.error("Could not save configuration", errorMessage(err))
load()
} finally {
setSaving(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
@@ -95,6 +111,24 @@ export default function BranchConfigurationPage() {
</div>
</div>
<div className="flex items-start gap-4 py-2">
<Switch
checked={config.warehouseBranchingEnabled}
onCheckedChange={toggleWarehouses}
disabled={saving || !config.branchingEnabled}
aria-label="Divide warehouses by branch"
className="mt-1 shrink-0"
/>
<div className="flex flex-col gap-1">
<span className="text-base font-medium text-foreground">Divide warehouses by branch</span>
<span className="text-sm text-muted-foreground">
On: Warehouse create/edit asks for a branch, and the warehouse list shows a Branch
column. Off: warehouses stop showing/asking for a branch while Employees and other
branch-aware records are unaffected. Has no effect while &quot;Require branches&quot; above is off.
</span>
</div>
</div>
{config.updatedAt && (
<p className="pt-2 text-sm text-muted-foreground">
Last changed {new Date(config.updatedAt).toLocaleString()}
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, Power, Save } from "lucide-react"
import { AlertTriangle, ArrowLeft, ArrowRight, Power, Save } from "lucide-react"
import { navApi } from "@/lib/api/nav"
import { rolesApi } from "@/lib/api/roles"
@@ -11,6 +11,7 @@ import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { NavItem, Role } from "@/types/rbac"
import { RolePermissionActions, fillUntouchedActions } from "@/components/auth/RolePermissionActions"
import { RolePermissionTree } from "@/components/auth/RolePermissionTree"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -34,6 +35,9 @@ export default function RoleDetailPage() {
const [name, setName] = useState("")
const [navItemIds, setNavItemIds] = useState<Set<number>>(new Set())
const [subNavItemIds, setSubNavItemIds] = useState<Set<number>>(new Set())
const [navItemActions, setNavItemActions] = useState<Record<number, number>>({})
const [subNavItemActions, setSubNavItemActions] = useState<Record<number, number>>({})
const [permissionStep, setPermissionStep] = useState<"select" | "actions">("select")
const [errors, setErrors] = useState<Record<string, string>>({})
const [conflict, setConflict] = useState(false)
@@ -53,6 +57,9 @@ export default function RoleDetailPage() {
setTree(navTree)
setNavItemIds(new Set(permissions.navItemIds))
setSubNavItemIds(new Set(permissions.subNavItemIds))
setNavItemActions(permissions.navItemActions)
setSubNavItemActions(permissions.subNavItemActions)
setPermissionStep("select")
setConflict(false)
})
.catch((err) => setLoadError(errorMessage(err)))
@@ -109,12 +116,23 @@ export default function RoleDetailPage() {
}
}
// Mandatory: every selected section must have at least one action granted before
// permissions can be saved — an item left untouched on the actions step defaults to 0
// (RolePermissionActions' "plain until clicked" behavior), which would silently leave
// a role able to see a section but do nothing in it.
const allActionsSet =
Array.from(navItemIds).every((id) => (navItemActions[id] ?? 0) !== 0) &&
Array.from(subNavItemIds).every((id) => (subNavItemActions[id] ?? 0) !== 0)
async function handleSavePermissions() {
if (!allActionsSet) return
setSavingPermissions(true)
try {
await rolesApi.assignPermissions(roleId, {
navItemIds: Array.from(navItemIds),
subNavItemIds: Array.from(subNavItemIds),
navItemActions: fillUntouchedActions(navItemIds, navItemActions),
subNavItemActions: fillUntouchedActions(subNavItemIds, subNavItemActions),
})
toast.success("Permissions saved", "This role's visible sidebar sections have been updated.")
} catch (err) {
@@ -215,25 +233,65 @@ export default function RoleDetailPage() {
</section>
<section className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Sidebar permissions</h2>
<p className="text-sm text-muted-foreground">Choose which sections a user with this role can see.</p>
</div>
<RolePermissionTree
tree={tree}
selectedNavItemIds={navItemIds}
selectedSubNavItemIds={subNavItemIds}
onChange={(nav, sub) => {
setNavItemIds(nav)
setSubNavItemIds(sub)
}}
/>
<div className="flex justify-end">
<Button onClick={handleSavePermissions} disabled={savingPermissions}>
<Save className="size-4" />
{savingPermissions ? "Saving…" : "Save permissions"}
</Button>
</div>
{permissionStep === "select" ? (
<>
<div>
<h2 className="text-lg font-semibold text-foreground">Sidebar permissions</h2>
<p className="text-sm text-muted-foreground">Choose which sections a user with this role can see.</p>
</div>
<RolePermissionTree
tree={tree}
selectedNavItemIds={navItemIds}
selectedSubNavItemIds={subNavItemIds}
onChange={(nav, sub) => {
setNavItemIds(nav)
setSubNavItemIds(sub)
}}
/>
<div className="flex justify-end">
<Button
onClick={() => setPermissionStep("actions")}
disabled={navItemIds.size === 0 && subNavItemIds.size === 0}
>
Next: set actions
<ArrowRight className="size-4" />
</Button>
</div>
</>
) : (
<>
<div>
<h2 className="text-lg font-semibold text-foreground">Actions</h2>
<p className="text-sm text-muted-foreground">
Choose which of View, Create, Edit, Delete and Approve this role gets in each selected section.
</p>
</div>
<RolePermissionActions
tree={tree}
selectedNavItemIds={navItemIds}
selectedSubNavItemIds={subNavItemIds}
navItemActions={navItemActions}
subNavItemActions={subNavItemActions}
onActionsChange={(navActions, subActions) => {
setNavItemActions(navActions)
setSubNavItemActions(subActions)
}}
/>
{!allActionsSet && (
<p className="text-sm text-warning">Every selected section needs at least one action before you can save.</p>
)}
<div className="flex justify-between">
<Button variant="outline" onClick={() => setPermissionStep("select")}>
<ArrowLeft className="size-4" />
Back
</Button>
<Button onClick={handleSavePermissions} disabled={savingPermissions || !allActionsSet}>
<Save className="size-4" />
{savingPermissions ? "Saving…" : "Save permissions"}
</Button>
</div>
</>
)}
</section>
<div className="flex justify-end">
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { Save } from "lucide-react"
import { ArrowLeft, ArrowRight, Save } from "lucide-react"
import { navApi } from "@/lib/api/nav"
import { rolesApi } from "@/lib/api/roles"
@@ -11,6 +11,7 @@ import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { NavItem } from "@/types/rbac"
import { RolePermissionActions, fillUntouchedActions } from "@/components/auth/RolePermissionActions"
import { RolePermissionTree } from "@/components/auth/RolePermissionTree"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -28,6 +29,8 @@ function deriveCode(name: string): string {
.replace(/^_+|_+$/g, "")
}
type PermissionStep = "select" | "actions"
export default function NewRolePage() {
const router = useRouter()
@@ -35,6 +38,9 @@ export default function NewRolePage() {
const [name, setName] = useState("")
const [navItemIds, setNavItemIds] = useState<Set<number>>(new Set())
const [subNavItemIds, setSubNavItemIds] = useState<Set<number>>(new Set())
const [navItemActions, setNavItemActions] = useState<Record<number, number>>({})
const [subNavItemActions, setSubNavItemActions] = useState<Record<number, number>>({})
const [permissionStep, setPermissionStep] = useState<PermissionStep>("select")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
@@ -43,6 +49,14 @@ export default function NewRolePage() {
}, [])
const code = deriveCode(name)
const hasSelection = navItemIds.size > 0 || subNavItemIds.size > 0
// Mandatory: every selected section must have at least one action granted before a
// role can be created — an item left untouched on the actions step defaults to 0
// (see RolePermissionActions' "plain until clicked" behavior), which would silently
// create a role that can see a section but do nothing in it.
const allActionsSet =
Array.from(navItemIds).every((id) => (navItemActions[id] ?? 0) !== 0) &&
Array.from(subNavItemIds).every((id) => (subNavItemActions[id] ?? 0) !== 0)
async function handleCreate() {
const nextErrors: Record<string, string> = {}
@@ -50,14 +64,17 @@ export default function NewRolePage() {
else if (!code) nextErrors.name = "Role name must contain at least one letter or number"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
if (hasSelection && !allActionsSet) return
setSubmitting(true)
try {
const result = await rolesApi.create({ code, name: name.trim() })
if (navItemIds.size > 0 || subNavItemIds.size > 0) {
if (hasSelection) {
await rolesApi.assignPermissions(result.data.roleId, {
navItemIds: Array.from(navItemIds),
subNavItemIds: Array.from(subNavItemIds),
navItemActions: fillUntouchedActions(navItemIds, navItemActions),
subNavItemActions: fillUntouchedActions(subNavItemIds, subNavItemActions),
})
}
toast.success("Role created", `${result.data.code} — ${result.data.name}`)
@@ -112,34 +129,82 @@ export default function NewRolePage() {
</section>
<section className="flex flex-col gap-4 rounded-xl border p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Sidebar permissions</h2>
<p className="text-sm text-muted-foreground">Choose which sections a user with this role can see.</p>
</div>
{tree === null ? (
<Skeleton className="h-48 w-full" />
{permissionStep === "select" ? (
<>
<div>
<h2 className="text-lg font-semibold text-foreground">Sidebar permissions</h2>
<p className="text-sm text-muted-foreground">Choose which sections a user with this role can see.</p>
</div>
{tree === null ? (
<Skeleton className="h-48 w-full" />
) : (
<RolePermissionTree
tree={tree}
selectedNavItemIds={navItemIds}
selectedSubNavItemIds={subNavItemIds}
onChange={(nav, sub) => {
setNavItemIds(nav)
setSubNavItemIds(sub)
}}
/>
)}
<div className="flex justify-end">
<Button onClick={() => setPermissionStep("actions")} disabled={!hasSelection}>
Next: set actions
<ArrowRight className="size-4" />
</Button>
</div>
</>
) : (
<RolePermissionTree
tree={tree}
selectedNavItemIds={navItemIds}
selectedSubNavItemIds={subNavItemIds}
onChange={(nav, sub) => {
setNavItemIds(nav)
setSubNavItemIds(sub)
}}
/>
<>
<div>
<h2 className="text-lg font-semibold text-foreground">Actions</h2>
<p className="text-sm text-muted-foreground">
Choose which of View, Create, Edit, Delete and Approve this role gets in each selected section.
</p>
</div>
{tree && (
<RolePermissionActions
tree={tree}
selectedNavItemIds={navItemIds}
selectedSubNavItemIds={subNavItemIds}
navItemActions={navItemActions}
subNavItemActions={subNavItemActions}
onActionsChange={(navActions, subActions) => {
setNavItemActions(navActions)
setSubNavItemActions(subActions)
}}
/>
)}
{!allActionsSet && (
<p className="text-sm text-warning">Every selected section needs at least one action before you can create the role.</p>
)}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={() => setPermissionStep("select")}>
<ArrowLeft className="size-4" />
Back
</Button>
<div className="flex gap-3">
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "outline" }))}>
Cancel
</Link>
<Button onClick={handleCreate} disabled={submitting || !allActionsSet}>
<Save className="size-4" />
{submitting ? "Creating…" : "Create role"}
</Button>
</div>
</div>
</>
)}
</section>
<div className="flex justify-end gap-3">
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "outline" }))}>
Cancel
</Link>
<Button onClick={handleCreate} disabled={submitting}>
<Save className="size-4" />
{submitting ? "Creating…" : "Create role"}
</Button>
</div>
{permissionStep === "select" && (
<div className="flex justify-end gap-3">
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "outline" }))}>
Cancel
</Link>
</div>
)}
</div>
)
}
@@ -8,11 +8,14 @@ import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { warehousesApi } from "@/lib/api/warehouses"
import { itemsApi } from "@/lib/api/items"
import { reasonCodesApi } from "@/lib/api/reason-codes"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { CreateAdjustmentLineInput, ReasonCode, StockAdjustment } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -39,13 +42,17 @@ function emptyLine(): DraftLine {
}
export default function NewAdjustmentPage() {
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [reasonCodes, setReasonCodes] = useState<ReasonCode[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [reasonCodeId, setReasonCodeId] = useState<number | null>(null)
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [lines, setLines] = useState<DraftLine[]>([emptyLine()])
const [submitError, setSubmitError] = useState<string | null>(null)
@@ -53,6 +60,12 @@ export default function NewAdjustmentPage() {
const [submitting, setSubmitting] = useState(false)
const [result, setResult] = useState<StockAdjustment | null>(null)
// Mirrors the GRN "new" page's branch resolution: an admin on "All Branches" picks one
// explicitly, everyone else (or an admin already scoped to a branch) is locked to it.
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
useEffect(() => {
Promise.all([warehousesApi.list(), itemsApi.list({ pageSize: 200, status: "Active" }), reasonCodesApi.list("Adjustment")])
.then(([wh, it, rc]) => {
@@ -63,6 +76,12 @@ export default function NewAdjustmentPage() {
.catch((err) => setLoadError(errorMessage(err)))
}, [])
useEffect(() => {
if (branchingEnabled && isAdmin) {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}
}, [branchingEnabled, isAdmin])
function updateLine(key: string, patch: Partial<DraftLine>) {
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
@@ -82,6 +101,10 @@ export default function NewAdjustmentPage() {
setSubmitError("A reason code is required for adjustments.")
return
}
if (branchingEnabled && !effectiveBranchId) {
setSubmitError("Select a branch for this adjustment.")
return
}
const nextLineErrors: Record<string, string> = {}
for (const line of lines) {
@@ -101,7 +124,12 @@ export default function NewAdjustmentPage() {
setSubmitting(true)
try {
const adjustment = await stockAdjustmentsApi.create({ warehouseId, reasonCodeId, lines: payloadLines })
const adjustment = await stockAdjustmentsApi.create({
warehouseId,
branchId: effectiveBranchId ?? 0,
reasonCodeId,
lines: payloadLines,
})
setResult(adjustment)
toast.success("Adjustment posted", `${adjustment.docNo} posted immediately (auto-post, FR-STK-07).`)
} catch (err) {
@@ -158,6 +186,30 @@ export default function NewAdjustmentPage() {
{!loading && !result && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{branchingEnabled && isAdmin && (
<div className="flex flex-col gap-2">
<Label className="text-base">Branch</Label>
{showBranchPicker ? (
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select branch" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{activeBranchName ?? `Branch #${lockedBranchId}`}
</div>
)}
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
@@ -1,15 +1,19 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation"
import { CheckCircle2, ClipboardCheck, Save } from "lucide-react"
import { ApiError } from "@/lib/api-client"
import { itemsApi } from "@/lib/api/items"
import { stockCountsApi } from "@/lib/api/stock-counts"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PostCountResponse, StockCount } from "@/types/stock"
import { ScanInput } from "@/components/scanning/ScanInput"
import { Button } from "@/components/ui/button"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -26,6 +30,8 @@ export default function CountDetailPage() {
const [saving, setSaving] = useState(false)
const [posting, setPosting] = useState(false)
const [postResult, setPostResult] = useState<PostCountResponse | null>(null)
const [scanError, setScanError] = useState<string | null>(null)
const qtyInputRefs = useRef<Record<number, HTMLInputElement | null>>({})
useEffect(() => {
if (!Number.isFinite(countId)) return
@@ -47,7 +53,7 @@ export default function CountDetailPage() {
.filter(([, v]) => v !== "")
.map(([countLineId, v]) => ({ countLineId: Number(countLineId), countedQty: Number(v) }))
const result = await stockCountsApi.enterCounts(countId, { lines })
setCount((prev) => (prev ? { ...prev, lines: result.lines } : prev))
setCount((prev) => (prev ? { ...prev, status: result.status, lines: result.lines } : prev))
toast.success("Counts saved", "Variances recalculated.")
} catch (err) {
setError(errorMessage(err))
@@ -57,6 +63,39 @@ export default function CountDetailPage() {
}
}
// Fast path for a barcode/RFID scan above the count table: resolves the code to an item (and,
// for a lot-tracked item, its specific batch/serial) and finds the matching pre-expanded count
// line to bump — one line per batch/serial per item already exists once a count is created, so
// this never creates a line, only locates and increments/focuses one that's already there.
async function handleScan(code: string) {
setScanError(null)
if (!count) return
try {
const result = await itemsApi.scan(code)
const match = count.lines.find((l) => {
if (l.itemId !== result.itemId) return false
if (result.matchedBy === "Batch") return l.batchId === result.batchId
if (result.matchedBy === "Serial") return l.serialId === result.serialId
return l.batchId === null && l.serialId === null
})
if (!match) {
setScanError(`No count line found for "${code}" — it may be an unexpected batch/serial not on this count.`)
return
}
setDrafts((prev) => ({
...prev,
[match.countLineId]: String((Number(prev[match.countLineId]) || 0) + 1),
}))
qtyInputRefs.current[match.countLineId]?.focus()
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
setScanError(`No item or lot found for "${code}"`)
} else {
setScanError(errorMessage(err))
}
}
}
async function handlePost() {
setError(null)
setPosting(true)
@@ -99,16 +138,26 @@ export default function CountDetailPage() {
</div>
</div>
{count.status === "Draft" && (
<div className="flex gap-3">
<Button size="lg" variant="outline" onClick={handleSave} disabled={saving}>
<Save className="size-5" />
{saving ? "Saving…" : "Save counts"}
</Button>
<Button size="lg" onClick={handlePost} disabled={posting}>
<ClipboardCheck className="size-5" />
{posting ? "Posting…" : "Post count"}
</Button>
{count.status !== "Posted" && (
<div className="flex flex-col items-end gap-1.5">
<div className="flex gap-3">
<Button size="lg" variant="outline" onClick={handleSave} disabled={saving}>
<Save className="size-5" />
{saving ? "Saving…" : "Save counts"}
</Button>
<Button
size="lg"
onClick={handlePost}
disabled={posting || count.status !== "Counted"}
title={count.status !== "Counted" ? "Save counts first" : undefined}
>
<ClipboardCheck className="size-5" />
{posting ? "Posting…" : "Post count"}
</Button>
</div>
{count.status !== "Counted" && (
<p className="text-sm text-muted-foreground">Save counts before posting.</p>
)}
</div>
)}
</div>
@@ -130,10 +179,23 @@ export default function CountDetailPage() {
</div>
)}
{count.status !== "Posted" && (
<div className="flex flex-col gap-3 rounded-lg border border-dashed border-input p-4">
<ScanInput
label="Scan to count"
hint="Scan an item or lot barcode to add one unit to its counted qty."
onScan={handleScan}
className="max-w-md"
/>
{scanError && <FieldError errors={[{ message: scanError }]} />}
</div>
)}
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-sm">Lot</TableHead>
<TableHead className="h-12 px-3 text-sm">System qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Counted qty</TableHead>
<TableHead className="h-12 px-3 text-sm">Variance</TableHead>
@@ -142,11 +204,17 @@ export default function CountDetailPage() {
<TableBody>
{count.lines.map((line) => (
<TableRow key={line.countLineId}>
<TableCell className="px-3 py-3.5">{line.itemId}</TableCell>
<TableCell className="px-3 py-3.5">#{line.itemId}</TableCell>
<TableCell className="px-3 py-3.5 text-sm text-muted-foreground">
{line.batchNo ?? line.serialNo ?? "—"}
</TableCell>
<TableCell className="px-3 py-3.5">{line.systemQty}</TableCell>
<TableCell className="px-3 py-3.5">
{count.status === "Draft" ? (
{count.status !== "Posted" ? (
<Input
ref={(el) => {
qtyInputRefs.current[line.countLineId] = el
}}
type="number"
step="any"
value={drafts[line.countLineId] ?? ""}
@@ -7,12 +7,15 @@ import {} from "lucide-react"
import { stockCountsApi } from "@/lib/api/stock-counts"
import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { itemsApi } from "@/lib/api/items"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { CountType } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
@@ -22,9 +25,12 @@ import { toast } from "@/components/ui/toast"
export default function NewCountPage() {
const router = useRouter()
const { isAdmin, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [branchFilterId, setBranchFilterId] = useState<number | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
@@ -43,6 +49,17 @@ export default function NewCountPage() {
.catch((err) => setLoadError(errorMessage(err)))
}, [])
// Admin-only branch picker — non-admins are pinned to their own branch directly.
useEffect(() => {
if (branchingEnabled && isAdmin) {
branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}
}, [branchingEnabled, isAdmin])
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
function toggleItem(itemId: number) {
setSelectedItemIds((prev) => {
const next = new Set(prev)
@@ -59,6 +76,10 @@ export default function NewCountPage() {
setSubmitError("Select a warehouse.")
return
}
if (branchingEnabled && !effectiveBranchId) {
setSubmitError("Select a branch.")
return
}
if (selectedItemIds.size === 0) {
setSubmitError("Select at least one item to count.")
return
@@ -66,7 +87,12 @@ export default function NewCountPage() {
setSubmitting(true)
try {
const count = await stockCountsApi.create({ warehouseId, countType, itemIds: [...selectedItemIds] })
const count = await stockCountsApi.create({
warehouseId,
branchId: effectiveBranchId ?? 0,
countType,
itemIds: [...selectedItemIds],
})
toast.success("Count created", `${count.docNo} — system quantities snapshotted.`)
router.push(`/dashboard/stock/counts/${count.countId}`)
} catch (err) {
@@ -97,6 +123,30 @@ export default function NewCountPage() {
{!loading && (
<>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{branchingEnabled && isAdmin && (
<div className="flex flex-col gap-2">
<Label className="text-base">Branch</Label>
{showBranchPicker ? (
<Select<number | null> value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="All branches" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex h-12 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{activeBranchName ?? `Branch #${lockedBranchId}`}
</div>
)}
</div>
)}
<div className="flex flex-col gap-2">
<Label className="text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={(v) => setWarehouseId(v)}>
@@ -12,13 +12,16 @@ import { StockCountSummary } from "@/types/stock"
import { Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { buttonVariants } from "@/components/ui/button"
import { KpiRow } from "@/components/ui/kpi-row"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { CountStatusBadge } from "@/components/stock/status-badges"
export default function CountsListPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
const [counts, setCounts] = useState<StockCountSummary[] | null>(null)
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
@@ -53,6 +56,8 @@ export default function CountsListPage() {
</Link>
</div>
<KpiRow tiles={stats ? [{ label: "Pending Counts", value: stats.pendingCounts, icon: ClipboardList }] : null} />
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
@@ -1,12 +1,105 @@
import Link from "next/link"
import {
ArrowLeftRight,
BellRing,
Boxes,
ClipboardList,
Coins,
History,
PackageSearch,
SlidersHorizontal,
Trash2,
} from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
const sections = [
{
title: "Adjustments",
description: "Correct stock quantities with a reason code.",
href: "/dashboard/stock/adjustments",
icon: SlidersHorizontal,
},
{
title: "Stock Counts",
description: "Cycle and physical counts, with variance posting.",
href: "/dashboard/stock/counts",
icon: ClipboardList,
},
{
title: "Stock Enquiry",
description: "Look up on-hand quantity and value by item and warehouse.",
href: "/dashboard/stock/enquiry",
icon: PackageSearch,
},
{
title: "Stock Ledger",
description: "FIFO cost layers and movement history.",
href: "/dashboard/stock/ledger",
icon: History,
},
{
title: "Reorder Alerts",
description: "Items below reorder point across warehouses.",
href: "/dashboard/stock/reorder-alerts",
icon: BellRing,
},
{
title: "Transfers",
description: "Move stock between warehouses.",
href: "/dashboard/stock/transfers",
icon: ArrowLeftRight,
},
{
title: "Valuation",
description: "Stock valuation by warehouse and item.",
href: "/dashboard/stock/valuation",
icon: Coins,
},
{
title: "Wastage",
description: "Record and track written-off stock.",
href: "/dashboard/stock/wastage",
icon: Trash2,
},
]
export default function StockHubPage() {
return (
<div className="flex flex-col gap-6">
<div>
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary">
<Boxes className="size-4" />
Stock
</div>
<h1 className="text-2xl font-bold text-foreground">Stock Management</h1>
<p className="text-base text-muted-foreground">
FIFO-costed stock across multiple warehouses (FR-STK-01..14).
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{sections.map((section) => {
const Icon = section.icon
return (
<Link
key={section.href}
href={section.href}
className="group rounded-2xl border bg-card p-5 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md"
>
<div className="mb-4 flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Icon className="size-5" />
</div>
<h2 className="text-lg font-semibold text-foreground">{section.title}</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">{section.description}</p>
<div className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "mt-4 px-0 text-primary")}>
Open
</div>
</Link>
)
})}
</div>
</div>
)
}
@@ -11,13 +11,16 @@ import { ReorderAlert } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button } from "@/components/ui/button"
import { KpiRow } from "@/components/ui/kpi-row"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
export default function ReorderAlertsPage() {
const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
const [alerts, setAlerts] = useState<ReorderAlert[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
@@ -70,6 +73,8 @@ export default function ReorderAlertsPage() {
</div>
</div>
<KpiRow tiles={stats ? [{ label: "Low Stock Alerts", value: stats.lowStockAlerts, icon: AlertTriangle }] : null} />
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
@@ -12,6 +12,8 @@ import { Valuation } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import BarChart from "@/components/ui/bar-chart"
import LineChart from "@/components/ui/line-chart"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -142,7 +144,41 @@ function ValuationContent() {
{valuation.layers.length === 0 ? (
<p className="text-base text-muted-foreground">No open FIFO layers for this item/warehouse.</p>
) : (
<Table className="text-base">
<>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-xl border p-5">
<p className="mb-3 text-sm font-medium text-muted-foreground">Value by FIFO layer</p>
<BarChart
labels={valuation.layers.map((l) => new Date(l.receiptDate).toLocaleDateString(undefined, { month: "short", day: "numeric" }))}
datasets={[{ label: `Value (${valuation.currency})`, data: valuation.layers.map((l) => l.value), backgroundColor: "rgba(59,130,246,0.6)" }]}
barPercentage={0.4}
categoryPercentage={0.95}
/>
</div>
<div className="rounded-xl border p-5">
<p className="mb-3 text-sm font-medium text-muted-foreground">Value per layer vs. cumulative value</p>
<LineChart
labels={valuation.layers.map((l) => new Date(l.receiptDate).toLocaleDateString(undefined, { month: "short", day: "numeric" }))}
datasets={[
{
label: `Value (${valuation.currency})`,
data: valuation.layers.map((l) => l.value),
borderColor: "#3b82f6",
backgroundColor: "rgba(59,130,246,0.15)",
},
{
label: `Cumulative value (${valuation.currency})`,
data: valuation.layers.reduce<number[]>((acc, l) => [...acc, (acc[acc.length - 1] ?? 0) + l.value], []),
borderColor: "#22c55e",
backgroundColor: "rgba(34,197,94,0.15)",
},
]}
/>
</div>
</div>
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Layer</TableHead>
@@ -164,6 +200,7 @@ function ValuationContent() {
))}
</TableBody>
</Table>
</>
)}
</>
)}
@@ -14,6 +14,7 @@ import { cn } from "@/lib/utils"
import { OnHand, ReasonCode, StockAdjustment } from "@/types/stock"
import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -23,6 +24,7 @@ import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
export default function NewWastagePage() {
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [reasonCodes, setReasonCodes] = useState<ReasonCode[] | null>(null)
@@ -80,6 +82,12 @@ export default function NewWastagePage() {
.finally(() => setOnHandLoading(false))
}, [itemId, warehouseId])
// The selected warehouse already carries its own branch (Warehouse.branchId) — reuse it
// rather than exposing a second branch picker; fall back to the caller's own/active branch
// for a deployment with branching enabled but no warehouse branch resolved yet.
const selectedWarehouseBranchId = (warehouses ?? []).find((w) => w.warehouseId === warehouseId)?.branchId ?? null
const effectiveBranchId = branchingEnabled ? selectedWarehouseBranchId ?? (isAdmin ? activeBranchId : myBranchId) : null
async function handleSubmit() {
setSubmitError(null)
const nextErrors: Record<string, string> = {}
@@ -88,6 +96,7 @@ export default function NewWastagePage() {
if (!qty || Number(qty) <= 0) nextErrors.qty = "Quantity must be greater than 0"
else if (onHand && Number(qty) > onHand.available) nextErrors.qty = `Insufficient quantity — only ${onHand.available} available`
if (!reasonCodeId) nextErrors.reasonCodeId = "Select a wastage reason"
if (branchingEnabled && !effectiveBranchId) nextErrors.warehouseId = nextErrors.warehouseId || "Could not resolve a branch for this warehouse"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
@@ -95,6 +104,7 @@ export default function NewWastagePage() {
try {
const adjustment = await wastageApi.record({
warehouseId: warehouseId as number,
branchId: effectiveBranchId ?? 0,
itemId: itemId as number,
binId,
qty: Number(qty),
+27 -3
View File
@@ -5,12 +5,15 @@ import Link from "next/link"
import { Eye, Pencil, Plus, Search, Trash2, Truck } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { generateVendorCode } from "@/lib/vendor-code"
import { EntityStatus, PaginationMeta } from "@/types/common"
import { Vendor } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
@@ -36,7 +39,9 @@ type StatusFilter = EntityStatus | "All"
const PAGE_SIZE = 25
export default function VendorsPage() {
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
const [vendors, setVendors] = useState<Vendor[] | null>(null)
const [branches, setBranches] = useState<Branch[]>([])
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -86,6 +91,19 @@ export default function VendorsPage() {
vendorsApi.list({ pageSize: 200 }).then((res) => setAllVendorCodes(res.items.map((v) => v.code))).catch(() => {})
}, [])
// Backend admins bypass branch scoping and see every vendor unfiltered — the header's
// branch switcher (docs/BRANCH_ISOLATION_PLAN.md §Frontend impact) is the only filter
// available for these entities, applied client-side since /vendors has no branchId param.
useEffect(() => {
if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [isAdmin, branchingEnabled])
function branchName(branchId: number) {
return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
}
const visibleVendors = isAdmin && branchingEnabled && activeBranchId ? (vendors ?? []).filter((v) => v.branchId === activeBranchId) : vendors
function resetForm() {
setName("")
setTerms("")
@@ -228,7 +246,7 @@ export default function VendorsPage() {
</div>
)}
{!error && vendors !== null && vendors.length === 0 && (
{!error && visibleVendors !== null && visibleVendors.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Truck className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
@@ -237,7 +255,7 @@ export default function VendorsPage() {
</div>
)}
{!error && vendors !== null && vendors.length > 0 && (
{!error && visibleVendors !== null && visibleVendors.length > 0 && (
<>
<Table className="text-base">
<TableHeader>
@@ -246,17 +264,23 @@ export default function VendorsPage() {
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Terms</TableHead>
<TableHead className="h-12 px-3 text-sm">Currency</TableHead>
{isAdmin && branchingEnabled && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vendors.map((v) => (
{visibleVendors.map((v) => (
<TableRow key={v.vendorId}>
<TableCell className="px-3 py-3.5 font-medium">{v.code}</TableCell>
<TableCell className="px-3 py-3.5">{v.name}</TableCell>
<TableCell className="px-3 py-3.5">{v.terms ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{v.currency}</TableCell>
{isAdmin && branchingEnabled && (
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{branchName(v.branchId)}</Badge>
</TableCell>
)}
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
@@ -1,13 +1,17 @@
"use client"
import { useEffect, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation"
import { MapPinned, Plus } from "lucide-react"
import { ArrowLeftRight, Boxes, ChevronLeft, ChevronRight, MapPinned, Plus } from "lucide-react"
import { warehousesApi } from "@/lib/api/warehouses"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { Bin, Warehouse } from "@/types/master-data"
import { Bin, ItemListItem, Warehouse } from "@/types/master-data"
import { LedgerEntry, OnHand } from "@/types/stock"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
@@ -22,13 +26,77 @@ import {
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import BarChart from "@/components/ui/bar-chart"
/** Charts stay legible with a lot of items only by capping to the top N by on-hand qty. */
const CHART_ROW_LIMIT = 15
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
/** Reusable pager for a client-side-sliced table — data for these tables is fetched in
* full up front, so paging is done in-memory rather than via query params. */
function TablePager({
page, pageSize, total, onPageChange, onPageSizeChange, itemLabel,
}: {
page: number
pageSize: number
total: number
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
itemLabel: string
}) {
const totalPages = Math.max(1, Math.ceil(total / pageSize))
if (total === 0) return null
return (
<div className="mt-4 flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(page - 1) * pageSize + 1}</span>–
<span className="font-medium text-foreground">{Math.min(page * pageSize, total)}</span>{" "}
of <span className="font-medium text-foreground">{total}</span> {itemLabel}
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={page <= 1} onClick={() => onPageChange(Math.max(1, page - 1))}>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {page} of {totalPages}
</span>
<Button type="button" variant="outline" size="sm" disabled={page >= totalPages} onClick={() => onPageChange(Math.min(totalPages, page + 1))}>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)
}
export default function WarehouseDetailPage() {
const params = useParams<{ id: string }>()
const warehouseId = Number(params.id)
const { warehouseBranchingEnabled } = useAuth()
const [warehouse, setWarehouse] = useState<Warehouse | null>(null)
const [bins, setBins] = useState<Bin[] | null>(null)
const [stock, setStock] = useState<OnHand[] | null>(null)
const [items, setItems] = useState<ItemListItem[] | null>(null)
const [ledger, setLedger] = useState<LedgerEntry[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
@@ -37,6 +105,13 @@ export default function WarehouseDetailPage() {
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [binsPage, setBinsPage] = useState(1)
const [binsPageSize, setBinsPageSize] = useState(5)
const [stockPage, setStockPage] = useState(1)
const [stockPageSize, setStockPageSize] = useState(5)
const [movementPage, setMovementPage] = useState(1)
const [movementPageSize, setMovementPageSize] = useState(5)
function loadBins() {
warehousesApi.listBins(warehouseId).then(setBins).catch((err) => setError(errorMessage(err)))
}
@@ -45,8 +120,39 @@ export default function WarehouseDetailPage() {
if (!Number.isFinite(warehouseId)) return
warehousesApi.get(warehouseId).then(setWarehouse).catch((err) => setError(errorMessage(err)))
loadBins()
stockApi.onHandList({ warehouseId, pageSize: 200 }).then((r) => setStock(r.items)).catch((err) => setError(errorMessage(err)))
itemsApi.list({ pageSize: 200 }).then((r) => setItems(r.items)).catch(() => setItems([]))
stockApi.ledger({ warehouseId, pageSize: 500 }).then((r) => setLedger(r.items)).catch((err) => setError(errorMessage(err)))
}, [warehouseId])
const itemsById = useMemo(() => new Map((items ?? []).map((i) => [i.itemId, i])), [items])
const stockRows = useMemo(() => (stock ?? []).slice().sort((a, b) => b.onHand - a.onHand), [stock])
const totalOnHand = stockRows.reduce((sum, r) => sum + r.onHand, 0)
const chartRows = stockRows.slice(0, CHART_ROW_LIMIT)
const chartLabels = chartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`)
const movementByItem = useMemo(() => {
const map = new Map<number, { in: number; out: number }>()
for (const entry of ledger ?? []) {
const row = map.get(entry.itemId) ?? { in: 0, out: 0 }
if (entry.direction === "In") row.in += entry.qtyBase
else row.out += entry.qtyBase
map.set(entry.itemId, row)
}
return [...map.entries()]
.map(([itemId, v]) => ({ itemId, ...v, total: v.in + v.out }))
.sort((a, b) => b.total - a.total)
}, [ledger])
const movementChartRows = movementByItem.slice(0, CHART_ROW_LIMIT)
const movementChartLabels = movementChartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`)
const pagedBins = bins?.slice((binsPage - 1) * binsPageSize, binsPage * binsPageSize) ?? []
const pagedStockRows = stockRows.slice((stockPage - 1) * stockPageSize, stockPage * stockPageSize)
const pagedMovementRows = movementByItem.slice((movementPage - 1) * movementPageSize, movementPage * movementPageSize)
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Bin code is required"
@@ -86,15 +192,20 @@ export default function WarehouseDetailPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground">{warehouse.code}</h1>
<p className="text-base text-muted-foreground">
{warehouse.name}
{warehouse.branchName && ` · ${warehouse.branchName}`} · Bin/location structure (FR-WH-01, FR-MD-07)
<div className="flex flex-col gap-3 rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">{warehouse.code}</h1>
<p className="mt-1 text-base text-muted-foreground">
{warehouse.name}
{warehouseBranchingEnabled && warehouse.branchName && ` · ${warehouse.branchName}`} · Bin/location structure (FR-WH-01, FR-MD-07)
</p>
{(warehouse.address || warehouse.phone) && (
<p className="mt-1 text-sm text-muted-foreground">
{warehouse.address}
{warehouse.address && warehouse.phone && " · "}
{warehouse.phone}
</p>
</div>
)}
</div>
<Dialog open={open} onOpenChange={setOpen}>
@@ -138,29 +249,218 @@ export default function WarehouseDetailPage() {
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{bins.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<MapPinned className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No bins yet in this warehouse.</p>
{/* Bins */}
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
<div className="flex items-center gap-2.5 border-b border-border pb-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<MapPinned className="size-4.5 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Bins / locations</h2>
<p className="text-sm text-muted-foreground">{bins.length} bin{bins.length === 1 ? "" : "s"} in this warehouse.</p>
</div>
</div>
) : (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bins.map((b) => (
<TableRow key={b.binId}>
<TableCell className="px-3 py-3.5 font-medium">{b.code}</TableCell>
<TableCell className="px-3 py-3.5">{b.binType ?? "—"}</TableCell>
</TableRow>
{bins.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<MapPinned className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No bins yet in this warehouse.</p>
</div>
) : (
<div className="mt-5">
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 px-4 text-sm">Type</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedBins.map((b) => (
<TableRow key={b.binId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5 font-medium">{b.code}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{b.binType ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
<TablePager
page={binsPage}
pageSize={binsPageSize}
total={bins.length}
onPageChange={setBinsPage}
onPageSizeChange={(size) => { setBinsPageSize(size); setBinsPage(1) }}
itemLabel="bins"
/>
</div>
)}
</section>
{/* Stock */}
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
<div className="flex items-center gap-2.5 border-b border-border pb-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Boxes className="size-4.5 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Stock on hand</h2>
<p className="text-sm text-muted-foreground">
{stockRows.length} item{stockRows.length === 1 ? "" : "s"} holding stock · {totalOnHand.toLocaleString()} units total
</p>
</div>
</div>
{stock === null || items === null ? (
<div className="mt-5 flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</TableBody>
</Table>
)}
</div>
) : stockRows.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<Boxes className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No stock recorded in this warehouse.</p>
</div>
) : (
<>
<div className="mt-5">
<p className="mb-3 text-sm font-medium text-muted-foreground">On-hand vs available (top {CHART_ROW_LIMIT} items by on-hand qty)</p>
<BarChart
labels={chartLabels}
datasets={[
{ label: "On hand", data: chartRows.map((r) => r.onHand), backgroundColor: "#6366f1" },
{ label: "Available", data: chartRows.map((r) => r.available), backgroundColor: "#22c55e" },
]}
/>
</div>
<div className="mt-6 overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Item</TableHead>
<TableHead className="h-11 px-4 text-sm">On hand</TableHead>
<TableHead className="h-11 px-4 text-sm">Available</TableHead>
<TableHead className="h-11 px-4 text-sm">On hold</TableHead>
<TableHead className="h-11 px-4 text-sm">In transit</TableHead>
<TableHead className="h-11 px-4 text-sm">Reserved</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedStockRows.map((row) => {
const item = itemsById.get(row.itemId)
return (
<TableRow key={row.itemId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5">
<div className="font-medium">{item?.sku ?? `#${row.itemId}`}</div>
<div className="text-sm text-muted-foreground">{item?.name}</div>
</TableCell>
<TableCell className="px-4 py-3.5">{row.onHand}</TableCell>
<TableCell className="px-4 py-3.5 font-medium">{row.available}</TableCell>
<TableCell className="px-4 py-3.5">{row.onHold}</TableCell>
<TableCell className="px-4 py-3.5">{row.inTransit}</TableCell>
<TableCell className="px-4 py-3.5">{row.reserved}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
<TablePager
page={stockPage}
pageSize={stockPageSize}
total={stockRows.length}
onPageChange={setStockPage}
onPageSizeChange={(size) => { setStockPageSize(size); setStockPage(1) }}
itemLabel="items"
/>
</>
)}
</section>
{/* Stock movement */}
<section className="rounded-2xl bg-card p-5 shadow-sm ring-1 ring-foreground/10 sm:p-6">
<div className="flex items-center gap-2.5 border-b border-border pb-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<ArrowLeftRight className="size-4.5 text-primary" />
</div>
<div>
<h2 className="text-base font-semibold text-foreground">Stock movement (In / Out)</h2>
<p className="text-sm text-muted-foreground">Cumulative ledger quantity per item in this warehouse (FR-STK-13).</p>
</div>
</div>
{ledger === null || items === null ? (
<div className="mt-5 flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : movementByItem.length === 0 ? (
<div className="flex flex-col items-center gap-4 py-16 text-center">
<ArrowLeftRight className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No stock movement recorded in this warehouse.</p>
</div>
) : (
<>
<div className="mt-5">
<p className="mb-3 text-sm font-medium text-muted-foreground">In vs out (top {CHART_ROW_LIMIT} items by total movement)</p>
<BarChart
labels={movementChartLabels}
datasets={[
{ label: "In", data: movementChartRows.map((r) => r.in), backgroundColor: "#22c55e" },
{ label: "Out", data: movementChartRows.map((r) => r.out), backgroundColor: "#ef4444" },
]}
/>
</div>
<div className="mt-6 overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Item</TableHead>
<TableHead className="h-11 px-4 text-sm">In</TableHead>
<TableHead className="h-11 px-4 text-sm">Out</TableHead>
<TableHead className="h-11 px-4 text-sm">Net</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pagedMovementRows.map((row) => {
const item = itemsById.get(row.itemId)
return (
<TableRow key={row.itemId} className="hover:bg-muted/20">
<TableCell className="px-4 py-3.5">
<div className="font-medium">{item?.sku ?? `#${row.itemId}`}</div>
<div className="text-sm text-muted-foreground">{item?.name}</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-success">+{row.in}</TableCell>
<TableCell className="px-4 py-3.5 text-destructive">−{row.out}</TableCell>
<TableCell className="px-4 py-3.5 font-medium">{row.in - row.out}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
<TablePager
page={movementPage}
pageSize={movementPageSize}
total={movementByItem.length}
onPageChange={setMovementPage}
onPageSizeChange={(size) => { setMovementPageSize(size); setMovementPage(1) }}
itemLabel="items"
/>
</>
)}
</section>
</div>
)
}
@@ -2,18 +2,23 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { Plus, Warehouse as WarehouseIcon } from "lucide-react"
import { ChevronLeft, ChevronRight, Eye, Pencil, Plus, Search, Trash2, Warehouse as WarehouseIcon } from "lucide-react"
import { warehousesApi } from "@/lib/api/warehouses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { Bin, Warehouse } from "@/types/master-data"
import { Branch } from "@/types/hrm"
import { useAuth } from "@/components/auth/AuthProvider"
import { Button } from "@/components/ui/button"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { FilterBar } from "@/components/ui/filter-bar"
import {
Dialog,
DialogContent,
@@ -35,6 +40,8 @@ function warehouseCodeBase(name: string): string {
return cleaned.slice(0, 10) || "WH"
}
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
/** Appends a numeric suffix until the code doesn't collide with an existing one — the
* backend enforces global uniqueness (409 on conflict) but has no generation of its own. */
function generateWarehouseCode(name: string, existingCodes: string[]): string {
@@ -46,36 +53,65 @@ function generateWarehouseCode(name: string, existingCodes: string[]): string {
}
export default function WarehousesPage() {
const { branchingEnabled } = useAuth()
const { warehouseBranchingEnabled } = useAuth()
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
const [bins, setBins] = useState<Bin[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(5)
const [search, setSearch] = useState("")
const [debouncedSearch, setDebouncedSearch] = useState("")
const [filterBranchId, setFilterBranchId] = useState<number | undefined>(undefined)
const [open, setOpen] = useState(false)
const [name, setName] = useState("")
const [address, setAddress] = useState("")
const [phone, setPhone] = useState("")
const [branchId, setBranchId] = useState(0)
const [branches, setBranches] = useState<Branch[]>([])
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [editTarget, setEditTarget] = useState<Warehouse | null>(null)
const [editCode, setEditCode] = useState("")
const [editName, setEditName] = useState("")
const [editAddress, setEditAddress] = useState("")
const [editPhone, setEditPhone] = useState("")
const [editBranchId, setEditBranchId] = useState(0)
const [editErrors, setEditErrors] = useState<Record<string, string>>({})
const [editSubmitting, setEditSubmitting] = useState(false)
const [actionPendingId, setActionPendingId] = useState<number | null>(null)
const generatedCode = name.trim() ? generateWarehouseCode(name, (warehouses ?? []).map((w) => w.code)) : ""
function load() {
warehousesApi
.list()
.list({ page, pageSize, q: debouncedSearch || undefined, branchId: filterBranchId })
.then(async (res) => {
setWarehouses(res.items)
setPagination(res.pagination)
const allBins = await Promise.all(res.items.map((w) => warehousesApi.listBins(w.warehouseId)))
setBins(allBins.flat())
})
.catch((err) => setError(errorMessage(err)))
}
useEffect(load, [])
useEffect(load, [page, pageSize, debouncedSearch, filterBranchId])
useEffect(() => setPage(1), [filterBranchId])
useEffect(() => {
if (branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [branchingEnabled])
if (warehouseBranchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [warehouseBranchingEnabled])
useEffect(() => {
const t = setTimeout(() => {
setPage(1)
setDebouncedSearch(search.trim())
}, 300)
return () => clearTimeout(t)
}, [search])
function binCount(warehouseId: number) {
return (bins ?? []).filter((b) => b.warehouseId === warehouseId).length
@@ -84,16 +120,18 @@ export default function WarehousesPage() {
async function handleCreate() {
const nextErrors: Record<string, string> = {}
if (!name.trim()) nextErrors.name = "Warehouse name is required"
if (branchingEnabled && !branchId) nextErrors.branchId = "Branch is required"
if (warehouseBranchingEnabled && !branchId) nextErrors.branchId = "Branch is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const warehouse = await warehousesApi.create({ code: generatedCode, name, branchId })
const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone || null, branchId })
toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`)
setOpen(false)
setName("")
setAddress("")
setPhone("")
setBranchId(0)
setErrors({})
load()
@@ -106,16 +144,66 @@ export default function WarehousesPage() {
}
}
function openEdit(w: Warehouse) {
setEditTarget(w)
setEditCode(w.code)
setEditName(w.name)
setEditAddress(w.address ?? "")
setEditPhone(w.phone ?? "")
setEditBranchId(w.branchId)
setEditErrors({})
}
async function handleUpdate() {
if (!editTarget) return
const nextErrors: Record<string, string> = {}
if (!editCode.trim()) nextErrors.code = "Code is required"
if (!editName.trim()) nextErrors.name = "Warehouse name is required"
if (warehouseBranchingEnabled && !editBranchId) nextErrors.branchId = "Branch is required"
setEditErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setEditSubmitting(true)
try {
const warehouse = await warehousesApi.update(editTarget.warehouseId, { code: editCode, name: editName, address: editAddress || null, phone: editPhone || null, branchId: editBranchId })
toast.success("Warehouse updated", `${warehouse.code} — ${warehouse.name}`)
setEditTarget(null)
load()
} catch (err) {
toast.error("Could not update warehouse", errorMessage(err))
} finally {
setEditSubmitting(false)
}
}
async function handleDelete(w: Warehouse) {
setActionPendingId(w.warehouseId)
try {
await warehousesApi.remove(w.warehouseId)
toast.success("Warehouse deleted", `${w.code} — ${w.name}`)
load()
} catch (err) {
toast.error("Could not delete warehouse", errorMessage(err))
} finally {
setActionPendingId(null)
}
}
const loading = !warehouses || !bins
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div>
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-2xl font-bold text-foreground">Warehouses</h1>
<p className="text-base text-muted-foreground">Multi-warehouse master data with per-warehouse bin/location structure (FR-WH-01, FR-MD-07).</p>
{pagination && (
<span className="rounded-full border bg-white px-2 py-0.5 text-xs font-medium text-muted-foreground">
{pagination.totalItems}
</span>
)}
</div>
<p className="text-base text-muted-foreground">Multi-warehouse master data with per-warehouse bin/location structure (FR-WH-01, FR-MD-07).</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
@@ -142,7 +230,15 @@ export default function WarehousesPage() {
<FieldLabel htmlFor="wh-code">Code (auto-generated)</FieldLabel>
<Input id="wh-code" value={generatedCode} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
</Field>
{branchingEnabled && (
<Field>
<FieldLabel htmlFor="wh-address">Address (optional)</FieldLabel>
<Input id="wh-address" value={address} onChange={(e) => setAddress(e.target.value)} placeholder="Street, city…" />
</Field>
<Field>
<FieldLabel htmlFor="wh-phone">Phone (optional)</FieldLabel>
<Input id="wh-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="+94 …" />
</Field>
{warehouseBranchingEnabled && (
<Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="wh-branch">Branch</FieldLabel>
<Select value={String(branchId)} onValueChange={(v) => setBranchId(v ? Number(v) : 0)}>
@@ -165,51 +261,253 @@ export default function WarehousesPage() {
</Dialog>
</div>
<FilterBar
search={search}
onSearchChange={setSearch}
searchPlaceholder="Search by code or name…"
advancedActive={filterBranchId !== undefined}
onClearAdvanced={warehouseBranchingEnabled ? () => setFilterBranchId(undefined) : undefined}
advanced={
warehouseBranchingEnabled ? (
<Field>
<FieldLabel htmlFor="wh-filter-branch">Branch</FieldLabel>
<Select value={filterBranchId ? String(filterBranchId) : "All"} onValueChange={(v) => setFilterBranchId(v && v !== "All" ? Number(v) : undefined)}>
<SelectTrigger id="wh-filter-branch"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="All">All branches</SelectItem>
{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}
</SelectContent>
</Select>
</Field>
) : undefined
}
/>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{loading && !error && (
<div className="flex flex-col gap-3">
<div className="overflow-hidden rounded-xl border">
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
<Skeleton className="h-3.5 w-16" />
<Skeleton className="ml-auto h-3.5 w-16" />
</div>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
<Skeleton className="size-9 shrink-0 rounded-lg" />
<div className="flex flex-1 flex-col gap-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="ml-4 h-7 w-16" />
</div>
))}
</div>
)}
{!loading && !error && warehouses.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<WarehouseIcon className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No warehouses yet.</p>
{!loading && !error && warehouses !== null && warehouses.length === 0 && debouncedSearch && (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-center">
<Search className="size-10 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">No warehouses match &quot;{debouncedSearch}&quot;</p>
<p className="text-sm text-muted-foreground">Try a different code or name.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setSearch("")}>
Clear search
</Button>
</div>
)}
{!loading && !error && warehouses.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Code</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
<TableHead className="h-12 px-3 text-sm">Bins</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{warehouses.map((w) => (
<TableRow key={w.warehouseId}>
<TableCell className="px-3 py-3.5">
<Link href={`/dashboard/warehouse/${w.warehouseId}`} className="font-medium text-primary hover:underline">
{w.code}
</Link>
</TableCell>
<TableCell className="px-3 py-3.5">{w.name}</TableCell>
<TableCell className="px-3 py-3.5">{w.branchName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{binCount(w.warehouseId)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{!loading && !error && warehouses !== null && warehouses.length === 0 && !debouncedSearch && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<WarehouseIcon className="size-12 text-muted-foreground" />
<div>
<p className="text-base font-medium text-foreground">No warehouses yet</p>
<p className="text-sm text-muted-foreground">Add your first warehouse to get started.</p>
</div>
</div>
)}
{!loading && !error && warehouses !== null && warehouses.length > 0 && (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto">
<Table className="table-fixed text-base">
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="h-11 px-4 text-sm">Code</TableHead>
<TableHead className="h-11 px-4 text-sm">Name</TableHead>
{warehouseBranchingEnabled && <TableHead className="h-11 px-4 text-sm">Branch</TableHead>}
<TableHead className="h-11 px-4 text-sm">Bins</TableHead>
<TableHead className="h-11 px-4 text-center text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{warehouses.map((w) => (
<TableRow key={w.warehouseId} className="group align-top hover:bg-muted/20">
<TableCell className="px-4 py-4">
<Link
href={`/dashboard/warehouse/${w.warehouseId}`}
className="rounded-md -m-1 inline-block p-1 font-semibold leading-tight text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
>
{w.code}
</Link>
</TableCell>
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{w.name}</TableCell>
{warehouseBranchingEnabled && (
<TableCell className="px-4 py-4">
{w.branchName ? (
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">{w.branchName}</Badge>
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
</TableCell>
)}
<TableCell className="px-4 py-4 text-sm text-muted-foreground">{binCount(w.warehouseId)}</TableCell>
<TableCell className="px-4 py-4">
<div className="flex items-center justify-center gap-1">
<Link href={`/dashboard/warehouse/${w.warehouseId}`} className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))} aria-label={`View ${w.code}`} title={`View ${w.code}`}>
<Eye className="size-4" />
</Link>
<Button variant="ghost" size="icon-sm" onClick={() => openEdit(w)} aria-label={`Edit ${w.code}`} title={`Edit ${w.code}`}>
<Pencil className="size-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10 disabled:pointer-events-auto disabled:cursor-not-allowed"
aria-label={`Delete ${w.code}`}
title={`Delete ${w.code}`}
disabled={actionPendingId === w.warehouseId}
/>
}
>
<Trash2 className="size-4" />
</AlertDialogTrigger>
<AlertDialogContent
variant="destructive"
title={`Delete ${w.code}?`}
description={`This permanently removes the warehouse "${w.name}". Blocked if it still has bins, stock, or documents referencing it.`}
confirmLabel="Delete"
onConfirm={() => handleDelete(w)}
/>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
{pagination && pagination.totalItems > 0 && (
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:justify-between">
<div className="flex items-center gap-3">
<p className="text-sm text-muted-foreground">
Showing <span className="font-medium text-foreground">{(pagination.page - 1) * pagination.pageSize + 1}</span>–
<span className="font-medium text-foreground">
{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)}
</span>{" "}
of <span className="font-medium text-foreground">{pagination.totalItems}</span> warehouses
</p>
<label className="flex items-center gap-1.5 text-sm text-muted-foreground">
Rows per page
<select
value={pageSize}
onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)) }}
className="h-8 rounded-md border border-black bg-white px-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
aria-label="Rows per page"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
)}
<Dialog open={!!editTarget} onOpenChange={(v) => { if (!v) setEditTarget(null) }}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>Edit warehouse</DialogTitle>
<DialogDescription>Update {editTarget?.code}&apos;s details.</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!editErrors.name}>
<FieldLabel htmlFor="wh-edit-name">Name</FieldLabel>
<Input id="wh-edit-name" value={editName} onChange={(e) => setEditName(e.target.value)} aria-invalid={!!editErrors.name} />
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
</Field>
<Field data-invalid={!!editErrors.code}>
<FieldLabel htmlFor="wh-edit-code">Code</FieldLabel>
<Input id="wh-edit-code" value={editCode} onChange={(e) => setEditCode(e.target.value)} aria-invalid={!!editErrors.code} />
<FieldError errors={[editErrors.code ? { message: editErrors.code } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="wh-edit-address">Address (optional)</FieldLabel>
<Input id="wh-edit-address" value={editAddress} onChange={(e) => setEditAddress(e.target.value)} placeholder="Street, city…" />
</Field>
<Field>
<FieldLabel htmlFor="wh-edit-phone">Phone (optional)</FieldLabel>
<Input id="wh-edit-phone" value={editPhone} onChange={(e) => setEditPhone(e.target.value)} placeholder="+94 …" />
</Field>
{warehouseBranchingEnabled && (
<Field data-invalid={!!editErrors.branchId}>
<FieldLabel htmlFor="wh-edit-branch">Branch</FieldLabel>
<Select value={String(editBranchId)} onValueChange={(v) => setEditBranchId(v ? Number(v) : 0)}>
<SelectTrigger id="wh-edit-branch" aria-invalid={!!editErrors.branchId}><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>{branches.map((b) => <SelectItem key={b.branchId} value={String(b.branchId)}>{b.name}</SelectItem>)}</SelectContent>
</Select>
<FieldError errors={[editErrors.branchId ? { message: editErrors.branchId } : undefined]} />
</Field>
)}
</FieldGroup>
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={() => setEditTarget(null)} disabled={editSubmitting}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleUpdate} disabled={editSubmitting}>
{editSubmitting ? "Saving…" : "Save changes"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}