new updates
This commit is contained in:
@@ -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 & 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 "{debouncedSearch}"</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 "New Ticket" 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'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'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 "Require branches" 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
@@ -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 "{debouncedSearch}"</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}'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
PackageCheck,
|
||||
PackageSearch,
|
||||
PackageX,
|
||||
Percent,
|
||||
PlayCircle,
|
||||
PieChart,
|
||||
Receipt,
|
||||
@@ -96,7 +97,9 @@ const navItems: {
|
||||
children: [
|
||||
{ title: "Item", code: "products.item", href: "/dashboard/products", icon: Boxes },
|
||||
{ title: "Category", code: "products.category", href: "/dashboard/products/categories", icon: ListTree },
|
||||
{ title: "Brand/UOM", code: "products.brand", href: "/dashboard/products/brands", icon: Tag, altCodes: ["products.uom"] },
|
||||
{ title: "Brand", code: "products.brand", href: "/dashboard/products/brands", icon: Tag },
|
||||
{ title: "UOM", code: "products.uom", href: "/dashboard/products/uoms", icon: Ruler },
|
||||
{ title: "Tax Rates", code: "products.tax-rates", href: "/dashboard/products/tax-rates", icon: Percent },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { ArrowLeft, Bell, Building2, LogOut, Settings, User } from "lucide-react"
|
||||
import { ArrowLeft, Bell, Boxes, Building2, FileText, LogOut, Receipt, Search, Settings, User } from "lucide-react"
|
||||
|
||||
import { authApi } from "@/lib/api/auth"
|
||||
import { searchApi } from "@/lib/api/search"
|
||||
import { notificationsApi } from "@/lib/api/notifications"
|
||||
import { clearStoredUser, displayName, getStoredUser } from "@/lib/auth-session"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { AuthUser } from "@/types/auth"
|
||||
import { Branch } from "@/types/hrm"
|
||||
import { NotificationItem } from "@/types/notification-feed"
|
||||
import { GlobalSearchResult, GlobalSearchResultType } from "@/types/search"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
@@ -125,44 +129,24 @@ function titleFromPath(pathname: string) {
|
||||
return segment.charAt(0).toUpperCase() + segment.slice(1)
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
time: string
|
||||
unread: boolean
|
||||
const SEARCH_TYPE_META: Record<GlobalSearchResultType, { label: string; icon: typeof Boxes; href: (id: number) => string }> = {
|
||||
Item: { label: "Items", icon: Boxes, href: (id) => `/dashboard/products/${id}` },
|
||||
PurchaseOrder: { label: "Purchase Orders", icon: FileText, href: (id) => `/dashboard/procurement/purchase-orders/${id}` },
|
||||
Invoice: { label: "Invoices", icon: Receipt, href: (id) => `/dashboard/sales/invoices/${id}` },
|
||||
Employee: { label: "Employees", icon: User, href: (id) => `/dashboard/hrm/employees/${id}` },
|
||||
}
|
||||
|
||||
const initialNotifications: Notification[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "New order received",
|
||||
description: "Order #3210 placed by John Martinez",
|
||||
time: "2m ago",
|
||||
unread: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Low stock alert",
|
||||
description: "SKU-2291 has dropped below its reorder threshold",
|
||||
time: "1h ago",
|
||||
unread: true,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Payment failed",
|
||||
description: "Order #3207 payment was declined",
|
||||
time: "3h ago",
|
||||
unread: false,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "New customer",
|
||||
description: "Grace Kim just created an account",
|
||||
time: "1d ago",
|
||||
unread: false,
|
||||
},
|
||||
]
|
||||
/** Relative time for a notification timestamp: "2m ago", "3h ago", "1d ago". */
|
||||
function relativeTime(iso: string): string {
|
||||
const diffMs = Date.now() - new Date(iso).getTime()
|
||||
const minutes = Math.floor(diffMs / 60000)
|
||||
if (minutes < 1) return "just now"
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname() || "/"
|
||||
@@ -171,8 +155,24 @@ export function Header() {
|
||||
const showBackButton = pathname !== "/dashboard"
|
||||
const { isAdmin, branchName, activeBranchId, activeBranchName, setActiveBranch, branchingEnabled } = useAuth()
|
||||
const branchLabel = isAdmin ? (activeBranchName ?? "All Branches") : branchName
|
||||
const [notifications, setNotifications] = useState(initialNotifications)
|
||||
const unreadCount = notifications.filter((n) => n.unread).length
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([])
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
|
||||
const loadNotifications = () =>
|
||||
notificationsApi
|
||||
.getFeed()
|
||||
.then((feed) => {
|
||||
setNotifications(feed.notifications)
|
||||
setUnreadCount(feed.unreadCount)
|
||||
})
|
||||
.catch(() => {
|
||||
setNotifications([])
|
||||
setUnreadCount(0)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadNotifications()
|
||||
}, [])
|
||||
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
useEffect(() => {
|
||||
@@ -197,8 +197,53 @@ export function Header() {
|
||||
setUser(getStoredUser())
|
||||
}, [])
|
||||
|
||||
const markAllAsRead = () =>
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
|
||||
// Global search bar: jump to an Item, PO, Invoice or Employee by number or name.
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [searchResults, setSearchResults] = useState<GlobalSearchResult[]>([])
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const searchBoxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const term = searchQuery.trim()
|
||||
if (term.length < 2) return
|
||||
|
||||
const handle = setTimeout(() => {
|
||||
searchApi
|
||||
.search(term)
|
||||
.then((r) => setSearchResults(r))
|
||||
.catch(() => setSearchResults([]))
|
||||
}, 200)
|
||||
return () => clearTimeout(handle)
|
||||
}, [searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (searchBoxRef.current && !searchBoxRef.current.contains(e.target as Node)) {
|
||||
setSearchOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onClickOutside)
|
||||
return () => document.removeEventListener("mousedown", onClickOutside)
|
||||
}, [])
|
||||
|
||||
function goToSearchResult(result: GlobalSearchResult) {
|
||||
setSearchOpen(false)
|
||||
setSearchQuery("")
|
||||
setSearchResults([])
|
||||
router.push(SEARCH_TYPE_META[result.type].href(result.id))
|
||||
}
|
||||
|
||||
const searchGrouped = (["Item", "PurchaseOrder", "Invoice", "Employee"] as GlobalSearchResultType[])
|
||||
.map((type) => ({ type, items: searchResults.filter((r) => r.type === type) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
|
||||
const markAllAsRead = () => {
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })))
|
||||
setUnreadCount(0)
|
||||
notificationsApi.markAllRead().catch(() => {
|
||||
// Best-effort — a failed mark-all-read is corrected on the next fetch.
|
||||
})
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
@@ -264,9 +309,58 @@ export function Header() {
|
||||
)
|
||||
))}
|
||||
|
||||
<div ref={searchBoxRef} className="relative hidden w-56 shrink-0 sm:block lg:w-72">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setSearchOpen(true)
|
||||
}}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
placeholder="Search item, PO, invoice, employee…"
|
||||
className="h-9 w-full rounded-full border border-transparent bg-muted pr-3 pl-9 text-xs text-foreground outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
/>
|
||||
{searchOpen && searchQuery.trim().length >= 2 && (
|
||||
<div className="absolute top-full right-0 z-50 mt-2 max-h-96 w-80 overflow-y-auto rounded-xl bg-popover py-1.5 text-sm shadow-md ring-1 ring-foreground/10">
|
||||
{searchResults.length === 0 ? (
|
||||
<p className="p-4 text-center text-sm text-muted-foreground">No matches found.</p>
|
||||
) : (
|
||||
searchGrouped.map((group) => {
|
||||
const meta = SEARCH_TYPE_META[group.type]
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<div key={group.type}>
|
||||
<p className="px-4 pt-1 pb-1 text-xs font-medium text-muted-foreground">{meta.label}</p>
|
||||
{group.items.map((item) => (
|
||||
<button
|
||||
key={`${item.type}-${item.id}`}
|
||||
type="button"
|
||||
onClick={() => goToSearchResult(item)}
|
||||
className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-primary/10"
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">{item.title}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{item.number}
|
||||
{item.subtitle ? ` · ${item.subtitle}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<Popover>
|
||||
<Popover onOpenChange={(open) => open && loadNotifications()}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
@@ -306,24 +400,24 @@ export function Header() {
|
||||
) : (
|
||||
notifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
key={notification.notificationId}
|
||||
className={cn(
|
||||
"flex gap-3 border-b border-foreground/10 px-4 py-3 last:border-0",
|
||||
notification.unread && "bg-primary/10"
|
||||
!notification.isRead && "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1.5 size-2 shrink-0 rounded-full",
|
||||
notification.unread ? "bg-primary" : "bg-transparent"
|
||||
!notification.isRead ? "bg-primary" : "bg-transparent"
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{notification.title}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{notification.description}
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{notification.time}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{relativeTime(notification.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
@@ -349,7 +443,7 @@ export function Header() {
|
||||
{user ? displayName(user) : "Signed in"}
|
||||
</p>
|
||||
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
|
||||
{branchLabel && (
|
||||
{branchingEnabled && branchLabel && (
|
||||
<p className="mt-1 flex items-center gap-1.5 text-sm font-normal text-muted-foreground">
|
||||
<Building2 className="size-3.5" />
|
||||
{branchLabel}
|
||||
|
||||
@@ -56,6 +56,12 @@ interface AuthContextValue {
|
||||
* app should be hidden entirely, not merely restricted by role.
|
||||
*/
|
||||
branchingEnabled: boolean
|
||||
/**
|
||||
* Independent sub-toggle for Warehouses only (Settings → Branch Configuration). False
|
||||
* hides Warehouse's branch picker/badge even while `branchingEnabled` stays true for
|
||||
* everything else. Always false when `branchingEnabled` itself is false.
|
||||
*/
|
||||
warehouseBranchingEnabled: boolean
|
||||
/** True until the first `/auth/me` response lands. */
|
||||
loading: boolean
|
||||
}
|
||||
@@ -71,6 +77,7 @@ const AuthContext = createContext<AuthContextValue>({
|
||||
activeBranchName: null,
|
||||
setActiveBranch: () => {},
|
||||
branchingEnabled: true,
|
||||
warehouseBranchingEnabled: true,
|
||||
loading: true,
|
||||
})
|
||||
|
||||
@@ -88,7 +95,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
.catch(() => {
|
||||
// Unauthenticated/unreachable — fall back to "no permissions" rather than
|
||||
// crash the shell; proxy.ts already redirects unauthenticated users to /login.
|
||||
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true })
|
||||
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true, warehouseBranchingEnabled: true })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
@@ -121,6 +128,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
activeBranchName: activeBranch?.name ?? null,
|
||||
setActiveBranch,
|
||||
branchingEnabled: me?.branchingEnabled ?? true,
|
||||
warehouseBranchingEnabled: me?.warehouseBranchingEnabled ?? true,
|
||||
loading: me === null,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client"
|
||||
|
||||
// Step 2 of the role-permission flow: after nav items/sub-items are picked in
|
||||
// RolePermissionTree, this lists exactly those selected items — grouped under their
|
||||
// parent module, main item first then its sub-items — and lets an admin toggle which
|
||||
// mutating actions (View/Create/Edit/Delete/Approve) the role gets within each —
|
||||
// ERPCore's RolePermission.Actions bitmask (Domain/Enums/RolePermissionAction.cs). Every
|
||||
// action pill starts plain (nothing granted yet) for a freshly-checked section — an
|
||||
// admin has to deliberately click each action to grant it, rather than everything
|
||||
// showing pre-granted and requiring clicks to take access away. A granted action turns
|
||||
// the same blue as a checked pill on the Sidebar permissions step, for one consistent
|
||||
// "selected" look across both steps.
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ALL_ROLE_PERMISSION_ACTIONS, NavItem } from "@/types/rbac"
|
||||
|
||||
interface SelectedRow {
|
||||
id: number
|
||||
kind: "nav" | "subnav"
|
||||
label: string
|
||||
}
|
||||
|
||||
interface SelectedGroup {
|
||||
navItemId: number
|
||||
navLabel: string
|
||||
/** The nav item's own row, when it's a leaf (no children) and was itself selected. */
|
||||
own: SelectedRow | null
|
||||
children: SelectedRow[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tree: NavItem[]
|
||||
selectedNavItemIds: Set<number>
|
||||
selectedSubNavItemIds: Set<number>
|
||||
navItemActions: Record<number, number>
|
||||
subNavItemActions: Record<number, number>
|
||||
onActionsChange: (navItemActions: Record<number, number>, subNavItemActions: Record<number, number>) => void
|
||||
}
|
||||
|
||||
// Same "selected" style as the sub-item pills on the Sidebar permissions step
|
||||
// (RolePermissionTree's child chips) — one color for every granted action, not one
|
||||
// color per action type.
|
||||
const ACTIVE_CLASS_NAME =
|
||||
"border-transparent bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-500/20 dark:text-blue-300 dark:hover:bg-blue-500/30"
|
||||
|
||||
const ACTION_FLAGS: { bit: number; label: string }[] = [
|
||||
{ bit: 1, label: "View" },
|
||||
{ bit: 2, label: "Create" },
|
||||
{ bit: 4, label: "Edit" },
|
||||
{ bit: 8, label: "Delete" },
|
||||
{ bit: 16, label: "Approve" },
|
||||
]
|
||||
|
||||
function actionsFor(id: number, actions: Record<number, number>): number {
|
||||
return actions[id] ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills in an explicit 0 for every selected id missing from the map before a save, so
|
||||
* an id the admin never clicked an action for is actually saved as "no actions" — what
|
||||
* the plain/unselected pills showed — instead of falling through to the backend's
|
||||
* RolePermission.Actions default of All for any id the request omits.
|
||||
*/
|
||||
export function fillUntouchedActions(ids: Iterable<number>, actions: Record<number, number>): Record<number, number> {
|
||||
const filled = { ...actions }
|
||||
for (const id of ids) if (!(id in filled)) filled[id] = 0
|
||||
return filled
|
||||
}
|
||||
|
||||
function buildSelectedGroups(tree: NavItem[], navIds: Set<number>, subNavIds: Set<number>): SelectedGroup[] {
|
||||
const groups: SelectedGroup[] = []
|
||||
for (const item of tree) {
|
||||
const own: SelectedRow | null =
|
||||
item.children.length === 0 && navIds.has(item.navItemId) ? { id: item.navItemId, kind: "nav", label: item.label } : null
|
||||
|
||||
const children: SelectedRow[] = item.children
|
||||
.filter((c) => subNavIds.has(c.subNavItemId))
|
||||
.map((c) => ({ id: c.subNavItemId, kind: "subnav" as const, label: c.label }))
|
||||
|
||||
if (own || children.length > 0) groups.push({ navItemId: item.navItemId, navLabel: item.label, own, children })
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/** Same "select all" convenience as the parent-item checkbox on the Sidebar permissions
|
||||
* step — sits in front of the section's label, not amongst the action pills, and
|
||||
* grants/clears every action in one click. */
|
||||
function SelectAllCheckbox({ actions, onToggleAll, small }: { actions: number; onToggleAll: (checked: boolean) => void; small?: boolean }) {
|
||||
const allChecked = actions === ALL_ROLE_PERMISSION_ACTIONS
|
||||
const someChecked = actions !== 0 && !allChecked
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={allChecked}
|
||||
indeterminate={someChecked}
|
||||
onCheckedChange={(checked) => onToggleAll(!!checked)}
|
||||
className={cn(
|
||||
"border-2 border-muted-foreground/50 data-checked:border-blue-600! data-checked:bg-blue-600! data-checked:text-white!",
|
||||
small ? "size-4.5 [&_svg]:size-3.5" : "size-5 [&_svg]:size-4"
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionRow({ actions, onToggle }: { actions: number; onToggle: (bit: number) => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{ACTION_FLAGS.map(({ bit, label }) => {
|
||||
const checked = (actions & bit) === bit
|
||||
return (
|
||||
<button
|
||||
key={bit}
|
||||
type="button"
|
||||
aria-pressed={checked}
|
||||
onClick={() => onToggle(bit)}
|
||||
className={cn(
|
||||
"rounded-full border px-3 py-1 text-sm font-medium transition-colors",
|
||||
checked ? ACTIVE_CLASS_NAME : "border-input bg-white text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RolePermissionActions({
|
||||
tree,
|
||||
selectedNavItemIds,
|
||||
selectedSubNavItemIds,
|
||||
navItemActions,
|
||||
subNavItemActions,
|
||||
onActionsChange,
|
||||
}: Props) {
|
||||
const groups = buildSelectedGroups(tree, selectedNavItemIds, selectedSubNavItemIds)
|
||||
|
||||
function toggleNav(id: number, bit: number) {
|
||||
const current = actionsFor(id, navItemActions)
|
||||
onActionsChange({ ...navItemActions, [id]: current ^ bit }, subNavItemActions)
|
||||
}
|
||||
|
||||
function toggleSubNav(id: number, bit: number) {
|
||||
const current = actionsFor(id, subNavItemActions)
|
||||
onActionsChange(navItemActions, { ...subNavItemActions, [id]: current ^ bit })
|
||||
}
|
||||
|
||||
function toggleAllNav(id: number, checked: boolean) {
|
||||
onActionsChange({ ...navItemActions, [id]: checked ? ALL_ROLE_PERMISSION_ACTIONS : 0 }, subNavItemActions)
|
||||
}
|
||||
|
||||
function toggleAllSubNav(id: number, checked: boolean) {
|
||||
onActionsChange(navItemActions, { ...subNavItemActions, [id]: checked ? ALL_ROLE_PERMISSION_ACTIONS : 0 })
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
No sections selected — go back and pick at least one.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<div className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 border-b bg-muted/40 px-4 py-2 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
<span>Section</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group.navItemId} className="border-b last:border-b-0">
|
||||
{group.own ? (
|
||||
<div className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 bg-white px-4 py-3">
|
||||
<label className="flex items-center gap-2.5">
|
||||
<SelectAllCheckbox
|
||||
actions={actionsFor(group.own.id, navItemActions)}
|
||||
onToggleAll={(checked) => toggleAllNav(group.own!.id, checked)}
|
||||
/>
|
||||
<span className="text-sm font-semibold">{group.own.label}</span>
|
||||
</label>
|
||||
<ActionRow actions={actionsFor(group.own.id, navItemActions)} onToggle={(bit) => toggleNav(group.own!.id, bit)} />
|
||||
</div>
|
||||
) : (
|
||||
group.children.length > 0 && (
|
||||
<div className="bg-muted/20 px-4 py-1.5 text-sm font-semibold text-foreground">{group.navLabel}</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{group.children.map((child) => (
|
||||
<div key={child.id} className="grid grid-cols-[minmax(9rem,14rem)_1fr] items-center gap-x-4 bg-white px-4 py-3 pl-8">
|
||||
<label className="flex items-center gap-2.5">
|
||||
<span className="text-muted-foreground/40">└</span>
|
||||
<SelectAllCheckbox
|
||||
actions={actionsFor(child.id, subNavItemActions)}
|
||||
onToggleAll={(checked) => toggleAllSubNav(child.id, checked)}
|
||||
small
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{child.label}</span>
|
||||
</label>
|
||||
<ActionRow actions={actionsFor(child.id, subNavItemActions)} onToggle={(bit) => toggleSubNav(child.id, bit)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
// Reusable capture box for USB/Bluetooth barcode & RFID scanners. Those devices are plain HID
|
||||
// keyboards: they "type" the scanned code into whatever input is focused, then send Enter — so
|
||||
// there is no camera/getUserMedia code here, just a text input that reacts to Enter (and to
|
||||
// losing focus with content still in it, in case a scanner is configured without a trailing
|
||||
// Enter) and clears itself after each read. It stays auto-focused so the next scan always lands
|
||||
// here rather than wherever the cursor last was in a large form.
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { ScanBarcode } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
export interface ScanInputProps {
|
||||
/** Fired once per scan with the trimmed code. The input is already cleared by the time this runs. */
|
||||
onScan: (code: string) => void
|
||||
label?: string
|
||||
/** Helper text under the input, e.g. "Scan item or lot barcode". */
|
||||
hint?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ScanInput({
|
||||
onScan,
|
||||
label = "Scan",
|
||||
hint = "Scan item or lot barcode",
|
||||
placeholder = "Scan or type a barcode, then press Enter",
|
||||
disabled = false,
|
||||
className,
|
||||
}: ScanInputProps) {
|
||||
const [value, setValue] = useState("")
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Refocus whenever the field becomes usable again (e.g. after being briefly disabled while a
|
||||
// lookup for the previous scan was in flight) so a scanner's next read always lands here.
|
||||
useEffect(() => {
|
||||
if (!disabled) inputRef.current?.focus()
|
||||
}, [disabled])
|
||||
|
||||
function commit() {
|
||||
const code = value.trim()
|
||||
setValue("")
|
||||
if (!code) return
|
||||
onScan(code)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
<Label className="flex items-center gap-1.5 text-base">
|
||||
<ScanBarcode className="size-4 text-muted-foreground" />
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoComplete="off"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return
|
||||
// A scanner's Enter is just another keystroke to the browser — stop it from also
|
||||
// submitting an enclosing <form> or activating a nearby default button.
|
||||
e.preventDefault()
|
||||
commit()
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Guards a scanner configured without a trailing Enter — but only fires for content
|
||||
// that's actually there, so a plain click-away with an empty box does nothing.
|
||||
if (value.trim()) commit()
|
||||
}}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
{hint && <p className="text-sm text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,10 +20,19 @@ type Props = {
|
||||
data: number[]
|
||||
backgroundColor?: string | string[]
|
||||
}>
|
||||
/** Container height in px — defaults to the original 260 so existing callers are unaffected. */
|
||||
height?: number
|
||||
/** Fraction of each category's slot a bar occupies (Chart.js default 0.9) — raise for thicker bars. */
|
||||
barPercentage?: number
|
||||
/** Fraction of the axis each category slot occupies (Chart.js default 0.8) — raise to shrink gaps between categories. */
|
||||
categoryPercentage?: number
|
||||
}
|
||||
|
||||
export default function BarChart({ labels, datasets }: Props) {
|
||||
const data = { labels, datasets }
|
||||
export default function BarChart({ labels, datasets, height = 260, barPercentage, categoryPercentage }: Props) {
|
||||
const data = {
|
||||
labels,
|
||||
datasets: datasets.map((d) => ({ ...d, barPercentage, categoryPercentage })),
|
||||
}
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
@@ -33,7 +42,7 @@ export default function BarChart({ labels, datasets }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", minHeight: 260 }}>
|
||||
<div style={{ width: "100%", height }}>
|
||||
<Bar data={data} options={options} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Search, SlidersHorizontal, X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
|
||||
interface FilterBarProps {
|
||||
search: string
|
||||
onSearchChange: (value: string) => void
|
||||
searchPlaceholder?: string
|
||||
searchAriaLabel?: string
|
||||
/** Rendered to the left of the search box — status tabs/pills, typically. */
|
||||
children?: React.ReactNode
|
||||
/** Content shown inside the "Advanced" popover. Omit to hide the Advanced button entirely. */
|
||||
advanced?: React.ReactNode
|
||||
/** Shows a dot on the Advanced button when any advanced filter is set. */
|
||||
advancedActive?: boolean
|
||||
/** Shown as a "Clear advanced filters" action inside the popover when provided. */
|
||||
onClearAdvanced?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard list-page filter bar: search box + optional left-side content (status
|
||||
* tabs/pills) + an optional "Advanced" popover for filters that don't fit inline
|
||||
* (customer, warehouse, department, date range, ...). One shape, reused across
|
||||
* Sales, HRM, and Warehouses list pages instead of each rolling its own.
|
||||
*/
|
||||
export function FilterBar({
|
||||
search, onSearchChange, searchPlaceholder = "Search…", searchAriaLabel,
|
||||
children, advanced, advancedActive, onClearAdvanced, className,
|
||||
}: FilterBarProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3 lg:flex-row lg:items-center", className)}>
|
||||
{children}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
||||
<div className="relative w-full lg:max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="h-10 pl-9 pr-9"
|
||||
aria-label={searchAriaLabel ?? searchPlaceholder}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchChange("")}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{advanced && (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm" className="relative shrink-0">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
Advanced
|
||||
{advancedActive && (
|
||||
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-primary" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="end" className="w-72 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{advanced}
|
||||
{onClearAdvanced && (
|
||||
<Button variant="ghost" size="sm" onClick={onClearAdvanced} className="self-start">
|
||||
Clear advanced filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type LucideIcon } from "lucide-react"
|
||||
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export interface KpiTileSpec {
|
||||
label: string
|
||||
value: number
|
||||
icon?: LucideIcon
|
||||
}
|
||||
|
||||
/**
|
||||
* A small row of KPI tiles for a list page — reuses the same GET /dashboard/stats
|
||||
* aggregate the Dashboard home page renders (see hooks/use-dashboard-stats.ts), just
|
||||
* picking out the one or two counts relevant to this page. Pass `null` while stats are
|
||||
* still loading to render skeleton placeholders instead of a layout jump.
|
||||
*/
|
||||
export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
|
||||
return (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{tiles
|
||||
? tiles.map((tile) => (
|
||||
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} />
|
||||
))
|
||||
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-[90px] rounded-xl" />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { dashboardApi } from "@/lib/api/dashboard"
|
||||
import { DashboardStats } from "@/types/dashboard"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
|
||||
/**
|
||||
* Shared cross-domain counts (pending POs, low-stock, pending leave, overdue invoices, ...)
|
||||
* from GET /dashboard/stats — the same aggregate the Dashboard home page renders, reused
|
||||
* here as small KPI tiles on individual list pages. Null while loading or on error, so
|
||||
* callers should render nothing (or a skeleton) until it resolves.
|
||||
*/
|
||||
export function useDashboardStats(): DashboardStats | null {
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const branchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
|
||||
dashboardApi
|
||||
.stats({ branchId })
|
||||
.then((s) => {
|
||||
if (!cancelled) setStats(s)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStats(null)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeBranchId, branchingEnabled])
|
||||
|
||||
return stats
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ApiResult, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CreateEmployeeRequest,
|
||||
EmployeeBankDetail,
|
||||
EmployeeDeleteResult,
|
||||
EmployeeDetail,
|
||||
EmployeeDocument,
|
||||
EmployeeListItem,
|
||||
@@ -59,9 +60,14 @@ export const employeesApi = {
|
||||
updateStatus(employeeId: number, status: EmployeeStatus): Promise<void> {
|
||||
return apiRequest<void>(`/employees/${employeeId}/status`, { method: "PATCH", body: { status } })
|
||||
},
|
||||
/** Permanently deletes the employee and every dependent HR record. Blocked (409) if payroll history exists. */
|
||||
remove(employeeId: number): Promise<void> {
|
||||
return apiRequest<void>(`/employees/${employeeId}`, { method: "DELETE" })
|
||||
/**
|
||||
* Permanently deletes the employee and every dependent HR record. Blocked (409) if payroll
|
||||
* history exists. `deleteUserAccount` asks for a linked login to be hard-deleted too, instead
|
||||
* of the default (deactivate) — the backend falls back to deactivating if the account has
|
||||
* real linked business/audit records, reflected in the returned result.
|
||||
*/
|
||||
remove(employeeId: number, deleteUserAccount = false): Promise<EmployeeDeleteResult> {
|
||||
return apiRequest<EmployeeDeleteResult>(`/employees/${employeeId}${buildQuery({ deleteUserAccount })}`, { method: "DELETE" })
|
||||
},
|
||||
|
||||
/** Advisory: does a System User already exist with this email? */
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Item,
|
||||
ItemListItem,
|
||||
ItemReorderSetting,
|
||||
ItemScanResult,
|
||||
TrackingMode,
|
||||
UpdateItemReorderRequest,
|
||||
UpdateItemRequest,
|
||||
@@ -57,4 +58,16 @@ export const itemsApi = {
|
||||
body: request,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolves a scanned barcode/RFID code (HID scanner keyboard input) against an item SKU,
|
||||
* a batch number, or a serial number — whichever it matches. `itemId` narrows the lookup
|
||||
* to a specific item's own batches/serials (e.g. scanning a lot barcode for a line that
|
||||
* already has its item picked). Throws `ApiError` with status 404 when the code matches
|
||||
* nothing — callers should catch that case and show an inline "unknown barcode" message
|
||||
* rather than a generic error (see components/scanning/ScanInput.tsx usage).
|
||||
*/
|
||||
scan(code: string, itemId?: number): Promise<ItemScanResult> {
|
||||
return apiRequest<ItemScanResult>(`/items/scan${buildQuery({ code, itemId })}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { apiRequest } from "@/lib/api-client"
|
||||
import { NotificationFeed } from "@/types/notification-feed"
|
||||
|
||||
export const notificationsApi = {
|
||||
getFeed(take = 20): Promise<NotificationFeed> {
|
||||
return apiRequest<NotificationFeed>(`/notifications?take=${take}`)
|
||||
},
|
||||
|
||||
markAllRead(): Promise<void> {
|
||||
return apiRequest<void>("/notifications/mark-all-read", { method: "POST" })
|
||||
},
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
PurchaseOrder,
|
||||
PurchaseOrderStatus,
|
||||
PurchaseOrderSummary,
|
||||
RejectPurchaseOrderRequest,
|
||||
UpdatePurchaseOrderRequest,
|
||||
} from "@/types/procurement"
|
||||
|
||||
@@ -39,7 +40,7 @@ export const purchaseOrdersApi = {
|
||||
return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`)
|
||||
},
|
||||
|
||||
/** Auto-approved on creation in Phase 1 (approvalRequired defaults false, FR-PROC-04). */
|
||||
/** Non-draft is issued straight into PendingApproval (FR-PROC-04). */
|
||||
create(request: CreatePurchaseOrderRequest): Promise<ApiResult<PurchaseOrder>> {
|
||||
return apiRequestWithETag<PurchaseOrder>("/purchase-orders", { method: "POST", body: request })
|
||||
},
|
||||
@@ -49,12 +50,19 @@ export const purchaseOrdersApi = {
|
||||
return apiRequestWithETag<PurchaseOrder>(`/purchase-orders/${poId}`, { method: "PUT", body: request, ifMatch })
|
||||
},
|
||||
|
||||
/** No-op in Phase 1 unless approvals are enabled (FR-PROC-04). */
|
||||
/** PendingApproval → Approved. 403 PO_APPROVAL_FORBIDDEN if the caller isn't an Admin or
|
||||
* is the PO's own creator; 409 PO_NOT_PENDING_APPROVAL otherwise. */
|
||||
approve(poId: number): Promise<PurchaseOrder> {
|
||||
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/approve`, { method: "POST" })
|
||||
},
|
||||
|
||||
/** Submit a Draft PO (Draft → Approved). 409 PO_NOT_EDITABLE if not Draft. */
|
||||
/** PendingApproval → Draft, so the requester can revise and resubmit. Same caller
|
||||
* restrictions as {@link approve}. */
|
||||
reject(poId: number, request: RejectPurchaseOrderRequest = {}): Promise<PurchaseOrder> {
|
||||
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/reject`, { method: "POST", body: request })
|
||||
},
|
||||
|
||||
/** Submit a Draft PO (Draft → PendingApproval). 409 PO_NOT_EDITABLE if not Draft. */
|
||||
submit(poId: number): Promise<PurchaseOrder> {
|
||||
return apiRequest<PurchaseOrder>(`/purchase-orders/${poId}/submit`, { method: "POST" })
|
||||
},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { apiRequest } from "@/lib/api-client"
|
||||
import { GlobalSearchResult } from "@/types/search"
|
||||
|
||||
export const searchApi = {
|
||||
search(q: string): Promise<GlobalSearchResult[]> {
|
||||
return apiRequest<GlobalSearchResult[]>(`/search?q=${encodeURIComponent(q)}`)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { apiRequest, apiRequestWithETag } from "@/lib/api-client"
|
||||
import { ApiResult } from "@/types/common"
|
||||
import {
|
||||
AddSupportTicketMessageRequest,
|
||||
CreateSupportTicketRequest,
|
||||
SupportTicket,
|
||||
SupportTicketSummary,
|
||||
UpdateSupportTicketStatusRequest,
|
||||
} from "@/types/support"
|
||||
|
||||
export const supportTicketsApi = {
|
||||
list(): Promise<SupportTicketSummary[]> {
|
||||
return apiRequest<SupportTicketSummary[]>("/support-tickets")
|
||||
},
|
||||
|
||||
get(ticketId: number): Promise<ApiResult<SupportTicket>> {
|
||||
return apiRequestWithETag<SupportTicket>(`/support-tickets/${ticketId}`)
|
||||
},
|
||||
|
||||
create(request: CreateSupportTicketRequest): Promise<ApiResult<SupportTicket>> {
|
||||
return apiRequestWithETag<SupportTicket>("/support-tickets", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
addMessage(ticketId: number, request: AddSupportTicketMessageRequest, ifMatch: string): Promise<ApiResult<SupportTicket>> {
|
||||
return apiRequestWithETag<SupportTicket>(`/support-tickets/${ticketId}/messages`, { method: "POST", body: request, ifMatch })
|
||||
},
|
||||
|
||||
updateStatus(ticketId: number, request: UpdateSupportTicketStatusRequest, ifMatch: string): Promise<ApiResult<SupportTicket>> {
|
||||
return apiRequestWithETag<SupportTicket>(`/support-tickets/${ticketId}/status`, { method: "PUT", body: request, ifMatch })
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// One typed client method per TaxRate endpoint (ERPCore Controllers/TaxRatesController.cs).
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
import { ApiResult, EntityStatus, PagedResponse } from "@/types/common"
|
||||
import { CreateTaxRateRequest, TaxRate, UpdateTaxRateRequest } from "@/types/master-data"
|
||||
|
||||
export interface ListTaxRatesParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
status?: EntityStatus
|
||||
}
|
||||
|
||||
export const taxRatesApi = {
|
||||
list(params: ListTaxRatesParams = {}): Promise<PagedResponse<TaxRate>> {
|
||||
return apiRequest<PagedResponse<TaxRate>>(`/tax-rates${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
get(taxRateId: number): Promise<ApiResult<TaxRate>> {
|
||||
return apiRequestWithETag<TaxRate>(`/tax-rates/${taxRateId}`)
|
||||
},
|
||||
|
||||
create(request: CreateTaxRateRequest): Promise<ApiResult<TaxRate>> {
|
||||
return apiRequestWithETag<TaxRate>("/tax-rates", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
update(taxRateId: number, request: UpdateTaxRateRequest, ifMatch: string): Promise<ApiResult<TaxRate>> {
|
||||
return apiRequestWithETag<TaxRate>(`/tax-rates/${taxRateId}`, { method: "PUT", body: request, ifMatch })
|
||||
},
|
||||
|
||||
/** Deactivate/reactivate. There is no DELETE — masters referenced by items are deactivated, never removed. */
|
||||
updateStatus(taxRateId: number, status: EntityStatus): Promise<void> {
|
||||
return apiRequest<void>(`/tax-rates/${taxRateId}/status`, { method: "PATCH", body: { status } })
|
||||
},
|
||||
}
|
||||
@@ -3,7 +3,15 @@
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { EntityStatus, PagedResponse } from "@/types/common"
|
||||
import { LoginActivity } from "@/types/audit"
|
||||
import { CreateUserRequest, ManagedUser, UpdateUserRoleRequest, UpdateUserStatusRequest, UserTypeOption } from "@/types/users"
|
||||
import {
|
||||
CreateUserRequest,
|
||||
ManagedUser,
|
||||
PurgeAuthHexIdentityRequest,
|
||||
PurgeAuthHexIdentityResult,
|
||||
UpdateUserRoleRequest,
|
||||
UpdateUserStatusRequest,
|
||||
UserTypeOption,
|
||||
} from "@/types/users"
|
||||
|
||||
export interface ListUsersParams {
|
||||
page?: number
|
||||
@@ -43,4 +51,14 @@ export const usersApi = {
|
||||
loginDevices(userId: number, params: { page?: number; pageSize?: number } = {}): Promise<PagedResponse<LoginActivity>> {
|
||||
return apiRequest<PagedResponse<LoginActivity>>(`/users/${userId}/login-devices${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* Admin-only escape hatch: permanently deletes a stale AuthHex identity blocking new
|
||||
* registrations (e.g. an email/username that survived a local User delete because the
|
||||
* automatic purge on that delete failed or wasn't deployed). Doesn't touch ERPCore's
|
||||
* own `users` table — there may be nothing there to touch.
|
||||
*/
|
||||
purgeAuthHexIdentity(request: PurgeAuthHexIdentityRequest): Promise<PurgeAuthHexIdentityResult> {
|
||||
return apiRequest<PurgeAuthHexIdentityResult>("/users/purge-authhex-identity", { method: "POST", body: request })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// One typed client method per warehouse/bin endpoint (docs/11-BACKEND-PHASE1.md §2.5,
|
||||
// FR-MD-07/FR-WH-01).
|
||||
//
|
||||
// Warehouses are create-and-list only: there is no PUT, no status, and no ETag on them.
|
||||
// Warehouses have no status/ETag, but do support update and delete (hard delete, blocked
|
||||
// with a 409 if the warehouse is still referenced by bins/stock/documents).
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { Bin, CreateBinRequest, CreateWarehouseRequest, Warehouse } from "@/types/master-data"
|
||||
import { Bin, CreateBinRequest, CreateWarehouseRequest, UpdateWarehouseRequest, Warehouse } from "@/types/master-data"
|
||||
|
||||
export type { CreateBinRequest, CreateWarehouseRequest }
|
||||
export type { CreateBinRequest, CreateWarehouseRequest, UpdateWarehouseRequest }
|
||||
|
||||
export interface ListWarehousesParams {
|
||||
page?: number
|
||||
@@ -29,6 +30,14 @@ export const warehousesApi = {
|
||||
return apiRequest<Warehouse>("/warehouses", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
update(warehouseId: number, request: UpdateWarehouseRequest): Promise<Warehouse> {
|
||||
return apiRequest<Warehouse>(`/warehouses/${warehouseId}`, { method: "PUT", body: request })
|
||||
},
|
||||
|
||||
remove(warehouseId: number): Promise<void> {
|
||||
return apiRequest<void>(`/warehouses/${warehouseId}`, { method: "DELETE" })
|
||||
},
|
||||
|
||||
/** Returns a bare array, not a paged envelope — the server does not page bins. */
|
||||
listBins(warehouseId: number): Promise<Bin[]> {
|
||||
return apiRequest<Bin[]>(`/warehouses/${warehouseId}/bins`)
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface ListWastageParams {
|
||||
|
||||
export interface RecordWastageInput {
|
||||
warehouseId: number
|
||||
/** Required when branching is enabled (mirrors `CreateAdjustmentRequest.branchId`). */
|
||||
branchId: number
|
||||
itemId: number
|
||||
binId?: number | null
|
||||
qty: number
|
||||
@@ -117,6 +119,7 @@ export const wastageApi = {
|
||||
record(input: RecordWastageInput): Promise<StockAdjustment> {
|
||||
return stockAdjustmentsApi.create({
|
||||
warehouseId: input.warehouseId,
|
||||
branchId: input.branchId,
|
||||
reasonCodeId: input.reasonCodeId,
|
||||
lines: [{ itemId: input.itemId, binId: input.binId ?? null, qtyDelta: -Math.abs(input.qty) }],
|
||||
})
|
||||
|
||||
@@ -73,6 +73,14 @@ export function validateBrandName(name: string): Record<string, string> {
|
||||
return errors
|
||||
}
|
||||
|
||||
export function validateTaxRate(code: string, name: string, percentage: number): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!code.trim()) errors.code = "Code is required"
|
||||
if (!name.trim()) errors.name = "Name is required"
|
||||
if (Number.isNaN(percentage) || percentage < 0 || percentage > 100) errors.percentage = "Percentage must be between 0 and 100"
|
||||
return errors
|
||||
}
|
||||
|
||||
/** Renamed from validateVariantCategoryName — "variant categories" are Item Types now. */
|
||||
export function validateItemTypeName(name: string): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface Customer {
|
||||
creditLimit: number
|
||||
creditDays: number
|
||||
defaultWarehouseId: number | null
|
||||
/** Resolved server-side via `IBranchConfigService.ResolveBranchIdAsync` — never posted by the form. */
|
||||
branchId: number
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
|
||||
@@ -17,4 +17,6 @@ export interface DashboardStats {
|
||||
openRequisitions: number
|
||||
pendingCounts: number
|
||||
openRfqs: number
|
||||
pendingLeaveRequests: number
|
||||
overdueSalesInvoices: number
|
||||
}
|
||||
|
||||
@@ -17,12 +17,15 @@ export interface Branch {
|
||||
/** Singleton toggle for whether this deployment requires a branch at all. */
|
||||
export interface BranchConfig {
|
||||
branchingEnabled: boolean
|
||||
/** Independent sub-toggle: Warehouses only. Meaningless when `branchingEnabled` is off. */
|
||||
warehouseBranchingEnabled: boolean
|
||||
updatedAt: string | null
|
||||
updatedBy: number | null
|
||||
}
|
||||
|
||||
export interface UpdateBranchConfigRequest {
|
||||
branchingEnabled: boolean
|
||||
warehouseBranchingEnabled: boolean
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
@@ -91,6 +94,12 @@ export interface HrDocumentType {
|
||||
export type EmployeeStatus = "Active" | "Suspended" | "Resigned" | "Terminated" | "Retired"
|
||||
export type Gender = "Male" | "Female" | "Other"
|
||||
|
||||
export interface EmployeeDeleteResult {
|
||||
userAccountDeleted: boolean
|
||||
userAccountDeactivated: boolean
|
||||
authHexIdentityPurged: boolean
|
||||
}
|
||||
|
||||
export interface EmployeeListItem {
|
||||
employeeId: number
|
||||
employeeCode: string
|
||||
|
||||
@@ -48,7 +48,10 @@ export interface ItemListItem extends ItemContent {
|
||||
warranty: Warranty
|
||||
/** Coverage length in months; set when `warranty` is `"Warranty"`, null otherwise. */
|
||||
warrantyPeriodMonths: WarrantyPeriodMonths | null
|
||||
taxClass: string | null
|
||||
/** The TaxRate sales/purchase lines for this item default to; null ⇒ no default (0%). */
|
||||
taxRateId: number | null
|
||||
taxRateName: string | null
|
||||
taxRatePercentage: number | null
|
||||
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
|
||||
sellingMinPrice: number | null
|
||||
sellingMaxPrice: number | null
|
||||
@@ -72,6 +75,8 @@ export interface ItemReorderSetting {
|
||||
export interface Item extends ItemContent {
|
||||
itemId: number
|
||||
sku: string
|
||||
/** Optional barcode/RFID code for scan lookup (`GET /items/scan`); distinct from `sku`. */
|
||||
barcode: string | null
|
||||
name: string
|
||||
description: string | null
|
||||
categoryId: number
|
||||
@@ -85,7 +90,10 @@ export interface Item extends ItemContent {
|
||||
warranty: Warranty
|
||||
/** Coverage length in months; set when `warranty` is `"Warranty"`, null otherwise. */
|
||||
warrantyPeriodMonths: WarrantyPeriodMonths | null
|
||||
taxClass: string | null
|
||||
/** The TaxRate sales/purchase lines for this item default to; null ⇒ no default (0%). */
|
||||
taxRateId: number | null
|
||||
taxRateName: string | null
|
||||
taxRatePercentage: number | null
|
||||
/** Fixed sale price (Sales only); null ⇒ sell at stock/FIFO value. */
|
||||
sellingMinPrice: number | null
|
||||
sellingMaxPrice: number | null
|
||||
@@ -99,6 +107,8 @@ export interface Item extends ItemContent {
|
||||
export interface CreateItemRequest {
|
||||
/** Client-generated; the server only enforces uniqueness (`SKU_DUPLICATE`). */
|
||||
sku: string
|
||||
/** Optional barcode/RFID code for scan lookup; unique when set. */
|
||||
barcode?: string | null
|
||||
name: string
|
||||
description?: string | null
|
||||
categoryId: number
|
||||
@@ -114,7 +124,8 @@ export interface CreateItemRequest {
|
||||
warranty?: Warranty
|
||||
/** Required (one of `WARRANTY_PERIOD_MONTHS_OPTIONS`) when `warranty` is `"Warranty"`; ignored otherwise. */
|
||||
warrantyPeriodMonths?: WarrantyPeriodMonths | null
|
||||
taxClass?: string | null
|
||||
/** The TaxRate sales/purchase lines for this item default to; omit/null for no default (0%). */
|
||||
taxRateId?: number | null
|
||||
/** Optional fixed sale price (Sales only). Null/omitted ⇒ sell at stock/FIFO value. */
|
||||
sellingMinPrice?: number | null
|
||||
sellingMaxPrice?: number | null
|
||||
@@ -137,6 +148,8 @@ export interface Warehouse {
|
||||
warehouseId: number
|
||||
code: string
|
||||
name: string
|
||||
address: string | null
|
||||
phone: string | null
|
||||
branchId: number
|
||||
branchName: string | null
|
||||
}
|
||||
@@ -144,9 +157,13 @@ export interface Warehouse {
|
||||
export interface CreateWarehouseRequest {
|
||||
code: string
|
||||
name: string
|
||||
address?: string | null
|
||||
phone?: string | null
|
||||
branchId: number
|
||||
}
|
||||
|
||||
export type UpdateWarehouseRequest = CreateWarehouseRequest
|
||||
|
||||
export interface Bin {
|
||||
binId: number
|
||||
warehouseId: number
|
||||
@@ -167,6 +184,8 @@ export interface Vendor {
|
||||
taxReg: string | null
|
||||
currency: string
|
||||
status: EntityStatus
|
||||
/** Resolved server-side via `IBranchConfigService.ResolveBranchIdAsync` — never posted by the form. */
|
||||
branchId: number
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
@@ -246,6 +265,29 @@ export interface UpdateBrandRequest {
|
||||
name: string
|
||||
}
|
||||
|
||||
/** The real backing for Item.taxRateId — replaces the old free-text "tax class" label. */
|
||||
export interface TaxRate {
|
||||
taxRateId: number
|
||||
code: string
|
||||
name: string
|
||||
percentage: number
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface CreateTaxRateRequest {
|
||||
code: string
|
||||
name: string
|
||||
percentage: number
|
||||
}
|
||||
|
||||
export interface UpdateTaxRateRequest {
|
||||
code: string
|
||||
name: string
|
||||
percentage: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Item type master (docs/11 §2.7) — a dimension *name* such as Color, Size or Material.
|
||||
* Formerly `VariantCategory` in this app.
|
||||
@@ -311,3 +353,24 @@ export interface UpdateProductConfigRequest {
|
||||
batchListsEnabled: boolean
|
||||
warrantyPeriodsEnabled: boolean
|
||||
}
|
||||
|
||||
// --- Barcode / RFID lot scanning --------------------------------------------------------
|
||||
|
||||
/** What a scanned code was resolved against — a bare item SKU/barcode, a batch number, or a serial number. */
|
||||
export type ItemScanMatchedBy = "Item" | "Batch" | "Serial"
|
||||
|
||||
/** Response of `GET /items/scan?code=...` (docs/11 barcode scanning). 404 when the code matches nothing. */
|
||||
export interface ItemScanResult {
|
||||
itemId: number
|
||||
sku: string
|
||||
name: string
|
||||
trackingMode: TrackingMode
|
||||
baseUomId: number
|
||||
matchedBy: ItemScanMatchedBy
|
||||
batchId: number | null
|
||||
batchNo: string | null
|
||||
expiryDate: string | null
|
||||
serialId: number | null
|
||||
serialNo: string | null
|
||||
serialStatus: string | null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface NotificationItem {
|
||||
notificationId: number
|
||||
moduleKey: string
|
||||
title: string
|
||||
message: string
|
||||
relatedEntityType: string | null
|
||||
relatedEntityId: number | null
|
||||
createdAt: string
|
||||
isRead: boolean
|
||||
}
|
||||
|
||||
export interface NotificationFeed {
|
||||
notifications: NotificationItem[]
|
||||
unreadCount: number
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export interface Requisition {
|
||||
docNo: string
|
||||
status: RequisitionStatus
|
||||
requestedBy: number
|
||||
/** Resolved server-side via `IBranchConfigService.ResolveBranchIdAsync` — never posted by the form. */
|
||||
branchId: number
|
||||
createdAt: string
|
||||
lines: ReqLine[]
|
||||
}
|
||||
@@ -27,6 +29,7 @@ export interface RequisitionSummary {
|
||||
docNo: string
|
||||
status: RequisitionStatus
|
||||
requestedBy: number
|
||||
branchId: number
|
||||
createdAt: string
|
||||
lineCount: number
|
||||
}
|
||||
@@ -60,6 +63,8 @@ export interface Rfq {
|
||||
rfqId: number
|
||||
docNo: string
|
||||
requisitionId: number
|
||||
/** Resolved server-side via `IBranchConfigService.ResolveBranchIdAsync` — never posted by the form. */
|
||||
branchId: number
|
||||
status: RfqStatus
|
||||
lines: RfqLine[]
|
||||
}
|
||||
@@ -68,6 +73,7 @@ export interface RfqSummary {
|
||||
rfqId: number
|
||||
docNo: string
|
||||
requisitionId: number
|
||||
branchId: number
|
||||
status: RfqStatus
|
||||
lineCount: number
|
||||
quotationCount: number
|
||||
@@ -156,9 +162,14 @@ export interface PurchaseOrder {
|
||||
docNo: string
|
||||
vendorId: number
|
||||
requisitionId: number | null
|
||||
/** Resolved server-side via `IBranchConfigService.ResolveBranchIdAsync` — never posted by the form. */
|
||||
branchId: number
|
||||
status: PurchaseOrderStatus
|
||||
approvalRequired: boolean
|
||||
createdBy: number
|
||||
/** Set only once the PO reaches Approved. */
|
||||
approvedBy: number | null
|
||||
approvedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
totals: PoTotals
|
||||
@@ -169,8 +180,10 @@ export interface PurchaseOrderSummary {
|
||||
poId: number
|
||||
docNo: string
|
||||
vendorId: number
|
||||
branchId: number
|
||||
status: PurchaseOrderStatus
|
||||
approvalRequired: boolean
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
totals: PoTotals
|
||||
}
|
||||
@@ -187,7 +200,7 @@ export interface CreatePurchaseOrderRequest {
|
||||
vendorId: number
|
||||
requisitionId?: number | null
|
||||
lines: CreatePoLineInput[]
|
||||
/** When true the PO is created as an editable/deletable Draft; false (default) auto-approves. */
|
||||
/** When true the PO is created as an editable/deletable Draft; false (default) issues it straight into PendingApproval. */
|
||||
saveAsDraft?: boolean
|
||||
}
|
||||
|
||||
@@ -198,6 +211,10 @@ export interface CancelPurchaseOrderRequest {
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
export interface RejectPurchaseOrderRequest {
|
||||
reason?: string | null
|
||||
}
|
||||
|
||||
// --- 3.4 Purchase Returns -------------------------------------------------------------
|
||||
|
||||
export type PurchaseReturnStatus = "Draft" | "Posted"
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface PurchaseInvoiceSummary {
|
||||
vendorInvoiceNo: string
|
||||
vendorInvoiceDate: string
|
||||
vendorId: number
|
||||
/** Resolved server-side via `IBranchConfigService.ResolveBranchIdAsync` — never posted by the form. */
|
||||
branchId: number
|
||||
vendorSnapshotName: string
|
||||
status: PurchaseInvoiceStatus
|
||||
grnCount: number
|
||||
|
||||
@@ -40,15 +40,32 @@ export interface NavItem {
|
||||
children: SubNavItem[]
|
||||
}
|
||||
|
||||
// Mirrors ERPCore's Domain/Enums/RolePermissionAction.cs — a bitmask, not a set of
|
||||
// booleans, so a permission row can grant any combination in one integer.
|
||||
export const RolePermissionAction = {
|
||||
View: 1,
|
||||
Create: 2,
|
||||
Edit: 4,
|
||||
Delete: 8,
|
||||
Approve: 16,
|
||||
} as const
|
||||
|
||||
export const ALL_ROLE_PERMISSION_ACTIONS =
|
||||
RolePermissionAction.View | RolePermissionAction.Create | RolePermissionAction.Edit | RolePermissionAction.Delete | RolePermissionAction.Approve
|
||||
|
||||
export interface RolePermissions {
|
||||
roleId: number
|
||||
navItemIds: number[]
|
||||
subNavItemIds: number[]
|
||||
navItemActions: Record<number, number>
|
||||
subNavItemActions: Record<number, number>
|
||||
}
|
||||
|
||||
export interface AssignRolePermissionsRequest {
|
||||
navItemIds: number[]
|
||||
subNavItemIds: number[]
|
||||
navItemActions: Record<number, number>
|
||||
subNavItemActions: Record<number, number>
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
@@ -59,4 +76,5 @@ export interface MeResponse {
|
||||
branchId: number | null
|
||||
branchName: string | null
|
||||
branchingEnabled: boolean
|
||||
warehouseBranchingEnabled: boolean
|
||||
}
|
||||
|
||||
@@ -222,6 +222,8 @@ export interface CreateSalesSlipRequest {
|
||||
warehouseId: number
|
||||
cashierUserId: number
|
||||
lines: CreateSalesSlipLineRequest[]
|
||||
/** Omit for the default: fully paid (paidAmount === grandTotal, balanceAmount === 0). */
|
||||
paidAmount?: number | null
|
||||
}
|
||||
|
||||
export type UpdateSalesSlipRequest = CreateSalesSlipRequest
|
||||
@@ -424,6 +426,10 @@ export interface SalesDayEnd {
|
||||
discountTotal: number
|
||||
taxTotal: number
|
||||
grandTotal: number
|
||||
/** Sum of what slips' customers actually paid at ring-up — debited to Cash on close. */
|
||||
paidTotal: number
|
||||
/** grandTotal - paidTotal — sold "on account," debited to Accounts Receivable on close. */
|
||||
balanceTotal: number
|
||||
costOfGoodsSold: number
|
||||
glJournalNo: string | null
|
||||
glPostedAt: string | null
|
||||
@@ -443,6 +449,8 @@ export interface SalesDayEndSummary {
|
||||
slipCount: number
|
||||
bundleCount: number
|
||||
grandTotal: number
|
||||
paidTotal: number
|
||||
balanceTotal: number
|
||||
glJournalNo: string | null
|
||||
closedAt: string
|
||||
}
|
||||
@@ -469,6 +477,8 @@ export interface SalesDayEndPreview {
|
||||
discountTotal: number
|
||||
taxTotal: number
|
||||
grandTotal: number
|
||||
paidTotal: number
|
||||
balanceTotal: number
|
||||
slipNumbers: string[]
|
||||
bundleNumbers: string[]
|
||||
draftSlipsBlockingClose: DraftSlipBlockingClose[]
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export type GlobalSearchResultType = "Item" | "PurchaseOrder" | "Invoice" | "Employee"
|
||||
|
||||
export interface GlobalSearchResult {
|
||||
type: GlobalSearchResultType
|
||||
id: number
|
||||
number: string
|
||||
title: string
|
||||
subtitle: string | null
|
||||
}
|
||||
@@ -200,6 +200,8 @@ export interface CreateAdjustmentLineInput {
|
||||
|
||||
export interface CreateAdjustmentRequest {
|
||||
warehouseId: number
|
||||
/** Required when branching is enabled; 422 "Branch is required." otherwise. */
|
||||
branchId: number
|
||||
/** Mandatory (FR-STK-07); omitting it returns 400 REASON_CODE_REQUIRED. */
|
||||
reasonCodeId: number
|
||||
lines: CreateAdjustmentLineInput[]
|
||||
@@ -217,6 +219,11 @@ export interface CountLine {
|
||||
systemQty: number
|
||||
countedQty: number | null
|
||||
variance: number | null
|
||||
/** Set when counts are pre-expanded per lot (one line per batch/serial per item). */
|
||||
batchId: number | null
|
||||
batchNo: string | null
|
||||
serialId: number | null
|
||||
serialNo: string | null
|
||||
}
|
||||
|
||||
export interface StockCount {
|
||||
@@ -243,6 +250,7 @@ export interface StockCountSummary {
|
||||
|
||||
export interface CreateCountRequest {
|
||||
warehouseId: number
|
||||
branchId: number
|
||||
countType: CountType
|
||||
itemIds: number[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
export type TicketStatus = "Open" | "InProgress" | "Resolved"
|
||||
|
||||
export interface SupportTicketMessage {
|
||||
messageId: number
|
||||
senderUserId: number
|
||||
senderName: string
|
||||
isFromAdmin: boolean
|
||||
message: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface SupportTicketSummary {
|
||||
ticketId: number
|
||||
subject: string
|
||||
status: TicketStatus
|
||||
createdBy: number
|
||||
createdByName: string
|
||||
createdAt: string
|
||||
updatedBy: number | null
|
||||
updatedAt: string | null
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
export interface SupportTicket {
|
||||
ticketId: number
|
||||
subject: string
|
||||
status: TicketStatus
|
||||
createdBy: number
|
||||
createdByName: string
|
||||
createdAt: string
|
||||
updatedBy: number | null
|
||||
updatedByName: string | null
|
||||
updatedAt: string | null
|
||||
messages: SupportTicketMessage[]
|
||||
}
|
||||
|
||||
export interface CreateSupportTicketRequest {
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface AddSupportTicketMessageRequest {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface UpdateSupportTicketStatusRequest {
|
||||
status: TicketStatus
|
||||
}
|
||||
@@ -54,3 +54,23 @@ export interface UserTypeOption {
|
||||
code: string | null
|
||||
description: string | null
|
||||
}
|
||||
|
||||
/** At least one identifier must be supplied — matches AuthHex's own OR-across-fields dedupe. */
|
||||
export interface PurgeAuthHexIdentityRequest {
|
||||
email?: string | null
|
||||
mobileNumber?: string | null
|
||||
nic?: string | null
|
||||
userName?: string | null
|
||||
}
|
||||
|
||||
export interface DeletedAuthHexUser {
|
||||
id: string
|
||||
fullName: string | null
|
||||
userName: string | null
|
||||
email: string | null
|
||||
}
|
||||
|
||||
export interface PurgeAuthHexIdentityResult {
|
||||
deletedCount: number
|
||||
deleted: DeletedAuthHexUser[] | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user