feat: COMPANY_29 하드코딩 페이지 추가 (설계/영업/기준정보/생산 27파일)

This commit is contained in:
kjs
2026-03-29 13:41:46 +09:00
parent 2e9b67a509
commit 3e935792d4
27 changed files with 25419 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,839 @@
"use client";
import React, { useState, useMemo, useCallback, useEffect } from "react";
import {
Search,
RotateCcw,
Plus,
Pencil,
Trash2,
Calendar,
Upload,
PointerIcon,
Ruler,
ClipboardList,
FileText,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
getDesignRequestList,
createDesignRequest,
updateDesignRequest,
deleteDesignRequest,
} from "@/lib/api/design";
// ========== 타입 ==========
interface HistoryItem {
id?: string;
step: string;
history_date: string;
user_name: string;
description: string;
}
interface DesignRequest {
id: string;
request_no: string;
source_type: string;
request_date: string;
due_date: string;
design_type: string;
priority: string;
status: string;
approval_step: string;
target_name: string;
customer: string;
req_dept: string;
requester: string;
designer: string;
order_no: string;
spec: string;
change_type: string;
drawing_no: string;
urgency: string;
reason: string;
content: string;
apply_timing: string;
review_memo: string;
project_id: string;
ecn_no: string;
created_date: string;
updated_date: string;
writer: string;
company_code: string;
history: HistoryItem[];
impact: string[];
}
// ========== 스타일 맵 ==========
const STATUS_STYLES: Record<string, string> = {
: "bg-muted text-foreground",
: "bg-muted text-foreground",
: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
: "bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300",
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
: "bg-muted text-muted-foreground",
};
const TYPE_STYLES: Record<string, string> = {
: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
};
const PRIORITY_STYLES: Record<string, string> = {
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
: "bg-muted text-foreground",
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
};
const STATUS_PROGRESS: Record<string, number> = {
신규접수: 0,
접수대기: 0,
검토중: 20,
설계진행: 50,
설계검토: 80,
출도완료: 100,
반려: 0,
종료: 100,
};
function getProgressColor(p: number) {
if (p >= 100) return "bg-emerald-500";
if (p >= 60) return "bg-amber-500";
if (p >= 20) return "bg-blue-500";
return "bg-muted";
}
function getProgressTextColor(p: number) {
if (p >= 100) return "text-emerald-500";
if (p >= 60) return "text-amber-500";
if (p >= 20) return "text-blue-500";
return "text-muted-foreground";
}
const INITIAL_FORM = {
request_no: "",
request_date: "",
due_date: "",
design_type: "",
priority: "보통",
target_name: "",
customer: "",
req_dept: "",
requester: "",
designer: "",
order_no: "",
spec: "",
drawing_no: "",
content: "",
};
// ========== 메인 컴포넌트 ==========
export default function DesignRequestPage() {
const [requests, setRequests] = useState<DesignRequest[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [filterStatus, setFilterStatus] = useState("");
const [filterType, setFilterType] = useState("");
const [filterPriority, setFilterPriority] = useState("");
const [filterKeyword, setFilterKeyword] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(INITIAL_FORM);
const today = useMemo(() => new Date(), []);
// 데이터 조회
const fetchRequests = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = { source_type: "dr" };
if (filterStatus && filterStatus !== "__all__") params.status = filterStatus;
if (filterType && filterType !== "__all__") {
// design_type은 서버에서 직접 필터링하지 않으므로 클라이언트에서 처리
}
if (filterPriority && filterPriority !== "__all__") params.priority = filterPriority;
if (filterKeyword) params.search = filterKeyword;
const res = await getDesignRequestList(params);
if (res.success && res.data) {
setRequests(res.data);
} else {
setRequests([]);
}
} catch {
setRequests([]);
} finally {
setLoading(false);
}
}, [filterStatus, filterPriority, filterKeyword]);
useEffect(() => {
fetchRequests();
}, [fetchRequests]);
// 클라이언트 사이드 필터링 (design_type은 서버에서 지원하지 않으므로)
const filteredRequests = useMemo(() => {
let list = requests;
if (filterType && filterType !== "__all__") {
list = list.filter((item) => item.design_type === filterType);
}
return list;
}, [requests, filterType]);
const selectedItem = useMemo(() => {
if (!selectedId) return null;
return requests.find((r) => r.id === selectedId) || null;
}, [selectedId, requests]);
const statusCounts = useMemo(() => {
return {
접수대기: requests.filter((r) => r.status === "접수대기" || r.status === "신규접수").length,
설계진행: requests.filter((r) => r.status === "설계진행").length,
출도완료: requests.filter((r) => r.status === "출도완료").length,
};
}, [requests]);
const handleResetFilter = useCallback(() => {
setFilterStatus("");
setFilterType("");
setFilterPriority("");
setFilterKeyword("");
}, []);
// 채번: 기존 데이터 기반으로 다음 번호 생성
const generateNextNo = useCallback(() => {
const year = new Date().getFullYear();
const existing = requests.filter((r) => r.request_no?.startsWith(`DR-${year}-`));
const maxNum = existing.reduce((max, r) => {
const parts = r.request_no?.split("-");
const num = parts?.length >= 3 ? parseInt(parts[2]) : 0;
return num > max ? num : max;
}, 0);
return `DR-${year}-${String(maxNum + 1).padStart(4, "0")}`;
}, [requests]);
const handleOpenRegister = useCallback(() => {
setIsEditMode(false);
setEditingId(null);
setForm({
...INITIAL_FORM,
request_no: generateNextNo(),
request_date: new Date().toISOString().split("T")[0],
});
setModalOpen(true);
}, [generateNextNo]);
const handleOpenEdit = useCallback(() => {
if (!selectedItem) return;
setIsEditMode(true);
setEditingId(selectedItem.id);
setForm({
request_no: selectedItem.request_no || "",
request_date: selectedItem.request_date || "",
due_date: selectedItem.due_date || "",
design_type: selectedItem.design_type || "",
priority: selectedItem.priority || "보통",
target_name: selectedItem.target_name || "",
customer: selectedItem.customer || "",
req_dept: selectedItem.req_dept || "",
requester: selectedItem.requester || "",
designer: selectedItem.designer || "",
order_no: selectedItem.order_no || "",
spec: selectedItem.spec || "",
drawing_no: selectedItem.drawing_no || "",
content: selectedItem.content || "",
});
setModalOpen(true);
}, [selectedItem]);
const handleSave = useCallback(async () => {
if (!form.target_name.trim()) { alert("설비/제품명을 입력하세요."); return; }
if (!form.design_type) { alert("의뢰 유형을 선택하세요."); return; }
if (!form.due_date) { alert("납기를 입력하세요."); return; }
if (!form.spec.trim()) { alert("요구사양을 입력하세요."); return; }
setSaving(true);
try {
const payload = {
request_no: form.request_no,
source_type: "dr",
request_date: form.request_date,
due_date: form.due_date,
design_type: form.design_type,
priority: form.priority,
target_name: form.target_name,
customer: form.customer,
req_dept: form.req_dept,
requester: form.requester,
designer: form.designer,
order_no: form.order_no,
spec: form.spec,
drawing_no: form.drawing_no,
content: form.content,
};
let res;
if (isEditMode && editingId) {
res = await updateDesignRequest(editingId, payload);
} else {
res = await createDesignRequest({
...payload,
status: "신규접수",
history: [{
step: "신규접수",
history_date: form.request_date || new Date().toISOString().split("T")[0],
user_name: form.requester || "시스템",
description: `${form.req_dept || ""}에서 설계의뢰 등록`,
}],
});
}
if (res.success) {
setModalOpen(false);
await fetchRequests();
if (isEditMode && editingId) {
setSelectedId(editingId);
} else if (res.data?.id) {
setSelectedId(res.data.id);
}
} else {
alert(`저장 실패: ${res.message || "알 수 없는 오류"}`);
}
} catch (err: any) {
alert(`저장 중 오류가 발생했습니다: ${err.message}`);
} finally {
setSaving(false);
}
}, [form, isEditMode, editingId, fetchRequests]);
const handleDelete = useCallback(async () => {
if (!selectedId || !selectedItem) return;
const displayNo = selectedItem.request_no || selectedId;
if (!confirm(`${displayNo} 설계의뢰를 삭제하시겠습니까?`)) return;
try {
const res = await deleteDesignRequest(selectedId);
if (res.success) {
setSelectedId(null);
await fetchRequests();
} else {
alert(`삭제 실패: ${res.message || "알 수 없는 오류"}`);
}
} catch (err: any) {
alert(`삭제 중 오류가 발생했습니다: ${err.message}`);
}
}, [selectedId, selectedItem, fetchRequests]);
const getDueDateInfo = useCallback(
(dueDate: string) => {
if (!dueDate) return { text: "-", color: "text-muted-foreground" };
const due = new Date(dueDate);
const diff = Math.ceil((due.getTime() - today.getTime()) / 86400000);
if (diff < 0) return { text: `${Math.abs(diff)}일 초과`, color: "text-destructive" };
if (diff === 0) return { text: "오늘", color: "text-amber-500" };
if (diff <= 7) return { text: `${diff}일 남음`, color: "text-amber-500" };
return { text: `${diff}일 남음`, color: "text-emerald-500" };
},
[today]
);
const getProgress = useCallback((status: string) => {
return STATUS_PROGRESS[status] ?? 0;
}, []);
return (
<div className="flex h-full flex-col gap-2 p-3">
{/* 검색 섹션 */}
<div className="flex shrink-0 flex-wrap items-center gap-2 rounded-lg border bg-card px-3 py-2">
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="상태 전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["신규접수", "접수대기", "검토중", "설계진행", "설계검토", "출도완료", "반려", "종료"].map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="유형 전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["신규설계", "유사설계", "개조설계"].map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterPriority} onValueChange={setFilterPriority}>
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="우선순위 전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["긴급", "높음", "보통", "낮음"].map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={filterKeyword}
onChange={(e) => setFilterKeyword(e.target.value)}
placeholder="의뢰번호 / 설비명 / 고객명 검색"
className="h-7 w-[240px] pl-7 text-xs"
/>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleResetFilter}>
<RotateCcw className="mr-1 h-3 w-3" />
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => fetchRequests()}>
<Search className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 메인 영역 */}
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
{/* 왼쪽: 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-1.5">
<span className="text-sm font-bold">
<Ruler className="mr-1 inline h-4 w-4" />
(<span className="text-primary">{filteredRequests.length}</span>)
</span>
<Button size="sm" className="h-7 text-xs" onClick={handleOpenRegister}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<ScrollArea className="flex-1">
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground"> ...</span>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px] text-[11px]"></TableHead>
<TableHead className="w-[70px] text-center text-[11px]"></TableHead>
<TableHead className="w-[70px] text-center text-[11px]"></TableHead>
<TableHead className="w-[60px] text-center text-[11px]"></TableHead>
<TableHead className="text-[11px]">/</TableHead>
<TableHead className="w-[90px] text-[11px]"></TableHead>
<TableHead className="w-[70px] text-[11px]"></TableHead>
<TableHead className="w-[85px] text-[11px]"></TableHead>
<TableHead className="w-[65px] text-center text-[11px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRequests.length === 0 && (
<TableRow>
<TableCell colSpan={9} className="py-12 text-center">
<div className="flex flex-col items-center gap-1 text-muted-foreground">
<Ruler className="h-8 w-8" />
<span className="text-sm"> </span>
</div>
</TableCell>
</TableRow>
)}
{filteredRequests.map((item) => {
const progress = getProgress(item.status);
return (
<TableRow
key={item.id}
className={cn("cursor-pointer", selectedId === item.id && "bg-accent")}
onClick={() => setSelectedId(item.id)}
>
<TableCell className="text-[11px] font-semibold text-primary">{item.request_no || "-"}</TableCell>
<TableCell className="text-center">
{item.design_type ? (
<Badge className={cn("text-[9px]", TYPE_STYLES[item.design_type])}>{item.design_type}</Badge>
) : "-"}
</TableCell>
<TableCell className="text-center">
<Badge className={cn("text-[9px]", STATUS_STYLES[item.status])}>{item.status}</Badge>
</TableCell>
<TableCell className="text-center">
<Badge className={cn("text-[9px]", PRIORITY_STYLES[item.priority])}>{item.priority}</Badge>
</TableCell>
<TableCell className="text-xs font-medium">{item.target_name || "-"}</TableCell>
<TableCell className="text-[11px]">{item.customer || "-"}</TableCell>
<TableCell className="text-[11px]">{item.designer || "-"}</TableCell>
<TableCell className="text-[11px]">{item.due_date || "-"}</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full transition-all", getProgressColor(progress))} style={{ width: `${progress}%` }} />
</div>
<span className={cn("text-[10px] font-semibold", getProgressTextColor(progress))}>{progress}%</span>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</ScrollArea>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 상세 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-1.5">
<span className="text-sm font-bold">
<ClipboardList className="mr-1 inline h-4 w-4" />
</span>
{selectedItem && (
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-6 text-[10px]" onClick={handleOpenEdit}>
<Pencil className="mr-0.5 h-3 w-3" />
</Button>
<Button variant="outline" size="sm" className="h-6 text-[10px] text-destructive hover:text-destructive" onClick={handleDelete}>
<Trash2 className="mr-0.5 h-3 w-3" />
</Button>
</div>
)}
</div>
<ScrollArea className="flex-1">
<div className="p-3">
{/* 상태 카드 */}
<div className="mb-3 grid grid-cols-3 gap-2">
<Card
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
onClick={() => setFilterStatus("접수대기")}
>
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-blue-500">{statusCounts.}</div>
</Card>
<Card
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
onClick={() => setFilterStatus("설계진행")}
>
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-amber-500">{statusCounts.}</div>
</Card>
<Card
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
onClick={() => setFilterStatus("출도완료")}
>
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-emerald-500">{statusCounts.}</div>
</Card>
</div>
{/* 상세 내용 */}
{!selectedItem ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<PointerIcon className="h-8 w-8" />
<span className="text-sm"> </span>
</div>
) : (
<div className="space-y-4">
{/* 기본 정보 */}
<div>
<div className="mb-2 text-xs font-bold">
<FileText className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-lg border bg-muted/10 p-3">
<InfoRow label="의뢰번호" value={<span className="font-semibold text-primary">{selectedItem.request_no || "-"}</span>} />
<InfoRow label="상태" value={<Badge className={cn("text-[10px]", STATUS_STYLES[selectedItem.status])}>{selectedItem.status}</Badge>} />
<InfoRow label="유형" value={selectedItem.design_type ? <Badge className={cn("text-[10px]", TYPE_STYLES[selectedItem.design_type])}>{selectedItem.design_type}</Badge> : "-"} />
<InfoRow label="우선순위" value={<Badge className={cn("text-[10px]", PRIORITY_STYLES[selectedItem.priority])}>{selectedItem.priority}</Badge>} />
<InfoRow label="설비/제품명" value={selectedItem.target_name || "-"} />
<InfoRow label="고객명" value={selectedItem.customer || "-"} />
<InfoRow label="의뢰부서 / 의뢰자" value={`${selectedItem.req_dept || "-"} / ${selectedItem.requester || "-"}`} />
<InfoRow label="설계담당" value={selectedItem.designer || "미배정"} />
<InfoRow label="의뢰일자" value={selectedItem.request_date || "-"} />
<InfoRow
label="납기"
value={
selectedItem.due_date ? (
<span>
{selectedItem.due_date}{" "}
<span className={cn("text-[11px]", getDueDateInfo(selectedItem.due_date).color)}>
({getDueDateInfo(selectedItem.due_date).text})
</span>
</span>
) : "-"
}
/>
<InfoRow label="수주번호" value={selectedItem.order_no || "-"} />
<InfoRow
label="진행률"
value={
(() => {
const progress = getProgress(selectedItem.status);
return (
<div className="flex items-center gap-2">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full", getProgressColor(progress))} style={{ width: `${progress}%` }} />
</div>
<span className={cn("text-xs font-bold", getProgressTextColor(progress))}>{progress}%</span>
</div>
);
})()
}
/>
</div>
</div>
{/* 요구사양 */}
<div>
<div className="mb-2 text-xs font-bold">
<FileText className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="rounded-lg border bg-muted/10 p-3">
<pre className="whitespace-pre-wrap font-sans text-xs leading-relaxed">{selectedItem.spec || "-"}</pre>
{selectedItem.drawing_no && (
<div className="mt-2 text-xs">
<span className="text-muted-foreground"> : </span>
<span className="text-primary">{selectedItem.drawing_no}</span>
</div>
)}
{selectedItem.content && (
<div className="mt-1 text-xs">
<span className="text-muted-foreground">: </span>{selectedItem.content}
</div>
)}
</div>
</div>
{/* 진행 이력 */}
{selectedItem.history && selectedItem.history.length > 0 && (
<div>
<div className="mb-2 text-xs font-bold">
<Calendar className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="space-y-0">
{selectedItem.history.map((h, idx) => {
const isLast = idx === selectedItem.history.length - 1;
const isDone = h.step === "출도완료" || h.step === "종료";
return (
<div key={h.id || idx} className="flex gap-3">
<div className="flex flex-col items-center">
<div
className={cn(
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full border-2",
isLast && !isDone
? "border-blue-500 bg-blue-500"
: isDone || !isLast
? "border-emerald-500 bg-emerald-500"
: "border-muted-foreground bg-muted-foreground"
)}
/>
{!isLast && <div className="w-px flex-1 bg-border" />}
</div>
<div className="pb-3">
<Badge className={cn("text-[9px]", STATUS_STYLES[h.step])}>{h.step}</Badge>
<div className="mt-0.5 text-xs">{h.description}</div>
<div className="text-[10px] text-muted-foreground">{h.history_date} · {h.user_name}</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
</ScrollArea>
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* 등록/수정 모달 */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[1100px]">
<DialogHeader>
<DialogTitle className="text-lg">
{isEditMode ? <><Pencil className="mr-1.5 inline h-5 w-5" /> </> : <><Plus className="mr-1.5 inline h-5 w-5" /> </>}
</DialogTitle>
<DialogDescription className="text-sm">
{isEditMode ? "설계의뢰 정보를 수정합니다." : "새 설계의뢰를 등록합니다."}
</DialogDescription>
</DialogHeader>
<div className="flex gap-6">
{/* 좌측: 기본 정보 */}
<div className="w-[420px] shrink-0 space-y-4">
<div className="text-sm font-bold">
<FileText className="mr-1 inline h-4 w-4" />
</div>
<div>
<Label className="text-sm"></Label>
<Input value={form.request_no} readOnly className="h-9 text-sm" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm"></Label>
<Input type="date" value={form.request_date} onChange={(e) => setForm((p) => ({ ...p, request_date: e.target.value }))} className="h-9 text-sm" />
</div>
<div>
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input type="date" value={form.due_date} onChange={(e) => setForm((p) => ({ ...p, due_date: e.target.value }))} className="h-9 text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Select value={form.design_type} onValueChange={(v) => setForm((p) => ({ ...p, design_type: v }))}>
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["신규설계", "유사설계", "개조설계"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Select value={form.priority} onValueChange={(v) => setForm((p) => ({ ...p, priority: v }))}>
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["긴급", "높음", "보통", "낮음"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-sm">/ <span className="text-destructive">*</span></Label>
<Input value={form.target_name} onChange={(e) => setForm((p) => ({ ...p, target_name: e.target.value }))} placeholder="설비 또는 제품명 입력" className="h-9 text-sm" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm"></Label>
<Select value={form.req_dept} onValueChange={(v) => setForm((p) => ({ ...p, req_dept: v }))}>
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["영업팀", "기획팀", "생산팀", "품질팀"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm"></Label>
<Input value={form.requester} onChange={(e) => setForm((p) => ({ ...p, requester: e.target.value }))} placeholder="의뢰자명" className="h-9 text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm"></Label>
<Input value={form.customer} onChange={(e) => setForm((p) => ({ ...p, customer: e.target.value }))} placeholder="고객/거래처명" className="h-9 text-sm" />
</div>
<div>
<Label className="text-sm"></Label>
<Input value={form.order_no} onChange={(e) => setForm((p) => ({ ...p, order_no: e.target.value }))} placeholder="관련 수주번호" className="h-9 text-sm" />
</div>
</div>
<div>
<Label className="text-sm"></Label>
<Select value={form.designer} onValueChange={(v) => setForm((p) => ({ ...p, designer: v }))}>
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["이설계", "박도면", "최기구", "김전장"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
{/* 우측: 상세 내용 */}
<div className="flex min-w-0 flex-1 flex-col gap-4">
<div className="text-sm font-bold">
<FileText className="mr-1 inline h-4 w-4" />
</div>
<div className="flex-1">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Textarea
value={form.spec}
onChange={(e) => setForm((p) => ({ ...p, spec: e.target.value }))}
placeholder={"고객 요구사양 또는 설비 사양을 상세히 기술하세요\n\n예시:\n- 작업 대상: SUS304 Φ20 파이프\n- 가공 방식: 자동 절단 + 면취\n- 생산 속도: 60EA/분\n- 치수 공차: ±0.1mm"}
className="min-h-[180px] text-sm"
/>
</div>
<div>
<Label className="text-sm"> </Label>
<Input value={form.drawing_no} onChange={(e) => setForm((p) => ({ ...p, drawing_no: e.target.value }))} placeholder="유사 설비명 또는 참조 도면번호" className="h-9 text-sm" />
</div>
<div>
<Label className="text-sm"></Label>
<Textarea value={form.content} onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} placeholder="기타 참고 사항" className="min-h-[70px] text-sm" rows={3} />
</div>
<div>
<div className="text-sm font-bold">
<Upload className="mr-1 inline h-4 w-4" />
</div>
<div className="mt-1.5 cursor-pointer rounded-lg border-2 border-dashed p-5 text-center transition-colors hover:border-primary hover:bg-accent/50">
<Upload className="mx-auto h-6 w-6 text-muted-foreground" />
<div className="mt-1.5 text-sm text-muted-foreground"> (, , )</div>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setModalOpen(false)} className="h-10 px-6 text-sm" disabled={saving}></Button>
<Button onClick={handleSave} className="h-10 px-6 text-sm" disabled={saving}>
{saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
{saving ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ========== 정보 행 서브컴포넌트 ==========
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start gap-1">
<span className="min-w-[80px] shrink-0 text-[11px] text-muted-foreground">{label}</span>
<span className="text-xs font-medium">{value}</span>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,752 @@
"use client";
/**
* 설비정보 — 하드코딩 페이지
*
* 좌측: 설비 목록 (equipment_mng)
* 우측: 탭 (기본정보 / 점검항목 / 소모품)
* 점검항목 복사 기능 포함
*/
import React, { useState, 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Wrench, ClipboardCheck, Package, Copy, Info, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
import { ImageUpload } from "@/components/common/ImageUpload";
const EQUIP_TABLE = "equipment_mng";
const INSPECTION_TABLE = "equipment_inspection_item";
const CONSUMABLE_TABLE = "equipment_consumable";
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "equipment_code", label: "설비코드", width: "w-[110px]" },
{ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]" },
{ key: "equipment_type", label: "설비유형", width: "w-[90px]" },
{ key: "manufacturer", label: "제조사", width: "w-[100px]" },
{ key: "installation_location", label: "설치장소", width: "w-[100px]" },
{ key: "operation_status", label: "가동상태", width: "w-[80px]" },
];
const INSPECTION_COLUMNS: DataGridColumn[] = [
{ key: "inspection_item", label: "점검항목", minWidth: "min-w-[120px]", editable: true },
{ key: "inspection_cycle", label: "점검주기", width: "w-[80px]" },
{ key: "inspection_method", label: "점검방법", width: "w-[80px]" },
{ key: "lower_limit", label: "하한치", width: "w-[70px]", editable: true },
{ key: "upper_limit", label: "상한치", width: "w-[70px]", editable: true },
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
{ key: "inspection_content", label: "점검내용", minWidth: "min-w-[150px]", editable: true },
];
const CONSUMABLE_COLUMNS: DataGridColumn[] = [
{ key: "image_path", label: "이미지", width: "w-[50px]", renderType: "image", sortable: false, filterable: false },
{ key: "consumable_name", label: "소모품명", minWidth: "min-w-[120px]", editable: true },
{ key: "replacement_cycle", label: "교체주기", width: "w-[90px]", editable: true },
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
{ key: "specification", label: "규격", width: "w-[100px]", editable: true },
{ key: "manufacturer", label: "제조사", width: "w-[100px]", editable: true },
];
export default function EquipmentInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측
const [equipments, setEquipments] = useState<any[]>([]);
const [equipLoading, setEquipLoading] = useState(false);
const [equipCount, setEquipCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
// 우측 탭
const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info");
const [inspections, setInspections] = useState<any[]>([]);
const [inspectionLoading, setInspectionLoading] = useState(false);
const [consumables, setConsumables] = useState<any[]>([]);
const [consumableLoading, setConsumableLoading] = useState(false);
// 카테고리
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 모달
const [equipModalOpen, setEquipModalOpen] = useState(false);
const [equipEditMode, setEquipEditMode] = useState(false);
const [equipForm, setEquipForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 기본정보 탭 편집 폼
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
const [infoSaving, setInfoSaving] = useState(false);
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySourceEquip, setCopySourceEquip] = useState("");
const [copyItems, setCopyItems] = useState<any[]>([]);
const [copyChecked, setCopyChecked] = useState<Set<string>>(new Set());
const [copyLoading, setCopyLoading] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("equipment-info");
if (saved) applyTableSettings(saved);
}, []);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// equipment_mng 카테고리
for (const col of ["equipment_type", "operation_status"]) {
try {
const res = await apiClient.get(`/table-categories/${EQUIP_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
// inspection 카테고리
for (const col of ["inspection_cycle", "inspection_method"]) {
try {
const res = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCatOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return catOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 설비 조회
const fetchEquipments = useCallback(async () => {
setEquipLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
useEffect(() => { fetchEquipments(); }, [fetchEquipments]);
const selectedEquip = equipments.find((e) => e.id === selectedEquipId);
// 기본정보 탭 폼 초기화 (설비 선택 변경 시)
useEffect(() => {
if (selectedEquip) setInfoForm({ ...selectedEquip });
else setInfoForm({});
}, [selectedEquipId]); // eslint-disable-line react-hooks/exhaustive-deps
// 기본정보 저장
const handleInfoSave = async () => {
if (!infoForm.id) return;
setInfoSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = infoForm;
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("저장되었습니다.");
fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); }
finally { setInfoSaving(false); }
};
// 우측: 점검항목 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetch = async () => {
setInspectionLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setInspections(raw.map((r: any) => ({
...r,
inspection_cycle: resolve("inspection_cycle", r.inspection_cycle),
inspection_method: resolve("inspection_method", r.inspection_method),
})));
} catch { setInspections([]); } finally { setInspectionLoading(false); }
};
fetch();
}, [selectedEquip?.equipment_code, catOptions]);
// 우측: 소모품 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetch = async () => {
setConsumableLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
setConsumables(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setConsumables([]); } finally { setConsumableLoading(false); }
};
fetch();
}, [selectedEquip?.equipment_code]);
// 새로고침 헬퍼
const refreshRight = () => {
const eid = selectedEquipId;
setSelectedEquipId(null);
setTimeout(() => setSelectedEquipId(eid), 50);
};
// 설비 등록/수정
const openEquipRegister = () => { setEquipForm({}); setEquipEditMode(false); setEquipModalOpen(true); };
const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); };
const handleEquipSave = async () => {
if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; }
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = equipForm;
if (equipEditMode && id) {
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/add`, fields);
toast.success("등록되었습니다.");
}
setEquipModalOpen(false); fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
const handleEquipDelete = async () => {
if (!selectedEquipId) return;
const ok = await confirm("설비를 삭제하시겠습니까?", { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: [{ id: selectedEquipId }] });
toast.success("삭제되었습니다."); setSelectedEquipId(null); fetchEquipments();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
setSaving(true);
try {
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...inspectionForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다."); setInspectionModalOpen(false); refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 소모품 추가
// 소모품 품목 로드 (item_info에서 type 또는 division 라벨이 "소모품"인 것)
const loadConsumableItems = async () => {
try {
const flatten = (vals: any[]): any[] => {
const r: any[] = [];
for (const v of vals) { r.push(v); if (v.children?.length) r.push(...flatten(v.children)); }
return r;
};
// type과 division 카테고리 모두에서 "소모품" 코드 찾기
const [typeRes, divRes] = await Promise.all([
apiClient.get(`/table-categories/item_info/type/values`),
apiClient.get(`/table-categories/item_info/division/values`),
]);
const consumableType = flatten(typeRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
const consumableDiv = flatten(divRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
if (!consumableType && !consumableDiv) { setConsumableItemOptions([]); return; }
// 두 필터 결과를 합산 (중복 제거)
const filters: any[] = [];
if (consumableType) filters.push({ columnName: "type", operator: "equals", value: consumableType.valueCode });
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
const results = await Promise.all(filters.map((f) =>
apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [f] },
autoFilter: true,
})
));
const allItems = new Map<string, any>();
for (const res of results) {
const rows = res.data?.data?.data || res.data?.data?.rows || [];
for (const row of rows) allItems.set(row.id, row);
}
setConsumableItemOptions(Array.from(allItems.values()));
} catch { setConsumableItemOptions([]); }
};
const handleConsumableSave = async () => {
if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; }
setSaving(true);
try {
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
...consumableForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다."); setConsumableModalOpen(false); refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 점검항목 복사: 소스 설비 선택 시 점검항목 로드
const loadCopyItems = async (equipCode: string) => {
setCopySourceEquip(equipCode);
setCopyChecked(new Set());
if (!equipCode) { setCopyItems([]); return; }
setCopyLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
autoFilter: true,
});
setCopyItems(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setCopyItems([]); } finally { setCopyLoading(false); }
};
const handleCopyApply = async () => {
const selected = copyItems.filter((i) => copyChecked.has(i.id));
if (selected.length === 0) { toast.error("복사할 항목을 선택해주세요."); return; }
setSaving(true);
try {
for (const item of selected) {
const { id, created_date, updated_date, writer, company_code, equipment_code, ...fields } = item;
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...fields, equipment_code: selectedEquip?.equipment_code,
});
}
toast.success(`${selected.length}개 점검항목이 복사되었습니다.`);
setCopyModalOpen(false); refreshRight();
} catch { toast.error("복사 실패"); } finally { setSaving(false); }
};
// 엑셀
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
// 셀렉트 렌더링 헬퍼
const catSelect = (key: string, value: string, onChange: (v: string) => void, placeholder: string) => (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
{(catOptions[key] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
return (
<div className="flex h-full flex-col gap-3 p-3">
<DynamicSearchFilter tableName={EQUIP_TABLE} filterId="equipment-info" onFilterChange={setSearchFilters} dataCount={equipCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
onClick={async () => {
setExcelDetecting(true);
try {
const r = await autoDetectMultiTableConfig(EQUIP_TABLE);
if (r.success && r.data) { setExcelChainConfig(r.data); setExcelUploadOpen(true); }
else toast.error("테이블 구조 분석 실패");
} catch { toast.error("오류"); } finally { setExcelDetecting(false); }
}}>
{excelDetecting ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5 mr-1" />}
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 설비 목록 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Wrench className="w-4 h-4" /> <Badge variant="secondary" className="font-normal">{equipCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={openEquipRegister}><Plus className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={openEquipEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="destructive" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
</div>
</div>
<DataGrid gridId="equip-left" columns={LEFT_COLUMNS} data={equipments} loading={equipLoading}
selectedId={selectedEquipId} onSelect={setSelectedEquipId} onRowDoubleClick={() => openEquipEdit()}
emptyMessage="등록된 설비가 없습니다" />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 탭 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-2 border-b bg-muted/10 shrink-0">
<div className="flex items-center gap-1">
{([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => (
<button key={tab} onClick={() => setRightTab(tab)}
className={cn("px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1",
rightTab === tab ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground")}>
<Icon className="w-3.5 h-3.5" />{label}
{tab === "inspection" && inspections.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{inspections.length}</Badge>}
{tab === "consumable" && consumables.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{consumables.length}</Badge>}
</button>
))}
{selectedEquip && <Badge variant="outline" className="font-normal ml-2 text-xs">{selectedEquip.equipment_name}</Badge>}
</div>
<div className="flex gap-1.5">
{rightTab === "inspection" && (
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
</div>
{!selectedEquipId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm"> </div>
) : rightTab === "info" ? (
<div className="p-4 overflow-auto">
<div className="flex justify-end mb-3">
<Button size="sm" onClick={handleInfoSave} disabled={infoSaving}>
{infoSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={infoForm.equipment_code || ""} className="h-9 bg-muted/50" disabled />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.equipment_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, equipment_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("equipment_type", infoForm.equipment_type, (v) => setInfoForm((p) => ({ ...p, equipment_type: v })), "설비유형")}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.installation_location || ""} onChange={(e) => setInfoForm((p) => ({ ...p, installation_location: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.manufacturer || ""} onChange={(e) => setInfoForm((p) => ({ ...p, manufacturer: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.model_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, model_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={infoForm.introduction_date || ""} onChange={(v) => setInfoForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("operation_status", infoForm.operation_status, (v) => setInfoForm((p) => ({ ...p, operation_status: v })), "가동상태")}
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<Input value={infoForm.remarks || ""} onChange={(e) => setInfoForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<ImageUpload value={infoForm.image_path} onChange={(v) => setInfoForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={infoForm.id} columnName="image_path" />
</div>
</div>
</div>
) : rightTab === "inspection" ? (
<DataGrid gridId="equip-inspection" columns={INSPECTION_COLUMNS} data={inspections} loading={inspectionLoading}
showRowNumber={false} tableName={INSPECTION_TABLE} emptyMessage="점검항목이 없습니다"
onCellEdit={() => refreshRight()} />
) : (
<DataGrid gridId="equip-consumable" columns={CONSUMABLE_COLUMNS} data={consumables} loading={consumableLoading}
showRowNumber={false} tableName={CONSUMABLE_TABLE} emptyMessage="소모품이 없습니다"
onCellEdit={() => refreshRight()} />
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 설비 등록/수정 모달 */}
<FullscreenDialog open={equipModalOpen} onOpenChange={setEquipModalOpen}
title={equipEditMode ? "설비 수정" : "설비 등록"} description={equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}
defaultMaxWidth="max-w-2xl"
footer={<><Button variant="outline" onClick={() => setEquipModalOpen(false)}></Button>
<Button onClick={handleEquipSave} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} </Button></>}>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} /></div>
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.installation_location || ""} onChange={(e) => setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.manufacturer || ""} onChange={(e) => setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.model_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<FormDatePicker value={equipForm.introduction_date || ""} onChange={(v) => setEquipForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<Input value={equipForm.remarks || ""} onChange={(e) => setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={equipForm.image_path} onChange={(v) => setEquipForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" /></div>
</div>
</FullscreenDialog>
{/* 점검항목 추가 모달 */}
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle> </DialogTitle><DialogDescription>{selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="점검항목" className="h-9" /></div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => setInspectionForm((p) => ({ ...p, inspection_method: v })), "점검방법")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.lower_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, lower_limit: e.target.value }))} placeholder="하한치" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.upper_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, upper_limit: e.target.value }))} placeholder="상한치" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.inspection_content || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setInspectionModalOpen(false)}></Button>
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button></DialogFooter>
</DialogContent>
</Dialog>
{/* 소모품 추가 모달 */}
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle> </DialogTitle><DialogDescription>{selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5 col-span-2"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{consumableItemOptions.length > 0 ? (
<Select value={consumableForm.consumable_name || ""} onValueChange={(v) => {
const item = consumableItemOptions.find((i) => (i.item_name || i.item_number) === v);
setConsumableForm((p) => ({
...p,
consumable_name: v,
specification: item?.size || p.specification || "",
unit: item?.unit || p.unit || "",
manufacturer: item?.manufacturer || p.manufacturer || "",
}));
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="소모품 선택" /></SelectTrigger>
<SelectContent>
{consumableItemOptions.map((item) => (
<SelectItem key={item.id} value={item.item_name || item.item_number}>
{item.item_name}{item.size ? ` (${item.size})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div>
<Input value={consumableForm.consumable_name || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, consumable_name: e.target.value }))}
placeholder="소모품명 직접 입력" className="h-9" />
<p className="text-xs text-muted-foreground mt-1"> </p>
</div>
)}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.replacement_cycle || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.unit || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.specification || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.manufacturer || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setConsumableModalOpen(false)}></Button>
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button></DialogFooter>
</DialogContent>
</Dialog>
{/* 점검항목 복사 모달 */}
<Dialog open={copyModalOpen} onOpenChange={setCopyModalOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader><DialogTitle> </DialogTitle>
<DialogDescription> {selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select value={copySourceEquip} onValueChange={(v) => loadCopyItems(v)}>
<SelectTrigger className="h-9"><SelectValue placeholder="복사할 설비 선택" /></SelectTrigger>
<SelectContent>
{equipments.filter((e) => e.equipment_code !== selectedEquip?.equipment_code).map((e) => (
<SelectItem key={e.equipment_code} value={e.equipment_code}>{e.equipment_name} ({e.equipment_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border rounded-lg overflow-auto max-h-[300px]">
{copyLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : copyItems.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">{copySourceEquip ? "점검항목이 없습니다" : "설비를 선택하세요"}</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox" checked={copyItems.length > 0 && copyChecked.size === copyItems.length}
onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} />
</TableHead>
<TableHead></TableHead><TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead><TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[70px]"></TableHead><TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyItems.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer", copyChecked.has(item.id) && "bg-primary/5")}
onClick={() => setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}>
<TableCell className="text-center"><input type="checkbox" checked={copyChecked.has(item.id)} readOnly /></TableCell>
<TableCell className="text-sm">{item.inspection_item}</TableCell>
<TableCell className="text-xs">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-xs">{resolve("inspection_method", item.inspection_method)}</TableCell>
<TableCell className="text-xs">{item.lower_limit || "-"}</TableCell>
<TableCell className="text-xs">{item.upper_limit || "-"}</TableCell>
<TableCell className="text-xs">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{copyChecked.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setCopyModalOpen(false)}></Button>
<Button onClick={handleCopyApply} disabled={saving || copyChecked.size === 0}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Copy className="w-4 h-4 mr-1.5" />}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 (멀티테이블) */}
{excelChainConfig && (
<MultiTableExcelUploadModal open={excelUploadOpen}
onOpenChange={(open) => { setExcelUploadOpen(open); if (!open) setExcelChainConfig(null); }}
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
)}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={EQUIP_TABLE}
settingsId="equipment-info"
onSave={applyTableSettings}
/>
{ConfirmDialogComponent}
</div>
);
}
@@ -0,0 +1,597 @@
"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,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
getWorkOrders,
getMaterialStatus,
getWarehouses,
type WorkOrder,
type MaterialData,
type WarehouseData,
} from "@/lib/api/materialStatus";
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-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";
};
export default function MaterialStatusPage() {
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<number[]>([]);
const [selectedWoId, setSelectedWoId] = useState<number | 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: number, checked: boolean) => {
setCheckedWoIds((prev) =>
checked ? [...prev, id] : prev.filter((i) => i !== id)
);
}, []);
const handleSelectWo = useCallback((id: number) => {
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-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"
onClick={handleSearch}
disabled={workOrdersLoading}
>
{workOrdersLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<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}
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>
</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="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.plan_no || wo.work_order_no || `WO-${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.item_name}
</span>
<span className="text-xs text-muted-foreground">
({wo.item_code})
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>:</span>
<span className="font-semibold text-foreground">
{Number(wo.plan_qty).toLocaleString()}
</span>
<span className="mx-1">|</span>
<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/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>
<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-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">
{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="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.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 =
material.required > 0
? Math.min(
(material.current / material.required) * 100,
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>
{/* 위치별 재고 */}
{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>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,926 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
ResizableHandle, ResizablePanel, ResizablePanelGroup,
} from "@/components/ui/resizable";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandInput, CommandList, CommandEmpty, CommandItem } from "@/components/ui/command";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import {
Search, Plus, Trash2, RotateCcw, Loader2, Package, Box, X, Save, Edit2, Download, ChevronsUpDown, Check,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
getItemsByDivision, getGeneralItems,
type PkgUnit, type PkgUnitItem, type LoadingUnit, type LoadingUnitPkg, type ItemInfoForPkg,
} from "@/lib/api/packaging";
// --- 코드 → 라벨 매핑 ---
const PKG_TYPE_LABEL: Record<string, string> = {
BOX: "박스", PACK: "팩", CANBOARD: "캔보드", AIRCAP: "에어캡",
ZIPCOS: "집코스", CYLINDER: "원통형", POLYCARTON: "포리/카톤",
};
const LOADING_TYPE_LABEL: Record<string, string> = {
PALLET: "파렛트", WOOD_PALLET: "목재파렛트", PLASTIC_PALLET: "플라스틱파렛트",
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
CAGE: "케이지", ETC: "기타",
};
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용" };
const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-emerald-100 text-emerald-800" : "bg-gray-100 text-gray-600";
const fmtSize = (w: any, l: any, h: any) => {
const vals = [w, l, h].map(v => Number(v) || 0);
return vals.some(v => v > 0) ? vals.join("×") : "-";
};
// 규격 문자열에서 치수 파싱
function parseSpecDimensions(spec: string | null) {
if (!spec) return { w: 0, l: 0, h: 0 };
const m3 = spec.match(/(\d+)\s*[x×]\s*(\d+)\s*[x×]\s*(\d+)/i);
if (m3) return { w: parseInt(m3[1]), l: parseInt(m3[2]), h: parseInt(m3[3]) };
const m2 = spec.match(/(\d+)\s*[x×]\s*(\d+)/i);
if (m2) return { w: parseInt(m2[1]), l: parseInt(m2[2]), h: 0 };
return { w: 0, l: 0, h: 0 };
}
export default function PackagingPage() {
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [activeTab, setActiveTab] = useState<"packing" | "loading">("packing");
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
// 포장재 데이터
const [pkgUnits, setPkgUnits] = useState<PkgUnit[]>([]);
const [pkgLoading, setPkgLoading] = useState(false);
const [selectedPkg, setSelectedPkg] = useState<PkgUnit | null>(null);
const [pkgItems, setPkgItems] = useState<PkgUnitItem[]>([]);
const [pkgItemsLoading, setPkgItemsLoading] = useState(false);
// 적재함 데이터
const [loadingUnits, setLoadingUnits] = useState<LoadingUnit[]>([]);
const [loadingLoading, setLoadingLoading] = useState(false);
const [selectedLoading, setSelectedLoading] = useState<LoadingUnit | null>(null);
const [loadingPkgs, setLoadingPkgs] = useState<LoadingUnitPkg[]>([]);
const [loadingPkgsLoading, setLoadingPkgsLoading] = useState(false);
// 모달
const [pkgModalOpen, setPkgModalOpen] = useState(false);
const [pkgModalMode, setPkgModalMode] = useState<"create" | "edit">("create");
const [pkgForm, setPkgForm] = useState<Record<string, any>>({});
const [pkgItemOptions, setPkgItemOptions] = useState<ItemInfoForPkg[]>([]);
const [pkgItemPopoverOpen, setPkgItemPopoverOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false);
const [loadModalMode, setLoadModalMode] = useState<"create" | "edit">("create");
const [loadForm, setLoadForm] = useState<Record<string, any>>({});
const [loadItemOptions, setLoadItemOptions] = useState<ItemInfoForPkg[]>([]);
const [loadItemPopoverOpen, setLoadItemPopoverOpen] = useState(false);
const [itemMatchModalOpen, setItemMatchModalOpen] = useState(false);
const [itemMatchKeyword, setItemMatchKeyword] = useState("");
const [itemMatchResults, setItemMatchResults] = useState<ItemInfoForPkg[]>([]);
const [itemMatchSelected, setItemMatchSelected] = useState<ItemInfoForPkg | null>(null);
const [itemMatchQty, setItemMatchQty] = useState(1);
const [pkgMatchModalOpen, setPkgMatchModalOpen] = useState(false);
const [pkgMatchQty, setPkgMatchQty] = useState(1);
const [pkgMatchMethod, setPkgMatchMethod] = useState("");
const [pkgMatchSelected, setPkgMatchSelected] = useState<PkgUnit | null>(null);
const [pkgMatchSearchKw, setPkgMatchSearchKw] = useState("");
const [saving, setSaving] = useState(false);
// --- 데이터 로드 ---
const fetchPkgUnits = useCallback(async () => {
setPkgLoading(true);
try {
const res = await getPkgUnits();
if (res.success) setPkgUnits(res.data);
} catch { /* ignore */ } finally { setPkgLoading(false); }
}, []);
const fetchLoadingUnits = useCallback(async () => {
setLoadingLoading(true);
try {
const res = await getLoadingUnits();
if (res.success) setLoadingUnits(res.data);
} catch { /* ignore */ } finally { setLoadingLoading(false); }
}, []);
useEffect(() => { fetchPkgUnits(); fetchLoadingUnits(); }, [fetchPkgUnits, fetchLoadingUnits]);
// 포장재 선택 시 매칭 품목 로드
const selectPkg = useCallback(async (pkg: PkgUnit) => {
setSelectedPkg(pkg);
setPkgItemsLoading(true);
try {
const res = await getPkgUnitItems(pkg.pkg_code);
if (res.success) setPkgItems(res.data);
} catch { setPkgItems([]); } finally { setPkgItemsLoading(false); }
}, []);
// 적재함 선택 시 포장구성 로드
const selectLoading = useCallback(async (lu: LoadingUnit) => {
setSelectedLoading(lu);
setLoadingPkgsLoading(true);
try {
const res = await getLoadingUnitPkgs(lu.loading_code);
if (res.success) setLoadingPkgs(res.data);
} catch { setLoadingPkgs([]); } finally { setLoadingPkgsLoading(false); }
}, []);
// 검색 필터 적용
const filteredPkgUnits = pkgUnits.filter((p) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw));
});
const filteredLoadingUnits = loadingUnits.filter((l) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (l.loading_code?.toLowerCase().includes(kw) || l.loading_name?.toLowerCase().includes(kw));
});
// --- 포장재 등록/수정 모달 ---
const openPkgModal = async (mode: "create" | "edit") => {
setPkgModalMode(mode);
if (mode === "edit" && selectedPkg) {
setPkgForm({ ...selectedPkg });
} else {
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" });
}
setPkgItemPopoverOpen(false);
try {
const res = await getItemsByDivision("포장재");
if (res.success) setPkgItemOptions(res.data);
} catch { setPkgItemOptions([]); }
setPkgModalOpen(true);
};
const onPkgItemSelect = (item: ItemInfoForPkg) => {
setPkgItemPopoverOpen(false);
const dims = parseSpecDimensions(item.size);
setPkgForm((prev) => ({
...prev,
pkg_code: item.item_number,
pkg_name: item.item_name,
width_mm: dims.w || prev.width_mm,
length_mm: dims.l || prev.length_mm,
height_mm: dims.h || prev.height_mm,
}));
};
const savePkgUnit = async () => {
if (!pkgForm.pkg_code || !pkgForm.pkg_name) { toast.error("포장코드와 포장명은 필수입니다."); return; }
if (!pkgForm.pkg_type) { toast.error("포장유형을 선택해주세요."); return; }
setSaving(true);
try {
if (pkgModalMode === "create") {
const res = await createPkgUnit(pkgForm);
if (res.success) { toast.success("포장재 등록 완료"); setPkgModalOpen(false); fetchPkgUnits(); }
} else {
const res = await updatePkgUnit(pkgForm.id, pkgForm);
if (res.success) { toast.success("포장재 수정 완료"); setPkgModalOpen(false); fetchPkgUnits(); setSelectedPkg(res.data); }
}
} catch { toast.error("저장 실패"); } finally { setSaving(false); }
};
const handleDeletePkg = async (pkg: PkgUnit) => {
const ok = await confirm(`"${pkg.pkg_name}" 포장재를 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deletePkgUnit(pkg.id);
toast.success("삭제 완료");
setSelectedPkg(null); setPkgItems([]);
fetchPkgUnits();
} catch { toast.error("삭제 실패"); }
};
// --- 적재함 등록/수정 모달 ---
const openLoadModal = async (mode: "create" | "edit") => {
setLoadModalMode(mode);
if (mode === "edit" && selectedLoading) {
setLoadForm({ ...selectedLoading });
} else {
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" });
}
setLoadItemPopoverOpen(false);
try {
const res = await getItemsByDivision("적재함");
if (res.success) setLoadItemOptions(res.data);
} catch { setLoadItemOptions([]); }
setLoadModalOpen(true);
};
const onLoadItemSelect = (item: ItemInfoForPkg) => {
setLoadItemPopoverOpen(false);
const dims = parseSpecDimensions(item.size);
setLoadForm((prev) => ({
...prev,
loading_code: item.item_number,
loading_name: item.item_name,
width_mm: dims.w || prev.width_mm,
length_mm: dims.l || prev.length_mm,
height_mm: dims.h || prev.height_mm,
}));
};
const saveLoadingUnit = async () => {
if (!loadForm.loading_code || !loadForm.loading_name) { toast.error("적재함코드와 적재함명은 필수입니다."); return; }
if (!loadForm.loading_type) { toast.error("적재유형을 선택해주세요."); return; }
setSaving(true);
try {
if (loadModalMode === "create") {
const res = await createLoadingUnit(loadForm);
if (res.success) { toast.success("적재함 등록 완료"); setLoadModalOpen(false); fetchLoadingUnits(); }
} else {
const res = await updateLoadingUnit(loadForm.id, loadForm);
if (res.success) { toast.success("적재함 수정 완료"); setLoadModalOpen(false); fetchLoadingUnits(); setSelectedLoading(res.data); }
}
} catch { toast.error("저장 실패"); } finally { setSaving(false); }
};
const handleDeleteLoading = async (lu: LoadingUnit) => {
const ok = await confirm(`"${lu.loading_name}" 적재함을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deleteLoadingUnit(lu.id);
toast.success("삭제 완료");
setSelectedLoading(null); setLoadingPkgs([]);
fetchLoadingUnits();
} catch { toast.error("삭제 실패"); }
};
// --- 품목 추가 모달 (포장재 매칭) ---
const openItemMatchModal = async () => {
setItemMatchKeyword(""); setItemMatchSelected(null); setItemMatchQty(1);
setItemMatchModalOpen(true);
try {
const res = await getGeneralItems();
if (res.success) setItemMatchResults(res.data);
} catch { setItemMatchResults([]); }
};
const searchItemsForMatch = async () => {
try {
const res = await getGeneralItems(itemMatchKeyword || undefined);
if (res.success) setItemMatchResults(res.data);
} catch { setItemMatchResults([]); }
};
const saveItemMatch = async () => {
if (!selectedPkg || !itemMatchSelected) { toast.error("품목을 선택해주세요."); return; }
if (itemMatchQty <= 0) { toast.error("포장수량을 입력해주세요."); return; }
setSaving(true);
try {
const res = await createPkgUnitItem({
pkg_code: selectedPkg.pkg_code,
item_number: itemMatchSelected.item_number,
pkg_qty: itemMatchQty,
});
if (res.success) { toast.success("품목 추가 완료"); setItemMatchModalOpen(false); selectPkg(selectedPkg); }
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
};
const handleDeletePkgItem = async (item: PkgUnitItem) => {
const ok = await confirm("매칭 품목을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deletePkgUnitItem(item.id);
toast.success("삭제 완료");
if (selectedPkg) selectPkg(selectedPkg);
} catch { toast.error("삭제 실패"); }
};
// --- 포장단위 추가 모달 (적재함 구성) ---
const openPkgMatchModal = () => {
setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod(""); setPkgMatchSearchKw("");
setPkgMatchModalOpen(true);
};
const savePkgMatch = async () => {
if (!selectedLoading || !pkgMatchSelected) { toast.error("포장단위를 선택해주세요."); return; }
if (pkgMatchQty <= 0) { toast.error("최대적재수량을 입력해주세요."); return; }
setSaving(true);
try {
const res = await createLoadingUnitPkg({
loading_code: selectedLoading.loading_code,
pkg_code: pkgMatchSelected.pkg_code,
max_load_qty: pkgMatchQty,
load_method: pkgMatchMethod || undefined,
});
if (res.success) { toast.success("포장단위 추가 완료"); setPkgMatchModalOpen(false); selectLoading(selectedLoading); }
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
};
const handleDeleteLoadPkg = async (lp: LoadingUnitPkg) => {
const ok = await confirm("적재 구성을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deleteLoadingUnitPkg(lp.id);
toast.success("삭제 완료");
if (selectedLoading) selectLoading(selectedLoading);
} catch { toast.error("삭제 실패"); }
};
return (
<div className="flex h-full flex-col gap-3 p-4">
{/* 검색 바 */}
<div className="flex items-center gap-2 rounded-lg border bg-card p-3">
<Input
placeholder="포장코드 / 포장명 / 적재함명 검색"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="h-9 w-[280px] text-xs"
/>
<Button size="sm" variant="outline" onClick={() => setSearchKeyword("")} className="h-9">
<RotateCcw className="mr-1 h-4 w-4" />
</Button>
</div>
{/* 탭 */}
<div className="flex gap-1 border-b">
{([["packing", "포장재 관리", filteredPkgUnits.length] as const, ["loading", "적재함 관리", filteredLoadingUnits.length] as const]).map(([tab, label, count]) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px",
activeTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab === "packing" ? <Package className="h-4 w-4" /> : <Box className="h-4 w-4" />}
{label}
<Badge variant="secondary" className="text-[10px] px-1.5">{count}</Badge>
</button>
))}
</div>
{/* 탭 콘텐츠 */}
<div className="flex-1 overflow-hidden">
{activeTab === "packing" ? (
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* 좌측: 포장재 목록 */}
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2.5">
<span className="text-sm font-semibold"> <span className="text-muted-foreground font-normal">({filteredPkgUnits.length})</span></span>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("create")}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="text-[11px] bg-muted/50">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[90px]">(mm)</TableHead>
<TableHead className="p-2 w-[70px] text-right"></TableHead>
<TableHead className="p-2 w-[55px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pkgLoading ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
) : filteredPkgUnits.length === 0 ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs"> </TableCell></TableRow>
) : filteredPkgUnits.map((p) => (
<TableRow
key={p.id}
className={cn("cursor-pointer text-xs", selectedPkg?.id === p.id && "bg-primary/5")}
onClick={() => selectPkg(p)}
>
<TableCell className="p-2 font-medium truncate max-w-[100px]">{p.pkg_code}</TableCell>
<TableCell className="p-2 truncate max-w-[120px]">{p.pkg_name}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type || "-"}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(p.status))}>{STATUS_LABEL[p.status] || p.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 상세 */}
<ResizablePanel defaultSize={55} minSize={30}>
{!selectedPkg ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Package className="h-12 w-12 opacity-20 mb-2" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex h-full flex-col">
{/* 요약 헤더 */}
<div className="flex items-center justify-between border-b bg-blue-50 dark:bg-blue-950/20 px-4 py-3">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-blue-600" />
<div>
<div className="font-bold text-sm">{selectedPkg.pkg_name}</div>
<div className="text-[11px] text-muted-foreground">{selectedPkg.pkg_code} · {PKG_TYPE_LABEL[selectedPkg.pkg_type] || selectedPkg.pkg_type} · {fmtSize(selectedPkg.width_mm, selectedPkg.length_mm, selectedPkg.height_mm)}mm</div>
</div>
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("edit")}>
<Edit2 className="mr-1 h-3 w-3" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeletePkg(selectedPkg)}>
<Trash2 className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 매칭 품목 */}
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground"> ({pkgItems.length})</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openItemMatchModal}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-auto">
{pkgItemsLoading ? (
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : pkgItems.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs"> </div>
) : (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[50px]"></TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
<TableHead className="p-2 w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{pkgItems.map((item) => (
<TableRow key={item.id} className="text-xs">
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
<X className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
) : (
/* 적재함 관리 탭 */
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2.5">
<span className="text-sm font-semibold"> <span className="text-muted-foreground font-normal">({filteredLoadingUnits.length})</span></span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("create")}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="text-[11px] bg-muted/50">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[80px]"></TableHead>
<TableHead className="p-2 w-[90px]">(mm)</TableHead>
<TableHead className="p-2 w-[70px] text-right"></TableHead>
<TableHead className="p-2 w-[55px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingLoading ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
) : filteredLoadingUnits.length === 0 ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs"> </TableCell></TableRow>
) : filteredLoadingUnits.map((l) => (
<TableRow
key={l.id}
className={cn("cursor-pointer text-xs", selectedLoading?.id === l.id && "bg-primary/5")}
onClick={() => selectLoading(l)}
>
<TableCell className="p-2 font-medium truncate max-w-[100px]">{l.loading_code}</TableCell>
<TableCell className="p-2 truncate max-w-[120px]">{l.loading_name}</TableCell>
<TableCell className="p-2">{LOADING_TYPE_LABEL[l.loading_type] || l.loading_type || "-"}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(l.status))}>{STATUS_LABEL[l.status] || l.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={55} minSize={30}>
{!selectedLoading ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Box className="h-12 w-12 opacity-20 mb-2" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b bg-green-50 dark:bg-green-950/20 px-4 py-3">
<div className="flex items-center gap-3">
<Box className="h-5 w-5 text-green-600" />
<div>
<div className="font-bold text-sm">{selectedLoading.loading_name}</div>
<div className="text-[11px] text-muted-foreground">{selectedLoading.loading_code} · {LOADING_TYPE_LABEL[selectedLoading.loading_type] || selectedLoading.loading_type} · {fmtSize(selectedLoading.width_mm, selectedLoading.length_mm, selectedLoading.height_mm)}mm</div>
</div>
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("edit")}><Edit2 className="mr-1 h-3 w-3" /> </Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeleteLoading(selectedLoading)}><Trash2 className="mr-1 h-3 w-3" /> </Button>
</div>
</div>
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground"> ({loadingPkgs.length})</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openPkgMatchModal}><Plus className="mr-1 h-3 w-3" /> </Button>
</div>
<div className="flex-1 overflow-auto">
{loadingPkgsLoading ? (
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : loadingPkgs.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs"> </div>
) : (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{loadingPkgs.map((lp) => (
<TableRow key={lp.id} className="text-xs">
<TableCell className="p-2 font-medium">{lp.pkg_code}</TableCell>
<TableCell className="p-2">{lp.pkg_name || "-"}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[lp.pkg_type || ""] || lp.pkg_type || "-"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(lp.max_load_qty).toLocaleString()}</TableCell>
<TableCell className="p-2">{lp.load_method || "-"}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeleteLoadPkg(lp)}><X className="h-3 w-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
{/* 포장재 등록/수정 모달 */}
<FullscreenDialog open={pkgModalOpen} onOpenChange={setPkgModalOpen}
title={pkgModalMode === "create" ? "포장재 등록" : "포장재 수정"}
description="품목정보에서 포장재를 선택하면 코드와 이름이 자동 연동됩니다."
footer={
<div className="flex gap-2">
<Button variant="outline" onClick={() => setPkgModalOpen(false)}></Button>
<Button onClick={savePkgUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} </Button>
</div>
}
>
<div className="space-y-4 p-6">
{/* 품목정보 연결 */}
{pkgModalMode === "create" && (
<div className="rounded-lg border bg-blue-50 dark:bg-blue-950/20 p-4">
<Label className="text-xs font-semibold mb-2 block"> (구분: 포장재)</Label>
<Popover open={pkgItemPopoverOpen} onOpenChange={setPkgItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
{pkgForm.pkg_code
? `${pkgForm.pkg_name} (${pkgForm.pkg_code})`
: "품목정보에서 포장재를 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = pkgItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{pkgItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onPkgItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", pkgForm.pkg_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-xs"></Label><Input value={pkgForm.pkg_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div><Label className="text-xs"></Label><Input value={pkgForm.pkg_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div>
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select value={pkgForm.pkg_type || ""} onValueChange={(v) => setPkgForm((p) => ({ ...p, pkg_type: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>{Object.entries(PKG_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={pkgForm.status || "ACTIVE"} onValueChange={(v) => setPkgForm((p) => ({ ...p, status: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs font-semibold"></Label>
<div className="grid grid-cols-3 gap-3 mt-2">
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.width_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.length_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.height_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={pkgForm.self_weight_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={pkgForm.max_load_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(L)</Label><Input type="number" value={pkgForm.volume_l || ""} onChange={(e) => setPkgForm((p) => ({ ...p, volume_l: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
</div>
</div>
<div><Label className="text-xs"></Label><Input value={pkgForm.remarks || ""} onChange={(e) => setPkgForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
</div>
</FullscreenDialog>
{/* 적재함 등록/수정 모달 */}
<FullscreenDialog open={loadModalOpen} onOpenChange={setLoadModalOpen}
title={loadModalMode === "create" ? "적재함 등록" : "적재함 수정"}
description="품목정보에서 적재함을 선택하면 코드와 이름이 자동 연동됩니다."
footer={
<div className="flex gap-2">
<Button variant="outline" onClick={() => setLoadModalOpen(false)}></Button>
<Button onClick={saveLoadingUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} </Button>
</div>
}
>
<div className="space-y-4 p-6">
{loadModalMode === "create" && (
<div className="rounded-lg border bg-green-50 dark:bg-green-950/20 p-4">
<Label className="text-xs font-semibold mb-2 block"> (구분: 적재함)</Label>
<Popover open={loadItemPopoverOpen} onOpenChange={setLoadItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
{loadForm.loading_code
? `${loadForm.loading_name} (${loadForm.loading_code})`
: "품목정보에서 적재함을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = loadItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{loadItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onLoadItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", loadForm.loading_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-xs"></Label><Input value={loadForm.loading_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div><Label className="text-xs"></Label><Input value={loadForm.loading_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div>
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select value={loadForm.loading_type || ""} onValueChange={(v) => setLoadForm((p) => ({ ...p, loading_type: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>{Object.entries(LOADING_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={loadForm.status || "ACTIVE"} onValueChange={(v) => setLoadForm((p) => ({ ...p, status: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs font-semibold"></Label>
<div className="grid grid-cols-3 gap-3 mt-2">
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.width_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.length_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.height_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={loadForm.self_weight_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={loadForm.max_load_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]"></Label><Input type="number" value={loadForm.max_stack || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_stack: e.target.value }))} className="h-8 text-xs" /></div>
</div>
</div>
<div><Label className="text-xs"></Label><Input value={loadForm.remarks || ""} onChange={(e) => setLoadForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
</div>
</FullscreenDialog>
{/* 품목 추가 모달 (포장재 매칭) */}
<Dialog open={itemMatchModalOpen} onOpenChange={setItemMatchModalOpen}>
<DialogContent className="max-w-[900px]">
<DialogHeader>
<DialogTitle> {selectedPkg?.pkg_name}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Input placeholder="품목코드 / 품목명 검색 (입력 시 자동 검색)" value={itemMatchKeyword}
onChange={(e) => {
setItemMatchKeyword(e.target.value);
const kw = e.target.value;
clearTimeout((window as any).__itemMatchTimer);
(window as any).__itemMatchTimer = setTimeout(async () => {
try {
const res = await getGeneralItems(kw || undefined);
if (res.success) setItemMatchResults(res.data);
} catch { /* ignore */ }
}, 300);
}}
className="h-9 text-xs" />
<div className="max-h-[300px] overflow-auto border rounded">
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2 w-[30px]" />
<TableHead className="p-2 w-[130px]"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[100px]"></TableHead>
<TableHead className="p-2 w-[80px]"></TableHead>
<TableHead className="p-2 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16"> </TableCell></TableRow>
) : itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer text-xs", itemMatchSelected?.id === item.id && "bg-primary/10")}
onClick={() => setItemMatchSelected(item)}>
<TableCell className="p-2 text-center">{itemMatchSelected?.id === item.id ? "✓" : ""}</TableCell>
<TableCell className="p-2 font-medium truncate max-w-[130px]">{item.item_number}</TableCell>
<TableCell className="p-2 truncate max-w-[200px]">{item.item_name}</TableCell>
<TableCell className="p-2 truncate">{item.spec || "-"}</TableCell>
<TableCell className="p-2 truncate">{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-end gap-4">
<div className="flex-1">
<Label className="text-xs"> </Label>
<Input value={itemMatchSelected ? `${itemMatchSelected.item_name} (${itemMatchSelected.item_number})` : ""} readOnly className="h-9 bg-muted text-xs" />
</div>
<div className="w-[120px]">
<Label htmlFor="pkg-item-match-qty" className="text-xs">(EA) <span className="text-destructive">*</span></Label>
<Input id="pkg-item-match-qty" type="number" value={itemMatchQty} onChange={(e) => setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setItemMatchModalOpen(false)}></Button>
<Button type="button" data-action-type="custom" onClick={saveItemMatch} disabled={saving || !itemMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 포장단위 추가 모달 (적재함 구성) */}
<Dialog open={pkgMatchModalOpen} onOpenChange={setPkgMatchModalOpen}>
<DialogContent className="max-w-[800px]">
<DialogHeader>
<DialogTitle> {selectedLoading?.loading_name}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Input
placeholder="포장코드 / 포장명 검색"
value={pkgMatchSearchKw}
onChange={(e) => setPkgMatchSearchKw(e.target.value)}
className="h-9 text-xs"
/>
<div className="max-h-[300px] overflow-auto border rounded">
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2 w-[30px]" />
<TableHead className="p-2 w-[120px]"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[100px]">(mm)</TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(() => {
const kw = pkgMatchSearchKw.toLowerCase();
const filtered = pkgUnits.filter(p =>
p.status === "ACTIVE"
&& !loadingPkgs.some(lp => lp.pkg_code === p.pkg_code)
&& (!kw || p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw))
);
return filtered.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16"> </TableCell></TableRow>
) : filtered.map((p) => (
<TableRow key={p.id} className={cn("cursor-pointer text-xs", pkgMatchSelected?.id === p.id && "bg-primary/10")}
onClick={() => setPkgMatchSelected(p)}>
<TableCell className="p-2 text-center">{pkgMatchSelected?.id === p.id ? "✓" : ""}</TableCell>
<TableCell className="p-2 font-medium">{p.pkg_code}</TableCell>
<TableCell className="p-2">{p.pkg_name}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
</TableRow>
));
})()}
</TableBody>
</Table>
</div>
<div className="flex items-end gap-4">
<div className="w-[150px]">
<Label htmlFor="loading-pkg-match-qty" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="loading-pkg-match-qty" type="number" value={pkgMatchQty} onChange={(e) => setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
</div>
<div className="flex-1">
<Label className="text-xs"></Label>
<Input value={pkgMatchMethod} onChange={(e) => setPkgMatchMethod(e.target.value)} placeholder="수직/수평/혼합" className="h-9 text-xs" />
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPkgMatchModalOpen(false)}></Button>
<Button type="button" data-action-type="custom" onClick={savePkgMatch} disabled={saving || !pkgMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{ConfirmDialogComponent}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,498 @@
"use client";
/**
* 부서관리 — 하드코딩 페이지
*
* 좌측: 부서 목록 (dept_info)
* 우측: 선택한 부서의 인원 목록 (user_info)
*
* 모달: 부서 등록(dept_info), 사원 추가(user_info)
*/
import React, { useState, 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 { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Building2, Users, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
const DEPT_TABLE = "dept_info";
const USER_TABLE = "user_info";
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
{ key: "dept_name", label: "부서명", minWidth: "min-w-[150px]" },
{ key: "parent_dept_code", label: "상위부서", width: "w-[100px]" },
{ key: "status", label: "상태", width: "w-[70px]" },
];
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "sabun", label: "사번", width: "w-[80px]" },
{ key: "user_name", label: "이름", width: "w-[90px]" },
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
{ key: "position_name", label: "직급", width: "w-[80px]" },
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]" },
{ key: "email", label: "이메일", minWidth: "min-w-[150px]" },
];
export default function DepartmentPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 부서
const [depts, setDepts] = useState<any[]>([]);
const [deptLoading, setDeptLoading] = useState(false);
const [deptCount, setDeptCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
// 우측: 사원
const [members, setMembers] = useState<any[]>([]);
const [memberLoading, setMemberLoading] = useState(false);
// 부서 모달
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [deptEditMode, setDeptEditMode] = useState(false);
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 사원 모달
const [userModalOpen, setUserModalOpen] = useState(false);
const [userEditMode, setUserEditMode] = useState(false);
const [userForm, setUserForm] = useState<Record<string, any>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("department");
if (saved) applyTableSettings(saved);
}, []);
// 부서 조회
const fetchDepts = useCallback(async () => {
setDeptLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// dept_info에 id 컬럼이 없으므로 dept_code를 id로 매핑
const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code }));
setDepts(data);
setDeptCount(res.data?.data?.total || data.length);
} catch (err) {
console.error("부서 조회 실패:", err);
toast.error("부서 목록을 불러오는데 실패했습니다.");
} finally {
setDeptLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchDepts(); }, [fetchDepts]);
// 선택된 부서
const selectedDept = depts.find((d) => d.id === selectedDeptId);
const selectedDeptCode = selectedDept?.dept_code || null;
// 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서)
const fetchMembers = useCallback(async () => {
setMemberLoading(true);
try {
const filters = selectedDeptCode
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
: [];
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setMembers([]); } finally { setMemberLoading(false); }
}, [selectedDeptCode]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
// 부서 등록
const openDeptRegister = () => {
setDeptForm({});
setDeptEditMode(false);
setDeptModalOpen(true);
};
const openDeptEdit = () => {
if (!selectedDept) return;
setDeptForm({ ...selectedDept });
setDeptEditMode(true);
setDeptModalOpen(true);
};
const handleDeptSave = async () => {
if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; }
setSaving(true);
try {
if (deptEditMode && deptForm.dept_code) {
await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, {
originalData: { dept_code: deptForm.dept_code },
updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null },
});
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, {
dept_code: deptForm.dept_code || "",
dept_name: deptForm.dept_name,
parent_dept_code: deptForm.parent_dept_code || null,
});
toast.success("등록되었습니다.");
}
setDeptModalOpen(false);
fetchDepts();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 부서 삭제
const handleDeptDelete = async () => {
if (!selectedDeptCode) return;
const ok = await confirm("부서를 삭제하시겠습니까?", {
description: "해당 부서에 소속된 사원 정보는 유지됩니다.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, {
data: [{ dept_code: selectedDeptCode }],
});
toast.success("삭제되었습니다.");
setSelectedDeptId(null);
fetchDepts();
} catch { toast.error("삭제에 실패했습니다."); }
};
// 사원 추가
const openUserModal = (editData?: any) => {
if (editData) {
setUserEditMode(true);
setUserForm({ ...editData, user_password: "" });
} else {
setUserEditMode(false);
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
}
setFormErrors({});
setUserModalOpen(true);
};
const handleUserFormChange = (field: string, value: string) => {
const formatted = formatField(field, value);
setUserForm((prev) => ({ ...prev, [field]: formatted }));
const error = validateField(field, formatted);
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
};
const handleUserSave = async () => {
if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; }
if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; }
const errors = validateForm(userForm, ["cell_phone", "email"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
setSaving(true);
try {
// 비밀번호 미입력 시 기본값 (신규만)
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
await apiClient.post("/admin/users/with-dept", {
userInfo: {
user_id: userForm.user_id,
user_name: userForm.user_name,
user_name_eng: userForm.user_name_eng || undefined,
user_password: password || undefined,
email: userForm.email || undefined,
tel: userForm.tel || undefined,
cell_phone: userForm.cell_phone || undefined,
sabun: userForm.sabun || undefined,
position_name: userForm.position_name || undefined,
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
status: userForm.status || "active",
},
mainDept: userForm.dept_code ? {
dept_code: userForm.dept_code,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
position_name: userForm.position_name || undefined,
} : undefined,
isUpdate: userEditMode,
});
toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다.");
setUserModalOpen(false);
fetchMembers();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (depts.length === 0) return;
const data = depts.map((d) => ({
부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status,
}));
await exportToExcel(data, "부서관리.xlsx", "부서");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={DEPT_TABLE}
filterId="department"
onFilterChange={setSearchFilters}
dataCount={deptCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 부서 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{deptCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={openDeptRegister}><Plus className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openDeptEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="destructive" size="sm" disabled={!selectedDeptCode} onClick={handleDeptDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
</div>
</div>
<DataGrid
gridId="dept-left"
columns={LEFT_COLUMNS}
data={depts}
loading={deptLoading}
selectedId={selectedDeptId}
onSelect={(id) => {
setSelectedDeptId((prev) => (prev === id ? null : id));
}}
onRowDoubleClick={() => openDeptEdit()}
emptyMessage="등록된 부서가 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 사원 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4" />
{selectedDept ? "부서 인원" : "전체 사원"}
{selectedDept && <Badge variant="outline" className="font-normal">{selectedDept.dept_name}</Badge>}
{members.length > 0 && <Badge variant="secondary" className="font-normal">{members.length}</Badge>}
</div>
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
<DataGrid
gridId="dept-right"
columns={RIGHT_COLUMNS}
data={members}
loading={memberLoading}
showRowNumber={false}
tableName={USER_TABLE}
emptyMessage={selectedDeptCode ? "소속 사원이 없습니다" : "등록된 사원이 없습니다"}
onRowDoubleClick={(row) => openUserModal(row)}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 부서 등록/수정 모달 */}
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
<DialogDescription>{deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={deptForm.dept_code || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_code: e.target.value }))}
placeholder="부서코드" className="h-9" disabled={deptEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={deptForm.dept_name || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
placeholder="부서명" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={deptForm.parent_dept_code || ""} onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="상위부서 선택 (선택사항)" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeptModalOpen(false)}></Button>
<Button onClick={handleDeptSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 사원 추가 모달 */}
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
<DialogDescription>{userEditMode ? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.` : selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"> ID <span className="text-destructive">*</span></Label>
<Input value={userForm.user_id || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={userForm.user_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
placeholder="이름" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
placeholder="사번" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.position_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
placeholder="직급" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
<SelectContent>
{depts.map((d) => <SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.cell_phone || ""} onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} />
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.email || ""} onChange={(e) => handleUserFormChange("email", e.target.value)}
placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} />
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={userForm.regdate || ""} onChange={(v) => setUserForm((p) => ({ ...p, regdate: v }))} placeholder="입사일" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={userForm.end_date || ""} onChange={(v) => setUserForm((p) => ({ ...p, end_date: v }))} placeholder="퇴사일" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserModalOpen(false)}></Button>
<Button onClick={handleUserSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DEPT_TABLE}
userId={user?.userId}
onSuccess={() => fetchDepts()}
/>
{ConfirmDialogComponent}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={DEPT_TABLE}
settingsId="department"
onSave={applyTableSettings}
/>
</div>
);
}
@@ -0,0 +1,517 @@
"use client";
import React, { useState, 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Plus, Trash2, RotateCcw, Save, Search, Loader2, FileSpreadsheet, Download,
Package, Pencil, Copy,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
// 테이블 컬럼 정의
const TABLE_COLUMNS = [
{ key: "item_number", label: "품목코드", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
{ key: "division", label: "관리품목", width: "w-[100px]" },
{ key: "type", label: "품목구분", width: "w-[100px]" },
{ key: "size", label: "규격", width: "w-[100px]" },
{ key: "unit", label: "단위", width: "w-[80px]" },
{ key: "material", label: "재질", width: "w-[100px]" },
{ key: "status", label: "상태", width: "w-[80px]" },
{ key: "selling_price", label: "판매가격", width: "w-[100px]" },
{ key: "standard_price", label: "기준단가", width: "w-[100px]" },
{ key: "weight", label: "중량", width: "w-[80px]" },
{ key: "inventory_unit", label: "재고단위", width: "w-[80px]" },
{ key: "user_type01", label: "대분류", width: "w-[100px]" },
{ key: "user_type02", label: "중분류", width: "w-[100px]" },
];
// 등록 모달 필드 정의
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "category" },
{ key: "type", label: "품목구분", type: "category" },
{ key: "size", label: "규격", type: "text" },
{ key: "unit", label: "단위", type: "category" },
{ key: "material", label: "재질", type: "category" },
{ key: "status", label: "상태", type: "category" },
{ key: "weight", label: "중량", type: "text" },
{ key: "volum", label: "부피", type: "text" },
{ key: "specific_gravity", label: "비중", type: "text" },
{ key: "inventory_unit", label: "재고단위", type: "category" },
{ key: "selling_price", label: "판매가격", type: "text" },
{ key: "standard_price", label: "기준단가", type: "text" },
{ key: "currency_code", label: "통화", type: "category" },
{ key: "user_type01", label: "대분류", type: "category" },
{ key: "user_type02", label: "중분류", type: "category" },
{ key: "meno", label: "메모", type: "textarea" },
];
const TABLE_NAME = "item_info";
export default function ItemInfoPage() {
const { user } = useAuth();
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
const [searchDivision, setSearchDivision] = useState("all");
const [searchType, setSearchType] = useState("all");
const [searchStatus, setSearchStatus] = useState("all");
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<Record<string, any>>({});
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 카테고리 옵션 (API에서 로드)
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 카테고리 컬럼 목록
const CATEGORY_COLUMNS = ["division", "type", "unit", "material", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
// 카테고리 옵션 로드 (table_name + column_name 기반)
useEffect(() => {
const loadCategories = async () => {
try {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
await Promise.all(
CATEGORY_COLUMNS.map(async (colName) => {
try {
const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[colName] = flatten(res.data.data);
}
} catch { /* skip */ }
})
);
setCategoryOptions(optMap);
} catch (err) {
console.error("카테고리 로드 실패:", err);
}
};
loadCategories();
}, []);
// 데이터 조회
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const filters: any[] = [];
if (searchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
}
if (searchDivision !== "all") {
filters.push({ columnName: "division", operator: "equals", value: searchDivision });
}
if (searchType !== "all") {
filters.push({ columnName: "type", operator: "equals", value: searchType });
}
if (searchStatus !== "all") {
filters.push({ columnName: "status", operator: "equals", value: searchStatus });
}
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// 카테고리 코드→라벨 변환
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATEGORY_COLUMNS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setTotalCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchKeyword, searchDivision, searchType, searchStatus, categoryOptions]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
// 카테고리 코드 → 라벨 변환
const getCategoryLabel = (columnName: string, code: string) => {
if (!code) return "";
const opts = categoryOptions[columnName];
if (!opts) return code;
const found = opts.find((o) => o.code === code);
return found?.label || code;
};
// 등록 모달 열기
const openRegisterModal = () => {
setFormData({});
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
};
// 수정 모달 열기
const openEditModal = (item: any) => {
setFormData({ ...item });
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
};
// 복사 모달 열기
const openCopyModal = (item: any) => {
const { id, item_number, created_date, updated_date, writer, ...rest } = item;
setFormData(rest);
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
};
// 저장
const handleSave = async () => {
if (!formData.item_name) {
toast.error("품명은 필수 입력입니다.");
return;
}
setSaving(true);
try {
if (isEditMode && editId) {
// 수정
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
updatedData: updateFields,
});
toast.success("수정되었습니다.");
} else {
// 등록
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, insertFields);
toast.success("등록되었습니다.");
}
setIsModalOpen(false);
fetchItems();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 삭제
const handleDelete = async () => {
if (!selectedId) {
toast.error("삭제할 품목을 선택해주세요.");
return;
}
if (!confirm("선택한 품목을 삭제하시겠습니까?")) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: [{ id: selectedId }],
});
toast.success("삭제되었습니다.");
setSelectedId(null);
fetchItems();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했습니다.");
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) {
toast.error("다운로드할 데이터가 없습니다.");
return;
}
const exportData = items.map((item) => {
const row: Record<string, any> = {};
for (const col of TABLE_COLUMNS) {
row[col.label] = getCategoryLabel(col.key, item[col.key]) || item[col.key] || "";
}
return row;
});
await exportToExcel(exportData, "품목정보.xlsx", "품목정보");
toast.success("엑셀 다운로드 완료");
};
// 검색 초기화
const handleResetSearch = () => {
setSearchKeyword("");
setSearchDivision("all");
setSearchType("all");
setSearchStatus("all");
};
// 카테고리 셀렉트 렌더링
const renderCategorySelect = (field: typeof FORM_FIELDS[0]) => {
const options = categoryOptions[field.key] || [];
return (
<Select
value={formData[field.key] || ""}
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={`${field.label} 선택`} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.code} value={opt.code}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<Card className="shrink-0">
<CardContent className="p-4 flex flex-wrap items-end gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">/</Label>
<Input
placeholder="검색"
className="w-[180px] h-9"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && fetchItems()}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchDivision} onValueChange={setSearchDivision}>
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchType} onValueChange={setSearchType}>
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["type"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="w-[110px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["status"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
{/* 메인 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Package className="w-5 h-5" />
<Badge variant="secondary" className="font-normal">{totalCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openCopyModal(item);
}}>
<Copy className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openEditModal(item);
}}>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground gap-2">
<Package className="w-8 h-8 opacity-50" />
<span> </span>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[40px] text-center">No</TableHead>
{TABLE_COLUMNS.map((col) => (
<TableHead key={col.key} className={col.width}>{col.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow
key={item.id}
className={cn("cursor-pointer", selectedId === item.id && "bg-primary/5")}
onClick={() => setSelectedId(item.id)}
onDoubleClick={() => openEditModal(item)}
>
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
{TABLE_COLUMNS.map((col) => (
<TableCell key={col.key} className="text-sm">
{["division", "type", "unit", "material", "status", "inventory_unit", "user_type01", "user_type02", "currency_code"].includes(col.key)
? getCategoryLabel(col.key, item[col.key])
: item[col.key] || ""}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-auto">
<DialogHeader>
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
<DialogDescription>
{isEditMode ? "품목 정보를 수정합니다." : "새로운 품목을 등록합니다."}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
{FORM_FIELDS.map((field) => (
<div key={field.key} className={cn("space-y-1.5", field.type === "textarea" && "col-span-2")}>
<Label className="text-sm">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === "category" ? (
renderCategorySelect(field)
) : field.type === "textarea" ? (
<Textarea
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.label}
rows={3}
/>
) : (
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.disabled ? field.placeholder : field.label}
disabled={field.disabled && !isEditMode}
className="h-9"
/>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={TABLE_NAME}
userId={user?.userId}
onSuccess={() => {
fetchItems();
}}
/>
</div>
);
}
@@ -0,0 +1,534 @@
"use client";
/**
* 외주품목정보 — 하드코딩 페이지
*
* 좌측: 품목 목록 (subcontractor_item_mapping 기반 품목, item_info 조인)
* 우측: 선택한 품목의 외주업체 정보 (subcontractor_item_mapping → subcontractor_mng 조인)
*
* 외주업체관리와 양방향 연동 (같은 subcontractor_item_mapping 테이블)
*/
import React, { useState, 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, Settings2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
const ITEM_TABLE = "item_info";
const MAPPING_TABLE = "subcontractor_item_mapping";
const SUBCONTRACTOR_TABLE = "subcontractor_mng";
// 좌측: 품목 컬럼
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "item_number", label: "품번", width: "w-[110px]" },
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
{ key: "size", label: "규격", width: "w-[90px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
// 우측: 외주업체 정보 컬럼
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "subcontractor_code", label: "외주업체코드", width: "w-[110px]" },
{ key: "subcontractor_name", label: "외주업체명", minWidth: "min-w-[120px]" },
{ key: "subcontractor_item_code", label: "외주품번", width: "w-[100px]" },
{ key: "subcontractor_item_name", label: "외주품명", width: "w-[100px]" },
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
];
export default function SubcontractorItemPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 품목
const [items, setItems] = useState<any[]>([]);
const [itemLoading, setItemLoading] = useState(false);
const [itemCount, setItemCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 우측: 외주업체
const [subcontractorItems, setSubcontractorItems] = useState<any[]>([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(false);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 외주업체 추가 모달
const [subSelectOpen, setSubSelectOpen] = useState(false);
const [subSearchKeyword, setSubSearchKeyword] = useState("");
const [subSearchResults, setSubSearchResults] = useState<any[]>([]);
const [subSearchLoading, setSubSearchLoading] = useState(false);
const [subCheckedIds, setSubCheckedIds] = useState<Set<string>>(new Set());
// 품목 수정 모달
const [editItemOpen, setEditItemOpen] = useState(false);
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("subcontractor-item");
if (saved) applyTableSettings(saved);
}, []);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
try {
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
const outsourcingDivisionCode = categoryOptions["division"]?.find(
(o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주")
)?.code;
const fetchItems = useCallback(async () => {
setItemLoading(true);
try {
const filters: any[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
// division = 외주관리 필터 추가
if (outsourcingDivisionCode) {
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setItemCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setItemLoading(false);
}
}, [searchFilters, categoryOptions, outsourcingDivisionCode]);
useEffect(() => { fetchItems(); }, [fetchItems]);
// 선택된 품목
const selectedItem = items.find((i) => i.id === selectedItemId);
// 우측: 외주업체 목록 조회
useEffect(() => {
if (!selectedItem?.item_number) { setSubcontractorItems([]); return; }
const itemKey = selectedItem.item_number;
const fetchSubcontractorItems = async () => {
setSubcontractorLoading(true);
try {
// subcontractor_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
// subcontractor_id → subcontractor_mng 조인 (외주업체명)
const subIds = [...new Set(mappings.map((m: any) => m.subcontractor_id).filter(Boolean))];
let subMap: Record<string, any> = {};
if (subIds.length > 0) {
try {
const subRes = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: subIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "subcontractor_code", operator: "in", value: subIds }] },
autoFilter: true,
});
for (const s of (subRes.data?.data?.data || subRes.data?.data?.rows || [])) {
subMap[s.subcontractor_code] = s;
}
} catch { /* skip */ }
}
setSubcontractorItems(mappings.map((m: any) => ({
...m,
subcontractor_code: m.subcontractor_id,
subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "",
})));
} catch (err) {
console.error("외주업체 조회 실패:", err);
} finally {
setSubcontractorLoading(false);
}
};
fetchSubcontractorItems();
}, [selectedItem?.item_number]);
// 외주업체 검색
const searchSubcontractors = async () => {
setSubSearchLoading(true);
try {
const filters: any[] = [];
if (subSearchKeyword) filters.push({ columnName: "subcontractor_name", operator: "contains", value: subSearchKeyword });
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const all = res.data?.data?.data || res.data?.data?.rows || [];
// 이미 등록된 외주업체 제외
const existing = new Set(subcontractorItems.map((s: any) => s.subcontractor_id || s.subcontractor_code));
setSubSearchResults(all.filter((s: any) => !existing.has(s.subcontractor_code)));
} catch { /* skip */ } finally { setSubSearchLoading(false); }
};
// 외주업체 추가 저장
const addSelectedSubcontractors = async () => {
const selected = subSearchResults.filter((s) => subCheckedIds.has(s.id));
if (selected.length === 0 || !selectedItem) return;
try {
for (const sub of selected) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
subcontractor_id: sub.subcontractor_code,
item_id: selectedItem.item_number,
});
}
toast.success(`${selected.length}개 외주업체가 추가되었습니다.`);
setSubCheckedIds(new Set());
setSubSelectOpen(false);
// 우측 새로고침
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "외주업체 추가에 실패했습니다.");
}
};
// 품목 수정
const openEditItem = () => {
if (!selectedItem) return;
setEditItemForm({ ...selectedItem });
setEditItemOpen(true);
};
const handleEditSave = async () => {
if (!editItemForm.id) return;
setSaving(true);
try {
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
originalData: { id: editItemForm.id },
updatedData: {
selling_price: editItemForm.selling_price || null,
standard_price: editItemForm.standard_price || null,
currency_code: editItemForm.currency_code || null,
},
});
toast.success("수정되었습니다.");
setEditItemOpen(false);
fetchItems();
} catch (err: any) {
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) return;
const data = items.map((i) => ({
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
}));
await exportToExcel(data, "외주품목정보.xlsx", "외주품목");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={ITEM_TABLE}
filterId="subcontractor-item"
onFilterChange={setSearchFilters}
dataCount={itemCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 외주품목 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Package className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{itemCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
<DataGrid
gridId="subcontractor-item-left"
columns={LEFT_COLUMNS}
data={items}
loading={itemLoading}
selectedId={selectedItemId}
onSelect={setSelectedItemId}
onRowDoubleClick={() => openEditItem()}
emptyMessage="등록된 외주품목이 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 외주업체 정보 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4" />
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
</div>
<Button variant="outline" size="sm" disabled={!selectedItemId}
onClick={() => { setSubCheckedIds(new Set()); setSubSelectOpen(true); searchSubcontractors(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{!selectedItemId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<DataGrid
gridId="subcontractor-item-right"
columns={RIGHT_COLUMNS}
data={subcontractorItems}
loading={subcontractorLoading}
showRowNumber={false}
emptyMessage="등록된 외주업체가 없습니다"
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 품목 수정 모달 */}
<FullscreenDialog
open={editItemOpen}
onOpenChange={setEditItemOpen}
title="외주품목 수정"
description={`${editItemForm.item_number || ""}${editItemForm.item_name || ""}`}
defaultMaxWidth="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => setEditItemOpen(false)}></Button>
<Button onClick={handleEditSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="grid grid-cols-2 gap-4 py-4">
{[
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "material", label: "재질" },
{ key: "status", label: "상태" },
].map((f) => (
<div key={f.key} className="space-y-1.5">
<Label className="text-sm text-muted-foreground">{f.label}</Label>
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
</div>
))}
<div className="col-span-2 border-t my-2" />
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
placeholder="판매가격" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
placeholder="기준단가" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</FullscreenDialog>
{/* 외주업체 추가 모달 */}
<Dialog open={subSelectOpen} onOpenChange={setSubSelectOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="외주업체명 검색" value={subSearchKeyword}
onChange={(e) => setSubSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchSubcontractors()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchSubcontractors} disabled={subSearchLoading} className="h-9">
{subSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={subSearchResults.length > 0 && subCheckedIds.size === subSearchResults.length}
onChange={(e) => {
if (e.target.checked) setSubCheckedIds(new Set(subSearchResults.map((s) => s.id)));
else setSubCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : subSearchResults.map((s) => (
<TableRow key={s.id} className={cn("cursor-pointer", subCheckedIds.has(s.id) && "bg-primary/5")}
onClick={() => setSubCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(s.id)) next.delete(s.id); else next.add(s.id);
return next;
})}>
<TableCell className="text-center"><input type="checkbox" checked={subCheckedIds.has(s.id)} readOnly /></TableCell>
<TableCell className="text-xs">{s.subcontractor_code}</TableCell>
<TableCell className="text-sm">{s.subcontractor_name}</TableCell>
<TableCell className="text-xs">{s.division}</TableCell>
<TableCell className="text-xs">{s.contact_person}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{subCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setSubSelectOpen(false)}></Button>
<Button onClick={addSelectedSubcontractors} disabled={subCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {subCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={ITEM_TABLE}
userId={user?.userId}
onSuccess={() => fetchItems()}
/>
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={ITEM_TABLE}
settingsId="subcontractor-item"
onSave={applyTableSettings}
/>
{ConfirmDialogComponent}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,845 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Loader2,
Settings,
Plus,
Pencil,
Trash2,
Search,
RotateCcw,
Wrench,
} from "lucide-react";
import { toast } from "sonner";
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 { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import {
getProcessList,
createProcess,
updateProcess,
deleteProcesses,
getProcessEquipments,
addProcessEquipment,
removeProcessEquipment,
getEquipmentList,
type ProcessMaster,
type ProcessEquipment,
type Equipment,
} from "@/lib/api/processInfo";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
const ALL_VALUE = "__all__";
export function ProcessMasterTab() {
const [processes, setProcesses] = useState<ProcessMaster[]>([]);
const [equipmentMaster, setEquipmentMaster] = useState<Equipment[]>([]);
const [processTypeOptions, setProcessTypeOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
const [loadingInitial, setLoadingInitial] = useState(true);
const [loadingList, setLoadingList] = useState(false);
const [loadingEquipments, setLoadingEquipments] = useState(false);
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
const [filterType, setFilterType] = useState<string>(ALL_VALUE);
const [filterUseYn, setFilterUseYn] = useState<string>(ALL_VALUE);
const [selectedProcess, setSelectedProcess] = useState<ProcessMaster | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
const [equipmentPick, setEquipmentPick] = useState<string>("");
const [addingEquipment, setAddingEquipment] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<"add" | "edit">("add");
const [savingForm, setSavingForm] = useState(false);
const [formProcessCode, setFormProcessCode] = useState("");
const [formProcessName, setFormProcessName] = useState("");
const [formProcessType, setFormProcessType] = useState<string>("");
const [formStandardTime, setFormStandardTime] = useState("");
const [formWorkerCount, setFormWorkerCount] = useState("");
const [formUseYn, setFormUseYn] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const processTypeMap = useMemo(() => {
const m = new Map<string, string>();
processTypeOptions.forEach((o) => m.set(o.valueCode, o.valueLabel));
return m;
}, [processTypeOptions]);
const getProcessTypeLabel = useCallback(
(code: string) => processTypeMap.get(code) ?? code,
[processTypeMap]
);
const loadProcesses = useCallback(async () => {
setLoadingList(true);
try {
const res = await getProcessList({
processCode: filterCode.trim() || undefined,
processName: filterName.trim() || undefined,
processType: filterType === ALL_VALUE ? undefined : filterType,
useYn: filterUseYn === ALL_VALUE ? undefined : filterUseYn,
});
if (!res.success) {
toast.error(res.message || "공정 목록을 불러오지 못했습니다.");
return;
}
setProcesses(res.data ?? []);
} finally {
setLoadingList(false);
}
}, [filterCode, filterName, filterType, filterUseYn]);
const loadInitial = useCallback(async () => {
setLoadingInitial(true);
try {
const [procRes, eqRes] = await Promise.all([getProcessList(), getEquipmentList()]);
if (!procRes.success) {
toast.error(procRes.message || "공정 목록을 불러오지 못했습니다.");
} else {
setProcesses(procRes.data ?? []);
}
if (!eqRes.success) {
toast.error(eqRes.message || "설비 목록을 불러오지 못했습니다.");
} else {
setEquipmentMaster(eqRes.data ?? []);
}
const ptRes = await getCategoryValues("process_mng", "process_type");
if (ptRes.success && "data" in ptRes && Array.isArray(ptRes.data)) {
const activeValues = ptRes.data.filter((v: any) => v.isActive !== false);
const seen = new Set<string>();
const unique = activeValues.filter((v: any) => {
if (seen.has(v.valueCode)) return false;
seen.add(v.valueCode);
return true;
});
setProcessTypeOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
}
} finally {
setLoadingInitial(false);
}
}, []);
useEffect(() => {
void loadInitial();
}, [loadInitial]);
useEffect(() => {
setSelectedProcess((prev) => {
if (!prev) return prev;
if (!processes.some((p) => p.id === prev.id)) return null;
return prev;
});
}, [processes]);
useEffect(() => {
setEquipmentPick("");
}, [selectedProcess?.id]);
useEffect(() => {
if (!selectedProcess) {
setProcessEquipments([]);
return;
}
let cancelled = false;
setLoadingEquipments(true);
void (async () => {
const res = await getProcessEquipments(selectedProcess.process_code);
if (cancelled) return;
if (!res.success) {
toast.error(res.message || "공정 설비를 불러오지 못했습니다.");
setProcessEquipments([]);
} else {
setProcessEquipments(res.data ?? []);
}
setLoadingEquipments(false);
})();
return () => {
cancelled = true;
};
}, [selectedProcess?.process_code]);
const allSelected = useMemo(() => {
if (processes.length === 0) return false;
return processes.every((p) => selectedIds.has(p.id));
}, [processes, selectedIds]);
const toggleAll = (checked: boolean) => {
if (checked) {
setSelectedIds(new Set(processes.map((p) => p.id)));
} else {
setSelectedIds(new Set());
}
};
const toggleOne = (id: string, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) next.add(id);
else next.delete(id);
return next;
});
};
const handleResetFilters = () => {
setFilterCode("");
setFilterName("");
setFilterType(ALL_VALUE);
setFilterUseYn(ALL_VALUE);
};
const handleSearch = () => {
void loadProcesses();
};
const openAdd = () => {
setFormMode("add");
setEditingId(null);
setFormProcessCode("");
setFormProcessName("");
setFormProcessType(processTypeOptions[0]?.valueCode ?? "");
setFormStandardTime("");
setFormWorkerCount("");
setFormUseYn("Y");
setFormOpen(true);
};
const openEdit = () => {
if (!selectedProcess) {
toast.message("수정할 공정을 좌측 목록에서 선택하세요.");
return;
}
setFormMode("edit");
setEditingId(selectedProcess.id);
setFormProcessCode(selectedProcess.process_code);
setFormProcessName(selectedProcess.process_name);
setFormProcessType(selectedProcess.process_type);
setFormStandardTime(selectedProcess.standard_time ?? "");
setFormWorkerCount(selectedProcess.worker_count ?? "");
setFormUseYn(selectedProcess.use_yn);
setFormOpen(true);
};
const submitForm = async () => {
if (!formProcessName.trim()) {
toast.error("공정명을 입력하세요.");
return;
}
setSavingForm(true);
try {
if (formMode === "add") {
const res = await createProcess({
process_name: formProcessName.trim(),
process_type: formProcessType,
standard_time: formStandardTime.trim() || "0",
worker_count: formWorkerCount.trim() || "0",
use_yn: formUseYn,
});
if (!res.success || !res.data) {
toast.error(res.message || "등록에 실패했습니다.");
return;
}
toast.success("공정이 등록되었습니다.");
setFormOpen(false);
await loadProcesses();
setSelectedProcess(res.data);
setSelectedIds(new Set());
} else if (editingId) {
const res = await updateProcess(editingId, {
process_name: formProcessName.trim(),
process_type: formProcessType,
standard_time: formStandardTime.trim() || "0",
worker_count: formWorkerCount.trim() || "0",
use_yn: formUseYn,
});
if (!res.success || !res.data) {
toast.error(res.message || "수정에 실패했습니다.");
return;
}
toast.success("공정이 수정되었습니다.");
setFormOpen(false);
await loadProcesses();
setSelectedProcess(res.data);
}
} finally {
setSavingForm(false);
}
};
const openDelete = () => {
if (selectedIds.size === 0) {
toast.message("삭제할 공정을 체크박스로 선택하세요.");
return;
}
setDeleteOpen(true);
};
const confirmDelete = async () => {
const ids = Array.from(selectedIds);
setDeleting(true);
try {
const res = await deleteProcesses(ids);
if (!res.success) {
toast.error(res.message || "삭제에 실패했습니다.");
return;
}
toast.success(`${ids.length}건 삭제되었습니다.`);
setDeleteOpen(false);
setSelectedIds(new Set());
if (selectedProcess && ids.includes(selectedProcess.id)) {
setSelectedProcess(null);
}
await loadProcesses();
} finally {
setDeleting(false);
}
};
const availableEquipments = useMemo(() => {
const used = new Set(processEquipments.map((e) => e.equipment_code));
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
}, [equipmentMaster, processEquipments]);
const handleAddEquipment = async () => {
if (!selectedProcess) return;
if (!equipmentPick) {
toast.message("추가할 설비를 선택하세요.");
return;
}
setAddingEquipment(true);
try {
const res = await addProcessEquipment({
process_code: selectedProcess.process_code,
equipment_code: equipmentPick,
});
if (!res.success) {
toast.error(res.message || "설비 추가에 실패했습니다.");
return;
}
toast.success("설비가 등록되었습니다.");
setEquipmentPick("");
const listRes = await getProcessEquipments(selectedProcess.process_code);
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
} finally {
setAddingEquipment(false);
}
};
const handleRemoveEquipment = async (row: ProcessEquipment) => {
const res = await removeProcessEquipment(row.id);
if (!res.success) {
toast.error(res.message || "설비 제거에 실패했습니다.");
return;
}
toast.success("설비가 제거되었습니다.");
if (selectedProcess) {
const listRes = await getProcessEquipments(selectedProcess.process_code);
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
}
};
const listBusy = loadingInitial || loadingList;
return (
<div className="flex min-h-[560px] flex-1 flex-col gap-3">
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
<div className="flex shrink-0 flex-col gap-2 border-b bg-muted/30 p-3 sm:p-4">
<div className="flex flex-wrap items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" aria-hidden />
<span className="text-sm font-semibold sm:text-base"> </span>
</div>
<div className="flex flex-wrap items-end gap-2">
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Input
value={filterCode}
onChange={(e) => setFilterCode(e.target.value)}
placeholder="코드"
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[140px] sm:text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Input
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
placeholder="이름"
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[160px] sm:text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="h-8 w-[120px] text-xs sm:h-10 sm:w-[130px] sm:text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
</SelectItem>
{processTypeOptions.map((o, idx) => (
<SelectItem key={`pt-filter-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
{o.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Select value={filterUseYn} onValueChange={setFilterUseYn}>
<SelectTrigger className="h-8 w-[100px] text-xs sm:h-10 sm:w-[110px] sm:text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
</SelectItem>
<SelectItem value="Y" className="text-xs sm:text-sm"></SelectItem>
<SelectItem value="N" className="text-xs sm:text-sm"></SelectItem>
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={handleResetFilters}
>
<RotateCcw className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={handleSearch}
disabled={listBusy}
>
<Search className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={openAdd}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={openEdit}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="destructive"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={openDelete}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-2 sm:p-3">
{listBusy ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="mt-2 text-xs sm:text-sm"> ...</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10 text-center">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="전체 선택"
className="mx-auto"
/>
</TableHead>
<TableHead className="text-xs sm:text-sm"></TableHead>
<TableHead className="text-xs sm:text-sm"></TableHead>
<TableHead className="text-xs sm:text-sm"></TableHead>
<TableHead className="text-right text-xs sm:text-sm">()</TableHead>
<TableHead className="text-right text-xs sm:text-sm"></TableHead>
<TableHead className="text-center text-xs sm:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{processes.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-12 text-center text-muted-foreground">
<p className="text-xs sm:text-sm"> .</p>
</TableCell>
</TableRow>
) : (
processes.map((row) => (
<TableRow
key={row.id}
className={cn(
"cursor-pointer transition-colors",
selectedProcess?.id === row.id && "bg-accent"
)}
onClick={() => setSelectedProcess(row)}
>
<TableCell
className="text-center"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={selectedIds.has(row.id)}
onCheckedChange={(v) => toggleOne(row.id, v === true)}
aria-label={`${row.process_code} 선택`}
className="mx-auto"
/>
</TableCell>
<TableCell className="text-xs font-medium sm:text-sm">
{row.process_code}
</TableCell>
<TableCell className="text-xs sm:text-sm">{row.process_name}</TableCell>
<TableCell className="text-xs sm:text-sm">
<Badge variant="secondary" className="text-[10px] sm:text-xs">
{getProcessTypeLabel(row.process_type)}
</Badge>
</TableCell>
<TableCell className="text-right text-xs sm:text-sm">
{row.standard_time ?? "-"}
</TableCell>
<TableCell className="text-right text-xs sm:text-sm">
{row.worker_count ?? "-"}
</TableCell>
<TableCell className="text-center text-xs sm:text-sm">
<Badge
variant={row.use_yn === "N" ? "outline" : "default"}
className="text-[10px] sm:text-xs"
>
{row.use_yn === "Y" ? "사용" : "미사용"}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
</ScrollArea>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/30 px-3 py-2 sm:px-4 sm:py-3">
<Wrench className="h-4 w-4 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold sm:text-base"> </p>
{selectedProcess ? (
<p className="truncate text-xs text-muted-foreground sm:text-sm">
{selectedProcess.process_name}{" "}
<span className="text-muted-foreground/80">({selectedProcess.process_code})</span>
</p>
) : (
<p className="text-xs text-muted-foreground sm:text-sm"> </p>
)}
</div>
</div>
{!selectedProcess ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-center text-muted-foreground">
<Settings className="h-10 w-10 opacity-40" />
<p className="text-sm font-medium text-foreground"> </p>
<p className="max-w-xs text-xs sm:text-sm">
.
</p>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-3 p-3 sm:p-4">
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-0 flex-1 space-y-1 sm:max-w-xs">
<Label className="text-xs sm:text-sm"> </Label>
<Select
key={selectedProcess.id}
value={equipmentPick || undefined}
onValueChange={setEquipmentPick}
disabled={addingEquipment || availableEquipments.length === 0}
>
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="설비를 선택하세요" />
</SelectTrigger>
<SelectContent>
{availableEquipments.map((eq) => (
<SelectItem
key={eq.id}
value={eq.equipment_code}
className="text-xs sm:text-sm"
>
{eq.equipment_code} · {eq.equipment_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={() => void handleAddEquipment()}
disabled={addingEquipment || !equipmentPick}
>
{addingEquipment ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="mr-1 h-3.5 w-3.5" />
)}
</Button>
</div>
<div className="min-h-0 flex-1">
{loadingEquipments ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Loader2 className="h-7 w-7 animate-spin" />
<p className="mt-2 text-xs sm:text-sm"> ...</p>
</div>
) : processEquipments.length === 0 ? (
<p className="py-8 text-center text-xs text-muted-foreground sm:text-sm">
. .
</p>
) : (
<ScrollArea className="h-[min(420px,calc(100vh-20rem))] pr-3">
<ul className="space-y-2">
{processEquipments.map((pe) => (
<li key={pe.id}>
<Card className="rounded-lg border bg-card text-card-foreground shadow-sm">
<CardContent className="flex items-center gap-3 p-3 sm:p-4">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium sm:text-sm">
{pe.equipment_code}
</p>
<p className="truncate text-xs text-muted-foreground sm:text-sm">
{pe.equipment_name || "설비명 없음"}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 text-xs sm:h-9 sm:text-sm"
onClick={() => void handleRemoveEquipment(pe)}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</CardContent>
</Card>
</li>
))}
</ul>
</ScrollArea>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{formMode === "add" ? "공정 추가" : "공정 수정"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="pm-process-name" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="pm-process-name"
value={formProcessName}
onChange={(e) => setFormProcessName(e.target.value)}
placeholder="공정명"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Select value={formProcessType} onValueChange={setFormProcessType}>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{processTypeOptions.map((o, idx) => (
<SelectItem key={`pt-form-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
{o.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="pm-standard-time" className="text-xs sm:text-sm">
()
</Label>
<Input
id="pm-standard-time"
value={formStandardTime}
onChange={(e) => setFormStandardTime(e.target.value)}
placeholder="0"
inputMode="numeric"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="pm-worker-count" className="text-xs sm:text-sm">
</Label>
<Input
id="pm-worker-count"
value={formWorkerCount}
onChange={(e) => setFormWorkerCount(e.target.value)}
placeholder="0"
inputMode="numeric"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Select value={formUseYn} onValueChange={setFormUseYn}>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y" className="text-xs sm:text-sm"></SelectItem>
<SelectItem value="N" className="text-xs sm:text-sm"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => setFormOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={savingForm}
>
</Button>
<Button
type="button"
onClick={() => void submitForm()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={savingForm}
>
{savingForm ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{selectedIds.size} . - .
.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => setDeleteOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={deleting}
>
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void confirmDelete()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={deleting}
>
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,17 @@
"use client";
import { ProcessWorkStandardComponent } from "@/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent";
export function ProcessWorkStandardTab() {
return (
<div className="h-[calc(100vh-12rem)]">
<ProcessWorkStandardComponent
config={{
itemListMode: "registered",
screenCode: "screen_1599",
leftPanelTitle: "등록 품목 및 공정",
}}
/>
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
import React, { useState } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Settings, GitBranch, ClipboardList } from "lucide-react";
import { ProcessMasterTab } from "./ProcessMasterTab";
import { ItemRoutingTab } from "./ItemRoutingTab";
import { ProcessWorkStandardTab } from "./ProcessWorkStandardTab";
export default function ProcessInfoPage() {
const [activeTab, setActiveTab] = useState("process");
return (
<div className="flex h-[calc(100vh-4rem)] flex-col bg-muted/30">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col">
<div className="shrink-0 border-b bg-background px-4">
<TabsList className="h-12 bg-transparent gap-1">
<TabsTrigger
value="process"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
>
<Settings className="mr-2 h-4 w-4" />
</TabsTrigger>
<TabsTrigger
value="routing"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
>
<GitBranch className="mr-2 h-4 w-4" />
</TabsTrigger>
<TabsTrigger
value="workstandard"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
>
<ClipboardList className="mr-2 h-4 w-4" />
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="process" className="flex-1 overflow-hidden mt-0">
<ProcessMasterTab />
</TabsContent>
<TabsContent value="routing" className="flex-1 overflow-hidden mt-0">
<ItemRoutingTab />
</TabsContent>
<TabsContent value="workstandard" className="flex-1 overflow-hidden mt-0">
<ProcessWorkStandardTab />
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,539 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import {
Loader2, Save, RotateCcw, Plus, Trash2, Pencil, ClipboardCheck,
ChevronRight, GripVertical, AlertCircle,
} from "lucide-react";
import { toast } from "sonner";
import {
getWIWorkStandard, copyWorkStandard, saveWIWorkStandard, resetWIWorkStandard,
WIWorkItem, WIWorkItemDetail, WIWorkStandardProcess,
} from "@/lib/api/workInstruction";
interface WorkStandardEditModalProps {
open: boolean;
onClose: () => void;
workInstructionNo: string;
routingVersionId: string;
routingName: string;
itemName: string;
itemCode: string;
}
const PHASES = [
{ key: "PRE", label: "사전작업" },
{ key: "MAIN", label: "본작업" },
{ key: "POST", label: "후작업" },
];
const DETAIL_TYPES = [
{ value: "checklist", label: "체크리스트" },
{ value: "inspection", label: "검사항목" },
{ value: "procedure", label: "작업절차" },
{ value: "input", label: "직접입력" },
{ value: "lookup", label: "문서참조" },
{ value: "equip_inspection", label: "설비점검" },
{ value: "equip_condition", label: "설비조건" },
{ value: "production_result", label: "실적등록" },
{ value: "material_input", label: "자재투입" },
];
export function WorkStandardEditModal({
open,
onClose,
workInstructionNo,
routingVersionId,
routingName,
itemName,
itemCode,
}: WorkStandardEditModalProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [processes, setProcesses] = useState<WIWorkStandardProcess[]>([]);
const [isCustom, setIsCustom] = useState(false);
const [selectedProcessIdx, setSelectedProcessIdx] = useState(0);
const [selectedPhase, setSelectedPhase] = useState("PRE");
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
// 작업항목 추가 모달
const [addItemOpen, setAddItemOpen] = useState(false);
const [addItemTitle, setAddItemTitle] = useState("");
const [addItemRequired, setAddItemRequired] = useState("Y");
// 상세 추가 모달
const [addDetailOpen, setAddDetailOpen] = useState(false);
const [addDetailType, setAddDetailType] = useState("checklist");
const [addDetailContent, setAddDetailContent] = useState("");
const [addDetailRequired, setAddDetailRequired] = useState("N");
// 데이터 로드
const loadData = useCallback(async () => {
if (!workInstructionNo || !routingVersionId) return;
setLoading(true);
try {
const res = await getWIWorkStandard(workInstructionNo, routingVersionId);
if (res.success && res.data) {
setProcesses(res.data.processes);
setIsCustom(res.data.isCustom);
setSelectedProcessIdx(0);
setSelectedPhase("PRE");
setSelectedWorkItemId(null);
setDirty(false);
}
} catch (err) {
console.error("공정작업기준 로드 실패", err);
} finally {
setLoading(false);
}
}, [workInstructionNo, routingVersionId]);
useEffect(() => {
if (open) loadData();
}, [open, loadData]);
const currentProcess = processes[selectedProcessIdx] || null;
const currentWorkItems = useMemo(() => {
if (!currentProcess) return [];
return currentProcess.workItems.filter(wi => wi.work_phase === selectedPhase);
}, [currentProcess, selectedPhase]);
const selectedWorkItem = useMemo(() => {
if (!selectedWorkItemId || !currentProcess) return null;
return currentProcess.workItems.find(wi => wi.id === selectedWorkItemId) || null;
}, [selectedWorkItemId, currentProcess]);
// 커스텀 복사 확인 후 수정
const ensureCustom = useCallback(async () => {
if (isCustom) return true;
try {
const res = await copyWorkStandard(workInstructionNo, routingVersionId);
if (res.success) {
await loadData();
setIsCustom(true);
return true;
}
} catch (err) {
toast.error("원본 복사에 실패했습니다");
}
return false;
}, [isCustom, workInstructionNo, routingVersionId, loadData]);
// 작업항목 추가
const handleAddWorkItem = useCallback(async () => {
if (!addItemTitle.trim()) { toast.error("제목을 입력하세요"); return; }
const ok = await ensureCustom();
if (!ok || !currentProcess) return;
const newItem: WIWorkItem = {
id: `temp-${Date.now()}`,
routing_detail_id: currentProcess.routing_detail_id,
work_phase: selectedPhase,
title: addItemTitle.trim(),
is_required: addItemRequired,
sort_order: currentWorkItems.length + 1,
details: [],
};
setProcesses(prev => {
const next = [...prev];
next[selectedProcessIdx] = {
...next[selectedProcessIdx],
workItems: [...next[selectedProcessIdx].workItems, newItem],
};
return next;
});
setAddItemTitle("");
setAddItemRequired("Y");
setAddItemOpen(false);
setDirty(true);
setSelectedWorkItemId(newItem.id!);
}, [addItemTitle, addItemRequired, ensureCustom, currentProcess, selectedPhase, currentWorkItems, selectedProcessIdx]);
// 작업항목 삭제
const handleDeleteWorkItem = useCallback(async (id: string) => {
const ok = await ensureCustom();
if (!ok) return;
setProcesses(prev => {
const next = [...prev];
next[selectedProcessIdx] = {
...next[selectedProcessIdx],
workItems: next[selectedProcessIdx].workItems.filter(wi => wi.id !== id),
};
return next;
});
if (selectedWorkItemId === id) setSelectedWorkItemId(null);
setDirty(true);
}, [ensureCustom, selectedProcessIdx, selectedWorkItemId]);
// 상세 추가
const handleAddDetail = useCallback(async () => {
if (!addDetailContent.trim() && addDetailType !== "production_result" && addDetailType !== "material_input") {
toast.error("내용을 입력하세요");
return;
}
if (!selectedWorkItemId) return;
const ok = await ensureCustom();
if (!ok) return;
const content = addDetailContent.trim() ||
DETAIL_TYPES.find(d => d.value === addDetailType)?.label || addDetailType;
const newDetail: WIWorkItemDetail = {
id: `temp-detail-${Date.now()}`,
work_item_id: selectedWorkItemId,
detail_type: addDetailType,
content,
is_required: addDetailRequired,
sort_order: (selectedWorkItem?.details?.length || 0) + 1,
};
setProcesses(prev => {
const next = [...prev];
const workItems = [...next[selectedProcessIdx].workItems];
const wiIdx = workItems.findIndex(wi => wi.id === selectedWorkItemId);
if (wiIdx >= 0) {
workItems[wiIdx] = {
...workItems[wiIdx],
details: [...(workItems[wiIdx].details || []), newDetail],
detail_count: (workItems[wiIdx].detail_count || 0) + 1,
};
}
next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems };
return next;
});
setAddDetailContent("");
setAddDetailType("checklist");
setAddDetailRequired("N");
setAddDetailOpen(false);
setDirty(true);
}, [addDetailContent, addDetailType, addDetailRequired, selectedWorkItemId, selectedWorkItem, ensureCustom, selectedProcessIdx]);
// 상세 삭제
const handleDeleteDetail = useCallback(async (detailId: string) => {
if (!selectedWorkItemId) return;
const ok = await ensureCustom();
if (!ok) return;
setProcesses(prev => {
const next = [...prev];
const workItems = [...next[selectedProcessIdx].workItems];
const wiIdx = workItems.findIndex(wi => wi.id === selectedWorkItemId);
if (wiIdx >= 0) {
workItems[wiIdx] = {
...workItems[wiIdx],
details: (workItems[wiIdx].details || []).filter(d => d.id !== detailId),
detail_count: Math.max(0, (workItems[wiIdx].detail_count || 1) - 1),
};
}
next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems };
return next;
});
setDirty(true);
}, [selectedWorkItemId, ensureCustom, selectedProcessIdx]);
// 저장
const handleSave = useCallback(async () => {
if (!currentProcess) return;
setSaving(true);
try {
const ok = await ensureCustom();
if (!ok) return;
const res = await saveWIWorkStandard(
workInstructionNo,
currentProcess.routing_detail_id,
currentProcess.workItems
);
if (res.success) {
toast.success("공정작업기준이 저장되었습니다");
setDirty(false);
await loadData();
} else {
toast.error("저장에 실패했습니다");
}
} catch (err) {
toast.error("저장 중 오류가 발생했습니다");
} finally {
setSaving(false);
}
}, [currentProcess, ensureCustom, workInstructionNo, loadData]);
// 원본으로 초기화
const handleReset = useCallback(async () => {
if (!confirm("커스터마이징한 내용을 모두 삭제하고 원본으로 되돌리시겠습니까?")) return;
try {
const res = await resetWIWorkStandard(workInstructionNo);
if (res.success) {
toast.success("원본으로 초기화되었습니다");
await loadData();
}
} catch (err) {
toast.error("초기화에 실패했습니다");
}
}, [workInstructionNo, loadData]);
const getDetailTypeLabel = (type: string) =>
DETAIL_TYPES.find(d => d.value === type)?.label || type;
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] h-[85vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2">
<ClipboardCheck className="w-4 h-4" />
- {itemName}
{routingName && <Badge variant="secondary" className="text-xs ml-2">{routingName}</Badge>}
{isCustom && <Badge variant="outline" className="text-xs ml-1 border-amber-300 text-amber-700"></Badge>}
</DialogTitle>
<DialogDescription className="text-xs">
[{workInstructionNo}] . .
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : processes.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<AlertCircle className="w-10 h-10 mb-3 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
{/* 공정 탭 */}
<div className="flex items-center gap-1 px-4 py-2 border-b bg-muted/30 overflow-x-auto shrink-0">
{processes.map((proc, idx) => (
<Button
key={proc.routing_detail_id}
variant={selectedProcessIdx === idx ? "default" : "ghost"}
size="sm"
className={cn("text-xs shrink-0 h-8", selectedProcessIdx === idx && "shadow-sm")}
onClick={() => {
setSelectedProcessIdx(idx);
setSelectedWorkItemId(null);
}}
>
<span className="mr-1.5 font-mono text-[10px] opacity-70">{proc.seq_no}.</span>
{proc.process_name}
{proc.workItems.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] h-4 px-1">{proc.workItems.length}</Badge>
)}
</Button>
))}
</div>
{/* 작업 단계 탭 */}
<div className="flex items-center gap-1 px-4 py-2 border-b shrink-0">
{PHASES.map(phase => {
const count = currentProcess?.workItems.filter(wi => wi.work_phase === phase.key).length || 0;
return (
<Button
key={phase.key}
variant={selectedPhase === phase.key ? "secondary" : "ghost"}
size="sm"
className="text-xs h-7"
onClick={() => { setSelectedPhase(phase.key); setSelectedWorkItemId(null); }}
>
{phase.label}
{count > 0 && <Badge variant="outline" className="ml-1 text-[10px] h-4 px-1">{count}</Badge>}
</Button>
);
})}
</div>
{/* 작업항목 + 상세 split */}
<div className="flex-1 flex overflow-hidden">
{/* 좌측: 작업항목 목록 */}
<div className="w-[280px] shrink-0 border-r flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/20 shrink-0">
<span className="text-xs font-semibold"></span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => { setAddItemTitle(""); setAddItemOpen(true); }}>
<Plus className="w-3.5 h-3.5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{currentWorkItems.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-6"> </div>
) : currentWorkItems.map((wi) => (
<div
key={wi.id}
className={cn(
"group rounded-md border p-2.5 cursor-pointer transition-colors",
selectedWorkItemId === wi.id ? "border-primary bg-primary/5" : "hover:bg-muted/50"
)}
onClick={() => setSelectedWorkItemId(wi.id!)}
>
<div className="flex items-start justify-between gap-1">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium truncate">{wi.title}</div>
<div className="flex items-center gap-1.5 mt-1">
{wi.is_required === "Y" && <Badge variant="destructive" className="text-[9px] h-4 px-1"></Badge>}
<span className="text-[10px] text-muted-foreground"> {wi.details?.length || wi.detail_count || 0}</span>
</div>
</div>
<Button
variant="ghost" size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0"
onClick={e => { e.stopPropagation(); handleDeleteWorkItem(wi.id!); }}
>
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</div>
{/* 우측: 상세 목록 */}
<div className="flex-1 flex flex-col overflow-hidden">
{!selectedWorkItem ? (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<ChevronRight className="w-8 h-8 mb-2 opacity-20" />
<p className="text-xs"> </p>
</div>
) : (
<>
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/20 shrink-0">
<div>
<span className="text-xs font-semibold">{selectedWorkItem.title}</span>
<span className="text-[10px] text-muted-foreground ml-2"> </span>
</div>
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => { setAddDetailContent(""); setAddDetailType("checklist"); setAddDetailOpen(true); }}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{(!selectedWorkItem.details || selectedWorkItem.details.length === 0) ? (
<div className="text-xs text-muted-foreground text-center py-8"> </div>
) : selectedWorkItem.details.map((detail, dIdx) => (
<div key={detail.id || dIdx} className="group flex items-start gap-2 rounded-md border p-3 hover:bg-muted/30">
<GripVertical className="w-3.5 h-3.5 mt-0.5 text-muted-foreground/30 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] h-4 px-1.5 shrink-0">
{getDetailTypeLabel(detail.detail_type || "checklist")}
</Badge>
{detail.is_required === "Y" && <Badge variant="destructive" className="text-[9px] h-4 px-1"></Badge>}
</div>
<p className="text-xs mt-1 break-all">{detail.content || "-"}</p>
{detail.remark && <p className="text-[10px] text-muted-foreground mt-0.5">{detail.remark}</p>}
{detail.detail_type === "inspection" && (detail.lower_limit || detail.upper_limit) && (
<div className="text-[10px] text-muted-foreground mt-1">
: {detail.lower_limit || "-"} ~ {detail.upper_limit || "-"} {detail.unit || ""}
</div>
)}
</div>
<Button
variant="ghost" size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0"
onClick={() => handleDeleteDetail(detail.id!)}
>
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
</div>
))}
</div>
</>
)}
</div>
</div>
</div>
)}
<DialogFooter className="px-6 py-3 border-t shrink-0 flex items-center justify-between">
<div>
{isCustom && (
<Button variant="outline" size="sm" className="text-xs" onClick={handleReset}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={onClose}></Button>
<Button onClick={handleSave} disabled={saving || (!dirty && isCustom)}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</div>
</DialogFooter>
{/* 작업항목 추가 다이얼로그 */}
<Dialog open={addItemOpen} onOpenChange={setAddItemOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]" onClick={e => e.stopPropagation()}>
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs">
{PHASES.find(p => p.key === selectedPhase)?.label} .
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs"> *</Label>
<Input value={addItemTitle} onChange={e => setAddItemTitle(e.target.value)} placeholder="작업항목 제목" className="h-8 text-xs mt-1" />
</div>
<div className="flex items-center gap-2">
<Checkbox checked={addItemRequired === "Y"} onCheckedChange={v => setAddItemRequired(v ? "Y" : "N")} />
<Label className="text-xs"> </Label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => setAddItemOpen(false)}></Button>
<Button size="sm" onClick={handleAddWorkItem}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 상세 추가 다이얼로그 */}
<Dialog open={addDetailOpen} onOpenChange={setAddDetailOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[450px]" onClick={e => e.stopPropagation()}>
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs">
"{selectedWorkItem?.title}" .
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs"></Label>
<Select value={addDetailType} onValueChange={setAddDetailType}>
<SelectTrigger className="h-8 text-xs mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{DETAIL_TYPES.map(dt => (
<SelectItem key={dt.value} value={dt.value} className="text-xs">{dt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Input value={addDetailContent} onChange={e => setAddDetailContent(e.target.value)} placeholder="상세 내용 입력" className="h-8 text-xs mt-1" />
</div>
<div className="flex items-center gap-2">
<Checkbox checked={addDetailRequired === "Y"} onCheckedChange={v => setAddDetailRequired(v ? "Y" : "N")} />
<Label className="text-xs"> </Label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => setAddDetailOpen(false)}></Button>
<Button size="sm" onClick={handleAddDetail}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,782 @@
"use client";
import React, { useState, 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Plus, Trash2, RotateCcw, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown, ClipboardCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import {
getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions,
getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList,
getRoutingVersions, RoutingVersionData,
} from "@/lib/api/workInstruction";
import { WorkStandardEditModal } from "./WorkStandardEditModal";
type SourceType = "production" | "order" | "item";
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
"일반": { label: "일반", cls: "bg-blue-100 text-blue-800 border-blue-200" },
"긴급": { label: "긴급", cls: "bg-red-100 text-red-800 border-red-200" },
};
const PROGRESS_BADGE: Record<string, { label: string; cls: string }> = {
"대기": { label: "대기", cls: "bg-amber-100 text-amber-800" },
"진행중": { label: "진행중", cls: "bg-blue-100 text-blue-800" },
"완료": { label: "완료", cls: "bg-emerald-100 text-emerald-800" },
};
interface EquipmentOption { id: string; equipment_code: string; equipment_name: string; }
interface EmployeeOption { user_id: string; user_name: string; dept_name: string | null; }
interface SelectedItem {
itemCode: string; itemName: string; spec: string; qty: number; remark: string;
sourceType: SourceType; sourceTable: string; sourceId: string | number;
}
export default function WorkInstructionPage() {
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [equipmentOptions, setEquipmentOptions] = useState<EquipmentOption[]>([]);
const [employeeOptions, setEmployeeOptions] = useState<EmployeeOption[]>([]);
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
const [debouncedKeyword, setDebouncedKeyword] = useState("");
const [searchStatus, setSearchStatus] = useState("all");
const [searchProgress, setSearchProgress] = useState("all");
const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState("");
// 1단계: 등록 모달
const [isRegModalOpen, setIsRegModalOpen] = useState(false);
const [regSourceType, setRegSourceType] = useState<SourceType | "">("");
const [regSourceData, setRegSourceData] = useState<any[]>([]);
const [regSourceLoading, setRegSourceLoading] = useState(false);
const [regKeyword, setRegKeyword] = useState("");
const [regCheckedIds, setRegCheckedIds] = useState<Set<string>>(new Set());
const [regMergeSameItem, setRegMergeSameItem] = useState(true);
const [regPage, setRegPage] = useState(1);
const [regPageSize] = useState(20);
const [regTotalCount, setRegTotalCount] = useState(0);
// 2단계: 확인 모달
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const [confirmItems, setConfirmItems] = useState<SelectedItem[]>([]);
const [confirmWiNo, setConfirmWiNo] = useState("");
const [confirmStatus, setConfirmStatus] = useState("일반");
const [confirmStartDate, setConfirmStartDate] = useState("");
const [confirmEndDate, setConfirmEndDate] = useState("");
const nv = (v: string) => v || "none";
const fromNv = (v: string) => v === "none" ? "" : v;
const [confirmEquipmentId, setConfirmEquipmentId] = useState("");
const [confirmWorkTeam, setConfirmWorkTeam] = useState("");
const [confirmWorker, setConfirmWorker] = useState("");
const [saving, setSaving] = useState(false);
// 수정 모달
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editOrder, setEditOrder] = useState<any>(null);
const [editItems, setEditItems] = useState<SelectedItem[]>([]);
const [editStatus, setEditStatus] = useState("일반");
const [editStartDate, setEditStartDate] = useState("");
const [editEndDate, setEditEndDate] = useState("");
const [editEquipmentId, setEditEquipmentId] = useState("");
const [editWorkTeam, setEditWorkTeam] = useState("");
const [editWorker, setEditWorker] = useState("");
const [editRemark, setEditRemark] = useState("");
const [editSaving, setEditSaving] = useState(false);
const [addQty, setAddQty] = useState("");
const [addEquipment, setAddEquipment] = useState("");
const [addWorkTeam, setAddWorkTeam] = useState("");
const [addWorker, setAddWorker] = useState("");
const [confirmWorkerOpen, setConfirmWorkerOpen] = useState(false);
const [editWorkerOpen, setEditWorkerOpen] = useState(false);
const [addWorkerOpen, setAddWorkerOpen] = useState(false);
// 라우팅 관련 상태
const [confirmRouting, setConfirmRouting] = useState("");
const [confirmRoutingOptions, setConfirmRoutingOptions] = useState<RoutingVersionData[]>([]);
const [editRouting, setEditRouting] = useState("");
const [editRoutingOptions, setEditRoutingOptions] = useState<RoutingVersionData[]>([]);
// 공정작업기준 모달 상태
const [wsModalOpen, setWsModalOpen] = useState(false);
const [wsModalWiNo, setWsModalWiNo] = useState("");
const [wsModalRoutingId, setWsModalRoutingId] = useState("");
const [wsModalRoutingName, setWsModalRoutingName] = useState("");
const [wsModalItemName, setWsModalItemName] = useState("");
const [wsModalItemCode, setWsModalItemCode] = useState("");
useEffect(() => { const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500); return () => clearTimeout(t); }, [searchKeyword]);
useEffect(() => {
getEquipmentList().then(r => { if (r.success) setEquipmentOptions(r.data || []); });
getEmployeeList().then(r => { if (r.success) setEmployeeOptions(r.data || []); });
}, []);
const fetchOrders = useCallback(async () => {
setLoading(true);
try {
const params: any = {};
if (searchDateFrom) params.dateFrom = searchDateFrom;
if (searchDateTo) params.dateTo = searchDateTo;
if (searchStatus !== "all") params.status = searchStatus;
if (searchProgress !== "all") params.progressStatus = searchProgress;
if (debouncedKeyword.trim()) params.keyword = debouncedKeyword.trim();
const r = await getWorkInstructionList(params);
if (r.success) setOrders(r.data || []);
} catch {} finally { setLoading(false); }
}, [searchDateFrom, searchDateTo, searchStatus, searchProgress, debouncedKeyword]);
useEffect(() => { fetchOrders(); }, [fetchOrders]);
const handleResetSearch = () => {
setSearchKeyword(""); setDebouncedKeyword(""); setSearchStatus("all"); setSearchProgress("all");
setSearchDateFrom(""); setSearchDateTo("");
};
// ─── 1단계 등록 ───
const openRegModal = () => {
setRegSourceType("production"); setRegSourceData([]); setRegKeyword(""); setRegCheckedIds(new Set());
setRegPage(1); setRegTotalCount(0); setRegMergeSameItem(true); setIsRegModalOpen(true);
};
const fetchRegSource = useCallback(async (pageOverride?: number) => {
if (!regSourceType) return;
setRegSourceLoading(true);
try {
const p = pageOverride ?? regPage;
const params: any = { page: p, pageSize: regPageSize };
if (regKeyword.trim()) params.keyword = regKeyword.trim();
let r;
switch (regSourceType) {
case "production": r = await getWIProductionPlanSource(params); break;
case "order": r = await getWISalesOrderSource(params); break;
case "item": r = await getWIItemSource(params); break;
}
if (r?.success) { setRegSourceData(r.data || []); setRegTotalCount(r.totalCount || 0); }
} catch {} finally { setRegSourceLoading(false); }
}, [regSourceType, regKeyword, regPage, regPageSize]);
useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [regSourceType]);
const getRegId = (item: any) => regSourceType === "item" ? (item.item_code || item.id) : String(item.id);
const toggleRegItem = (id: string) => { setRegCheckedIds(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); };
const toggleRegAll = () => { if (regCheckedIds.size === regSourceData.length) setRegCheckedIds(new Set()); else setRegCheckedIds(new Set(regSourceData.map(getRegId))); };
const applyRegistration = () => {
if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; }
const items: SelectedItem[] = [];
for (const item of regSourceData) {
if (!regCheckedIds.has(getRegId(item))) continue;
if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code });
else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id });
else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id });
}
// 동일품목 합산
if (regMergeSameItem) {
const merged = new Map<string, SelectedItem>();
for (const it of items) {
const key = it.itemCode;
if (merged.has(key)) { merged.get(key)!.qty += it.qty; }
else { merged.set(key, { ...it }); }
}
setConfirmItems(Array.from(merged.values()));
} else {
setConfirmItems(items);
}
setConfirmWiNo("불러오는 중...");
setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]);
setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker("");
setConfirmRouting(""); setConfirmRoutingOptions([]);
previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)"));
// 첫 번째 품목의 라우팅 로드
const firstItem = items.length > 0 ? items[0] : null;
if (firstItem) {
getRoutingVersions("__new__", firstItem.itemCode).then(r => {
if (r.success && r.data) {
setConfirmRoutingOptions(r.data);
const defaultRouting = r.data.find(rv => rv.is_default);
if (defaultRouting) setConfirmRouting(defaultRouting.id);
}
}).catch(() => {});
}
setIsRegModalOpen(false); setIsConfirmModalOpen(true);
};
// ─── 2단계 최종 적용 ───
const finalizeRegistration = async () => {
if (confirmItems.length === 0) { alert("품목이 없습니다."); return; }
setSaving(true);
try {
const payload = {
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
routing: confirmRouting || null,
items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
};
const r = await saveWorkInstruction(payload);
if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); }
else alert(r.message || "저장 실패");
} catch (e: any) { alert(e.message || "저장 실패"); } finally { setSaving(false); }
};
// ─── 수정 모달 ───
const openEditModal = (order: any) => {
const wiNo = order.work_instruction_no;
const relatedDetails = orders.filter(o => o.work_instruction_no === wiNo);
setEditOrder(order); setEditStatus(order.status || "일반");
setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || "");
setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || "");
setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || "");
setEditItems(relatedDetails.map((d: any) => ({
itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "",
qty: Number(d.detail_qty || 0), remark: d.detail_remark || "",
sourceType: (d.source_table === "sales_order_detail" ? "order" : d.source_table === "production_plan_mng" ? "production" : "item") as SourceType,
sourceTable: d.source_table || "item_info", sourceId: d.source_id || "",
})));
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
setEditRouting(order.routing_version_id || "");
setEditRoutingOptions([]);
// 라우팅 옵션 로드
const itemCode = order.item_number || order.part_code || "";
if (itemCode) {
getRoutingVersions(wiNo, itemCode).then(r => {
if (r.success && r.data) setEditRoutingOptions(r.data);
}).catch(() => {});
}
setIsEditModalOpen(true);
};
const addEditItem = () => {
if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; }
setEditItems(prev => [...prev, {
itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "",
qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "",
}]);
setAddQty("");
};
const saveEdit = async () => {
if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; }
setEditSaving(true);
try {
const payload = {
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
routing: editRouting || null,
items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
};
const r = await saveWorkInstruction(payload);
if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); }
else alert(r.message || "저장 실패");
} catch (e: any) { alert(e.message || "저장 실패"); } finally { setEditSaving(false); }
};
const handleDelete = async (wiId: string) => {
if (!confirm("이 작업지시를 삭제하시겠습니까?")) return;
const r = await deleteWorkInstructions([wiId]);
if (r.success) { fetchOrders(); } else alert(r.message || "삭제 실패");
};
const getProgress = (o: any) => {
const t = Number(o.total_qty || 0), c = Number(o.completed_qty || 0);
return t === 0 ? 0 : Math.min(100, Math.round((c / t) * 100));
};
const getProgressLabel = (o: any) => { const p = getProgress(o); if (o.progress_status) return o.progress_status; if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기"; };
const totalRegPages = Math.max(1, Math.ceil(regTotalCount / regPageSize));
const getDisplayNo = (o: any) => {
const cnt = Number(o.detail_count || 1);
const seq = Number(o.detail_seq || 1);
if (cnt <= 1) return o.work_instruction_no || "-";
return `${o.work_instruction_no}-${String(seq).padStart(2, "0")}`;
};
const openWorkStandardModal = (wiNo: string, routingVersionId: string, routingName: string, itemName: string, itemCode: string) => {
if (!routingVersionId) { alert("라우팅이 선택되지 않았습니다."); return; }
setWsModalWiNo(wiNo);
setWsModalRoutingId(routingVersionId);
setWsModalRoutingName(routingName);
setWsModalItemName(itemName);
setWsModalItemCode(itemCode);
setWsModalOpen(true);
};
const getWorkerName = (userId: string) => {
if (!userId) return "-";
const emp = employeeOptions.find(e => e.user_id === userId);
return emp ? emp.user_name : userId;
};
const WorkerCombobox = ({ value, onChange, open, onOpenChange, className, triggerClassName }: {
value: string; onChange: (v: string) => void; open: boolean; onOpenChange: (v: boolean) => void;
className?: string; triggerClassName?: string;
}) => (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open}
className={cn("w-full justify-between font-normal", triggerClassName || "h-9 text-sm")}>
{value ? (employeeOptions.find(e => e.user_id === value)?.user_name || value) : "작업자 선택"}
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="이름 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-4 text-center"> </CommandEmpty>
<CommandGroup>
<CommandItem value="__none__" onSelect={() => { onChange(""); onOpenChange(false); }} className="text-xs">
<Check className={cn("mr-2 h-3.5 w-3.5", !value ? "opacity-100" : "opacity-0")} />
</CommandItem>
{employeeOptions.map(emp => (
<CommandItem key={emp.user_id} value={`${emp.user_name} ${emp.user_id}`}
onSelect={() => { onChange(emp.user_id); onOpenChange(false); }} className="text-xs">
<Check className={cn("mr-2 h-3.5 w-3.5", value === emp.user_id ? "opacity-100" : "opacity-0")} />
{emp.user_name}{emp.dept_name ? ` (${emp.dept_name})` : ""}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
return (
<div className="flex flex-col h-full gap-4 p-4">
{/* 검색 */}
<Card>
<CardContent className="p-4">
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<div className="flex items-center gap-2">
<div className="w-[150px]"><FormDatePicker value={searchDateFrom} onChange={setSearchDateFrom} placeholder="시작일" /></div>
<span className="text-muted-foreground">~</span>
<div className="w-[150px]"><FormDatePicker value={searchDateTo} onChange={setSearchDateTo} placeholder="종료일" /></div>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input placeholder="작업지시번호/품목명" value={searchKeyword} onChange={e => setSearchKeyword(e.target.value)} className="h-9 w-[200px]" />
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="h-9 w-[120px]"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="all"></SelectItem><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchProgress} onValueChange={setSearchProgress}>
<SelectTrigger className="h-9 w-[130px]"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="all"></SelectItem><SelectItem value="대기"></SelectItem><SelectItem value="진행중"></SelectItem><SelectItem value="완료"></SelectItem></SelectContent>
</Select>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}><RotateCcw className="w-4 h-4 mr-1.5" /> </Button>
</div>
</div>
</CardContent>
</Card>
{/* 메인 테이블 */}
<Card className="flex-1 flex flex-col overflow-hidden">
<CardContent className="p-0 flex flex-col flex-1 overflow-hidden">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Wrench className="w-4 h-4" />
<Badge variant="secondary" className="text-xs">{new Set(orders.map(o => o.work_instruction_no)).size} ({orders.length})</Badge>
</h3>
<Button size="sm" onClick={openRegModal}><Plus className="w-4 h-4 mr-1.5" /> </Button>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[70px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[150px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={13} className="text-center py-12"><Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
) : orders.length === 0 ? (
<TableRow><TableCell colSpan={13} className="text-center py-12 text-muted-foreground"> </TableCell></TableRow>
) : orders.map((o, rowIdx) => {
const pct = getProgress(o);
const pLabel = getProgressLabel(o);
const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"];
const sBadge = STATUS_BADGE[o.status] || STATUS_BADGE["일반"];
const isFirstOfGroup = Number(o.detail_seq) === 1;
return (
<TableRow key={`${o.wi_id}-${o.detail_id}`} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs font-medium">{getDisplayNo(o)}</TableCell>
<TableCell className="text-center"><Badge variant="outline" className={cn("text-[10px]", sBadge.cls)}>{sBadge.label}</Badge></TableCell>
<TableCell className="text-center">
{isFirstOfGroup ? (
<div className="flex flex-col items-center gap-1">
<Badge variant="secondary" className={cn("text-[10px]", pBadge.cls)}>{pBadge.label}</Badge>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all", pct >= 100 ? "bg-emerald-500" : pct > 0 ? "bg-blue-500" : "bg-gray-300")} style={{ width: `${pct}%` }} />
</div>
<span className="text-[10px] text-muted-foreground">{pct}%</span>
</div>
) : <span className="text-[10px] text-muted-foreground"></span>}
</TableCell>
<TableCell className="text-sm">{o.item_name || o.item_number || "-"}</TableCell>
<TableCell className="text-xs">{o.item_spec || "-"}</TableCell>
<TableCell className="text-right text-xs font-medium">{Number(o.detail_qty || 0).toLocaleString()}</TableCell>
<TableCell className="text-xs">{isFirstOfGroup ? (o.equipment_name || "-") : ""}</TableCell>
<TableCell className="text-xs">
{isFirstOfGroup ? (
o.routing_version_id ? (
<button
className="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer text-xs text-left"
onClick={e => {
e.stopPropagation();
openWorkStandardModal(
o.work_instruction_no,
o.routing_version_id,
o.routing_name || "",
o.item_name || o.item_number || "",
o.item_number || ""
);
}}
>
{o.routing_name || "라우팅"} <ClipboardCheck className="w-3 h-3 inline ml-0.5" />
</button>
) : <span className="text-muted-foreground">-</span>
) : ""}
</TableCell>
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.work_team || "-") : ""}</TableCell>
<TableCell className="text-xs">{isFirstOfGroup ? getWorkerName(o.worker) : ""}</TableCell>
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.start_date || "-") : ""}</TableCell>
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.end_date || "-") : ""}</TableCell>
<TableCell className="text-center">
{isFirstOfGroup && (
<div className="flex items-center justify-center gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs px-2" onClick={() => openEditModal(o)}><Pencil className="w-3 h-3 mr-1" /> </Button>
<Button variant="outline" size="sm" className="h-7 text-xs px-2 text-destructive border-destructive/30 hover:bg-destructive/10" onClick={() => handleDelete(o.wi_id)}><Trash2 className="w-3 h-3 mr-1" /> </Button>
</div>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* ── 1단계: 등록 모달 ── */}
<Dialog open={isRegModalOpen} onOpenChange={setIsRegModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2"><Plus className="w-4 h-4" /> </DialogTitle>
<DialogDescription className="text-xs"> "작업지시 적용" .</DialogDescription>
</DialogHeader>
<div className="px-6 py-3 border-b bg-muted/30 flex items-center gap-3 flex-wrap shrink-0">
<Label className="text-sm font-semibold whitespace-nowrap">:</Label>
<Select value={regSourceType} onValueChange={v => setRegSourceType(v as SourceType)}>
<SelectTrigger className="h-9 w-[160px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent><SelectItem value="production"></SelectItem><SelectItem value="order"></SelectItem><SelectItem value="item"></SelectItem></SelectContent>
</Select>
{regSourceType && (<>
<Input placeholder="검색..." value={regKeyword} onChange={e => setRegKeyword(e.target.value)} className="h-9 w-[220px]"
onKeyDown={e => { if (e.key === "Enter") { setRegPage(1); fetchRegSource(1); } }} />
<Button size="sm" className="h-9" onClick={() => { setRegPage(1); fetchRegSource(1); }} disabled={regSourceLoading}>
{regSourceLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}<span className="ml-1.5"></span>
</Button>
</>)}
<div className="flex-1" />
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<Checkbox checked={regMergeSameItem} onCheckedChange={v => setRegMergeSameItem(!!v)} />
<span className="text-sm font-semibold"> </span>
</label>
</div>
<div className="flex-1 overflow-auto px-6 py-4">
{!regSourceType ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm"> </div>
) : regSourceLoading ? (
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : regSourceData.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm"> </div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[50px] text-center"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
{regSourceType === "item" && <><TableHead className="w-[120px]"></TableHead><TableHead></TableHead><TableHead className="w-[120px]"></TableHead></>}
{regSourceType === "order" && <><TableHead className="w-[110px]"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px]"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
</TableRow>
</TableHeader>
<TableBody>
{regSourceData.map((item, idx) => {
const id = getRegId(item);
const checked = regCheckedIds.has(id);
return (
<TableRow key={`${regSourceType}-${id}-${idx}`} className={cn("cursor-pointer hover:bg-muted/50", checked && "bg-primary/5")} onClick={() => toggleRegItem(id)}>
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
{regSourceType === "item" && <><TableCell className="text-xs font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-xs">{item.spec || "-"}</TableCell></>}
{regSourceType === "order" && <><TableCell className="text-xs">{item.order_no}</TableCell><TableCell className="text-xs">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-xs">{item.spec || "-"}</TableCell><TableCell className="text-right text-xs">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-xs">{item.due_date || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-xs">{item.plan_no}</TableCell><TableCell className="text-xs">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-xs">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-xs">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-xs">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-xs">{item.equipment_name || "-"}</TableCell></>}
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
{regTotalCount > 0 && (
<div className="px-6 py-2 border-t bg-muted/10 flex items-center justify-between shrink-0">
<span className="text-xs text-muted-foreground"> {regTotalCount} (: {regCheckedIds.size})</span>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" className="h-7 w-7" disabled={regPage <= 1} onClick={() => { const p = regPage - 1; setRegPage(p); fetchRegSource(p); }}><ChevronLeft className="w-3.5 h-3.5" /></Button>
<span className="text-xs font-medium px-2">{regPage} / {totalRegPages}</span>
<Button variant="outline" size="icon" className="h-7 w-7" disabled={regPage >= totalRegPages} onClick={() => { const p = regPage + 1; setRegPage(p); fetchRegSource(p); }}><ChevronRight className="w-3.5 h-3.5" /></Button>
</div>
</div>
)}
<DialogFooter className="px-6 py-3 border-t shrink-0">
<Button variant="outline" onClick={() => setIsRegModalOpen(false)}></Button>
<Button onClick={applyRegistration} disabled={regCheckedIds.size === 0}><ArrowRight className="w-4 h-4 mr-1.5" /> </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── 2단계: 확인 모달 ── */}
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[1000px] max-h-[90vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2"><CheckCircle2 className="w-4 h-4" /> </DialogTitle>
<DialogDescription className="text-xs"> "최종 적용" .</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto p-6 space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-sm font-semibold mb-4"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted/50 text-muted-foreground" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1.5"><Label className="text-xs"></Label><FormDatePicker value={confirmStartDate} onChange={setConfirmStartDate} placeholder="시작일" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><FormDatePicker value={confirmEndDate} onChange={setConfirmEndDate} placeholder="완료예정일" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
</div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
</div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Select value={nv(confirmRouting)} onValueChange={v => setConfirmRouting(fromNv(v))}>
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{confirmRoutingOptions.map(rv => (
<SelectItem key={rv.id} value={rv.id}>
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="border rounded-lg p-5">
<h4 className="text-sm font-semibold mb-3"> </h4>
<div className="max-h-[300px] overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow><TableHead className="w-[60px]"></TableHead><TableHead className="w-[120px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead></TableRow>
</TableHeader>
<TableBody>
{confirmItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-xs">{item.spec || "-"}</TableCell>
<TableCell><Input type="number" className="h-7 text-xs w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
<DialogFooter className="px-6 py-3 border-t shrink-0">
<Button variant="outline" onClick={() => { setIsConfirmModalOpen(false); setIsRegModalOpen(true); }}><ChevronLeft className="w-4 h-4 mr-1" /> </Button>
<Button variant="outline" onClick={() => setIsConfirmModalOpen(false)}></Button>
<Button onClick={finalizeRegistration} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <CheckCircle2 className="w-4 h-4 mr-1.5" />} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── 수정 모달 ── */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[90vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2"><Wrench className="w-4 h-4" /> - {editOrder?.work_instruction_no}</DialogTitle>
<DialogDescription className="text-xs"> / .</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto p-6 space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-sm font-semibold mb-4"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="space-y-1.5"><Label className="text-xs"></Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><FormDatePicker value={editStartDate} onChange={setEditStartDate} placeholder="시작일" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><FormDatePicker value={editEndDate} onChange={setEditEndDate} placeholder="완료예정일" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select></div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
</div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Select value={nv(editRouting)} onValueChange={v => setEditRouting(fromNv(v))}>
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{editRoutingOptions.map(rv => (
<SelectItem key={rv.id} value={rv.id}>
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Button
variant="outline"
className="h-9 w-full text-xs"
disabled={!editRouting}
onClick={() => {
if (!editOrder || !editRouting) return;
const rv = editRoutingOptions.find(r => r.id === editRouting);
openWorkStandardModal(
editOrder.work_instruction_no,
editRouting,
rv?.version_name || "",
editOrder.item_name || editOrder.item_number || "",
editOrder.item_number || ""
);
}}
>
<ClipboardCheck className="w-3.5 h-3.5 mr-1.5" />
</Button>
</div>
<div className="space-y-1.5 col-span-2"><Label className="text-xs"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고" /></div>
</div>
</div>
{/* 인라인 추가 폼 */}
<div className="border rounded-lg p-4 bg-muted/20">
<div className="flex items-end gap-3 flex-wrap">
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"> <span className="text-destructive">*</span></Label><Input type="number" value={addQty} onChange={e => setAddQty(e.target.value)} className="h-8 w-24 text-xs" placeholder="0" /></div>
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"></Label><Select value={nv(addEquipment)} onValueChange={v => setAddEquipment(fromNv(v))}><SelectTrigger className="h-8 w-[160px] text-xs"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"></Label><Select value={nv(addWorkTeam)} onValueChange={v => setAddWorkTeam(fromNv(v))}><SelectTrigger className="h-8 w-[100px] text-xs"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"></SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"></Label>
<div className="w-[150px]"><WorkerCombobox value={addWorker} onChange={setAddWorker} open={addWorkerOpen} onOpenChange={setAddWorkerOpen} triggerClassName="h-8 text-xs" /></div>
</div>
<Button size="sm" className="h-8" onClick={addEditItem}><Plus className="w-3 h-3 mr-1" /> </Button>
</div>
</div>
{/* 품목 테이블 */}
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between p-3 bg-muted/20 border-b">
<span className="text-sm font-semibold"> </span>
<span className="text-xs text-muted-foreground">{editItems.length}</span>
</div>
<div className="max-h-[280px] overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow><TableHead className="w-[60px]"></TableHead><TableHead className="w-[120px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[100px] text-right"></TableHead><TableHead></TableHead><TableHead className="w-[60px]" /></TableRow>
</TableHeader>
<TableBody>
{editItems.length === 0 ? (
<TableRow><TableCell colSpan={7} className="text-center py-8 text-muted-foreground text-sm"> </TableCell></TableRow>
) : editItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
<TableCell className="text-xs max-w-[180px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-xs max-w-[100px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{editItems.length > 0 && (
<div className="p-3 border-t bg-muted/20 flex items-center justify-between">
<span className="text-sm font-semibold"> </span>
<span className="text-lg font-bold text-primary">{editItems.reduce((s, i) => s + i.qty, 0).toLocaleString()} EA</span>
</div>
)}
</div>
</div>
<DialogFooter className="px-6 py-3 border-t shrink-0">
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}></Button>
<Button onClick={saveEdit} disabled={editSaving}>{editSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 공정작업기준 수정 모달 */}
<WorkStandardEditModal
open={wsModalOpen}
onClose={() => setWsModalOpen(false)}
workInstructionNo={wsModalWiNo}
routingVersionId={wsModalRoutingId}
routingName={wsModalRoutingName}
itemName={wsModalItemName}
itemCode={wsModalItemCode}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,947 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
// Card, CardContent 제거 — DynamicSearchFilter가 대체
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
const DETAIL_TABLE = "sales_order_detail";
// 천단위 구분자 표시용 (입력 중에는 콤마 포함 표시, 저장 시 숫자만)
const formatNumber = (val: string) => {
const num = val.replace(/[^\d.-]/g, "");
if (!num) return "";
const parts = num.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
};
const parseNumber = (val: string) => val.replace(/,/g, "");
const MASTER_TABLE = "sales_order_mng";
// 메인 목록 테이블 컬럼 (sales_order_detail 기준)
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "order_no", label: "수주번호", width: "w-[120px]" },
{ key: "part_code", label: "품번", width: "w-[120px]", editable: true },
{ key: "part_name", label: "품명", minWidth: "min-w-[150px]", editable: true },
{ key: "spec", label: "규격", width: "w-[120px]", editable: true },
{ key: "unit", label: "단위", width: "w-[70px]", editable: true },
{ key: "qty", label: "수량", width: "w-[90px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
{ key: "ship_qty", label: "출하수량", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "balance_qty", label: "잔량", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "unit_price", label: "단가", width: "w-[100px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
{ key: "amount", label: "금액", width: "w-[110px]", formatNumber: true, align: "right" },
{ key: "due_date", label: "납기일", width: "w-[110px]" },
{ key: "memo", label: "메모", width: "w-[100px]", editable: true },
];
// 조건부 레이어 설정 (input_mode, sell_mode에 따라 표시 필드가 달라짐)
// Zone 10: input_mode → 공급업체우선(CAT_MLZWPH5R_983R) / 품목우선(CAT_MLZWPUQC_PB8Z)
// Zone 17: sell_mode → 해외판매(CAT_MLZWFF2Z_BQCV)
export default function SalesOrderPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
// 검색 필터 (DynamicSearchFilter에서 관리)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
// isModalFullscreen 제거됨 — FullscreenDialog 사용
const [isEditMode, setIsEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
const [detailRows, setDetailRows] = useState<any[]>([]);
// 품목 선택 모달 (리피터에서 품목 추가용)
const [itemSelectOpen, setItemSelectOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
const [itemSearchLoading, setItemSearchLoading] = useState(false);
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 출하계획 모달
const [shippingPlanOpen, setShippingPlanOpen] = useState(false);
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 체크된 행 (다중선택)
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 테이블 설정
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [gridColumns, setGridColumns] = useState<DataGridColumn[]>(GRID_COLUMNS);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
// 테이블 설정 적용 (컬럼 + 필터)
const applyTableSettings = useCallback((settings: TableSettings) => {
// 컬럼 표시/숨김/순서/너비
const colMap = new Map(GRID_COLUMNS.map((c) => [c.key, c]));
const applied: DataGridColumn[] = [];
for (const cs of settings.columns) {
if (!cs.visible) continue;
const orig = colMap.get(cs.columnName);
if (orig) {
applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined });
}
}
const settingKeys = new Set(settings.columns.map((c) => c.columnName));
for (const col of GRID_COLUMNS) {
if (!settingKeys.has(col.key)) applied.push(col);
}
setGridColumns(applied.length > 0 ? applied : GRID_COLUMNS);
// 필터 설정 → DynamicSearchFilter에 전달
setFilterConfig(settings.filters);
}, []);
// 마운트 시 저장된 설정 복원
useEffect(() => {
const saved = loadTableSettings("sales-order");
if (saved) applyTableSettings(saved);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 카테고리 로드
useEffect(() => {
const loadCategories = async () => {
const catColumns = ["sell_mode", "input_mode", "price_mode", "incoterms", "payment_term"];
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// 라벨 치환 + 중복 제거 (같은 label이면 첫 번째만 유지)
const LABEL_REPLACE: Record<string, string> = {
"공급업체 우선": "거래처 우선",
"공급업체우선": "거래처 우선",
};
const dedup = (items: { code: string; label: string }[]) => {
const seen = new Set<string>();
return items
.map((item) => ({ ...item, label: LABEL_REPLACE[item.label] || item.label }))
.filter((item) => {
const key = item.label.replace(/\s/g, "");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
await Promise.all(
catColumns.map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[col] = dedup(flatten(res.data.data));
}
} catch { /* skip */ }
})
);
// 거래처 목록도 로드
try {
const custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, {
page: 1, size: 500, autoFilter: true,
});
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
} catch { /* skip */ }
// 사용자 목록 로드 (담당자 선택용)
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager_id"] = users.map((u: any) => ({
code: u.user_id || u.id,
label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`,
}));
} catch { /* skip */ }
// item_info 카테고리도 로드 (unit, material 등 코드→라벨 변환용)
for (const col of ["unit", "material", "division", "type"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[`item_${col}`] = flatten(res.data.data);
}
} catch { /* skip */ }
}
setCategoryOptions(optMap);
};
loadCategories();
}, []);
// 데이터 조회
const fetchOrders = useCallback(async () => {
setLoading(true);
try {
const filters: any[] = searchFilters.map((f) => ({
columnName: f.columnName,
operator: f.operator,
value: f.value,
}));
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "order_no", order: "desc" },
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
// part_code → item_info 조인 (품명/규격이 비어있는 경우 보강)
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
let itemMap: Record<string, any> = {};
if (partCodes.length > 0) {
try {
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: partCodes.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: partCodes }] },
autoFilter: true,
});
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
for (const item of items) {
itemMap[item.item_number] = item;
}
} catch { /* skip */ }
}
// 조인 적용 + 카테고리 코드→라벨 변환
const resolveLabel = (key: string, code: string) => {
if (!code) return "";
const opts = categoryOptions[key];
if (!opts) return code;
return opts.find((o) => o.code === code)?.label || code;
};
const data = rows.map((row: any) => {
const item = itemMap[row.part_code];
const rawUnit = row.unit || item?.unit || "";
return {
...row,
part_name: row.part_name || item?.item_name || "",
spec: row.spec || item?.size || "",
unit: resolveLabel("item_unit", rawUnit) || rawUnit,
};
});
setOrders(data);
setTotalCount(res.data?.data?.total || data.length);
} catch (err) {
console.error("수주 조회 실패:", err);
toast.error("수주 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchOrders(); }, [fetchOrders]);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
const found = categoryOptions[col]?.find((o) => o.code === code);
return found?.label || code;
};
// 등록 모달 열기
// 납품처 목록 (거래처 선택 시 조회)
const [deliveryOptions, setDeliveryOptions] = useState<{ code: string; label: string }[]>([]);
const loadDeliveryOptions = async (customerCode: string) => {
if (!customerCode) { setDeliveryOptions([]); return; }
try {
const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] },
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setDeliveryOptions(rows.map((r: any) => ({
code: r.destination_code || r.id,
label: `${r.destination_name}${r.address ? ` (${r.address})` : ""}`,
})));
} catch { setDeliveryOptions([]); }
};
const openRegisterModal = () => {
// 기본값: 각 카테고리의 첫 번째 옵션
const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || "";
const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || "";
const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || "";
setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, manager_id: user?.userId || "" });
setDetailRows([]);
setDeliveryOptions([]);
setIsEditMode(false);
setIsModalOpen(true);
};
// 수정 모달 열기 (order_no로 마스터 + 디테일 조회)
const openEditModal = async (orderNo: string) => {
try {
// 마스터 조회
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masterData = (masterRes.data?.data?.data || masterRes.data?.data?.rows || [])[0];
// 디테일 조회
const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
setMasterForm(masterData || {});
setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` })));
setIsEditMode(true);
setIsModalOpen(true);
} catch (err) {
console.error("수주 상세 조회 실패:", err);
toast.error("수주 정보를 불러오는데 실패했습니다.");
}
};
// 삭제 (다중 선택)
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
const ok = await confirm(`${checkedIds.length}건의 수주 데이터를 삭제하시겠습니까?`, {
description: "삭제된 데이터는 복구할 수 없습니다.",
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
// 선택된 디테일 행 삭제
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: checkedIds.map((id) => ({ id })),
});
// 해당 수주번호의 남은 디테일이 없으면 마스터도 삭제
for (const orderNo of orderNos) {
const remaining = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const rows = remaining.data?.data?.data || remaining.data?.data?.rows || [];
if (rows.length === 0) {
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
});
}
}
}
toast.success("삭제되었습니다.");
setCheckedIds([]);
fetchOrders();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했습니다.");
}
};
// 저장 (마스터 + 디테일)
const handleSave = async () => {
if (!masterForm.order_no && !isEditMode) {
toast.error("수주번호는 필수입니다.");
return;
}
if (detailRows.length === 0) {
toast.error("품목을 1개 이상 추가해주세요.");
return;
}
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm;
if (isEditMode && id) {
// 마스터 수정
await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, {
originalData: { id },
updatedData: masterFields,
});
// 기존 디테일 삭제 후 재삽입
const existingDetails = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: masterForm.order_no }] },
autoFilter: true,
});
const existings = existingDetails.data?.data?.data || existingDetails.data?.data?.rows || [];
if (existings.length > 0) {
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: existings.map((d: any) => ({ id: d.id })),
});
}
} else {
// 마스터 등록
await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields);
}
// 디테일 등록
for (const row of detailRows) {
const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row;
await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, {
...detailFields,
order_no: masterForm.order_no,
});
}
toast.success(isEditMode ? "수정되었습니다." : "등록되었습니다.");
setIsModalOpen(false);
fetchOrders();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 품목 검색 (리피터에서 추가)
const searchItems = async () => {
setItemSearchLoading(true);
try {
const filters: any[] = [];
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
};
const addSelectedItemsToDetail = async () => {
const selected = itemSearchResults.filter((item) => itemCheckedIds.has(item.id));
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
// 단가방식에 따라 단가 조회
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
const today = new Date().toISOString().split("T")[0];
// 거래처별 단가 조회 (선택된 품목들에 대해)
let customerPriceMap: Record<string, string> = {};
if (isCustomerPrice && partnerId) {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
dataFilter: {
enabled: true,
filters: [
{ columnName: "customer_id", operator: "equals", value: partnerId },
{ columnName: "item_id", operator: "in", value: itemIds },
],
},
autoFilter: true,
});
const mappings = res.data?.data?.data || res.data?.data?.rows || [];
for (const m of mappings) {
// calculated_price 우선, 없으면 current_unit_price
const price = m.calculated_price || m.current_unit_price || "";
if (price) customerPriceMap[m.item_id] = String(price);
}
} catch (err) {
console.error("거래처별 단가 조회 실패:", err);
}
}
const newRows = selected.map((item) => {
const itemCode = item.item_number || item.id;
let unitPrice = "";
if (isStandardPrice) {
// 기준단가: item_info의 standard_price 또는 selling_price
unitPrice = item.standard_price || item.selling_price || "";
} else if (isCustomerPrice && partnerId) {
// 거래처별 단가
unitPrice = customerPriceMap[itemCode] || "";
}
return {
_id: `new_${Date.now()}_${Math.random()}`,
part_code: itemCode,
part_name: item.item_name,
spec: item.size || "",
material: getCategoryLabel("item_material", item.material) || item.material || "",
unit: getCategoryLabel("item_unit", item.unit) || item.unit || "",
qty: "",
unit_price: unitPrice,
amount: "",
due_date: "",
};
});
setDetailRows((prev) => [...prev, ...newRows]);
toast.success(`${selected.length}개 품목이 추가되었습니다.`);
setItemCheckedIds(new Set());
setItemSelectOpen(false);
};
const updateDetailRow = (idx: number, field: string, value: string) => {
setDetailRows((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: value };
// 수량 × 단가 = 금액 자동 계산
if (field === "qty" || field === "unit_price") {
const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0;
const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0;
next[idx].amount = (qty * price).toString();
}
return next;
});
};
const removeDetailRow = (idx: number) => {
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
};
// input_mode 값으로 레이어 판단
// 거래처 우선 (구: 공급업체 우선) - 두 코드 모두 지원
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
const isItemFirst = masterForm.input_mode === "CAT_MLZWPUQC_PB8Z" || masterForm.input_mode === "CAT_MLKG5FZO_HS1B";
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
const handleExcelDownload = async () => {
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
const data = orders.map((o) => {
const row: Record<string, any> = {};
for (const col of GRID_COLUMNS) row[col.label] = o[col.key] || "";
return row;
});
await exportToExcel(data, "수주관리.xlsx", "수주목록");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 필터 (사용자 설정 가능) */}
<DynamicSearchFilter
tableName={DETAIL_TABLE}
filterId="sales-order"
onFilterChange={setSearchFilters}
dataCount={totalCount}
externalFilterConfig={filterConfig}
/>
{/* 메인 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<ClipboardList className="w-5 h-5" />
<Badge variant="secondary" className="font-normal">{totalCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={checkedIds.length !== 1} onClick={() => {
const item = orders.find((o) => o.id === checkedIds[0]);
if (item) openEditModal(item.order_no);
}}>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && `(${checkedIds.length})`}
</Button>
<Button variant="outline" size="sm" disabled={checkedIds.length === 0} onClick={() => setShippingPlanOpen(true)}>
<Truck className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && `(${checkedIds.length})`}
</Button>
<Button variant="outline" size="sm" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
</div>
</div>
<DataGrid
gridId="sales-order"
columns={gridColumns}
data={orders}
loading={loading}
showCheckbox
showRowNumber={false}
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEditModal(row.order_no)}
tableName={DETAIL_TABLE}
emptyMessage="등록된 수주가 없습니다"
onCellEdit={() => fetchOrders()}
/>
</div>
{/* 수주 등록/수정 모달 */}
<FullscreenDialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={isEditMode ? "수주 수정" : "수주 등록"}
description={isEditMode ? "수주 정보를 수정합니다." : "새로운 수주를 등록합니다."}
footer={
<>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="space-y-4 py-2">
{/* 기본 레이어 (항상 표시) */}
<div className="grid grid-cols-4 gap-4">
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={masterForm.order_no || ""} onChange={(e) => setMasterForm((p) => ({ ...p, order_no: e.target.value }))}
placeholder="수주번호" className="h-9" disabled={isEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={masterForm.order_date || ""} onChange={(v) => setMasterForm((p) => ({ ...p, order_date: v }))} placeholder="수주일" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select value={masterForm.sell_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, sell_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["sell_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["input_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.price_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, price_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["price_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{/* 레이어 2: 거래처 우선 (거래처, 담당자, 납품처, 납품장소) */}
{isSupplierFirst && (
<div className="grid grid-cols-4 gap-4 border-t pt-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.partner_id || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }}>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.manager_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager_id: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["manager_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{deliveryOptions.length > 0 ? (
<Select value={masterForm.delivery_partner_id || ""} onValueChange={(v) => {
setMasterForm((p) => ({ ...p, delivery_partner_id: v }));
// 선택한 납품처의 주소를 자동 입력
const found = deliveryOptions.find((o) => o.code === v);
if (found) {
const addr = found.label.match(/\((.+)\)$/)?.[1] || "";
if (addr) setMasterForm((p) => ({ ...p, delivery_partner_id: v, delivery_address: addr }));
}
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="납품처 선택" /></SelectTrigger>
<SelectContent>
{deliveryOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input value={masterForm.delivery_partner_id || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))}
placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} />
)}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.delivery_address || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))}
placeholder="납품장소" className="h-9" />
</div>
</div>
)}
{/* 레이어 4: 해외판매 (인코텀즈, 결제조건, 통화, 선적항, 도착항, HS코드) */}
{isOverseas && (
<div className="grid grid-cols-3 gap-4 border-t pt-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.incoterms || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, incoterms: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["incoterms"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.payment_term || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, payment_term: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["payment_term"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.currency || ""} onChange={(e) => setMasterForm((p) => ({ ...p, currency: e.target.value }))}
placeholder="KRW" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.port_of_loading || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_loading: e.target.value }))}
className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.port_of_discharge || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_discharge: e.target.value }))}
className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm">HS Code</Label>
<Input value={masterForm.hs_code || ""} onChange={(e) => setMasterForm((p) => ({ ...p, hs_code: e.target.value }))}
className="h-9" />
</div>
</div>
)}
{/* 리피터 그리드 (품목 목록) — 레이어 2,3 공통 */}
<div className="border rounded-lg">
<div className="flex items-center justify-between p-3 border-b bg-muted/10">
<span className="text-sm font-semibold"> </span>
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="overflow-auto max-h-[300px]">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_code}>{row.part_code}</span></TableCell>
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
<TableCell className="text-xs">{row.spec}</TableCell>
<TableCell className="text-xs">{row.unit}</TableCell>
<TableCell>
<Input value={formatNumber(row.qty || "")} onChange={(e) => updateDetailRow(idx, "qty", parseNumber(e.target.value))}
className="h-8 text-sm text-right" />
</TableCell>
<TableCell>
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
className="h-8 text-sm text-right" />
</TableCell>
<TableCell className="text-sm text-right font-medium">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell>
<FormDatePicker value={row.due_date || ""} onChange={(v) => updateDetailRow(idx, "due_date", v)} placeholder="납기일" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* 메모 */}
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.memo || ""} onChange={(e) => setMasterForm((p) => ({ ...p, memo: e.target.value }))}
placeholder="메모" className="h-9" />
</div>
</div>
{/* 품목 선택 모달 (등록 모달 내부에 중첩) */}
<Dialog open={itemSelectOpen} onOpenChange={setItemSelectOpen}>
<DialogContent className="max-w-3xl max-h-[70vh]" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="품명/품목코드 검색" value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItems()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={itemSearchResults.length > 0 && itemCheckedIds.size === itemSearchResults.length}
onChange={(e) => {
if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id)));
else setItemCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[130px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itemSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : itemSearchResults.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer", itemCheckedIds.has(item.id) && "bg-primary/5")}
onClick={() => setItemCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
})}>
<TableCell className="text-center">
<input type="checkbox" checked={itemCheckedIds.has(item.id)} readOnly />
</TableCell>
<TableCell className="text-xs max-w-[130px]"><span className="block truncate" title={item.item_number}>{item.item_number}</span></TableCell>
<TableCell className="text-sm max-w-[150px]"><span className="block truncate" title={item.item_name}>{item.item_name}</span></TableCell>
<TableCell className="text-xs">{item.size}</TableCell>
<TableCell className="text-xs">{item.material}</TableCell>
<TableCell className="text-xs">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{itemCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(false); }}></Button>
<Button onClick={addSelectedItemsToDetail} disabled={itemCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {itemCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</FullscreenDialog>
{/* 출하계획 동시 등록 모달 */}
<ShippingPlanBatchModal
open={shippingPlanOpen}
onOpenChange={setShippingPlanOpen}
selectedDetailIds={checkedIds}
onSuccess={fetchOrders}
/>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DETAIL_TABLE}
userId={user?.userId}
onSuccess={() => fetchOrders()}
/>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={DETAIL_TABLE}
settingsId="sales-order"
onSave={applyTableSettings}
/>
{/* 공통 확인 다이얼로그 */}
{ConfirmDialogComponent}
</div>
);
}
@@ -0,0 +1,917 @@
"use client";
/**
*
*
* 좌측: 판매품목 (item_info, )
* 우측: 선택한 (customer_item_mapping customer_mng )
*
* ( customer_item_mapping )
*/
import React, { useState, 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, X, Settings2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
const ITEM_TABLE = "item_info";
const MAPPING_TABLE = "customer_item_mapping";
const CUSTOMER_TABLE = "customer_mng";
// 좌측: 판매품목 컬럼
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "item_number", label: "품번", width: "w-[110px]" },
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
{ key: "size", label: "규격", width: "w-[90px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
// 우측: 거래처 정보 컬럼
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "customer_code", label: "거래처코드", width: "w-[110px]" },
{ key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" },
{ key: "customer_item_code", label: "거래처품번", width: "w-[100px]" },
{ key: "customer_item_name", label: "거래처품명", width: "w-[100px]" },
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
];
export default function SalesItemPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 품목
const [items, setItems] = useState<any[]>([]);
const [itemLoading, setItemLoading] = useState(false);
const [itemCount, setItemCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 테이블 설정
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
// 우측: 거래처
const [customerItems, setCustomerItems] = useState<any[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 거래처 추가 모달
const [custSelectOpen, setCustSelectOpen] = useState(false);
const [custSearchKeyword, setCustSearchKeyword] = useState("");
const [custSearchResults, setCustSearchResults] = useState<any[]>([]);
const [custSearchLoading, setCustSearchLoading] = useState(false);
const [custCheckedIds, setCustCheckedIds] = useState<Set<string>>(new Set());
// 품목 수정 모달
const [editItemOpen, setEditItemOpen] = useState(false);
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 거래처 상세 입력 모달 (거래처 품번/품명 + 단가)
const [custDetailOpen, setCustDetailOpen] = useState(false);
const [selectedCustsForDetail, setSelectedCustsForDetail] = useState<any[]>([]);
const [custMappings, setCustMappings] = useState<Record<string, Array<{ _id: string; customer_item_code: string; customer_item_name: string }>>>({});
const [custPrices, setCustPrices] = useState<Record<string, Array<{
_id: string; start_date: string; end_date: string; currency_code: string;
base_price_type: string; base_price: string; discount_type: string;
discount_value: string; rounding_type: string; rounding_unit_value: string;
calculated_price: string;
}>>>({});
const [priceCategoryOptions, setPriceCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [editCustData, setEditCustData] = useState<any>(null);
// 테이블 설정 적용 (필터)
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
// 마운트 시 저장된 설정 복원
useEffect(() => {
const saved = loadTableSettings("sales-item");
if (saved) applyTableSettings(saved);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
try {
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
// 단가 카테고리
const priceOpts: Record<string, { code: string; label: string }[]> = {};
for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) {
try {
const res = await apiClient.get(`/table-categories/customer_item_prices/${col}/values`);
if (res.data?.success) priceOpts[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setPriceCategoryOptions(priceOpts);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 좌측: 품목 조회
const fetchItems = useCallback(async () => {
setItemLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setItemCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setItemLoading(false);
}
}, [searchFilters, categoryOptions]);
useEffect(() => { fetchItems(); }, [fetchItems]);
// 선택된 품목
const selectedItem = items.find((i) => i.id === selectedItemId);
// 우측: 거래처 목록 조회
useEffect(() => {
if (!selectedItem?.item_number) { setCustomerItems([]); return; }
const itemKey = selectedItem.item_number;
const fetchCustomerItems = async () => {
setCustomerLoading(true);
try {
// customer_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
// customer_id → customer_mng 조인 (거래처명)
const custIds = [...new Set(mappings.map((m: any) => m.customer_id).filter(Boolean))];
let custMap: Record<string, any> = {};
if (custIds.length > 0) {
try {
const custRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
page: 1, size: custIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "in", value: custIds }] },
autoFilter: true,
});
for (const c of (custRes.data?.data?.data || custRes.data?.data?.rows || [])) {
custMap[c.customer_code] = c;
}
} catch { /* skip */ }
}
setCustomerItems(mappings.map((m: any) => ({
...m,
customer_code: m.customer_id,
customer_name: custMap[m.customer_id]?.customer_name || "",
})));
} catch (err) {
console.error("거래처 조회 실패:", err);
} finally {
setCustomerLoading(false);
}
};
fetchCustomerItems();
}, [selectedItem?.item_number]);
// 거래처 검색
const searchCustomers = async () => {
setCustSearchLoading(true);
try {
const filters: any[] = [];
if (custSearchKeyword) filters.push({ columnName: "customer_name", operator: "contains", value: custSearchKeyword });
const res = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const all = res.data?.data?.data || res.data?.data?.rows || [];
// 이미 등록된 거래처 제외
const existing = new Set(customerItems.map((c: any) => c.customer_id || c.customer_code));
setCustSearchResults(all.filter((c: any) => !existing.has(c.customer_code)));
} catch { /* skip */ } finally { setCustSearchLoading(false); }
};
// 거래처 선택 → 상세 모달로 이동
const goToCustDetail = () => {
const selected = custSearchResults.filter((c) => custCheckedIds.has(c.id));
if (selected.length === 0) { toast.error("거래처를 선택해주세요."); return; }
setSelectedCustsForDetail(selected);
const mappings: typeof custMappings = {};
const prices: typeof custPrices = {};
for (const cust of selected) {
const key = cust.customer_code || cust.id;
mappings[key] = [];
prices[key] = [{
_id: `p_${Date.now()}_${Math.random()}`,
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: selectedItem?.standard_price || selectedItem?.selling_price || "",
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
calculated_price: selectedItem?.standard_price || selectedItem?.selling_price || "",
}];
}
setCustMappings(mappings);
setCustPrices(prices);
setCustSelectOpen(false);
setCustDetailOpen(true);
};
const addMappingRow = (custKey: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: [...(prev[custKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, customer_item_code: "", customer_item_name: "" }],
}));
};
const removeMappingRow = (custKey: string, rowId: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
}));
};
const updateMappingRow = (custKey: string, rowId: string, field: string, value: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r),
}));
};
const addPriceRow = (custKey: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: [...(prev[custKey] || []), {
_id: `p_${Date.now()}_${Math.random()}`,
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "",
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
calculated_price: "",
}],
}));
};
const removePriceRow = (custKey: string, rowId: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
}));
};
const updatePriceRow = (custKey: string, rowId: string, field: string, value: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).map((r) => {
if (r._id !== rowId) return r;
const updated = { ...r, [field]: value };
if (["base_price", "discount_type", "discount_value"].includes(field)) {
const bp = Number(updated.base_price) || 0;
const dv = Number(updated.discount_value) || 0;
const dt = updated.discount_type;
let calc = bp;
if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100);
else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv;
updated.calculated_price = String(Math.round(calc));
}
return updated;
}),
}));
};
const openEditCust = async (row: any) => {
const custKey = row.customer_code || row.customer_id;
// customer_mng에서 거래처 정보 조회
let custInfo: any = { customer_code: custKey, customer_name: row.customer_name || "" };
try {
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: custKey }] },
autoFilter: true,
});
const found = (res.data?.data?.data || res.data?.data?.rows || [])[0];
if (found) custInfo = found;
} catch { /* skip */ }
const mappingRows = [{
_id: `m_existing_${row.id}`,
customer_item_code: row.customer_item_code || "",
customer_item_name: row.customer_item_name || "",
}].filter((m) => m.customer_item_code || m.customer_item_name);
const priceRows = [{
_id: `p_existing_${row.id}`,
start_date: row.start_date || "",
end_date: row.end_date || "",
currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI",
base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW",
base_price: row.base_price ? String(row.base_price) : "",
discount_type: row.discount_type || "",
discount_value: row.discount_value ? String(row.discount_value) : "",
rounding_type: row.rounding_type || "",
rounding_unit_value: row.rounding_unit_value || "",
calculated_price: row.calculated_price ? String(row.calculated_price) : "",
}].filter((p) => p.base_price || p.start_date);
if (priceRows.length === 0) {
priceRows.push({
_id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "",
rounding_type: "", rounding_unit_value: "", calculated_price: "",
});
}
setSelectedCustsForDetail([custInfo]);
setCustMappings({ [custKey]: mappingRows });
setCustPrices({ [custKey]: priceRows });
setEditCustData(row);
setCustDetailOpen(true);
};
const handleCustDetailSave = async () => {
if (!selectedItem) return;
const isEditingExisting = !!editCustData;
setSaving(true);
try {
for (const cust of selectedCustsForDetail) {
const custKey = cust.customer_code || cust.id;
const mappingRows = custMappings[custKey] || [];
if (isEditingExisting && editCustData?.id) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: editCustData.id },
updatedData: {
customer_item_code: mappingRows[0]?.customer_item_code || "",
customer_item_name: mappingRows[0]?.customer_item_name || "",
},
});
// 기존 prices 삭제 후 재등록
try {
const existingPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "mapping_id", operator: "equals", value: editCustData.id },
]}, autoFilter: true,
});
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
data: existing.map((p: any) => ({ id: p.id })),
});
}
} catch { /* skip */ }
const priceRows = (custPrices[custKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
mapping_id: editCustData.id,
customer_id: custKey,
item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
} else {
// 신규 등록
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: custKey, item_id: selectedItem.item_number,
customer_item_code: mappingRows[0]?.customer_item_code || "",
customer_item_name: mappingRows[0]?.customer_item_name || "",
});
const mappingId = mappingRes.data?.data?.id || null;
for (let mi = 1; mi < mappingRows.length; mi++) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: custKey, item_id: selectedItem.item_number,
customer_item_code: mappingRows[mi].customer_item_code || "",
customer_item_name: mappingRows[mi].customer_item_name || "",
});
}
const priceRows = (custPrices[custKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
mapping_id: mappingId || "", customer_id: custKey, item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
}
}
toast.success(isEditingExisting ? "수정되었습니다." : `${selectedCustsForDetail.length}개 거래처가 추가되었습니다.`);
setCustDetailOpen(false);
setEditCustData(null);
setCustCheckedIds(new Set());
// 우측 새로고침
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 품목 수정
const openEditItem = () => {
if (!selectedItem) return;
setEditItemForm({ ...selectedItem });
setEditItemOpen(true);
};
const handleEditSave = async () => {
if (!editItemForm.id) return;
setSaving(true);
try {
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
originalData: { id: editItemForm.id },
updatedData: {
selling_price: editItemForm.selling_price || null,
standard_price: editItemForm.standard_price || null,
currency_code: editItemForm.currency_code || null,
},
});
toast.success("수정되었습니다.");
setEditItemOpen(false);
fetchItems();
} catch (err: any) {
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) return;
const data = items.map((i) => ({
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
}));
await exportToExcel(data, "판매품목정보.xlsx", "판매품목");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={ITEM_TABLE}
filterId="sales-item"
onFilterChange={setSearchFilters}
dataCount={itemCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 판매품목 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Package className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{itemCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
<DataGrid
gridId="sales-item-left"
columns={LEFT_COLUMNS}
data={items}
loading={itemLoading}
selectedId={selectedItemId}
onSelect={setSelectedItemId}
onRowDoubleClick={() => openEditItem()}
emptyMessage="등록된 판매품목이 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 거래처 정보 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4" />
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
</div>
<Button variant="outline" size="sm" disabled={!selectedItemId}
onClick={() => { setCustCheckedIds(new Set()); setCustSelectOpen(true); searchCustomers(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{!selectedItemId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<DataGrid
gridId="sales-item-right"
columns={RIGHT_COLUMNS}
data={customerItems}
loading={customerLoading}
showRowNumber={false}
emptyMessage="등록된 거래처가 없습니다"
onRowDoubleClick={(row) => openEditCust(row)}
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 품목 수정 모달 */}
<FullscreenDialog
open={editItemOpen}
onOpenChange={setEditItemOpen}
title="판매품목 수정"
description={`${editItemForm.item_number || ""}${editItemForm.item_name || ""}`}
defaultMaxWidth="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => setEditItemOpen(false)}></Button>
<Button onClick={handleEditSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="grid grid-cols-2 gap-4 py-4">
{/* 품목 기본정보 (읽기 전용) */}
{[
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "material", label: "재질" },
{ key: "status", label: "상태" },
].map((f) => (
<div key={f.key} className="space-y-1.5">
<Label className="text-sm text-muted-foreground">{f.label}</Label>
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
</div>
))}
<div className="col-span-2 border-t my-2" />
{/* 판매 설정 (수정 가능) */}
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
placeholder="판매가격" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
placeholder="기준단가" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</FullscreenDialog>
{/* 거래처 추가 모달 */}
<Dialog open={custSelectOpen} onOpenChange={setCustSelectOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="거래처명 검색" value={custSearchKeyword}
onChange={(e) => setCustSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchCustomers()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchCustomers} disabled={custSearchLoading} className="h-9">
{custSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={custSearchResults.length > 0 && custCheckedIds.size === custSearchResults.length}
onChange={(e) => {
if (e.target.checked) setCustCheckedIds(new Set(custSearchResults.map((c) => c.id)));
else setCustCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{custSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : custSearchResults.map((c) => (
<TableRow key={c.id} className={cn("cursor-pointer", custCheckedIds.has(c.id) && "bg-primary/5")}
onClick={() => setCustCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(c.id)) next.delete(c.id); else next.add(c.id);
return next;
})}>
<TableCell className="text-center"><input type="checkbox" checked={custCheckedIds.has(c.id)} readOnly /></TableCell>
<TableCell className="text-xs">{c.customer_code}</TableCell>
<TableCell className="text-sm">{c.customer_name}</TableCell>
<TableCell className="text-xs">{c.division}</TableCell>
<TableCell className="text-xs">{c.contact_person}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{custCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setCustSelectOpen(false)}></Button>
<Button onClick={goToCustDetail} disabled={custCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {custCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 거래처 상세 입력/수정 모달 */}
<FullscreenDialog
open={custDetailOpen}
onOpenChange={setCustDetailOpen}
title={`📋 거래처 상세정보 ${editCustData ? "수정" : "입력"}${selectedItem?.item_name || ""}`}
description={editCustData ? "거래처 품번/품명과 기간별 단가를 수정합니다." : "선택한 거래처의 품번/품명과 기간별 단가를 설정합니다."}
defaultMaxWidth="max-w-[1100px]"
footer={
<>
<Button variant="outline" onClick={() => {
setCustDetailOpen(false);
if (!editCustData) setCustSelectOpen(true);
setEditCustData(null);
}}>{editCustData ? "취소" : "← 이전"}</Button>
<Button onClick={handleCustDetailSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="space-y-6 py-2">
{selectedCustsForDetail.map((cust, idx) => {
const custKey = cust.customer_code || cust.id;
const mappingRows = custMappings[custKey] || [];
const prices = custPrices[custKey] || [];
return (
<div key={custKey} className="border rounded-xl overflow-hidden bg-card">
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-bold">{idx + 1}. {cust.customer_name || custKey}</div>
<div className="text-xs text-muted-foreground">{custKey}</div>
</div>
<div className="flex gap-4 p-4">
{/* 좌: 거래처 품번/품명 */}
<div className="flex-1 border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/10">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold"> / </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addMappingRow(custKey)}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{mappingRows.length === 0 ? (
<div className="text-xs text-muted-foreground py-2"> </div>
) : mappingRows.map((mRow, mIdx) => (
<div key={mRow._id} className="flex gap-2 items-center">
<span className="text-xs text-muted-foreground w-4 shrink-0">{mIdx + 1}</span>
<Input value={mRow.customer_item_code}
onChange={(e) => updateMappingRow(custKey, mRow._id, "customer_item_code", e.target.value)}
placeholder="거래처 품번" className="h-8 text-sm flex-1" />
<Input value={mRow.customer_item_name}
onChange={(e) => updateMappingRow(custKey, mRow._id, "customer_item_name", e.target.value)}
placeholder="거래처 품명" className="h-8 text-sm flex-1" />
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive shrink-0"
onClick={() => removeMappingRow(custKey, mRow._id)}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
{/* 우: 기간별 단가 */}
<div className="flex-1 border rounded-lg p-4 bg-amber-50/30 dark:bg-amber-950/10">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold"> </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addPriceRow(custKey)}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-3">
{prices.map((price, pIdx) => (
<div key={price._id} className="border rounded-lg p-3 bg-background space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> {pIdx + 1}</span>
{prices.length > 1 && (
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-destructive"
onClick={() => removePriceRow(custKey, price._id)}>
<X className="h-3 w-3" />
</Button>
)}
</div>
<div className="flex gap-2 items-center">
<div className="flex-1">
<FormDatePicker value={price.start_date}
onChange={(v) => updatePriceRow(custKey, price._id, "start_date", v)} placeholder="시작일" />
</div>
<span className="text-xs text-muted-foreground">~</span>
<div className="flex-1">
<FormDatePicker value={price.end_date}
onChange={(v) => updatePriceRow(custKey, price._id, "end_date", v)} placeholder="종료일" />
</div>
<div className="w-[80px]">
<Select value={price.currency_code} onValueChange={(v) => updatePriceRow(custKey, price._id, "currency_code", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-2 items-center">
<div className="w-[90px]">
<Select value={price.base_price_type} onValueChange={(v) => updatePriceRow(custKey, price._id, "base_price_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="기준" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["base_price_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Input value={price.base_price}
onChange={(e) => updatePriceRow(custKey, price._id, "base_price", e.target.value)}
className="h-8 text-xs text-right flex-1" placeholder="기준가" />
<div className="w-[90px]">
<Select value={price.discount_type} onValueChange={(v) => updatePriceRow(custKey, price._id, "discount_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="할인" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{(priceCategoryOptions["discount_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Input value={price.discount_value}
onChange={(e) => updatePriceRow(custKey, price._id, "discount_value", e.target.value)}
className="h-8 text-xs text-right w-[60px]" placeholder="0" />
<div className="w-[90px]">
<Select value={price.rounding_unit_value} onValueChange={(v) => updatePriceRow(custKey, price._id, "rounding_unit_value", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="반올림" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["rounding_unit_value"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1 border-t">
<span className="text-xs text-muted-foreground"> :</span>
<span className="font-bold text-sm">{price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"}</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
})}
</div>
</FullscreenDialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={ITEM_TABLE}
userId={user?.userId}
onSuccess={() => fetchItems()}
/>
{ConfirmDialogComponent}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={ITEM_TABLE}
settingsId="sales-item"
onSave={applyTableSettings}
/>
</div>
);
}
@@ -0,0 +1,845 @@
"use client";
import React, { useState, useMemo, useEffect, useCallback, useRef } 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react";
import { cn } from "@/lib/utils";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import {
getShippingOrderList,
saveShippingOrder,
deleteShippingOrders,
previewShippingOrderNo,
getShipmentPlanSource,
getSalesOrderSource,
getItemSource,
} from "@/lib/api/shipping";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo";
const STATUS_OPTIONS = [
{ value: "all", label: "전체" },
{ value: "READY", label: "준비중" },
{ value: "IN_PROGRESS", label: "진행중" },
{ value: "COMPLETED", label: "완료" },
];
const getStatusLabel = (s: string) => STATUS_OPTIONS.find(o => o.value === s)?.label || s;
const getStatusColor = (s: string) => {
switch (s) {
case "READY": return "bg-amber-100 text-amber-800 border-amber-200";
case "IN_PROGRESS": return "bg-blue-100 text-blue-800 border-blue-200";
case "COMPLETED": return "bg-emerald-100 text-emerald-800 border-emerald-200";
default: return "bg-gray-100 text-gray-800 border-gray-200";
}
};
const getSourceBadge = (s: string) => {
switch (s) {
case "shipmentPlan": return { label: "출하계획", cls: "bg-blue-100 text-blue-700" };
case "salesOrder": return { label: "수주", cls: "bg-emerald-100 text-emerald-700" };
case "itemInfo": return { label: "품목", cls: "bg-purple-100 text-purple-700" };
default: return { label: s, cls: "bg-gray-100 text-gray-700" };
}
};
interface SelectedItem {
id: string | number;
itemCode: string;
itemName: string;
spec: string;
material: string;
customer: string;
planQty: number;
orderQty: number;
sourceType: DataSourceType;
shipmentPlanId?: number;
salesOrderId?: number;
detailId?: string;
partnerCode?: string;
}
export default function ShippingOrderPage() {
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [checkedIds, setCheckedIds] = useState<number[]>([]);
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
const [searchCustomer, setSearchCustomer] = useState("");
const [debouncedKeyword, setDebouncedKeyword] = useState("");
const [debouncedCustomer, setDebouncedCustomer] = useState("");
const [searchStatus, setSearchStatus] = useState("all");
const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState("");
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
// 모달 폼
const [formOrderNumber, setFormOrderNumber] = useState("");
const [formOrderDate, setFormOrderDate] = useState("");
const [formCustomer, setFormCustomer] = useState("");
const [formPartnerId, setFormPartnerId] = useState("");
const [formStatus, setFormStatus] = useState("READY");
const [formCarrier, setFormCarrier] = useState("");
const [formVehicle, setFormVehicle] = useState("");
const [formDriver, setFormDriver] = useState("");
const [formDriverPhone, setFormDriverPhone] = useState("");
const [formArrival, setFormArrival] = useState("");
const [formAddress, setFormAddress] = useState("");
const [formMemo, setFormMemo] = useState("");
const [isTransportCollapsed, setIsTransportCollapsed] = useState(false);
// 모달 왼쪽 패널
const [dataSource, setDataSource] = useState<DataSourceType>("shipmentPlan");
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceData, setSourceData] = useState<any[]>([]);
const [sourceLoading, setSourceLoading] = useState(false);
const [selectedItems, setSelectedItems] = useState<SelectedItem[]>([]);
const [sourcePage, setSourcePage] = useState(1);
const [sourcePageSize] = useState(20);
const [sourceTotalCount, setSourceTotalCount] = useState(0);
// 텍스트 입력 debounce (500ms)
useEffect(() => {
const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500);
return () => clearTimeout(t);
}, [searchKeyword]);
useEffect(() => {
const t = setTimeout(() => setDebouncedCustomer(searchCustomer), 500);
return () => clearTimeout(t);
}, [searchCustomer]);
// 초기 날짜
useEffect(() => {
const today = new Date();
const from = new Date(today);
from.setMonth(from.getMonth() - 1);
setSearchDateFrom(from.toISOString().split("T")[0]);
setSearchDateTo(today.toISOString().split("T")[0]);
}, []);
// 데이터 조회
const fetchOrders = useCallback(async () => {
setLoading(true);
try {
const params: any = {};
if (searchDateFrom) params.dateFrom = searchDateFrom;
if (searchDateTo) params.dateTo = searchDateTo;
if (searchStatus !== "all") params.status = searchStatus;
if (debouncedCustomer.trim()) params.customer = debouncedCustomer.trim();
if (debouncedKeyword.trim()) params.keyword = debouncedKeyword.trim();
const result = await getShippingOrderList(params);
if (result.success) setOrders(result.data || []);
} catch (err) {
console.error("출하지시 조회 실패:", err);
} finally {
setLoading(false);
}
}, [searchDateFrom, searchDateTo, searchStatus, debouncedCustomer, debouncedKeyword]);
useEffect(() => {
if (searchDateFrom && searchDateTo) fetchOrders();
}, [fetchOrders]);
// 소스 데이터 조회
const fetchSourceData = useCallback(async (pageOverride?: number) => {
setSourceLoading(true);
try {
const currentPage = pageOverride ?? sourcePage;
const params: any = { page: currentPage, pageSize: sourcePageSize };
if (sourceKeyword.trim()) params.keyword = sourceKeyword.trim();
let result;
switch (dataSource) {
case "shipmentPlan":
result = await getShipmentPlanSource(params);
break;
case "salesOrder":
result = await getSalesOrderSource(params);
break;
case "itemInfo":
result = await getItemSource(params);
break;
}
if (result?.success) {
setSourceData(result.data || []);
setSourceTotalCount(result.totalCount || 0);
}
} catch (err) {
console.error("소스 데이터 조회 실패:", err);
} finally {
setSourceLoading(false);
}
}, [dataSource, sourceKeyword, sourcePage, sourcePageSize]);
useEffect(() => {
if (isModalOpen) {
setSourcePage(1);
fetchSourceData(1);
}
}, [isModalOpen, dataSource]);
// 핸들러
const handleResetSearch = () => {
setSearchKeyword("");
setSearchCustomer("");
setDebouncedKeyword("");
setDebouncedCustomer("");
setSearchStatus("all");
const today = new Date();
const from = new Date(today);
from.setMonth(from.getMonth() - 1);
setSearchDateFrom(from.toISOString().split("T")[0]);
setSearchDateTo(today.toISOString().split("T")[0]);
};
const handleCheckAll = (checked: boolean) => {
setCheckedIds(checked ? orders.map((o: any) => o.id) : []);
};
const handleDeleteSelected = async () => {
if (checkedIds.length === 0) return;
if (!confirm(`선택한 ${checkedIds.length}개의 출하지시를 삭제하시겠습니까?`)) return;
try {
const result = await deleteShippingOrders(checkedIds);
if (result.success) {
setCheckedIds([]);
fetchOrders();
alert("삭제되었습니다.");
}
} catch (err: any) {
alert(err.message || "삭제 실패");
}
};
// 모달 열기
const openModal = (order?: any) => {
if (order) {
setIsEditMode(true);
setEditId(order.id);
setFormOrderNumber(order.instruction_no || "");
setFormOrderDate(order.instruction_date ? order.instruction_date.split("T")[0] : "");
setFormCustomer(order.customer_name || "");
setFormPartnerId(order.partner_id || "");
setFormStatus(order.status || "READY");
setFormCarrier(order.carrier_name || "");
setFormVehicle(order.vehicle_no || "");
setFormDriver(order.driver_name || "");
setFormDriverPhone(order.driver_contact || "");
setFormArrival(order.arrival_time ? new Date(order.arrival_time).toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T").slice(0, 16) : "");
setFormAddress(order.delivery_address || "");
setFormMemo(order.memo || "");
const items = order.items || [];
setSelectedItems(items.filter((it: any) => it.id).map((it: any) => {
const srcType = it.source_type || "shipmentPlan";
// 소스 데이터와 매칭할 수 있도록 원래 소스 id를 사용
let sourceId: string | number = it.id;
if (srcType === "shipmentPlan" && it.shipment_plan_id) sourceId = it.shipment_plan_id;
else if (srcType === "salesOrder" && it.detail_id) sourceId = it.detail_id;
else if (srcType === "itemInfo") sourceId = it.item_code || "";
return {
id: sourceId,
itemCode: it.item_code || "",
itemName: it.item_name || "",
spec: it.spec || "",
material: it.material || "",
customer: order.customer_name || "",
planQty: Number(it.plan_qty || 0),
orderQty: Number(it.order_qty || 0),
sourceType: srcType,
shipmentPlanId: it.shipment_plan_id,
salesOrderId: it.sales_order_id,
detailId: it.detail_id,
partnerCode: order.partner_id,
};
}));
} else {
setIsEditMode(false);
setEditId(null);
setFormOrderNumber("불러오는 중...");
setFormOrderDate(new Date().toISOString().split("T")[0]);
previewShippingOrderNo().then(r => {
if (r.success) setFormOrderNumber(r.instructionNo);
else setFormOrderNumber("(자동생성)");
}).catch(() => setFormOrderNumber("(자동생성)"));
setFormCustomer("");
setFormPartnerId("");
setFormStatus("READY");
setFormCarrier("");
setFormVehicle("");
setFormDriver("");
setFormDriverPhone("");
setFormArrival("");
setFormAddress("");
setFormMemo("");
setSelectedItems([]);
}
setDataSource("shipmentPlan");
setSourceKeyword("");
setSourceData([]);
setIsTransportCollapsed(false);
setIsModalOpen(true);
};
// 소스 아이템 선택 토글
const toggleSourceItem = (item: any) => {
const key = dataSource === "shipmentPlan" ? item.id
: dataSource === "salesOrder" ? item.id
: item.item_code;
const exists = selectedItems.findIndex(s => {
// 같은 소스 타입에서 id 매칭
if (s.sourceType === dataSource) {
if (dataSource === "itemInfo") return s.itemCode === key;
return String(s.id) === String(key);
}
// 다른 소스 타입이라도 원래 소스 id로 매칭
if (dataSource === "shipmentPlan" && s.shipmentPlanId) return String(s.shipmentPlanId) === String(item.id);
if (dataSource === "salesOrder" && s.detailId) return String(s.detailId) === String(item.id);
return false;
});
if (exists > -1) {
setSelectedItems(prev => prev.filter((_, i) => i !== exists));
} else {
const newItem: SelectedItem = {
id: key,
itemCode: item.item_code || "",
itemName: item.item_name || "",
spec: item.spec || "",
material: item.material || "",
customer: item.customer_name || "",
planQty: Number(item.plan_qty || item.qty || item.balance_qty || 0),
orderQty: Number(item.plan_qty || item.balance_qty || item.qty || 1),
sourceType: dataSource,
shipmentPlanId: dataSource === "shipmentPlan" ? item.id : undefined,
salesOrderId: dataSource === "salesOrder" ? (item.master_id || undefined) : undefined,
detailId: dataSource === "salesOrder" ? item.id : (dataSource === "shipmentPlan" ? item.detail_id : undefined),
partnerCode: item.partner_code || "",
};
setSelectedItems(prev => [...prev, newItem]);
if (!formCustomer && item.customer_name) {
setFormCustomer(item.customer_name);
setFormPartnerId(item.partner_code || "");
}
}
};
const removeSelectedItem = (idx: number) => {
setSelectedItems(prev => prev.filter((_, i) => i !== idx));
};
const updateOrderQty = (idx: number, val: number) => {
setSelectedItems(prev => prev.map((item, i) => i === idx ? { ...item, orderQty: val } : item));
};
// 저장
const handleSave = async () => {
if (!formOrderDate) { alert("출하지시일을 입력해주세요."); return; }
if (selectedItems.length === 0) { alert("품목을 선택해주세요."); return; }
setSaving(true);
try {
const payload = {
id: isEditMode ? editId : undefined,
instructionDate: formOrderDate,
partnerId: formPartnerId || formCustomer,
status: formStatus,
memo: formMemo,
carrierName: formCarrier,
vehicleNo: formVehicle,
driverName: formDriver,
driverContact: formDriverPhone,
arrivalTime: formArrival ? `${formArrival}+09:00` : null,
deliveryAddress: formAddress,
items: selectedItems.map(item => ({
itemCode: item.itemCode,
itemName: item.itemName,
spec: item.spec,
material: item.material,
orderQty: item.orderQty,
planQty: item.planQty,
shipQty: 0,
sourceType: item.sourceType,
shipmentPlanId: item.shipmentPlanId,
salesOrderId: item.salesOrderId,
detailId: item.detailId,
})),
};
const result = await saveShippingOrder(payload);
if (result.success) {
setIsModalOpen(false);
fetchOrders();
alert(isEditMode ? "출하지시가 수정되었습니다." : "출하지시가 등록되었습니다.");
} else {
alert(result.message || "저장 실패");
}
} catch (err: any) {
alert(err.message || "저장 중 오류 발생");
} finally {
setSaving(false);
}
};
const formatDate = (d: string) => d ? d.split("T")[0] : "-";
const dataSourceTitle: Record<DataSourceType, string> = {
shipmentPlan: "출하계획 목록",
salesOrder: "수주정보 목록",
itemInfo: "품목정보 목록",
};
return (
<div className="flex flex-col h-[calc(100vh-4rem)] bg-muted/30 p-4 gap-4">
{/* 검색 */}
<Card className="shrink-0">
<CardContent className="p-4 flex flex-wrap items-end gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input placeholder="검색" className="w-[160px] h-9" value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input placeholder="거래처 검색" className="w-[140px] h-9" value={searchCustomer}
onChange={(e) => setSearchCustomer(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="w-[110px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<div className="flex items-center gap-2">
<div className="w-[160px]">
<FormDatePicker value={searchDateFrom} onChange={setSearchDateFrom} placeholder="시작일" />
</div>
<span className="text-muted-foreground">~</span>
<div className="w-[160px]">
<FormDatePicker value={searchDateTo} onChange={setSearchDateTo} placeholder="종료일" />
</div>
</div>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
{/* 메인 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Truck className="w-5 h-5" />
<Badge variant="secondary" className="font-normal">{orders.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={() => openModal()}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDeleteSelected}>
<Trash2 className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && `(${checkedIds.length})`}
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[40px] text-center">
<Checkbox checked={orders.length > 0 && checkedIds.length === orders.length} onCheckedChange={handleCheckAll} />
</TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[130px]"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.length === 0 ? (
<TableRow>
<TableCell colSpan={13} className="h-40 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<Truck className="w-12 h-12 text-muted-foreground/30" />
<div className="font-medium"> </div>
<div className="text-sm"> </div>
</div>
</TableCell>
</TableRow>
) : (
orders.map((order: any) => {
const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : [];
if (items.length === 0) {
return (
<TableRow key={order.id} className="cursor-pointer hover:bg-muted/50" onClick={() => openModal(order)}>
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
<Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
if (c) setCheckedIds(p => [...p, order.id]);
else setCheckedIds(p => p.filter(i => i !== order.id));
}} />
</TableCell>
<TableCell className="font-medium">{order.instruction_no}</TableCell>
<TableCell className="text-center">{formatDate(order.instruction_date)}</TableCell>
<TableCell>{order.customer_name || "-"}</TableCell>
<TableCell>{order.carrier_name || "-"}</TableCell>
<TableCell>{order.vehicle_no || "-"}</TableCell>
<TableCell>{order.driver_name || "-"}</TableCell>
<TableCell className="text-center">
<span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(order.status))}>{getStatusLabel(order.status)}</span>
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell className="text-right">0</TableCell>
<TableCell className="text-center">-</TableCell>
<TableCell className="text-xs text-muted-foreground truncate max-w-[100px]">{order.memo || "-"}</TableCell>
</TableRow>
);
}
return items.map((item: any, itemIdx: number) => (
<TableRow key={`${order.id}-${item.id}`} className="cursor-pointer hover:bg-muted/50" onClick={() => openModal(order)}>
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
{itemIdx === 0 && <Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
if (c) setCheckedIds(p => [...p, order.id]);
else setCheckedIds(p => p.filter(i => i !== order.id));
}} />}
</TableCell>
<TableCell className="font-medium">{itemIdx === 0 ? order.instruction_no : ""}</TableCell>
<TableCell className="text-center">{itemIdx === 0 ? formatDate(order.instruction_date) : ""}</TableCell>
<TableCell>{itemIdx === 0 ? (order.customer_name || "-") : ""}</TableCell>
<TableCell>{itemIdx === 0 ? (order.carrier_name || "-") : ""}</TableCell>
<TableCell>{itemIdx === 0 ? (order.vehicle_no || "-") : ""}</TableCell>
<TableCell>{itemIdx === 0 ? (order.driver_name || "-") : ""}</TableCell>
<TableCell className="text-center">
{itemIdx === 0 && <span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(order.status))}>{getStatusLabel(order.status)}</span>}
</TableCell>
<TableCell className="text-xs text-muted-foreground">{item.item_code}</TableCell>
<TableCell className="font-medium text-sm">{item.item_name}</TableCell>
<TableCell className="text-right">{Number(item.order_qty || 0).toLocaleString()}</TableCell>
<TableCell className="text-center">
{(() => { const b = getSourceBadge(item.source_type || ""); return <span className={cn("px-2 py-0.5 rounded-full text-[10px]", b.cls)}>{b.label}</span>; })()}
</TableCell>
<TableCell className="text-xs text-muted-foreground truncate max-w-[100px]">{itemIdx === 0 ? (order.memo || "-") : ""}</TableCell>
</TableRow>
));
})
)}
</TableBody>
</Table>
)}
</div>
</div>
{/* 등록/수정 모달 */}
<FullscreenDialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={isEditMode ? "출하지시 수정" : "출하지시 등록"}
description={isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."}
defaultMaxWidth="max-w-[90vw]"
defaultWidth="w-[1400px]"
footer={
<>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="flex-1 overflow-hidden">
<ResizablePanelGroup direction="horizontal">
{/* 왼쪽: 데이터 소스 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="p-3 border-b bg-muted/30 flex flex-wrap items-center gap-2 shrink-0">
<Select value={dataSource} onValueChange={(v) => setDataSource(v as DataSourceType)}>
<SelectTrigger className="w-[130px] h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="shipmentPlan"></SelectItem>
<SelectItem value="salesOrder"></SelectItem>
<SelectItem value="itemInfo"></SelectItem>
</SelectContent>
</Select>
<Input placeholder="품번, 품명 검색" className="flex-1 h-8 text-xs min-w-[120px]"
value={sourceKeyword} onChange={(e) => setSourceKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { setSourcePage(1); fetchSourceData(1); }}} />
<Button size="sm" className="h-8 text-xs" onClick={() => { setSourcePage(1); fetchSourceData(1); }} disabled={sourceLoading}>
{sourceLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Search className="w-3 h-3" />}
<span className="ml-1"></span>
</Button>
</div>
<div className="px-4 py-2 flex items-center justify-between border-b shrink-0">
<div className="text-sm font-medium">
{dataSourceTitle[dataSource]}
<span className="text-muted-foreground ml-2 font-normal">
: <span className="text-primary font-semibold">{selectedItems.length}</span>
</span>
</div>
</div>
<div className="flex-1 overflow-auto">
{sourceLoading ? (
<div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : sourceData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<div className="text-sm"> </div>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
{dataSource === "shipmentPlan" && <TableHead className="w-[70px] text-center"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{sourceData.map((item: any, idx: number) => {
const itemId = dataSource === "itemInfo" ? item.item_code : item.id;
const isSelected = selectedItems.some(s => {
// 같은 소스 타입에서 id 매칭
if (s.sourceType === dataSource) {
if (dataSource === "itemInfo") return s.itemCode === itemId;
return String(s.id) === String(itemId);
}
// 다른 소스 타입이라도 같은 품번이면 중복 방지
if (dataSource === "shipmentPlan" && s.shipmentPlanId) return String(s.shipmentPlanId) === String(item.id);
if (dataSource === "salesOrder" && s.detailId) return String(s.detailId) === String(item.id);
return false;
});
return (
<TableRow key={`${dataSource}-${itemId}-${idx}`} className={cn("cursor-pointer hover:bg-muted/50", isSelected && "bg-primary/5")} onClick={() => toggleSourceItem(item)}>
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={() => toggleSourceItem(item)} />
</TableCell>
<TableCell className="text-xs">{item.item_code || "-"}</TableCell>
<TableCell className="text-sm font-medium">{item.item_name || "-"}</TableCell>
<TableCell className="text-xs text-muted-foreground">{item.spec || "-"}</TableCell>
<TableCell className="text-xs">{item.customer_name || "-"}</TableCell>
<TableCell className="text-right text-xs">{Number(item.plan_qty || item.qty || item.balance_qty || 0).toLocaleString()}</TableCell>
{dataSource === "shipmentPlan" && (
<TableCell className="text-center">
<Badge variant="secondary" className="text-[10px]">{getStatusLabel(item.status)}</Badge>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
{/* 페이징 */}
{sourceTotalCount > 0 && (
<div className="px-4 py-2 border-t bg-muted/10 flex items-center justify-between shrink-0">
<span className="text-xs text-muted-foreground">
{sourceTotalCount} {(sourcePage - 1) * sourcePageSize + 1}-{Math.min(sourcePage * sourcePageSize, sourceTotalCount)}
</span>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
onClick={() => { const p = sourcePage - 1; setSourcePage(p); fetchSourceData(p); }}>
<ChevronLeft className="w-3.5 h-3.5" />
</Button>
<span className="text-xs font-medium px-2">{sourcePage} / {Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize))}</span>
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
onClick={() => { const p = sourcePage + 1; setSourcePage(p); fetchSourceData(p); }}>
<ChevronRight className="w-3.5 h-3.5" />
</Button>
</div>
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 폼 */}
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex flex-col h-full overflow-auto p-5 bg-muted/20 gap-5">
{/* 기본 정보 */}
<div className="bg-background border rounded-lg p-5 shrink-0">
<h3 className="text-sm font-semibold mb-4"> </h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input value={formOrderNumber} readOnly className="h-9 bg-muted/50 text-muted-foreground" />
</div>
<div className="space-y-1.5">
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<FormDatePicker value={formOrderDate} onChange={setFormOrderDate} placeholder="날짜 선택" />
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input value={formCustomer} readOnly placeholder="품목 선택 시 자동" className="h-9 bg-muted/50" />
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Select value={formStatus} onValueChange={setFormStatus}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="READY"></SelectItem>
<SelectItem value="IN_PROGRESS"></SelectItem>
<SelectItem value="COMPLETED"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 운송 정보 */}
<div className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden shrink-0">
<button className="w-full px-5 py-3 flex items-center justify-between text-left" onClick={() => setIsTransportCollapsed(!isTransportCollapsed)}>
<h3 className="text-sm font-semibold text-amber-900 flex items-center gap-2">
<Truck className="w-4 h-4" /> <span className="text-[11px] font-normal text-muted-foreground">()</span>
</h3>
{isTransportCollapsed ? <ChevronRight className="w-4 h-4 text-amber-700" /> : <ChevronDown className="w-4 h-4 text-amber-700" />}
</button>
{!isTransportCollapsed && (
<div className="px-5 pb-4 grid grid-cols-3 gap-3">
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formCarrier} onChange={(e) => setFormCarrier(e.target.value)} className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formVehicle} onChange={(e) => setFormVehicle(e.target.value)} className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formDriver} onChange={(e) => setFormDriver(e.target.value)} className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formDriverPhone} onChange={(e) => setFormDriverPhone(e.target.value)} className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><FormDatePicker value={formArrival} onChange={setFormArrival} placeholder="도착예정일시" includeTime /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formAddress} onChange={(e) => setFormAddress(e.target.value)} className="h-9" /></div>
</div>
)}
</div>
{/* 선택된 품목 */}
<div className="bg-background border rounded-lg p-5 flex-1 flex flex-col min-h-[200px]">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Badge variant="default" className="text-[10px]">{selectedItems.length}</Badge>
</h3>
<div className="flex-1 overflow-auto min-h-0">
{selectedItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<div className="text-sm"> </div>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center"></TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[90px] text-center"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[40px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedItems.map((item, idx) => {
const b = getSourceBadge(item.sourceType);
return (
<TableRow key={`${item.sourceType}-${item.id}-${idx}`}>
<TableCell className="text-center">
<span className={cn("px-1.5 py-0.5 rounded text-[10px]", b.cls)}>{b.label.charAt(0)}</span>
</TableCell>
<TableCell className="text-xs">{item.itemCode}</TableCell>
<TableCell className="text-sm font-medium">{item.itemName}</TableCell>
<TableCell className="text-center">
<Input type="number" value={item.orderQty} onChange={(e) => updateOrderQty(idx, parseInt(e.target.value) || 0)}
min={1} className="h-7 w-[70px] text-xs text-right mx-auto" />
</TableCell>
<TableCell className="text-right text-xs">{item.planQty ? item.planQty.toLocaleString() : "-"}</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeSelectedItem(idx)}>
<X className="w-3.5 h-3.5 text-destructive" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</div>
{/* 메모 */}
<div className="bg-background border rounded-lg p-5 shrink-0">
<h3 className="text-sm font-semibold mb-3"></h3>
<Textarea value={formMemo} onChange={(e) => setFormMemo(e.target.value)} placeholder="출하지시 관련 메모" rows={2} className="resize-y" />
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</FullscreenDialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName="shipment_instruction"
onSuccess={() => {
fetchOrders();
}}
/>
</div>
);
}
@@ -0,0 +1,557 @@
"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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
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 { Textarea } from "@/components/ui/textarea";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Search, Download, X, Save, Ban, RotateCcw, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
getShipmentPlanList,
updateShipmentPlan,
type ShipmentPlanListItem,
} from "@/lib/api/shipping";
const STATUS_OPTIONS = [
{ value: "all", label: "전체" },
{ value: "READY", label: "준비" },
{ value: "CONFIRMED", label: "확정" },
{ value: "SHIPPING", label: "출하중" },
{ value: "COMPLETED", label: "완료" },
{ value: "CANCEL_REQUEST", label: "취소요청" },
{ value: "CANCELLED", label: "취소완료" },
];
const getStatusLabel = (status: string) => {
const found = STATUS_OPTIONS.find(o => o.value === status);
return found?.label || status;
};
const getStatusColor = (status: string) => {
switch (status) {
case "READY": return "bg-blue-100 text-blue-800 border-blue-200";
case "CONFIRMED": return "bg-indigo-100 text-indigo-800 border-indigo-200";
case "SHIPPING": return "bg-amber-100 text-amber-800 border-amber-200";
case "COMPLETED": return "bg-emerald-100 text-emerald-800 border-emerald-200";
case "CANCEL_REQUEST": return "bg-rose-100 text-rose-800 border-rose-200";
case "CANCELLED": return "bg-slate-100 text-slate-800 border-slate-200";
default: return "bg-gray-100 text-gray-800 border-gray-200";
}
};
export default function ShippingPlanPage() {
const [data, setData] = useState<ShipmentPlanListItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [checkedIds, setCheckedIds] = useState<number[]>([]);
// 검색
const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState("");
const [searchStatus, setSearchStatus] = useState("all");
const [searchCustomer, setSearchCustomer] = useState("");
const [searchKeyword, setSearchKeyword] = useState("");
// 상세 패널 편집
const [editPlanQty, setEditPlanQty] = useState("");
const [editPlanDate, setEditPlanDate] = useState("");
const [editMemo, setEditMemo] = useState("");
const [isDetailChanged, setIsDetailChanged] = useState(false);
const [saving, setSaving] = useState(false);
// 날짜 초기화
useEffect(() => {
const today = new Date();
const threeMonthsAgo = new Date(today);
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const oneMonthLater = new Date(today);
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
setSearchDateTo(oneMonthLater.toISOString().split("T")[0]);
}, []);
// 데이터 조회
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: any = {};
if (searchDateFrom) params.dateFrom = searchDateFrom;
if (searchDateTo) params.dateTo = searchDateTo;
if (searchStatus !== "all") params.status = searchStatus;
if (searchCustomer.trim()) params.customer = searchCustomer.trim();
if (searchKeyword.trim()) params.keyword = searchKeyword.trim();
const result = await getShipmentPlanList(params);
if (result.success) {
setData(result.data || []);
}
} catch (err) {
console.error("출하계획 조회 실패:", err);
} finally {
setLoading(false);
}
}, [searchDateFrom, searchDateTo, searchStatus, searchCustomer, searchKeyword]);
// 초기 로드 + 검색 시 자동 조회
useEffect(() => {
if (searchDateFrom && searchDateTo) {
fetchData();
}
}, [searchDateFrom, searchDateTo]);
const handleSearch = () => fetchData();
const handleResetSearch = () => {
setSearchStatus("all");
setSearchCustomer("");
setSearchKeyword("");
const today = new Date();
const threeMonthsAgo = new Date(today);
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
const oneMonthLater = new Date(today);
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
setSearchDateTo(oneMonthLater.toISOString().split("T")[0]);
};
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
const groupedData = useMemo(() => {
const orderMap = new Map<string, ShipmentPlanListItem[]>();
const orderKeys: string[] = [];
data.forEach(plan => {
const key = plan.order_no || `_no_order_${plan.id}`;
if (!orderMap.has(key)) {
orderMap.set(key, []);
orderKeys.push(key);
}
orderMap.get(key)!.push(plan);
});
return orderKeys.map(key => ({
orderNo: key,
plans: orderMap.get(key)!,
}));
}, [data]);
const handleRowClick = (plan: ShipmentPlanListItem) => {
if (isDetailChanged && selectedId !== plan.id) {
if (!confirm("변경사항이 있습니다. 저장하지 않고 이동하시겠습니까?")) return;
}
setSelectedId(plan.id);
setEditPlanQty(String(Number(plan.plan_qty)));
setEditPlanDate(plan.plan_date ? plan.plan_date.split("T")[0] : "");
setEditMemo(plan.memo || "");
setIsDetailChanged(false);
};
const handleCheckAll = (checked: boolean) => {
if (checked) {
setCheckedIds(data.filter(p => p.status !== "CANCELLED").map(p => p.id));
} else {
setCheckedIds([]);
}
};
const handleCheck = (id: number, checked: boolean) => {
if (checked) {
setCheckedIds(prev => [...prev, id]);
} else {
setCheckedIds(prev => prev.filter(i => i !== id));
}
};
const handleSaveDetail = async () => {
if (!selectedId || !selectedPlan) return;
const qty = Number(editPlanQty);
if (qty <= 0) {
alert("계획수량은 0보다 커야 합니다.");
return;
}
if (!editPlanDate) {
alert("출하계획일을 입력해주세요.");
return;
}
setSaving(true);
try {
const result = await updateShipmentPlan(selectedId, {
planQty: qty,
planDate: editPlanDate,
memo: editMemo,
});
if (result.success) {
setIsDetailChanged(false);
alert("저장되었습니다.");
fetchData();
} else {
alert(result.message || "저장 실패");
}
} catch (err: any) {
alert(err.message || "저장 중 오류 발생");
} finally {
setSaving(false);
}
};
const formatDate = (dateStr: string) => {
if (!dateStr) return "-";
return dateStr.split("T")[0];
};
const formatNumber = (val: string | number) => {
const num = Number(val);
return isNaN(num) ? "0" : num.toLocaleString();
};
return (
<div className="flex flex-col h-[calc(100vh-4rem)] bg-muted/30 p-4 gap-4">
{/* 검색 영역 */}
<Card className="shrink-0">
<CardContent className="p-4 flex flex-wrap items-end gap-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="w-[140px] h-9"
value={searchDateFrom}
onChange={(e) => setSearchDateFrom(e.target.value)}
/>
<span className="text-muted-foreground">~</span>
<Input
type="date"
className="w-[140px] h-9"
value={searchDateTo}
onChange={(e) => setSearchDateTo(e.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="w-[120px] h-9">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map(o => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
placeholder="거래처 검색"
className="w-[150px] h-9"
value={searchCustomer}
onChange={(e) => setSearchCustomer(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">/</Label>
<Input
placeholder="수주번호 / 품목 검색"
className="w-[220px] h-9"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
<Button size="sm" className="h-9" onClick={handleSearch} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Search className="w-4 h-4 mr-2" />}
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
{/* 테이블 + 상세 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={selectedId ? 65 : 100} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Badge variant="secondary" className="font-normal">
{data.length}
</Badge>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[40px] text-center">
<Checkbox
checked={data.length > 0 && checkedIds.length === data.filter(p => p.status !== "CANCELLED").length}
onCheckedChange={handleCheckAll}
/>
</TableHead>
<TableHead className="w-[10%]"></TableHead>
<TableHead className="w-[8%] text-center"></TableHead>
<TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[7%] text-right"></TableHead>
<TableHead className="w-[7%] text-right"></TableHead>
<TableHead className="w-[8%] text-center"></TableHead>
<TableHead className="w-[6%] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupedData.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="h-32 text-center text-muted-foreground">
.
</TableCell>
</TableRow>
) : (
groupedData.map((group) =>
group.plans.map((plan, planIdx) => (
<TableRow
key={plan.id}
className={cn(
"cursor-pointer hover:bg-muted/50 transition-colors",
selectedId === plan.id && "bg-primary/5",
plan.status === "CANCELLED" && "opacity-60 bg-slate-50",
planIdx === 0 && "border-t-2 border-t-border"
)}
onClick={() => handleRowClick(plan)}
>
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
{planIdx === 0 && (
<Checkbox
checked={group.plans.every(p => checkedIds.includes(p.id))}
onCheckedChange={(c) => {
if (c) {
setCheckedIds(prev => [...new Set([...prev, ...group.plans.filter(p => p.status !== "CANCELLED").map(p => p.id)])]);
} else {
setCheckedIds(prev => prev.filter(id => !group.plans.some(p => p.id === id)));
}
}}
/>
)}
</TableCell>
<TableCell className="font-medium">
{planIdx === 0 ? (plan.order_no || "-") : ""}
</TableCell>
<TableCell className="text-center">
{planIdx === 0 ? formatDate(plan.due_date) : ""}
</TableCell>
<TableCell>
{planIdx === 0 ? (plan.customer_name || "-") : ""}
</TableCell>
<TableCell className="text-muted-foreground text-xs">{plan.part_code || "-"}</TableCell>
<TableCell className="font-medium">{plan.part_name || "-"}</TableCell>
<TableCell className="text-right">{formatNumber(plan.order_qty)}</TableCell>
<TableCell className="text-right font-semibold text-primary">{formatNumber(plan.plan_qty)}</TableCell>
<TableCell className="text-center">{formatDate(plan.plan_date)}</TableCell>
<TableCell className="text-center">
<span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(plan.status))}>
{getStatusLabel(plan.status)}
</span>
</TableCell>
</TableRow>
))
)
)}
</TableBody>
</Table>
)}
</div>
</div>
</ResizablePanel>
{/* 상세 패널 */}
{selectedId && selectedPlan && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={35} minSize={25}>
<div className="flex flex-col h-full bg-card">
<div className="flex items-center justify-between p-3 border-b shrink-0">
<span className="font-semibold text-sm">
{selectedPlan.shipment_plan_no || `#${selectedPlan.id}`}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={handleSaveDetail}
disabled={!isDetailChanged || saving}
className={cn(isDetailChanged ? "bg-primary" : "bg-muted text-muted-foreground")}
>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSelectedId(null)}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto p-4 space-y-6">
{/* 기본 정보 */}
<section>
<h3 className="text-sm font-semibold mb-3"> </h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border inline-block", getStatusColor(selectedPlan.status))}>
{getStatusLabel(selectedPlan.status)}
</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{selectedPlan.order_no || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{selectedPlan.customer_name || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{formatDate(selectedPlan.created_date)}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{formatDate(selectedPlan.due_date)}</span>
</div>
</div>
</section>
{/* 품목 정보 */}
<section>
<h3 className="text-sm font-semibold mb-3"> </h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm bg-muted/30 p-3 rounded-md border border-border/50">
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{selectedPlan.part_code || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span className="font-medium">{selectedPlan.part_name || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{selectedPlan.spec || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{selectedPlan.material || "-"}</span>
</div>
</div>
</section>
{/* 수량 정보 */}
<section>
<h3 className="text-sm font-semibold mb-3"> </h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-4 text-sm">
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{formatNumber(selectedPlan.order_qty)}</span>
</div>
<div>
<Label className="text-muted-foreground text-xs block mb-1"></Label>
<Input
type="number"
className="h-8"
value={editPlanQty}
onChange={(e) => { setEditPlanQty(e.target.value); setIsDetailChanged(true); }}
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
/>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{formatNumber(selectedPlan.shipped_qty)}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span className={cn("font-semibold",
(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty)) > 0
? "text-destructive"
: "text-emerald-600"
)}>
{formatNumber(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty))}
</span>
</div>
</div>
</section>
{/* 출하 정보 */}
<section>
<h3 className="text-sm font-semibold mb-3"> </h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-4 text-sm">
<div className="col-span-2">
<Label className="text-muted-foreground text-xs block mb-1"></Label>
<Input
type="date"
className="h-8"
value={editPlanDate}
onChange={(e) => { setEditPlanDate(e.target.value); setIsDetailChanged(true); }}
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
/>
</div>
<div className="col-span-2">
<Label className="text-muted-foreground text-xs block mb-1"></Label>
<Textarea
className="min-h-[80px] resize-y"
value={editMemo}
onChange={(e) => { setEditMemo(e.target.value); setIsDetailChanged(true); }}
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
placeholder="비고 입력"
/>
</div>
</div>
</section>
{/* 등록자 정보 */}
<section>
<h3 className="text-sm font-semibold mb-3"> </h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm text-muted-foreground">
<div>
<span className="text-xs block mb-1"></span>
<span className="text-foreground">{selectedPlan.created_by || "-"}</span>
</div>
<div>
<span className="text-xs block mb-1"></span>
<span className="text-foreground">{selectedPlan.created_date ? new Date(selectedPlan.created_date).toLocaleString("ko-KR") : "-"}</span>
</div>
</div>
</section>
</div>
</div>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
</div>
);
}