Files
pipeline/frontend/app/(main)/COMPANY_30/logistics/material-status/page.tsx
T
kjs 2a23cadb41 feat: Enhance user management and reporting features
- Added `end_date` field to user management for better tracking of user status.
- Updated SQL queries in `adminController` to include `end_date` during user save operations.
- Improved purchase report data handling by refining the logic for received quantities.
- Enhanced file preview functionality to streamline file path handling.
- Updated outbound and receiving controllers to ensure accurate updates to shipment and purchase order details.

These changes aim to improve the overall functionality and user experience in managing user data and reporting processes.
2026-04-08 15:33:09 +09:00

630 lines
25 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";
// Card 제거 — rounded-lg border bg-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,
Loader2,
Inbox,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
getWorkOrders,
getMaterialStatus,
getWarehouses,
type WorkOrder,
type MaterialData,
type WarehouseData,
} from "@/lib/api/materialStatus";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
const GRID_COLUMNS = [
{ key: "plan_no", label: "계획번호" },
{ key: "item_code", label: "품목코드" },
{ key: "item_name", label: "품목명" },
{ key: "plan_qty", label: "수량" },
{ key: "plan_date", label: "일자" },
{ key: "status", label: "상태" },
];
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: string) => {
const map: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
return map[status] || status;
};
const getStatusStyle = (status: string) => {
const map: Record<string, string> = {
planned: "bg-secondary text-secondary-foreground border-border",
pending: "bg-secondary text-secondary-foreground border-border",
in_progress: "bg-primary/10 text-primary border-primary/20",
completed: "bg-accent text-accent-foreground border-accent/50",
cancelled: "bg-muted text-muted-foreground border-border",
};
return map[status] || "bg-muted text-muted-foreground border-border";
};
export default function MaterialStatusPage() {
const ts = useTableSettings("c16-material-status", "work_instruction", GRID_COLUMNS);
const today = new Date();
const monthAgo = new Date(today);
monthAgo.setMonth(today.getMonth() - 1);
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(monthAgo));
const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
const [searchItemCode, setSearchItemCode] = useState("");
const [searchItemName, setSearchItemName] = useState("");
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
const [selectedWoId, setSelectedWoId] = useState<string | null>(null);
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
const [warehouse, setWarehouse] = useState("");
const [materialSearch, setMaterialSearch] = useState("");
const [showShortageOnly, setShowShortageOnly] = useState(false);
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();
}, []);
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(async () => {
if (checkedWoIds.length === 0) {
alert("자재를 조회할 작업지시를 선택해주세요.");
return;
}
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]);
const handleResetSearch = useCallback(() => {
const t = new Date();
const m = new Date(t);
m.setMonth(t.getMonth() - 1);
setSearchDateFrom(formatDate(m));
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;
});
}, [materials, materialSearch, showShortageOnly]);
return (
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3">
{/* 검색 영역 */}
<div className="shrink-0 flex flex-wrap items-end gap-3 rounded-lg border bg-card p-3">
<div className="flex flex-col gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<div className="flex items-center gap-1.5">
<Input
type="date"
className="h-9 w-[140px]"
value={searchDateFrom}
onChange={(e) => setSearchDateFrom(e.target.value)}
/>
<span className="text-muted-foreground/50 text-xs">~</span>
<Input
type="date"
className="h-9 w-[140px]"
value={searchDateTo}
onChange={(e) => setSearchDateTo(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
placeholder="품목코드"
className="h-9 w-[140px]"
value={searchItemCode}
onChange={(e) => setSearchItemCode(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input
placeholder="품목명"
className="h-9 w-[140px]"
value={searchItemName}
onChange={(e) => setSearchItemName(e.target.value)}
/>
</div>
<div className="flex-1" />
<div className="flex items-end gap-2">
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleResetSearch}
>
<RotateCcw className="mr-1 h-4 w-4" />
</Button>
<Button
size="sm"
className="h-9"
onClick={handleSearch}
disabled={workOrdersLoading}
>
{workOrdersLoading ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Search className="mr-1 h-4 w-4" />
)}
</Button>
</div>
</div>
{/* 메인 콘텐츠 (좌우 분할) */}
<div className="flex-1 overflow-hidden rounded-lg border bg-card">
<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/30 px-3 py-2.5 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">
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{workOrders.length}
</span>
<Button
size="sm"
className="h-8"
onClick={handleLoadSelectedMaterials}
disabled={materialsLoading}
>
{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" />
)}
</Button>
<Button variant="ghost" size="sm" className="h-8" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* 작업지시 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{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 ? (
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
<Inbox className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
ts.groupData(workOrders).map((wo) => {
if ((wo as any)._isGroupSummary || (wo as any)._isGroupHeader) return null;
return (
<div
key={wo.id}
className={cn(
"flex gap-3 rounded-lg border p-3 transition-all cursor-pointer",
"hover:border-primary/50 hover:shadow-sm",
selectedWoId === wo.id
? "border-primary bg-primary/5 shadow-sm"
: "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">
{ts.isVisible("plan_no") && (
<span className="text-sm font-bold text-primary">
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
</span>
)}
{ts.isVisible("status") && (
<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">
{ts.isVisible("item_name") && (
<span className="text-sm font-semibold">
{wo.item_name}
</span>
)}
{ts.isVisible("item_code") && (
<span className="text-xs text-muted-foreground">
({wo.item_code})
</span>
)}
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{ts.isVisible("plan_qty") && (
<>
<span>:</span>
<span className="font-semibold text-foreground">
{Number(wo.plan_qty).toLocaleString()}
</span>
</>
)}
{ts.isVisible("plan_qty") && ts.isVisible("plan_date") && (
<span className="mx-1">|</span>
)}
{ts.isVisible("plan_date") && (
<>
<span>:</span>
<span className="font-semibold text-foreground">
{wo.plan_date
? new Date(wo.plan_date)
.toISOString()
.slice(0, 10)
: "-"}
</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/30 px-3 py-2.5 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/30 px-4 py-2.5 shrink-0">
<Input
placeholder="원자재 검색"
className="h-8 min-w-[150px] flex-1 text-xs"
value={materialSearch}
onChange={(e) => setMaterialSearch(e.target.value)}
/>
<Select value={warehouse} onValueChange={setWarehouse}>
<SelectTrigger className="h-8 w-[180px] text-xs">
<SelectValue placeholder="전체 창고" />
</SelectTrigger>
<SelectContent>
<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})`
: ""}
</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium">
<Checkbox
checked={showShortageOnly}
onCheckedChange={(c) => setShowShortageOnly(c as boolean)}
/>
<span> </span>
</label>
<span className="ml-auto rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{filteredMaterials.length}
</span>
</div>
{/* 원자재 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{materialsLoading ? (
<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>
) : materials.length === 0 ? (
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
<Inbox className="h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : filteredMaterials.length === 0 ? (
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
<Package className="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 =
material.required > 0
? Math.min(
(material.current / material.required) * 100,
100
)
: 100;
return (
<div
key={material.code}
className={cn(
"rounded-lg border p-3 transition-all hover:shadow-sm",
isShortage
? "border-destructive/30 bg-destructive/5"
: "border-primary/15 bg-primary/5"
)}
>
{/* 메인 정보 라인 */}
<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-primary">
{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-primary"
)}
>
{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-primary bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
<CheckCircle2 className="h-3 w-3" />
</span>
)}
</div>
{/* 위치별 재고 */}
{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>
</span>
))}
</div>
)}
</div>
);
})
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}