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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user