ERP-node/frontend/app/(main)/logistics/material-status/page.tsx

610 lines
22 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Search,
RotateCcw,
Package,
ClipboardList,
Factory,
MapPin,
AlertTriangle,
CheckCircle2,
} from "lucide-react";
import { cn } from "@/lib/utils";
// --- Types ---
type WorkOrderStatus = "pending" | "in_progress";
interface WorkOrder {
id: string;
itemCode: string;
itemName: string;
quantity: number;
date: string;
status: WorkOrderStatus;
}
interface MaterialLocation {
location: string;
qty: number;
}
interface Material {
code: string;
name: string;
required: number;
current: number;
unit: string;
locations: MaterialLocation[];
}
interface Warehouse {
code: string;
name: string;
}
// --- Sample Data ---
const sampleWarehouses: Warehouse[] = [
{ code: "WH001", name: "제1창고 (위치관리)" },
{ code: "WH002", name: "제2창고 (위치관리)" },
{ code: "WH003", name: "제3창고 (위치관리)" },
];
const sampleWorkOrders: WorkOrder[] = [
{
id: "WO2024001",
itemCode: "PROD-A001",
itemName: "상품 A",
quantity: 1000,
date: "2024-11-06",
status: "pending",
},
{
id: "WO2024002",
itemCode: "PROD-A002",
itemName: "상품 B",
quantity: 500,
date: "2024-11-07",
status: "pending",
},
{
id: "WO2024003",
itemCode: "PROD-A003",
itemName: "상품 C",
quantity: 800,
date: "2024-11-08",
status: "pending",
},
{
id: "WO2024004",
itemCode: "PROD-A004",
itemName: "상품 D",
quantity: 1200,
date: "2024-11-09",
status: "in_progress",
},
];
const sampleMaterials: Material[] = [
{
code: "MAT-R001",
name: "원자재 A",
required: 5000,
current: 4200,
unit: "kg",
locations: [
{ location: "A-01-01", qty: 2000 },
{ location: "A-01-02", qty: 1500 },
{ location: "A-01-03", qty: 700 },
],
},
{
code: "MAT-R002",
name: "원자재 B",
required: 3000,
current: 3500,
unit: "kg",
locations: [
{ location: "A-02-01", qty: 2000 },
{ location: "A-02-02", qty: 1500 },
],
},
{
code: "MAT-R003",
name: "원자재 C",
required: 2000,
current: 800,
unit: "EA",
locations: [
{ location: "B-01-01", qty: 500 },
{ location: "B-01-02", qty: 300 },
],
},
{
code: "MAT-R004",
name: "원자재 D",
required: 1500,
current: 1500,
unit: "L",
locations: [{ location: "C-01-01", qty: 1500 }],
},
{
code: "MAT-R005",
name: "원자재 E",
required: 4000,
current: 2500,
unit: "kg",
locations: [
{ location: "A-03-01", qty: 1000 },
{ location: "A-03-02", qty: 1000 },
{ location: "A-03-03", qty: 500 },
],
},
];
const formatDate = (date: Date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
};
const getStatusLabel = (status: WorkOrderStatus) =>
status === "pending" ? "대기" : "진행중";
const getStatusStyle = (status: WorkOrderStatus) =>
status === "pending"
? "bg-amber-100 text-amber-700 border-amber-200"
: "bg-blue-100 text-blue-700 border-blue-200";
export default function MaterialStatusPage() {
const today = new Date();
const weekAgo = new Date(today);
weekAgo.setDate(today.getDate() - 7);
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(weekAgo));
const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
const [searchItemCode, setSearchItemCode] = useState("");
const [searchItemName, setSearchItemName] = useState("");
const [workOrders] = useState<WorkOrder[]>(sampleWorkOrders);
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
const [selectedWoId, setSelectedWoId] = useState<string | null>(null);
const [warehouse, setWarehouse] = useState(sampleWarehouses[0]?.code || "");
const [materialSearch, setMaterialSearch] = useState("");
const [showShortageOnly, setShowShortageOnly] = useState(false);
const [materials] = useState<Material[]>(sampleMaterials);
const isAllChecked =
workOrders.length > 0 && checkedWoIds.length === workOrders.length;
const handleCheckAll = useCallback(
(checked: boolean) => {
setCheckedWoIds(checked ? workOrders.map((wo) => wo.id) : []);
},
[workOrders]
);
const handleCheckWo = useCallback((id: string, checked: boolean) => {
setCheckedWoIds((prev) =>
checked ? [...prev, id] : prev.filter((i) => i !== id)
);
}, []);
const handleSelectWo = useCallback((id: string) => {
setSelectedWoId((prev) => (prev === id ? null : id));
}, []);
const handleLoadSelectedMaterials = useCallback(() => {
if (checkedWoIds.length === 0) {
alert("자재를 조회할 작업지시를 선택해주세요.");
return;
}
console.log("선택된 작업지시:", checkedWoIds);
}, [checkedWoIds]);
const handleResetSearch = useCallback(() => {
const t = new Date();
const w = new Date(t);
w.setDate(t.getDate() - 7);
setSearchDateFrom(formatDate(w));
setSearchDateTo(formatDate(t));
setSearchItemCode("");
setSearchItemName("");
setMaterialSearch("");
setShowShortageOnly(false);
}, []);
const filteredMaterials = useMemo(() => {
if (!warehouse) return [];
return materials.filter((m) => {
const searchLower = materialSearch.toLowerCase();
const matchesSearch =
!materialSearch ||
m.code.toLowerCase().includes(searchLower) ||
m.name.toLowerCase().includes(searchLower);
const matchesShortage = !showShortageOnly || m.current < m.required;
return matchesSearch && matchesShortage;
});
}, [materials, warehouse, materialSearch, showShortageOnly]);
return (
<div className="flex h-[calc(100vh-4rem)] flex-col gap-4 bg-muted/30 p-4">
{/* 헤더 */}
<div className="shrink-0">
<div className="flex items-center gap-3">
<Package className="h-7 w-7 text-primary" />
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
</div>
{/* 검색 영역 */}
<Card className="shrink-0">
<CardContent className="flex flex-wrap items-end gap-3 p-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<div className="flex items-center gap-2">
<Input
type="date"
className="h-9 w-[140px]"
value={searchDateFrom}
onChange={(e) => setSearchDateFrom(e.target.value)}
/>
<span className="text-muted-foreground">~</span>
<Input
type="date"
className="h-9 w-[140px]"
value={searchDateTo}
onChange={(e) => setSearchDateTo(e.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
placeholder="품목코드"
className="h-9 w-[140px]"
value={searchItemCode}
onChange={(e) => setSearchItemCode(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
placeholder="품목명"
className="h-9 w-[140px]"
value={searchItemName}
onChange={(e) => setSearchItemName(e.target.value)}
/>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleResetSearch}
>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button size="sm" className="h-9">
<Search className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 메인 콘텐츠 (좌우 분할) */}
<div className="flex-1 overflow-hidden rounded-lg border bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 왼쪽: 작업지시 리스트 */}
<ResizablePanel defaultSize={35} minSize={25}>
<div className="flex h-full flex-col">
{/* 패널 헤더 */}
<div className="flex items-center justify-between border-b bg-muted/10 p-3 shrink-0">
<div className="flex items-center gap-2">
<Checkbox
checked={isAllChecked}
onCheckedChange={handleCheckAll}
/>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-normal">
{workOrders.length}
</Badge>
<Button
size="sm"
className="h-8"
onClick={handleLoadSelectedMaterials}
>
<Search className="mr-1.5 h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 작업지시 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{workOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<ClipboardList className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
workOrders.map((wo) => (
<div
key={wo.id}
className={cn(
"flex gap-3 rounded-lg border-2 p-3 transition-all cursor-pointer",
"hover:border-primary hover:shadow-md hover:-translate-y-0.5",
selectedWoId === wo.id
? "border-primary bg-primary/5 shadow-md"
: "border-border"
)}
onClick={() => handleSelectWo(wo.id)}
>
<div
className="flex items-start pt-0.5"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={checkedWoIds.includes(wo.id)}
onCheckedChange={(c) =>
handleCheckWo(wo.id, c as boolean)
}
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary">
{wo.id}
</span>
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
)}
>
{getStatusLabel(wo.status)}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold">
{wo.itemName}
</span>
<span className="text-xs text-muted-foreground">
({wo.itemCode})
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>:</span>
<span className="font-semibold text-foreground">
{wo.quantity.toLocaleString()}
</span>
<span className="mx-1">|</span>
<span>:</span>
<span className="font-semibold text-foreground">
{wo.date}
</span>
</div>
</div>
</div>
))
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 원자재 현황 */}
<ResizablePanel defaultSize={65} minSize={35}>
<div className="flex h-full flex-col">
{/* 패널 헤더 */}
<div className="flex items-center gap-2 border-b bg-muted/10 p-3 shrink-0">
<Factory className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
</div>
{/* 필터 */}
<div className="flex flex-wrap items-center gap-3 border-b bg-muted/5 px-4 py-3 shrink-0">
<Input
placeholder="원자재 검색"
className="h-9 min-w-[150px] flex-1"
value={materialSearch}
onChange={(e) => setMaterialSearch(e.target.value)}
/>
<Select value={warehouse} onValueChange={setWarehouse}>
<SelectTrigger className="h-9 w-[200px]">
<SelectValue placeholder="창고 선택" />
</SelectTrigger>
<SelectContent>
{sampleWarehouses.map((wh) => (
<SelectItem key={wh.code} value={wh.code}>
{wh.name}
</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium">
<Checkbox
checked={showShortageOnly}
onCheckedChange={(c) => setShowShortageOnly(c as boolean)}
/>
<span> </span>
</label>
<span className="ml-auto text-sm font-semibold text-muted-foreground">
{filteredMaterials.length}
</span>
</div>
{/* 원자재 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{!warehouse ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Factory className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : filteredMaterials.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
filteredMaterials.map((material) => {
const shortage = material.required - material.current;
const isShortage = shortage > 0;
const percentage = Math.min(
(material.current / material.required) * 100,
100
);
return (
<div
key={material.code}
className={cn(
"rounded-lg border-2 p-3 transition-all hover:shadow-md hover:-translate-y-0.5",
isShortage
? "border-destructive/40 bg-destructive/2"
: "border-emerald-300/50 bg-emerald-50/20"
)}
>
{/* 메인 정보 라인 */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-bold">
{material.name}
</span>
<span className="text-xs text-muted-foreground">
({material.code})
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
:
</span>
<span className="text-xs font-semibold text-blue-600">
{material.required.toLocaleString()}
{material.unit}
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
:
</span>
<span
className={cn(
"text-xs font-semibold",
isShortage
? "text-destructive"
: "text-foreground"
)}
>
{material.current.toLocaleString()}
{material.unit}
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
{isShortage ? "부족:" : "여유:"}
</span>
<span
className={cn(
"text-xs font-semibold",
isShortage
? "text-destructive"
: "text-emerald-600"
)}
>
{Math.abs(shortage).toLocaleString()}
{material.unit}
</span>
<span className="text-xs font-semibold text-muted-foreground">
({percentage.toFixed(0)}%)
</span>
{isShortage ? (
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-destructive bg-destructive/10 px-2 py-0.5 text-[11px] font-semibold text-destructive">
<AlertTriangle className="h-3 w-3" />
</span>
) : (
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-emerald-500 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
<CheckCircle2 className="h-3 w-3" />
</span>
)}
</div>
{/* 위치별 재고 */}
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
{material.locations.map((loc, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
>
<span className="font-semibold font-mono text-primary">
{loc.location}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
{material.unit}
</span>
</span>
))}
</div>
</div>
);
})
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
);
}