|
|
|
@@ -0,0 +1,242 @@
|
|
|
|
|
"use client"
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useState } from "react"
|
|
|
|
|
import { useRouter } from "next/navigation"
|
|
|
|
|
import { PackageCheck } from "lucide-react"
|
|
|
|
|
|
|
|
|
|
import { cn } from "@/lib/utils"
|
|
|
|
|
import { productionRunsApi } from "@/lib/api/production-runs"
|
|
|
|
|
import { productionTemplatesApi } from "@/lib/api/production-templates"
|
|
|
|
|
import { warehousesApi } from "@/lib/api/warehouses"
|
|
|
|
|
import { errorMessage } from "@/lib/error-map"
|
|
|
|
|
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
|
|
|
|
|
import { Warehouse } from "@/types/master-data"
|
|
|
|
|
|
|
|
|
|
import { Badge } from "@/components/ui/badge"
|
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
|
|
|
import { Input } from "@/components/ui/input"
|
|
|
|
|
import { Label } from "@/components/ui/label"
|
|
|
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
|
|
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
|
|
|
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
|
|
|
|
|
|
|
|
type StatusFilter = ProductionRunStatus | "All"
|
|
|
|
|
|
|
|
|
|
function runStatusBadgeClass(status: ProductionRunStatus) {
|
|
|
|
|
if (status === "Completed") return "bg-success/10 text-success"
|
|
|
|
|
if (status === "Cancelled") return "bg-destructive/10 text-destructive"
|
|
|
|
|
return "bg-info/10 text-info"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 6dp on the wire (matches the server's PostReceiptAsync rounding); shown to 4dp so the unit
|
|
|
|
|
// cost of a fractional-value item is still legible without dragging the whole table wide.
|
|
|
|
|
function formatCost(value: number) {
|
|
|
|
|
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function ProducedItemsPage() {
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
|
|
|
|
// null = still loading (the codebase convention for "no data yet" vs "empty result").
|
|
|
|
|
const [data, setData] = useState<ProducedListResponse | null>(null)
|
|
|
|
|
const [loadError, setLoadError] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
|
|
|
|
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
|
|
|
|
|
|
|
|
|
const [from, setFrom] = useState("")
|
|
|
|
|
const [to, setTo] = useState("")
|
|
|
|
|
const [templateId, setTemplateId] = useState<number | null>(null)
|
|
|
|
|
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
|
|
|
|
const [status, setStatus] = useState<StatusFilter>("All")
|
|
|
|
|
|
|
|
|
|
const load = useCallback(() => {
|
|
|
|
|
setLoadError(null)
|
|
|
|
|
productionRunsApi
|
|
|
|
|
.listProduced({
|
|
|
|
|
from: from || undefined,
|
|
|
|
|
to: to || undefined,
|
|
|
|
|
templateId: templateId ?? undefined,
|
|
|
|
|
warehouseId: warehouseId ?? undefined,
|
|
|
|
|
status: status === "All" ? undefined : status,
|
|
|
|
|
})
|
|
|
|
|
.then(setData)
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
|
|
|
|
|
setLoadError(errorMessage(err))
|
|
|
|
|
})
|
|
|
|
|
}, [from, to, templateId, warehouseId, status])
|
|
|
|
|
|
|
|
|
|
useEffect(load, [load])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
Promise.all([productionTemplatesApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
|
|
|
|
|
.then(([templateRes, warehouseRes]) => {
|
|
|
|
|
setTemplates(templateRes.items)
|
|
|
|
|
setWarehouses(warehouseRes.items)
|
|
|
|
|
})
|
|
|
|
|
.catch((err) => setLoadError(errorMessage(err)))
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All"
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-6">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1>
|
|
|
|
|
<p className="text-base text-muted-foreground">
|
|
|
|
|
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
<Label className="text-base">From</Label>
|
|
|
|
|
<Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className="h-11 text-base" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
<Label className="text-base">To</Label>
|
|
|
|
|
<Input type="date" value={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
<Label className="text-base">Template</Label>
|
|
|
|
|
<Select<number | null> value={templateId} onValueChange={setTemplateId}>
|
|
|
|
|
<SelectTrigger className="h-11! w-full text-base">
|
|
|
|
|
<SelectValue placeholder="All templates" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{templates.map((t) => (
|
|
|
|
|
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
<Label className="text-base">Warehouse</Label>
|
|
|
|
|
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
|
|
|
|
|
<SelectTrigger className="h-11! w-full text-base">
|
|
|
|
|
<SelectValue placeholder="All warehouses" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{warehouses.map((w) => (
|
|
|
|
|
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
<Label className="text-base">Status</Label>
|
|
|
|
|
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
|
|
|
|
<SelectTrigger className="h-11! w-full text-base">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="All" className="text-base">All statuses</SelectItem>
|
|
|
|
|
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
|
|
|
|
|
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
|
|
|
|
|
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{hasFilters && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-fit"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setFrom("")
|
|
|
|
|
setTo("")
|
|
|
|
|
setTemplateId(null)
|
|
|
|
|
setWarehouseId(null)
|
|
|
|
|
setStatus("All")
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Clear filters
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{loadError && (
|
|
|
|
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{data === null ? (
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
<Skeleton className="h-14 w-full" />
|
|
|
|
|
<Skeleton className="h-14 w-full" />
|
|
|
|
|
<Skeleton className="h-14 w-full" />
|
|
|
|
|
</div>
|
|
|
|
|
) : data.items.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
|
|
|
|
<PackageCheck className="size-12 text-muted-foreground" />
|
|
|
|
|
<p className="text-base text-muted-foreground">
|
|
|
|
|
{hasFilters ? "No produced lines match your filter." : "No runs have produced anything yet."}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="overflow-x-auto rounded-xl border">
|
|
|
|
|
<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">Status</TableHead>
|
|
|
|
|
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
|
|
|
|
|
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
|
|
|
|
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
|
|
|
|
|
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
|
|
|
|
|
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
|
|
|
|
|
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
|
|
|
|
|
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
|
|
|
|
|
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
|
|
|
|
|
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{data.items.map((line) => (
|
|
|
|
|
<TableRow
|
|
|
|
|
key={line.runId}
|
|
|
|
|
className="cursor-pointer"
|
|
|
|
|
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
|
|
|
|
|
>
|
|
|
|
|
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5">
|
|
|
|
|
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
|
|
|
|
|
{line.status === "InProgress" ? "In Progress" : line.status}
|
|
|
|
|
</Badge>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5 text-muted-foreground">
|
|
|
|
|
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
))}
|
|
|
|
|
</TableBody>
|
|
|
|
|
<TableFooter>
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
|
|
|
|
|
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5" />
|
|
|
|
|
<TableCell className="px-3 py-3.5 text-right font-semibold">
|
|
|
|
|
Cost of Production: {formatCost(data.summary.totalCost)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="px-3 py-3.5" />
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableFooter>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|