610 lines
22 KiB
TypeScript
610 lines
22 KiB
TypeScript
"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>
|
|
);
|
|
}
|