2026-03-19 15:08:31 +09:00
|
|
|
"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,
|
2026-03-20 11:58:01 +09:00
|
|
|
Loader2,
|
2026-03-19 15:08:31 +09:00
|
|
|
} from "lucide-react";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
2026-03-20 11:58:01 +09:00
|
|
|
import {
|
|
|
|
|
getWorkOrders,
|
|
|
|
|
getMaterialStatus,
|
|
|
|
|
getWarehouses,
|
|
|
|
|
type WorkOrder,
|
|
|
|
|
type MaterialData,
|
|
|
|
|
type WarehouseData,
|
|
|
|
|
} from "@/lib/api/materialStatus";
|
2026-03-19 15:08:31 +09:00
|
|
|
|
|
|
|
|
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}`;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
const getStatusLabel = (status: string) => {
|
|
|
|
|
const map: Record<string, string> = {
|
|
|
|
|
planned: "계획",
|
|
|
|
|
in_progress: "진행중",
|
|
|
|
|
completed: "완료",
|
|
|
|
|
pending: "대기",
|
|
|
|
|
cancelled: "취소",
|
|
|
|
|
};
|
|
|
|
|
return map[status] || status;
|
|
|
|
|
};
|
2026-03-19 15:08:31 +09:00
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
const getStatusStyle = (status: string) => {
|
|
|
|
|
const map: Record<string, string> = {
|
|
|
|
|
planned: "bg-amber-100 text-amber-700 border-amber-200",
|
|
|
|
|
pending: "bg-amber-100 text-amber-700 border-amber-200",
|
|
|
|
|
in_progress: "bg-blue-100 text-blue-700 border-blue-200",
|
|
|
|
|
completed: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
|
|
|
|
cancelled: "bg-gray-100 text-gray-500 border-gray-200",
|
|
|
|
|
};
|
|
|
|
|
return map[status] || "bg-gray-100 text-gray-500 border-gray-200";
|
|
|
|
|
};
|
2026-03-19 15:08:31 +09:00
|
|
|
|
|
|
|
|
export default function MaterialStatusPage() {
|
|
|
|
|
const today = new Date();
|
2026-03-20 11:58:01 +09:00
|
|
|
const monthAgo = new Date(today);
|
|
|
|
|
monthAgo.setMonth(today.getMonth() - 1);
|
2026-03-19 15:08:31 +09:00
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(monthAgo));
|
2026-03-19 15:08:31 +09:00
|
|
|
const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
|
|
|
|
|
const [searchItemCode, setSearchItemCode] = useState("");
|
|
|
|
|
const [searchItemName, setSearchItemName] = useState("");
|
|
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
|
|
|
|
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
|
|
|
|
|
const [checkedWoIds, setCheckedWoIds] = useState<number[]>([]);
|
|
|
|
|
const [selectedWoId, setSelectedWoId] = useState<number | null>(null);
|
2026-03-19 15:08:31 +09:00
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
|
|
|
|
|
const [warehouse, setWarehouse] = useState("");
|
2026-03-19 15:08:31 +09:00
|
|
|
const [materialSearch, setMaterialSearch] = useState("");
|
|
|
|
|
const [showShortageOnly, setShowShortageOnly] = useState(false);
|
2026-03-20 11:58:01 +09:00
|
|
|
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
|
|
|
|
const [materialsLoading, setMaterialsLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
// 창고 목록 초기 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
(async () => {
|
|
|
|
|
const res = await getWarehouses();
|
|
|
|
|
if (res.success && res.data) {
|
|
|
|
|
setWarehouses(res.data);
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// 작업지시 검색
|
|
|
|
|
const handleSearch = useCallback(async () => {
|
|
|
|
|
setWorkOrdersLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const res = await getWorkOrders({
|
|
|
|
|
dateFrom: searchDateFrom,
|
|
|
|
|
dateTo: searchDateTo,
|
|
|
|
|
itemCode: searchItemCode || undefined,
|
|
|
|
|
itemName: searchItemName || undefined,
|
|
|
|
|
});
|
|
|
|
|
if (res.success && res.data) {
|
|
|
|
|
setWorkOrders(res.data);
|
|
|
|
|
setCheckedWoIds([]);
|
|
|
|
|
setSelectedWoId(null);
|
|
|
|
|
setMaterials([]);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setWorkOrdersLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [searchDateFrom, searchDateTo, searchItemCode, searchItemName]);
|
|
|
|
|
|
|
|
|
|
// 초기 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
handleSearch();
|
|
|
|
|
}, []);
|
2026-03-19 15:08:31 +09:00
|
|
|
|
|
|
|
|
const isAllChecked =
|
|
|
|
|
workOrders.length > 0 && checkedWoIds.length === workOrders.length;
|
|
|
|
|
|
|
|
|
|
const handleCheckAll = useCallback(
|
|
|
|
|
(checked: boolean) => {
|
|
|
|
|
setCheckedWoIds(checked ? workOrders.map((wo) => wo.id) : []);
|
|
|
|
|
},
|
|
|
|
|
[workOrders]
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
const handleCheckWo = useCallback((id: number, checked: boolean) => {
|
2026-03-19 15:08:31 +09:00
|
|
|
setCheckedWoIds((prev) =>
|
|
|
|
|
checked ? [...prev, id] : prev.filter((i) => i !== id)
|
|
|
|
|
);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
const handleSelectWo = useCallback((id: number) => {
|
2026-03-19 15:08:31 +09:00
|
|
|
setSelectedWoId((prev) => (prev === id ? null : id));
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
// 선택된 작업지시의 자재 조회
|
|
|
|
|
const handleLoadSelectedMaterials = useCallback(async () => {
|
2026-03-19 15:08:31 +09:00
|
|
|
if (checkedWoIds.length === 0) {
|
|
|
|
|
alert("자재를 조회할 작업지시를 선택해주세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-20 11:58:01 +09:00
|
|
|
|
|
|
|
|
setMaterialsLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const res = await getMaterialStatus({
|
|
|
|
|
planIds: checkedWoIds,
|
|
|
|
|
warehouseCode: warehouse || undefined,
|
|
|
|
|
});
|
|
|
|
|
if (res.success && res.data) {
|
|
|
|
|
setMaterials(res.data);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
setMaterialsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [checkedWoIds, warehouse]);
|
2026-03-19 15:08:31 +09:00
|
|
|
|
|
|
|
|
const handleResetSearch = useCallback(() => {
|
|
|
|
|
const t = new Date();
|
2026-03-20 11:58:01 +09:00
|
|
|
const m = new Date(t);
|
|
|
|
|
m.setMonth(t.getMonth() - 1);
|
|
|
|
|
setSearchDateFrom(formatDate(m));
|
2026-03-19 15:08:31 +09:00
|
|
|
setSearchDateTo(formatDate(t));
|
|
|
|
|
setSearchItemCode("");
|
|
|
|
|
setSearchItemName("");
|
|
|
|
|
setMaterialSearch("");
|
|
|
|
|
setShowShortageOnly(false);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const filteredMaterials = useMemo(() => {
|
|
|
|
|
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;
|
|
|
|
|
});
|
2026-03-20 11:58:01 +09:00
|
|
|
}, [materials, materialSearch, showShortageOnly]);
|
2026-03-19 15:08:31 +09:00
|
|
|
|
|
|
|
|
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>
|
2026-03-20 11:58:01 +09:00
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-9"
|
|
|
|
|
onClick={handleSearch}
|
|
|
|
|
disabled={workOrdersLoading}
|
|
|
|
|
>
|
|
|
|
|
{workOrdersLoading ? (
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Search className="mr-2 h-4 w-4" />
|
|
|
|
|
)}
|
2026-03-19 15:08:31 +09:00
|
|
|
검색
|
|
|
|
|
</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}
|
2026-03-20 11:58:01 +09:00
|
|
|
disabled={materialsLoading}
|
2026-03-19 15:08:31 +09:00
|
|
|
>
|
2026-03-20 11:58:01 +09:00
|
|
|
{materialsLoading ? (
|
|
|
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<Search className="mr-1.5 h-3.5 w-3.5" />
|
|
|
|
|
)}
|
2026-03-19 15:08:31 +09:00
|
|
|
자재조회
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 작업지시 목록 */}
|
|
|
|
|
<div className="flex-1 space-y-2 overflow-auto p-3">
|
2026-03-20 11:58:01 +09:00
|
|
|
{workOrdersLoading ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
|
|
|
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
작업지시를 조회하고 있습니다...
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : workOrders.length === 0 ? (
|
2026-03-19 15:08:31 +09:00
|
|
|
<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">
|
2026-03-20 11:58:01 +09:00
|
|
|
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
|
2026-03-19 15:08:31 +09:00
|
|
|
</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">
|
2026-03-20 11:58:01 +09:00
|
|
|
{wo.item_name}
|
2026-03-19 15:08:31 +09:00
|
|
|
</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
2026-03-20 11:58:01 +09:00
|
|
|
({wo.item_code})
|
2026-03-19 15:08:31 +09:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
|
|
|
<span>수량:</span>
|
|
|
|
|
<span className="font-semibold text-foreground">
|
2026-03-20 11:58:01 +09:00
|
|
|
{Number(wo.plan_qty).toLocaleString()}개
|
2026-03-19 15:08:31 +09:00
|
|
|
</span>
|
|
|
|
|
<span className="mx-1">|</span>
|
|
|
|
|
<span>일자:</span>
|
|
|
|
|
<span className="font-semibold text-foreground">
|
2026-03-20 11:58:01 +09:00
|
|
|
{wo.plan_date
|
|
|
|
|
? new Date(wo.plan_date)
|
|
|
|
|
.toISOString()
|
|
|
|
|
.slice(0, 10)
|
|
|
|
|
: "-"}
|
2026-03-19 15:08:31 +09:00
|
|
|
</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]">
|
2026-03-20 11:58:01 +09:00
|
|
|
<SelectValue placeholder="전체 창고" />
|
2026-03-19 15:08:31 +09:00
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2026-03-20 11:58:01 +09:00
|
|
|
<SelectItem value="__all__">전체 창고</SelectItem>
|
|
|
|
|
{warehouses.map((wh) => (
|
|
|
|
|
<SelectItem
|
|
|
|
|
key={wh.warehouse_code}
|
|
|
|
|
value={wh.warehouse_code}
|
|
|
|
|
>
|
|
|
|
|
{wh.warehouse_name}
|
|
|
|
|
{wh.warehouse_type
|
|
|
|
|
? ` (${wh.warehouse_type})`
|
|
|
|
|
: ""}
|
2026-03-19 15:08:31 +09:00
|
|
|
</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">
|
2026-03-20 11:58:01 +09:00
|
|
|
{materialsLoading ? (
|
2026-03-19 15:08:31 +09:00
|
|
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
2026-03-20 11:58:01 +09:00
|
|
|
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
|
2026-03-19 15:08:31 +09:00
|
|
|
<p className="text-sm text-muted-foreground">
|
2026-03-20 11:58:01 +09:00
|
|
|
자재현황을 조회하고 있습니다...
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : materials.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">
|
|
|
|
|
작업지시를 선택하고 자재조회 버튼을 클릭해주세요
|
2026-03-19 15:08:31 +09:00
|
|
|
</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;
|
2026-03-20 11:58:01 +09:00
|
|
|
const percentage =
|
|
|
|
|
material.required > 0
|
|
|
|
|
? Math.min(
|
|
|
|
|
(material.current / material.required) * 100,
|
|
|
|
|
100
|
|
|
|
|
)
|
|
|
|
|
: 100;
|
2026-03-19 15:08:31 +09:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
|
|
|
|
{/* 위치별 재고 */}
|
2026-03-20 11:58:01 +09:00
|
|
|
{material.locations.length > 0 && (
|
|
|
|
|
<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 || loc.warehouse}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="font-semibold">
|
|
|
|
|
{loc.qty.toLocaleString()}
|
|
|
|
|
{material.unit}
|
|
|
|
|
</span>
|
2026-03-19 15:08:31 +09:00
|
|
|
</span>
|
2026-03-20 11:58:01 +09:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-19 15:08:31 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</ResizablePanel>
|
|
|
|
|
</ResizablePanelGroup>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|