Merge branch 'main' of https://g.wace.me/jskim/vexplor_dev into jskim-node
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,833 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Calendar,
|
||||
Upload,
|
||||
Ruler,
|
||||
FileText,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Save,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
getDesignRequestList,
|
||||
createDesignRequest,
|
||||
updateDesignRequest,
|
||||
deleteDesignRequest,
|
||||
} from "@/lib/api/design";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
// ========== 타입 ==========
|
||||
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-warning/10 text-warning",
|
||||
설계진행: "bg-info/10 text-info",
|
||||
설계검토: "bg-primary/10 text-primary",
|
||||
출도완료: "bg-success/10 text-success",
|
||||
반려: "bg-destructive/10 text-destructive",
|
||||
종료: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
const TYPE_STYLES: Record<string, string> = {
|
||||
신규설계: "bg-info/10 text-info",
|
||||
유사설계: "bg-success/10 text-success",
|
||||
개조설계: "bg-warning/10 text-warning",
|
||||
};
|
||||
|
||||
const PRIORITY_STYLES: Record<string, string> = {
|
||||
긴급: "bg-destructive/10 text-destructive",
|
||||
높음: "bg-warning/10 text-warning",
|
||||
보통: "bg-muted text-foreground",
|
||||
낮음: "bg-success/10 text-success",
|
||||
};
|
||||
|
||||
const STATUS_PROGRESS: Record<string, number> = {
|
||||
신규접수: 0,
|
||||
접수대기: 0,
|
||||
검토중: 20,
|
||||
설계진행: 50,
|
||||
설계검토: 80,
|
||||
출도완료: 100,
|
||||
반려: 0,
|
||||
종료: 100,
|
||||
};
|
||||
|
||||
function getProgressColor(p: number) {
|
||||
if (p >= 100) return "bg-success";
|
||||
if (p >= 60) return "bg-warning";
|
||||
if (p >= 20) return "bg-info";
|
||||
return "bg-muted";
|
||||
}
|
||||
|
||||
function getProgressTextColor(p: number) {
|
||||
if (p >= 100) return "text-success";
|
||||
if (p >= 60) return "text-warning";
|
||||
if (p >= 20) return "text-info";
|
||||
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: "",
|
||||
};
|
||||
|
||||
// ========== Grid Columns ==========
|
||||
const DR_GRID_COLUMNS = [
|
||||
{ key: "request_no", label: "의뢰번호" },
|
||||
{ key: "design_type", label: "유형" },
|
||||
{ key: "status", label: "상태" },
|
||||
{ key: "priority", label: "우선순위" },
|
||||
{ key: "target_name", label: "설비/제품명" },
|
||||
{ key: "customer", label: "고객명" },
|
||||
{ key: "designer", label: "설계담당" },
|
||||
{ key: "due_date", label: "납기" },
|
||||
{ key: "progress", label: "진행률" },
|
||||
];
|
||||
|
||||
// ========== 메인 컴포넌트 ==========
|
||||
export default function DesignRequestPage() {
|
||||
const ts = useTableSettings("c16-design-request", "dsn_design_request", DR_GRID_COLUMNS);
|
||||
const [requests, setRequests] = useState<DesignRequest[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
// 검색 필터 (DynamicSearchFilter)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
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" };
|
||||
const res = await getDesignRequestList(params);
|
||||
if (res.success && res.data) {
|
||||
setRequests(res.data);
|
||||
} else {
|
||||
setRequests([]);
|
||||
}
|
||||
} catch {
|
||||
setRequests([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRequests();
|
||||
}, [fetchRequests]);
|
||||
|
||||
// 클라이언트 사이드 필터링 (DynamicSearchFilter)
|
||||
const filteredRequests = useMemo(() => {
|
||||
if (searchFilters.length === 0) return requests;
|
||||
return requests.filter((item) => {
|
||||
for (const f of searchFilters) {
|
||||
const val = item[f.columnName as keyof DesignRequest];
|
||||
const strVal = val !== undefined && val !== null ? (Array.isArray(val) ? val.join(",") : String(val)) : "";
|
||||
if (f.operator === "contains") {
|
||||
if (!strVal.toLowerCase().includes(f.value.toLowerCase())) return false;
|
||||
} else if (f.operator === "equals") {
|
||||
if (strVal !== f.value) return false;
|
||||
} else if (f.operator === "in") {
|
||||
const allowed = f.value.split("|");
|
||||
if (!allowed.includes(strVal)) return false;
|
||||
} else if (f.operator === "between") {
|
||||
const [from, to] = f.value.split("|");
|
||||
if (from && strVal < from) return false;
|
||||
if (to && strVal > to) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [requests, searchFilters]);
|
||||
|
||||
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 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 || "",
|
||||
});
|
||||
setDetailOpen(false);
|
||||
setModalOpen(true);
|
||||
}, [selectedItem]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!form.target_name.trim()) { toast.error("설비/제품명을 입력해 주세요."); return; }
|
||||
if (!form.design_type) { toast.error("의뢰 유형을 선택해 주세요."); return; }
|
||||
if (!form.due_date) { toast.error("납기를 입력해 주세요."); return; }
|
||||
if (!form.spec.trim()) { toast.error("요구사양을 입력해 주세요."); 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) {
|
||||
toast.success(isEditMode ? "수정되었어요." : "등록되었어요.");
|
||||
setModalOpen(false);
|
||||
await fetchRequests();
|
||||
if (isEditMode && editingId) {
|
||||
setSelectedId(editingId);
|
||||
} else if (res.data?.id) {
|
||||
setSelectedId(res.data.id);
|
||||
}
|
||||
} else {
|
||||
toast.error(res.message || "저장에 실패했어요.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("저장에 실패했어요.");
|
||||
} 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) {
|
||||
toast.success("삭제되었어요.");
|
||||
setSelectedId(null);
|
||||
setDetailOpen(false);
|
||||
await fetchRequests();
|
||||
} else {
|
||||
toast.error(res.message || "삭제에 실패했어요.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error("삭제에 실패했어요.");
|
||||
}
|
||||
}, [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-warning" };
|
||||
if (diff <= 7) return { text: `${diff}일 남음`, color: "text-warning" };
|
||||
return { text: `${diff}일 남음`, color: "text-success" };
|
||||
},
|
||||
[today]
|
||||
);
|
||||
|
||||
const getProgress = useCallback((status: string) => {
|
||||
return STATUS_PROGRESS[status] ?? 0;
|
||||
}, []);
|
||||
|
||||
const handleRowClick = useCallback((id: string) => {
|
||||
setSelectedId(id);
|
||||
setDetailOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 필터 */}
|
||||
<div className="shrink-0">
|
||||
<DynamicSearchFilter
|
||||
tableName="dsn_design_request"
|
||||
filterId="c16-design-request"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={filteredRequests.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 현황 카드 */}
|
||||
<div className="grid grid-cols-3 gap-3 shrink-0">
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-left">
|
||||
<div className="text-[10px] text-muted-foreground">접수대기</div>
|
||||
<div className="text-xl font-bold text-info">{statusCounts.접수대기}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-left">
|
||||
<div className="text-[10px] text-muted-foreground">설계진행</div>
|
||||
<div className="text-xl font-bold text-warning">{statusCounts.설계진행}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card px-3 py-2 text-left">
|
||||
<div className="text-[10px] text-muted-foreground">출도완료</div>
|
||||
<div className="text-xl font-bold text-success">{statusCounts.출도완료}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 바 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">설계의뢰 관리</h2>
|
||||
<Badge variant="secondary" className="font-mono">{filteredRequests.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={handleOpenRegister}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> 설계의뢰 등록
|
||||
</Button>
|
||||
<div className="mx-1 h-6 w-px bg-border" />
|
||||
<Button variant="outline" size="sm" disabled={!selectedId} onClick={handleOpenEdit}>
|
||||
<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>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden border rounded-lg bg-card">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{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 className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"text-[11px]",
|
||||
col.key === "request_no" && "w-[100px]",
|
||||
col.key === "design_type" && "w-[70px] text-center",
|
||||
col.key === "status" && "w-[70px] text-center",
|
||||
col.key === "priority" && "w-[60px] text-center",
|
||||
col.key === "customer" && "w-[90px]",
|
||||
col.key === "designer" && "w-[70px]",
|
||||
col.key === "due_date" && "w-[85px]",
|
||||
col.key === "progress" && "w-[65px] text-center",
|
||||
)}
|
||||
style={ts.getWidth(col.key) ? { width: ts.getWidth(col.key) } : undefined}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRequests.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center gap-1 text-muted-foreground">
|
||||
<Inbox 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={() => handleRowClick(item.id)}
|
||||
>
|
||||
{ts.isVisible("request_no") && <TableCell className="text-[11px] font-semibold text-primary">{item.request_no || "-"}</TableCell>}
|
||||
{ts.isVisible("design_type") && (
|
||||
<TableCell className="text-center">
|
||||
{item.design_type ? (
|
||||
<Badge className={cn("text-[9px]", TYPE_STYLES[item.design_type])}>{item.design_type}</Badge>
|
||||
) : "-"}
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("status") && (
|
||||
<TableCell className="text-center">
|
||||
<Badge className={cn("text-[9px]", STATUS_STYLES[item.status])}>{item.status}</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("priority") && (
|
||||
<TableCell className="text-center">
|
||||
<Badge className={cn("text-[9px]", PRIORITY_STYLES[item.priority])}>{item.priority}</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{ts.isVisible("target_name") && <TableCell className="text-[13px] font-medium">{item.target_name || "-"}</TableCell>}
|
||||
{ts.isVisible("customer") && <TableCell className="text-[11px]">{item.customer || "-"}</TableCell>}
|
||||
{ts.isVisible("designer") && <TableCell className="text-[11px]">{item.designer || "-"}</TableCell>}
|
||||
{ts.isVisible("due_date") && <TableCell className="text-[11px]">{item.due_date || "-"}</TableCell>}
|
||||
{ts.isVisible("progress") && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 다이얼로그 */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-[900px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{selectedItem ? `설계의뢰 상세 — ${selectedItem.request_no}` : "설계의뢰 상세"}</DialogTitle>
|
||||
<DialogDescription>설계의뢰의 상세 정보를 확인해요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{selectedItem && (
|
||||
<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-primary bg-primary"
|
||||
: isDone || !isLast
|
||||
? "border-success bg-success"
|
||||
: "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>
|
||||
<DialogFooter>
|
||||
{selectedItem && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={handleOpenEdit}>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />수정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive" onClick={handleDelete}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="max-w-5xl w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "설계의뢰 수정" : "설계의뢰 등록"}</DialogTitle>
|
||||
<DialogDescription>{isEditMode ? "설계의뢰 정보를 수정해요." : "새 설계의뢰를 등록해요."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col md:flex-row gap-6 p-6">
|
||||
{/* 좌측: 기본 정보 */}
|
||||
<div className="md:w-[420px] shrink-0 space-y-4">
|
||||
<h3 className="text-sm font-semibold pb-2 border-b">의뢰 기본 정보</h3>
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">의뢰번호</Label>
|
||||
<Input value={form.request_no} readOnly className="h-9 bg-muted cursor-not-allowed" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">의뢰일자</Label>
|
||||
<Input type="date" value={form.request_date} onChange={(e) => setForm((p) => ({ ...p, request_date: e.target.value }))} className="h-9" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">납기 <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" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">의뢰 유형 <span className="text-destructive">*</span></Label>
|
||||
<Select value={form.design_type} onValueChange={(v) => setForm((p) => ({ ...p, design_type: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{["신규설계", "유사설계", "개조설계"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">우선순위 <span className="text-destructive">*</span></Label>
|
||||
<Select value={form.priority} onValueChange={(v) => setForm((p) => ({ ...p, priority: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{["긴급", "높음", "보통", "낮음"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설비/제품명 <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" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">의뢰부서</Label>
|
||||
<Select value={form.req_dept} onValueChange={(v) => setForm((p) => ({ ...p, req_dept: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{["영업팀", "기획팀", "생산팀", "품질팀"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">의뢰자</Label>
|
||||
<Input value={form.requester} onChange={(e) => setForm((p) => ({ ...p, requester: e.target.value }))} placeholder="의뢰자명" className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">고객명</Label>
|
||||
<Input value={form.customer} onChange={(e) => setForm((p) => ({ ...p, customer: e.target.value }))} placeholder="고객/거래처명" className="h-9" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">수주번호</Label>
|
||||
<Input value={form.order_no} onChange={(e) => setForm((p) => ({ ...p, order_no: e.target.value }))} placeholder="관련 수주번호" className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설계담당자</Label>
|
||||
<Select value={form.designer} onValueChange={(v) => setForm((p) => ({ ...p, designer: v }))}>
|
||||
<SelectTrigger className="h-9"><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">
|
||||
<h3 className="text-sm font-semibold pb-2 border-b">요구사양 및 설명</h3>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">요구사양 <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]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">참조 도면번호</Label>
|
||||
<Input value={form.drawing_no} onChange={(e) => setForm((p) => ({ ...p, drawing_no: e.target.value }))} placeholder="유사 설비명 또는 참조 도면번호" className="h-9" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">비고</Label>
|
||||
<Textarea value={form.content} onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} placeholder="기타 참고 사항" className="min-h-[70px]" rows={3} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold pb-2 border-b mb-2">첨부파일</h3>
|
||||
<div className="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>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)} disabled={saving}>취소</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>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</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,876 @@
|
||||
"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,
|
||||
Inbox, 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 { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
|
||||
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const EQUIP_TABLE = "equipment_mng";
|
||||
const INSPECTION_TABLE = "equipment_inspection_item";
|
||||
const CONSUMABLE_TABLE = "equipment_consumable";
|
||||
|
||||
const GRID_COLUMNS_CONFIG = [
|
||||
{ key: "equipment_code", label: "설비코드" },
|
||||
{ key: "equipment_name", label: "설비명" },
|
||||
{ key: "equipment_type", label: "설비유형" },
|
||||
{ key: "manufacturer", label: "제조사" },
|
||||
{ key: "installation_location", label: "설치장소" },
|
||||
{ key: "operation_status", label: "가동상태" },
|
||||
];
|
||||
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 [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 [inspectionContinuous, setInspectionContinuous] = useState(false);
|
||||
|
||||
// 소모품 추가 모달
|
||||
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
|
||||
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
||||
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 ts = useTableSettings("c16-equipment-info", EQUIP_TABLE, GRID_COLUMNS_CONFIG);
|
||||
|
||||
// 카테고리 로드
|
||||
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 ["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 */ }
|
||||
}
|
||||
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 fetchData = 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,
|
||||
});
|
||||
setInspections(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setInspections([]); } finally { setInspectionLoading(false); }
|
||||
};
|
||||
fetchData();
|
||||
}, [selectedEquip?.equipment_code]);
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetchData = 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); }
|
||||
};
|
||||
fetchData();
|
||||
}, [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`, { id: crypto.randomUUID(), ...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("추가되었습니다.");
|
||||
if (inspectionContinuous) {
|
||||
setInspectionForm({});
|
||||
} else {
|
||||
setInspectionModalOpen(false);
|
||||
}
|
||||
refreshRight();
|
||||
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// 소모품 품목 로드
|
||||
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;
|
||||
};
|
||||
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("추가되었습니다.");
|
||||
if (consumableContinuous) {
|
||||
setConsumableForm({});
|
||||
} else {
|
||||
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">
|
||||
{/* 브레드크럼 */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
<span>설비관리</span>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<span className="text-foreground font-medium">설비정보</span>
|
||||
</div>
|
||||
|
||||
{/* 검색 바 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={EQUIP_TABLE}
|
||||
filterId="c16-equipment-info"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={equipCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<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-card">
|
||||
<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/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[13px] font-bold">설비 목록</h3>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{equipCount}건</span>
|
||||
</div>
|
||||
<div className="flex items-center 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>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete} className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10"><Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제</Button>
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)}>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{equipLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : equipments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Inbox className="w-8 h-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">등록된 설비가 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
{ts.isVisible("equipment_code") && <TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비코드</TableHead>}
|
||||
{ts.isVisible("equipment_name") && <TableHead className="min-w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비명</TableHead>}
|
||||
{ts.isVisible("equipment_type") && <TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비유형</TableHead>}
|
||||
{ts.isVisible("manufacturer") && <TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">제조사</TableHead>}
|
||||
{ts.isVisible("installation_location") && <TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설치장소</TableHead>}
|
||||
{ts.isVisible("operation_status") && <TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">가동상태</TableHead>}
|
||||
</TableRow>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{equipments.map((equip) => (
|
||||
<TableRow
|
||||
key={equip.id}
|
||||
className={cn("cursor-pointer", selectedEquipId === equip.id && "border-l-2 border-l-primary bg-primary/[0.08]")}
|
||||
onClick={() => setSelectedEquipId(equip.id)}
|
||||
onDoubleClick={openEquipEdit}
|
||||
>
|
||||
{ts.isVisible("equipment_code") && <TableCell className="text-[13px] font-mono">{equip.equipment_code}</TableCell>}
|
||||
{ts.isVisible("equipment_name") && <TableCell className="text-sm max-w-[150px] truncate" title={equip.equipment_name}>{equip.equipment_name || "-"}</TableCell>}
|
||||
{ts.isVisible("equipment_type") && <TableCell className="text-[13px]">{equip.equipment_type || "-"}</TableCell>}
|
||||
{ts.isVisible("manufacturer") && <TableCell className="text-[13px]">{equip.manufacturer || "-"}</TableCell>}
|
||||
{ts.isVisible("installation_location") && <TableCell className="text-[13px]">{equip.installation_location || "-"}</TableCell>}
|
||||
{ts.isVisible("operation_status") && <TableCell className="text-[13px]">{equip.operation_status || "-"}</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</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/50 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 flex-col items-center justify-center gap-3 m-3 border-2 border-dashed rounded-lg text-center">
|
||||
<Inbox className="w-12 h-12 text-muted-foreground/40" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-muted-foreground">설비를 선택해주세요</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">좌측에서 설비를 선택하면 상세 정보가 표시돼요</p>
|
||||
</div>
|
||||
</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>
|
||||
<Input type="date" value={infoForm.introduction_date || ""} onChange={(e) => setInfoForm((p) => ({ ...p, introduction_date: e.target.value }))} className="h-9" />
|
||||
</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" ? (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{inspectionLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : inspections.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<ClipboardCheck className="w-8 h-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">점검항목이 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검항목</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검주기</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검방법</TableHead>
|
||||
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">하한치</TableHead>
|
||||
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상한치</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검내용</TableHead>
|
||||
</TableRow>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{inspections.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-sm">{item.inspection_item || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
||||
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.lower_limit || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.upper_limit || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.inspection_content || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{consumableLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : consumables.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Package className="w-8 h-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">소모품이 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소모품명</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">교체주기</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">제조사</TableHead>
|
||||
</TableRow>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{consumables.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-sm">{item.consumable_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.replacement_cycle || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.specification || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.manufacturer || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* 설비 등록/수정 모달 */}
|
||||
<Dialog open={equipModalOpen} onOpenChange={setEquipModalOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{equipEditMode ? "설비 수정" : "설비 등록"}</DialogTitle>
|
||||
<DialogDescription>{equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<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>
|
||||
<Input type="date" value={equipForm.introduction_date || ""} onChange={(e) => setEquipForm((p) => ({ ...p, introduction_date: e.target.value }))} className="h-9" /></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>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<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>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 점검항목 추가 모달 */}
|
||||
<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 className="flex items-center justify-between sm:justify-between">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={inspectionContinuous} onCheckedChange={(c) => setInspectionContinuous(!!c)} />
|
||||
저장 후 계속 입력
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setInspectionModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button>
|
||||
</div>
|
||||
</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 className="flex items-center justify-between sm:justify-between">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={consumableContinuous} onCheckedChange={(c) => setConsumableContinuous(!!c)} />
|
||||
저장 후 계속 입력
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setConsumableModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 점검항목 복사 모달 */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={setCopyModalOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader><DialogTitle>점검항목 복사</DialogTitle>
|
||||
<DialogDescription>다른 설비의 점검항목을 선택하여 {selectedEquip?.equipment_name}에 복사합니다.</DialogDescription></DialogHeader>
|
||||
<div className="space-y-3 flex-1 overflow-y-auto">
|
||||
<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 noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<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] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검주기</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">점검방법</TableHead><TableHead className="w-[70px]">하한</TableHead>
|
||||
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상한</TableHead><TableHead className="w-[60px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</thead>
|
||||
<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-[13px]">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
||||
<TableCell className="text-[13px]">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.lower_limit || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.upper_limit || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{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={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
"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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Cpu, Settings2, Search, Inbox,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
|
||||
/* ───── 테이블명 ───── */
|
||||
const DATATYPE_TABLE = "plc_data_type";
|
||||
|
||||
const DATATYPE_COLUMNS = [
|
||||
{ key: "equipment_code", label: "설비코드" },
|
||||
{ key: "data_type", label: "데이터타입" },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "tag_address", label: "태그주소" },
|
||||
{ key: "collection_interval", label: "수집주기" },
|
||||
{ key: "lower_limit", label: "하한값" },
|
||||
{ key: "upper_limit", label: "상한값" },
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
const COLLECTION_TABLE = "plc_collection_config";
|
||||
const EQUIPMENT_TABLE = "equipment_mng";
|
||||
|
||||
/* ───── Cron 한글 변환 ───── */
|
||||
const cronToKorean = (cron: string): string => {
|
||||
if (!cron) return "";
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length < 5) return cron;
|
||||
const [min, hour] = parts;
|
||||
if (min === "*" && hour === "*") return "매 분마다";
|
||||
if (min !== "*" && hour === "*") return `매시 ${min}분마다`;
|
||||
if (min === "0" && hour !== "*") return `매일 ${hour}시 정각`;
|
||||
if (min === "*/5") return "5분마다";
|
||||
if (min === "*/10") return "10분마다";
|
||||
if (min === "*/30") return "30분마다";
|
||||
return cron;
|
||||
};
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (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(...flattenCategories(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export default function PlcSettingsPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const ts = useTableSettings("c16-plc-settings", DATATYPE_TABLE, DATATYPE_COLUMNS);
|
||||
|
||||
const [activeTab, setActiveTab] = useState("datatype");
|
||||
|
||||
/* ───── PLC 데이터타입 ───── */
|
||||
const [datatypes, setDatatypes] = useState<any[]>([]);
|
||||
const [dtLoading, setDtLoading] = useState(false);
|
||||
const [dtCount, setDtCount] = useState(0);
|
||||
const [dtChecked, setDtChecked] = useState<string[]>([]);
|
||||
const [dtModalOpen, setDtModalOpen] = useState(false);
|
||||
const [dtEditMode, setDtEditMode] = useState(false);
|
||||
const [dtForm, setDtForm] = useState<Record<string, any>>({});
|
||||
const [dtSaving, setDtSaving] = useState(false);
|
||||
const [dtKeyword, setDtKeyword] = useState("");
|
||||
|
||||
/* ───── 수집 설정 ───── */
|
||||
const [configs, setConfigs] = useState<any[]>([]);
|
||||
const [cfgLoading, setCfgLoading] = useState(false);
|
||||
const [cfgCount, setCfgCount] = useState(0);
|
||||
const [cfgChecked, setCfgChecked] = useState<string[]>([]);
|
||||
const [cfgModalOpen, setCfgModalOpen] = useState(false);
|
||||
const [cfgEditMode, setCfgEditMode] = useState(false);
|
||||
const [cfgForm, setCfgForm] = useState<Record<string, any>>({});
|
||||
const [cfgSaving, setCfgSaving] = useState(false);
|
||||
const [cfgKeyword, setCfgKeyword] = useState("");
|
||||
|
||||
/* ───── FK + 카테고리 옵션 ───── */
|
||||
const [equipOptions, setEquipOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [collectionTypeOptions, setCollectionTypeOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const eqRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, { page: 1, size: 500, autoFilter: true });
|
||||
const eqs = eqRes.data?.data?.data || eqRes.data?.data?.rows || [];
|
||||
setEquipOptions(eqs.map((r: any) => ({ code: r.equipment_code, label: `${r.equipment_code} - ${r.equipment_name || ""}` })));
|
||||
} catch { /* skip */ }
|
||||
try {
|
||||
const catRes = await apiClient.get(`/table-categories/${COLLECTION_TABLE}/collection_type/values`);
|
||||
if (catRes.data?.data?.length > 0) {
|
||||
setCollectionTypeOptions(flattenCategories(catRes.data.data));
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchDatatypes = useCallback(async (keyword?: string) => {
|
||||
setDtLoading(true);
|
||||
try {
|
||||
const kw = keyword !== undefined ? keyword : dtKeyword;
|
||||
const filters: any[] = [];
|
||||
if (kw.trim()) filters.push({ columnName: "equipment_code", operator: "contains", value: kw.trim() });
|
||||
const res = await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setDatatypes(rows);
|
||||
setDtCount(rows.length);
|
||||
} catch { toast.error("PLC 데이터타입 조회에 실패했어요"); }
|
||||
finally { setDtLoading(false); }
|
||||
}, [dtKeyword]);
|
||||
|
||||
const fetchConfigs = useCallback(async (keyword?: string) => {
|
||||
setCfgLoading(true);
|
||||
try {
|
||||
const kw = keyword !== undefined ? keyword : cfgKeyword;
|
||||
const filters: any[] = [];
|
||||
if (kw.trim()) filters.push({ columnName: "config_name", operator: "contains", value: kw.trim() });
|
||||
const res = await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setConfigs(rows);
|
||||
setCfgCount(rows.length);
|
||||
} catch { toast.error("수집 설정 조회에 실패했어요"); }
|
||||
finally { setCfgLoading(false); }
|
||||
}, [cfgKeyword]);
|
||||
|
||||
useEffect(() => { fetchDatatypes(); fetchConfigs(); }, []);
|
||||
|
||||
/* ═══════════════════ 데이터타입 CRUD ═══════════════════ */
|
||||
const openDtCreate = () => { setDtForm({}); setDtEditMode(false); setDtModalOpen(true); };
|
||||
const openDtEdit = (row: any) => { setDtForm({ ...row }); setDtEditMode(true); setDtModalOpen(true); };
|
||||
const saveDt = async () => {
|
||||
if (!dtForm.equipment_code) { toast.error("설비코드는 필수 입력이에요"); return; }
|
||||
setDtSaving(true);
|
||||
try {
|
||||
if (dtEditMode) {
|
||||
await apiClient.put(`/table-management/tables/${DATATYPE_TABLE}/edit`, {
|
||||
originalData: { id: dtForm.id }, updatedData: dtForm,
|
||||
});
|
||||
toast.success("PLC 데이터타입을 수정했어요");
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${DATATYPE_TABLE}/add`, dtForm);
|
||||
toast.success("PLC 데이터타입을 등록했어요");
|
||||
}
|
||||
setDtModalOpen(false);
|
||||
fetchDatatypes();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
finally { setDtSaving(false); }
|
||||
};
|
||||
const deleteDt = async () => {
|
||||
if (dtChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("PLC 데이터타입 삭제", { description: `선택한 ${dtChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${DATATYPE_TABLE}/delete`, {
|
||||
data: dtChecked.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${dtChecked.length}건을 삭제했어요`);
|
||||
setDtChecked([]);
|
||||
fetchDatatypes();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ 수집 설정 CRUD ═══════════════════ */
|
||||
const openCfgCreate = () => { setCfgForm({}); setCfgEditMode(false); setCfgModalOpen(true); };
|
||||
const openCfgEdit = (row: any) => { setCfgForm({ ...row }); setCfgEditMode(true); setCfgModalOpen(true); };
|
||||
const saveCfg = async () => {
|
||||
if (!cfgForm.config_name) { toast.error("설정명은 필수 입력이에요"); return; }
|
||||
setCfgSaving(true);
|
||||
try {
|
||||
if (cfgEditMode) {
|
||||
await apiClient.put(`/table-management/tables/${COLLECTION_TABLE}/edit`, {
|
||||
originalData: { id: cfgForm.id }, updatedData: cfgForm,
|
||||
});
|
||||
toast.success("수집 설정을 수정했어요");
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${COLLECTION_TABLE}/add`, cfgForm);
|
||||
toast.success("수집 설정을 등록했어요");
|
||||
}
|
||||
setCfgModalOpen(false);
|
||||
fetchConfigs();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
finally { setCfgSaving(false); }
|
||||
};
|
||||
const deleteCfg = async () => {
|
||||
if (cfgChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("수집 설정 삭제", { description: `선택한 ${cfgChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${COLLECTION_TABLE}/delete`, {
|
||||
data: cfgChecked.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${cfgChecked.length}건을 삭제했어요`);
|
||||
setCfgChecked([]);
|
||||
fetchConfigs();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<div className="border-b px-3">
|
||||
<TabsList className="bg-transparent h-auto p-0 gap-0">
|
||||
<TabsTrigger
|
||||
value="datatype"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
||||
>
|
||||
<Cpu className="w-4 h-4 mr-2" />
|
||||
PLC 데이터타입
|
||||
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{dtCount}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="collection"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
||||
>
|
||||
<Settings2 className="w-4 h-4 mr-2" />
|
||||
수집 설정
|
||||
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{cfgCount}</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* ──── PLC 데이터타입 탭 ──── */}
|
||||
<TabsContent value="datatype" className="p-3 mt-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 w-56 text-sm"
|
||||
placeholder="설비코드 검색..."
|
||||
value={dtKeyword}
|
||||
onChange={(e) => setDtKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && fetchDatatypes(dtKeyword)}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="h-8" onClick={() => fetchDatatypes(dtKeyword)}>
|
||||
<Search className="w-3.5 h-3.5 mr-1" />검색
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setDtKeyword(""); fetchDatatypes(""); }}>
|
||||
초기화
|
||||
</Button>
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">{dtCount}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openDtCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
const sel = datatypes.find(r => dtChecked.includes(r.id));
|
||||
if (sel) openDtEdit(sel); else toast.error("수정할 항목을 선택해주세요");
|
||||
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" onClick={deleteDt}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={datatypes.length > 0 && dtChecked.length === datatypes.length}
|
||||
onCheckedChange={(v) => setDtChecked(v ? datatypes.map(r => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dtLoading ? (
|
||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : datatypes.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 PLC 데이터타입이 없어요</p></TableCell></TableRow>
|
||||
) : datatypes.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn("cursor-pointer", dtChecked.includes(row.id) && "bg-primary/5")}
|
||||
onClick={() => setDtChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
||||
onDoubleClick={() => openDtEdit(row)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={dtChecked.includes(row.id)} onCheckedChange={(v) => setDtChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
||||
</TableCell>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableCell key={col.key} className={col.key === "is_active" ? "text-center" : ""}>
|
||||
{col.key === "is_active"
|
||||
? <Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
||||
: row[col.key] ?? ""}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ──── 수집 설정 탭 ──── */}
|
||||
<TabsContent value="collection" className="p-3 mt-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 w-56 text-sm"
|
||||
placeholder="설정명 검색..."
|
||||
value={cfgKeyword}
|
||||
onChange={(e) => setCfgKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && fetchConfigs(cfgKeyword)}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="h-8" onClick={() => fetchConfigs(cfgKeyword)}>
|
||||
<Search className="w-3.5 h-3.5 mr-1" />검색
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-8" onClick={() => { setCfgKeyword(""); fetchConfigs(""); }}>
|
||||
초기화
|
||||
</Button>
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">{cfgCount}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openCfgCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
const sel = configs.find(r => cfgChecked.includes(r.id));
|
||||
if (sel) openCfgEdit(sel); else toast.error("수정할 항목을 선택해주세요");
|
||||
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" onClick={deleteCfg}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={configs.length > 0 && cfgChecked.length === configs.length}
|
||||
onCheckedChange={(v) => setCfgChecked(v ? configs.map(r => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설정명</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소스연결ID</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소스테이블</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">대상테이블</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수집유형</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">스케줄(Cron)</TableHead>
|
||||
<TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cfgLoading ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : configs.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 수집 설정이 없어요</p></TableCell></TableRow>
|
||||
) : configs.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn("cursor-pointer", cfgChecked.includes(row.id) && "bg-primary/5")}
|
||||
onClick={() => setCfgChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
||||
onDoubleClick={() => openCfgEdit(row)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={cfgChecked.includes(row.id)} onCheckedChange={(v) => setCfgChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
||||
</TableCell>
|
||||
<TableCell>{row.config_name}</TableCell>
|
||||
<TableCell>{row.source_connection_id}</TableCell>
|
||||
<TableCell>{row.source_table}</TableCell>
|
||||
<TableCell>{row.target_table}</TableCell>
|
||||
<TableCell>{row.collection_type}</TableCell>
|
||||
<TableCell className="font-mono text-[13px]">{row.schedule_cron}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════ PLC 데이터타입 모달 ═══════════════════ */}
|
||||
<Dialog open={dtModalOpen} onOpenChange={setDtModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dtEditMode ? "PLC 데이터타입 수정" : "PLC 데이터타입 등록"}</DialogTitle>
|
||||
<DialogDescription>PLC 데이터타입 정보를 입력해주세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">설비코드 <span className="text-destructive">*</span></Label>
|
||||
<Select value={dtForm.equipment_code || ""} onValueChange={(v) => setDtForm(p => ({ ...p, equipment_code: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="설비를 선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{equipOptions.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 font-semibold text-muted-foreground">데이터타입</Label>
|
||||
<Input className="h-9" value={dtForm.data_type || ""} onChange={(e) => setDtForm(p => ({ ...p, data_type: e.target.value }))} placeholder="예: 온도, 압력" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">단위</Label>
|
||||
<Input className="h-9" value={dtForm.unit || ""} onChange={(e) => setDtForm(p => ({ ...p, unit: e.target.value }))} placeholder="예: ℃, bar" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">태그주소</Label>
|
||||
<Input className="h-9" value={dtForm.tag_address || ""} onChange={(e) => setDtForm(p => ({ ...p, tag_address: e.target.value }))} placeholder="예: D100" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">수집주기</Label>
|
||||
<Input className="h-9" value={dtForm.collection_interval || ""} onChange={(e) => setDtForm(p => ({ ...p, collection_interval: e.target.value }))} placeholder="예: 1000ms" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">하한값</Label>
|
||||
<Input className="h-9" type="number" value={dtForm.lower_limit ?? ""} onChange={(e) => setDtForm(p => ({ ...p, lower_limit: e.target.value }))} placeholder="하한값" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">상한값</Label>
|
||||
<Input className="h-9" type="number" value={dtForm.upper_limit ?? ""} onChange={(e) => setDtForm(p => ({ ...p, upper_limit: e.target.value }))} placeholder="상한값" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={dtForm.is_active ?? true} onCheckedChange={(v) => setDtForm(p => ({ ...p, is_active: !!v }))} />
|
||||
<Label className="text-sm">사용</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDtModalOpen(false)}>취소</Button>
|
||||
<Button onClick={saveDt} disabled={dtSaving}>
|
||||
{dtSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 수집 설정 모달 ═══════════════════ */}
|
||||
<Dialog open={cfgModalOpen} onOpenChange={setCfgModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{cfgEditMode ? "수집 설정 수정" : "수집 설정 등록"}</DialogTitle>
|
||||
<DialogDescription>수집 설정 정보를 입력해주세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">설정명 <span className="text-destructive">*</span></Label>
|
||||
<Input className="h-9" value={cfgForm.config_name || ""} onChange={(e) => setCfgForm(p => ({ ...p, config_name: e.target.value }))} placeholder="설정명을 입력해주세요" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">소스연결ID</Label>
|
||||
<Input className="h-9" value={cfgForm.source_connection_id || ""} onChange={(e) => setCfgForm(p => ({ ...p, source_connection_id: e.target.value }))} placeholder="소스연결ID" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">수집유형</Label>
|
||||
{collectionTypeOptions.length > 0 ? (
|
||||
<Select value={cfgForm.collection_type || ""} onValueChange={(v) => setCfgForm(p => ({ ...p, collection_type: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{collectionTypeOptions.map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input className="h-9" value={cfgForm.collection_type || ""} onChange={(e) => setCfgForm(p => ({ ...p, collection_type: e.target.value }))} placeholder="수집유형" />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">소스테이블</Label>
|
||||
<Input className="h-9" value={cfgForm.source_table || ""} onChange={(e) => setCfgForm(p => ({ ...p, source_table: e.target.value }))} placeholder="소스테이블명" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">대상테이블</Label>
|
||||
<Input className="h-9" value={cfgForm.target_table || ""} onChange={(e) => setCfgForm(p => ({ ...p, target_table: e.target.value }))} placeholder="대상테이블명" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">스케줄 (Cron)</Label>
|
||||
<Input className="h-9 font-mono text-sm" value={cfgForm.schedule_cron || ""} onChange={(e) => setCfgForm(p => ({ ...p, schedule_cron: e.target.value }))} placeholder="예: */5 * * * * (5분마다)" />
|
||||
{cfgForm.schedule_cron && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{cronToKorean(cfgForm.schedule_cron)}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={cfgForm.is_active ?? true} onCheckedChange={(v) => setCfgForm(p => ({ ...p, is_active: !!v }))} />
|
||||
<Label className="text-sm">사용</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCfgModalOpen(false)}>취소</Button>
|
||||
<Button onClick={saveCfg} disabled={cfgSaving}>
|
||||
{cfgSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,903 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
Truck,
|
||||
DollarSign,
|
||||
FileText,
|
||||
MapPin,
|
||||
Car,
|
||||
Plus,
|
||||
Trash2,
|
||||
Download,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Inbox,
|
||||
Loader2,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
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 { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
// ========== 타입 & 상수 ==========
|
||||
type TabKey = "carrier" | "cost" | "contract" | "route" | "vehicle";
|
||||
|
||||
interface TabColumnDef {
|
||||
key: string;
|
||||
label: string;
|
||||
width?: string;
|
||||
align?: "left" | "center" | "right";
|
||||
formatNumber?: boolean;
|
||||
}
|
||||
|
||||
interface FormFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "text" | "number" | "select" | "smartselect" | "date";
|
||||
required?: boolean;
|
||||
referenceKey?: "carrier" | "route";
|
||||
categoryKey?: string;
|
||||
options?: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
interface TabConfig {
|
||||
key: TabKey;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
tableName: string;
|
||||
columns: TabColumnDef[];
|
||||
formFields: FormFieldDef[];
|
||||
defaultSortColumn: string;
|
||||
}
|
||||
|
||||
const TAB_CONFIGS: TabConfig[] = [
|
||||
{
|
||||
key: "carrier",
|
||||
label: "운송업체",
|
||||
icon: <Truck className="h-3.5 w-3.5" />,
|
||||
tableName: "carrier_mng",
|
||||
defaultSortColumn: "carrier_code",
|
||||
columns: [
|
||||
{ key: "carrier_code", label: "업체코드", width: "120px" },
|
||||
{ key: "carrier_name", label: "업체명", width: "160px" },
|
||||
{ key: "carrier_type", label: "유형", width: "100px" },
|
||||
{ key: "contact_person", label: "담당자", width: "100px" },
|
||||
{ key: "contact_phone", label: "연락처", width: "130px" },
|
||||
{ key: "email", label: "이메일", width: "180px" },
|
||||
{ key: "address", label: "주소", width: "220px" },
|
||||
{ key: "rating", label: "등급", width: "70px", align: "center" },
|
||||
{ key: "status", label: "상태", width: "80px", align: "center" },
|
||||
],
|
||||
formFields: [
|
||||
{ key: "carrier_code", label: "업체코드", type: "text", required: true, placeholder: "업체코드를 입력해주세요" },
|
||||
{ key: "carrier_name", label: "업체명", type: "text", required: true, placeholder: "업체명을 입력해주세요" },
|
||||
{ key: "carrier_type", label: "유형", type: "select", categoryKey: "carrier_mng:carrier_type", placeholder: "유형을 선택해주세요" },
|
||||
{ key: "contact_person", label: "담당자", type: "text", placeholder: "담당자명" },
|
||||
{ key: "contact_phone", label: "연락처", type: "text", placeholder: "010-0000-0000" },
|
||||
{ key: "email", label: "이메일", type: "text", placeholder: "email@example.com" },
|
||||
{ key: "address", label: "주소", type: "text", placeholder: "주소를 입력해주세요" },
|
||||
{ key: "rating", label: "등급", type: "select", options: [1, 2, 3, 4, 5].map((v) => ({ value: String(v), label: `${v}등급` })) },
|
||||
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_mng:status", placeholder: "상태를 선택해주세요" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
label: "물류비",
|
||||
icon: <DollarSign className="h-3.5 w-3.5" />,
|
||||
tableName: "logistics_cost_mng",
|
||||
defaultSortColumn: "carrier_code",
|
||||
columns: [
|
||||
{ key: "carrier_code", label: "운송업체", width: "120px" },
|
||||
{ key: "route_code", label: "구간코드", width: "120px" },
|
||||
{ key: "base_fee", label: "기본요금", width: "110px", align: "right", formatNumber: true },
|
||||
{ key: "unit", label: "단위", width: "70px", align: "center" },
|
||||
{ key: "unit_fee", label: "단가", width: "110px", align: "right", formatNumber: true },
|
||||
{ key: "min_weight", label: "최소중량", width: "100px", align: "right", formatNumber: true },
|
||||
{ key: "max_weight", label: "최대중량", width: "100px", align: "right", formatNumber: true },
|
||||
{ key: "delivery_days", label: "배송일수", width: "80px", align: "center" },
|
||||
],
|
||||
formFields: [
|
||||
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
|
||||
{ key: "route_code", label: "배송구간", type: "smartselect", required: true, referenceKey: "route" },
|
||||
{ key: "base_fee", label: "기본요금", type: "number", placeholder: "0" },
|
||||
{ key: "unit", label: "단위", type: "text", placeholder: "kg, 건 등" },
|
||||
{ key: "unit_fee", label: "단가", type: "number", placeholder: "0" },
|
||||
{ key: "min_weight", label: "최소중량", type: "number", placeholder: "0" },
|
||||
{ key: "max_weight", label: "최대중량", type: "number", placeholder: "0" },
|
||||
{ key: "delivery_days", label: "배송일수", type: "number", placeholder: "0" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "contract",
|
||||
label: "계약서",
|
||||
icon: <FileText className="h-3.5 w-3.5" />,
|
||||
tableName: "carrier_contract_mng",
|
||||
defaultSortColumn: "contract_no",
|
||||
columns: [
|
||||
{ key: "contract_no", label: "계약번호", width: "130px" },
|
||||
{ key: "carrier_code", label: "운송업체", width: "120px" },
|
||||
{ key: "contract_start_date", label: "시작일", width: "110px" },
|
||||
{ key: "contract_end_date", label: "종료일", width: "110px" },
|
||||
{ key: "contract_amount", label: "계약금액", width: "130px", align: "right", formatNumber: true },
|
||||
{ key: "status", label: "상태", width: "80px", align: "center" },
|
||||
],
|
||||
formFields: [
|
||||
{ key: "contract_no", label: "계약번호", type: "text", required: true, placeholder: "계약번호를 입력해주세요" },
|
||||
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
|
||||
{ key: "contract_start_date", label: "시작일", type: "date", required: true },
|
||||
{ key: "contract_end_date", label: "종료일", type: "date", required: true },
|
||||
{ key: "contract_amount", label: "계약금액", type: "number", placeholder: "0" },
|
||||
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_contract_mng:status", placeholder: "상태를 선택해주세요" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "route",
|
||||
label: "배송구간",
|
||||
icon: <MapPin className="h-3.5 w-3.5" />,
|
||||
tableName: "delivery_route_mng",
|
||||
defaultSortColumn: "route_code",
|
||||
columns: [
|
||||
{ key: "route_code", label: "구간코드", width: "120px" },
|
||||
{ key: "route_name", label: "구간명", width: "160px" },
|
||||
{ key: "departure", label: "출발지", width: "120px" },
|
||||
{ key: "destination", label: "도착지", width: "120px" },
|
||||
{ key: "distance_km", label: "거리(km)", width: "100px", align: "right", formatNumber: true },
|
||||
{ key: "avg_time_hours", label: "평균시간(h)", width: "100px", align: "right" },
|
||||
{ key: "route_type", label: "구간유형", width: "100px" },
|
||||
{ key: "status", label: "상태", width: "80px", align: "center" },
|
||||
],
|
||||
formFields: [
|
||||
{ key: "route_code", label: "구간코드", type: "text", required: true, placeholder: "구간코드를 입력해주세요" },
|
||||
{ key: "route_name", label: "구간명", type: "text", required: true, placeholder: "구간명을 입력해주세요" },
|
||||
{ key: "departure", label: "출발지", type: "text", placeholder: "출발지" },
|
||||
{ key: "destination", label: "도착지", type: "text", placeholder: "도착지" },
|
||||
{ key: "distance_km", label: "거리(km)", type: "number", placeholder: "0" },
|
||||
{ key: "avg_time_hours", label: "평균시간(h)", type: "number", placeholder: "0" },
|
||||
{ key: "route_type", label: "구간유형", type: "select", categoryKey: "delivery_route_mng:route_type", placeholder: "유형을 선택해주세요" },
|
||||
{ key: "status", label: "상태", type: "select", categoryKey: "delivery_route_mng:status", placeholder: "상태를 선택해주세요" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "vehicle",
|
||||
label: "차량",
|
||||
icon: <Car className="h-3.5 w-3.5" />,
|
||||
tableName: "carrier_vehicle_mng",
|
||||
defaultSortColumn: "vehicle_code",
|
||||
columns: [
|
||||
{ key: "vehicle_code", label: "차량코드", width: "120px" },
|
||||
{ key: "vehicle_number", label: "차량번호", width: "120px" },
|
||||
{ key: "vehicle_type", label: "차종", width: "100px" },
|
||||
{ key: "carrier_code", label: "운송업체", width: "120px" },
|
||||
{ key: "load_capacity_kg", label: "적재용량(kg)", width: "120px", align: "right", formatNumber: true },
|
||||
{ key: "driver_name", label: "운전자", width: "100px" },
|
||||
{ key: "status", label: "상태", width: "80px", align: "center" },
|
||||
],
|
||||
formFields: [
|
||||
{ key: "vehicle_code", label: "차량코드", type: "text", required: true, placeholder: "차량코드를 입력해주세요" },
|
||||
{ key: "vehicle_number", label: "차량번호", type: "text", required: true, placeholder: "12가 3456" },
|
||||
{ key: "vehicle_type", label: "차종", type: "select", categoryKey: "carrier_vehicle_mng:vehicle_type", placeholder: "차종을 선택해주세요" },
|
||||
{ key: "carrier_code", label: "운송업체", type: "smartselect", required: true, referenceKey: "carrier" },
|
||||
{ key: "load_capacity_kg", label: "적재용량(kg)", type: "number", placeholder: "0" },
|
||||
{ key: "driver_name", label: "운전자", type: "text", placeholder: "운전자명" },
|
||||
{ key: "status", label: "상태", type: "select", categoryKey: "carrier_vehicle_mng:status", placeholder: "상태를 선택해주세요" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// 카테고리 계층 평탄화
|
||||
function flattenCategories(items: any[]): { value: string; label: string }[] {
|
||||
const result: { value: string; label: string }[] = [];
|
||||
function walk(arr: any[]) {
|
||||
for (const item of arr) {
|
||||
if (item.value || item.name) {
|
||||
result.push({
|
||||
value: item.value || item.name,
|
||||
label: item.label || item.name || item.value,
|
||||
});
|
||||
}
|
||||
if (item.children?.length) walk(item.children);
|
||||
}
|
||||
}
|
||||
walk(items);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ========== 메인 컴포넌트 ==========
|
||||
export default function LogisticsInfoPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
// 탭 상태
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("carrier");
|
||||
|
||||
// 검색 필터
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 탭별 독립 상태
|
||||
const [tabData, setTabData] = useState<Record<TabKey, any[]>>({
|
||||
carrier: [], cost: [], contract: [], route: [], vehicle: [],
|
||||
});
|
||||
const [tabLoading, setTabLoading] = useState<Record<TabKey, boolean>>({
|
||||
carrier: false, cost: false, contract: false, route: false, vehicle: false,
|
||||
});
|
||||
const [tabChecked, setTabChecked] = useState<Record<TabKey, string[]>>({
|
||||
carrier: [], cost: [], contract: [], route: [], vehicle: [],
|
||||
});
|
||||
|
||||
// FK 참조 데이터 (캐싱)
|
||||
const [carrierOptions, setCarrierOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [routeOptions, setRouteOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
// 카테고리 옵션 캐시
|
||||
const [categoryOptions, setCategoryOptions] = useState<Record<string, { value: string; label: string }[]>>({});
|
||||
const loadedCategories = useRef(new Set<string>());
|
||||
|
||||
// 모달 상태
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 테이블 설정 (탭별)
|
||||
const tsCarrier = useTableSettings("c16-logistics-carrier", TAB_CONFIGS[0].tableName, TAB_CONFIGS[0].columns);
|
||||
const tsCost = useTableSettings("c16-logistics-cost", TAB_CONFIGS[1].tableName, TAB_CONFIGS[1].columns);
|
||||
const tsContract = useTableSettings("c16-logistics-contract", TAB_CONFIGS[2].tableName, TAB_CONFIGS[2].columns);
|
||||
const tsRoute = useTableSettings("c16-logistics-route", TAB_CONFIGS[3].tableName, TAB_CONFIGS[3].columns);
|
||||
const tsVehicle = useTableSettings("c16-logistics-vehicle", TAB_CONFIGS[4].tableName, TAB_CONFIGS[4].columns);
|
||||
const tsMap: Record<TabKey, typeof tsCarrier> = { carrier: tsCarrier, cost: tsCost, contract: tsContract, route: tsRoute, vehicle: tsVehicle };
|
||||
const activeTs = tsMap[activeTab];
|
||||
|
||||
const activeConfig = useMemo(
|
||||
() => TAB_CONFIGS.find((c) => c.key === activeTab)!,
|
||||
[activeTab]
|
||||
);
|
||||
|
||||
// 컬럼 가시성 헬퍼
|
||||
const getVisibleColumns = (tabKey: TabKey) => tsMap[tabKey].visibleColumns;
|
||||
|
||||
// 클라이언트 사이드 필터링
|
||||
const filteredData = useMemo(() => {
|
||||
const data = tabData[activeTab];
|
||||
if (searchFilters.length === 0) return data;
|
||||
return data.filter((row) =>
|
||||
searchFilters.every((f) => {
|
||||
if (!f.value) return true;
|
||||
const kw = f.value.toLowerCase();
|
||||
if (f.columnName) {
|
||||
return String(row[f.columnName] ?? "").toLowerCase().includes(kw);
|
||||
}
|
||||
return Object.values(row).some((v) => String(v ?? "").toLowerCase().includes(kw));
|
||||
})
|
||||
);
|
||||
}, [tabData, activeTab, searchFilters]);
|
||||
|
||||
// FK 참조 데이터 로드
|
||||
const loadReferences = useCallback(async () => {
|
||||
try {
|
||||
const [carrierRes, routeRes] = await Promise.all([
|
||||
apiClient.post("/table-management/tables/carrier_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
sort: { columnName: "carrier_code", order: "asc" },
|
||||
}),
|
||||
apiClient.post("/table-management/tables/delivery_route_mng/data", {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
sort: { columnName: "route_code", order: "asc" },
|
||||
}),
|
||||
]);
|
||||
const carriers = carrierRes.data?.data?.data || carrierRes.data?.data?.rows || [];
|
||||
setCarrierOptions(
|
||||
carriers.map((r: any) => ({
|
||||
code: r.carrier_code || "",
|
||||
label: `${r.carrier_code} - ${r.carrier_name || ""}`,
|
||||
}))
|
||||
);
|
||||
const routes = routeRes.data?.data?.data || routeRes.data?.data?.rows || [];
|
||||
setRouteOptions(
|
||||
routes.map((r: any) => ({
|
||||
code: r.route_code || "",
|
||||
label: `${r.route_code} - ${r.route_name || ""}`,
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
// FK 참조 로드 실패 시 무시
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadReferences();
|
||||
}, [loadReferences]);
|
||||
|
||||
// 카테고리 옵션 로드
|
||||
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
|
||||
if (loadedCategories.current.has(tableColumn)) return;
|
||||
loadedCategories.current.add(tableColumn);
|
||||
const [tableName, columnName] = tableColumn.split(":");
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
const data = res.data?.data || [];
|
||||
setCategoryOptions((prev) => ({
|
||||
...prev,
|
||||
[tableColumn]: data.length > 0 ? flattenCategories(data) : [],
|
||||
}));
|
||||
} catch {
|
||||
setCategoryOptions((prev) => ({ ...prev, [tableColumn]: [] }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 활성 탭의 카테고리 로드
|
||||
useEffect(() => {
|
||||
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
|
||||
if (!config) return;
|
||||
config.formFields.forEach((f) => {
|
||||
if (f.categoryKey) loadCategoryOptions(f.categoryKey);
|
||||
});
|
||||
}, [activeTab, loadCategoryOptions]);
|
||||
|
||||
// 데이터 조회
|
||||
const fetchTabData = useCallback(async (tab: TabKey) => {
|
||||
const config = TAB_CONFIGS.find((c) => c.key === tab);
|
||||
if (!config) return;
|
||||
setTabLoading((prev) => ({ ...prev, [tab]: true }));
|
||||
try {
|
||||
const res = await apiClient.post(
|
||||
`/table-management/tables/${config.tableName}/data`,
|
||||
{
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
sort: { columnName: config.defaultSortColumn, order: "asc" },
|
||||
}
|
||||
);
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setTabData((prev) => ({ ...prev, [tab]: rows }));
|
||||
} catch {
|
||||
toast.error("데이터를 불러오는 데 실패했어요.");
|
||||
setTabData((prev) => ({ ...prev, [tab]: [] }));
|
||||
} finally {
|
||||
setTabLoading((prev) => ({ ...prev, [tab]: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기 데이터 로드 (탭 전환 시)
|
||||
useEffect(() => {
|
||||
fetchTabData(activeTab);
|
||||
}, [activeTab, fetchTabData]);
|
||||
|
||||
// 탭 변경
|
||||
const handleTabChange = useCallback((tab: string) => {
|
||||
setActiveTab(tab as TabKey);
|
||||
setSearchFilters([]);
|
||||
}, []);
|
||||
|
||||
// 등록 모달 열기
|
||||
const handleOpenAdd = useCallback(() => {
|
||||
setEditMode(false);
|
||||
setEditId(null);
|
||||
setFormData({});
|
||||
setFormOpen(true);
|
||||
}, []);
|
||||
|
||||
// 수정 모달 열기
|
||||
const handleOpenEdit = useCallback((row: any) => {
|
||||
setEditMode(true);
|
||||
setEditId(row.id ? String(row.id) : null);
|
||||
setFormData({ ...row });
|
||||
setFormOpen(true);
|
||||
}, []);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(async () => {
|
||||
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
|
||||
if (!config) return;
|
||||
|
||||
// 필수값 검증
|
||||
for (const field of config.formFields) {
|
||||
if (field.required && !formData[field.key]?.toString().trim()) {
|
||||
toast.error(`${field.label}은(는) 필수 입력이에요.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (editMode && editId) {
|
||||
await apiClient.put(`/table-management/tables/${config.tableName}/edit`, {
|
||||
originalData: { id: editId },
|
||||
updatedData: formData,
|
||||
});
|
||||
toast.success("수정이 완료되었어요.");
|
||||
} else {
|
||||
await apiClient.post(
|
||||
`/table-management/tables/${config.tableName}/add`,
|
||||
{ id: crypto.randomUUID(), ...formData }
|
||||
);
|
||||
toast.success("등록이 완료되었어요.");
|
||||
}
|
||||
setFormOpen(false);
|
||||
fetchTabData(activeTab);
|
||||
// FK 참조 테이블 변경 시 캐시 갱신
|
||||
if (activeTab === "carrier" || activeTab === "route") {
|
||||
loadReferences();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "저장에 실패했어요.");
|
||||
}
|
||||
}, [activeTab, editMode, editId, formData, fetchTabData, loadReferences]);
|
||||
|
||||
// 삭제
|
||||
const handleDelete = useCallback(async () => {
|
||||
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
|
||||
if (!config) return;
|
||||
const ids = tabChecked[activeTab];
|
||||
if (ids.length === 0) {
|
||||
toast.error("삭제할 항목을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
const ok = await confirm(`선택한 ${ids.length}건을 삭제할까요?`, {
|
||||
description: "삭제된 데이터는 복구할 수 없어요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(
|
||||
`/table-management/tables/${config.tableName}/delete`,
|
||||
{ data: ids.map((id) => ({ id })) }
|
||||
);
|
||||
toast.success(`${ids.length}건이 삭제되었어요.`);
|
||||
setTabChecked((prev) => ({ ...prev, [activeTab]: [] }));
|
||||
fetchTabData(activeTab);
|
||||
if (activeTab === "carrier" || activeTab === "route") {
|
||||
loadReferences();
|
||||
}
|
||||
} catch {
|
||||
toast.error("삭제에 실패했어요.");
|
||||
}
|
||||
}, [activeTab, tabChecked, confirm, fetchTabData, loadReferences]);
|
||||
|
||||
// 엑셀 다운로드 (필터된 데이터 기준)
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
const config = TAB_CONFIGS.find((c) => c.key === activeTab);
|
||||
if (!config) return;
|
||||
if (filteredData.length === 0) {
|
||||
toast.error("다운로드할 데이터가 없어요.");
|
||||
return;
|
||||
}
|
||||
const exportData = filteredData.map((row) => {
|
||||
const obj: Record<string, any> = {};
|
||||
config.columns.forEach((col) => {
|
||||
obj[col.label] = row[col.key] ?? "";
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
await exportToExcel(exportData, `${config.label}.xlsx`, config.label);
|
||||
toast.success("엑셀 다운로드가 완료되었어요.");
|
||||
}, [activeTab, filteredData]);
|
||||
|
||||
// 폼 필드 변경
|
||||
const updateFormField = useCallback((key: string, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
// 행 체크 토글
|
||||
const toggleRowCheck = useCallback((tabKey: TabKey, rowId: string) => {
|
||||
setTabChecked((prev) => {
|
||||
const ids = prev[tabKey];
|
||||
return {
|
||||
...prev,
|
||||
[tabKey]: ids.includes(rowId) ? ids.filter((x) => x !== rowId) : [...ids, rowId],
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 전체 체크 토글
|
||||
const toggleAllCheck = useCallback((tabKey: TabKey, checked: boolean) => {
|
||||
setTabChecked((prev) => ({
|
||||
...prev,
|
||||
[tabKey]: checked ? tabData[tabKey].map((r: any) => String(r.id)) : [],
|
||||
}));
|
||||
}, [tabData]);
|
||||
|
||||
// 폼 필드 렌더
|
||||
const renderFormField = useCallback(
|
||||
(field: FormFieldDef) => {
|
||||
const value = formData[field.key] ?? "";
|
||||
// 수정 모드에서 코드/번호 필드는 읽기전용
|
||||
const isCodeField =
|
||||
editMode &&
|
||||
field.type === "text" &&
|
||||
(field.key.endsWith("_code") || field.key.endsWith("_no"));
|
||||
|
||||
switch (field.type) {
|
||||
case "text":
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => updateFormField(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
readOnly={isCodeField}
|
||||
className={cn(
|
||||
"h-9 text-sm",
|
||||
isCodeField && "bg-muted text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => updateFormField(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
);
|
||||
case "select": {
|
||||
const opts =
|
||||
field.options ||
|
||||
(field.categoryKey ? categoryOptions[field.categoryKey] : []) ||
|
||||
[];
|
||||
return (
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={(v) => updateFormField(field.key, v)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder={field.placeholder || "선택해주세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{opts.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
case "smartselect": {
|
||||
// SmartSelect 대신 Select로 직접 구현
|
||||
const opts =
|
||||
field.referenceKey === "carrier" ? carrierOptions : routeOptions;
|
||||
return (
|
||||
<Select
|
||||
value={String(value)}
|
||||
onValueChange={(v) => updateFormField(field.key, v)}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder={field.placeholder || "선택해주세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{opts.map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={value ? String(value).split("T")[0] : ""}
|
||||
onChange={(e) => updateFormField(field.key, e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[formData, editMode, carrierOptions, routeOptions, categoryOptions, updateFormField]
|
||||
);
|
||||
|
||||
// ========== 렌더링 ==========
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3">
|
||||
{/* 검색 필터 바 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={activeConfig.tableName}
|
||||
filterId="c16-logistics-info"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={activeTs.filterConfig}
|
||||
dataCount={filteredData.length}
|
||||
/>
|
||||
|
||||
{/* 탭 + 콘텐츠 영역 */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border bg-card"
|
||||
>
|
||||
<TabsList className="h-auto w-full shrink-0 justify-start gap-0 rounded-none border-b bg-muted/30 p-0">
|
||||
{TAB_CONFIGS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className="flex items-center gap-1.5 rounded-none border-b-2 border-transparent px-4 py-2.5 text-sm font-medium text-muted-foreground data-[state=active]:border-primary data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 h-5 min-w-[22px] justify-center px-1.5 font-mono text-[10px]"
|
||||
>
|
||||
{tabData[tab.key]?.length || 0}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{TAB_CONFIGS.map((tab) => {
|
||||
const displayData = tab.key === activeTab ? filteredData : tabData[tab.key];
|
||||
const isAllChecked =
|
||||
tabData[tab.key].length > 0 &&
|
||||
tabData[tab.key].every((r: any) => tabChecked[tab.key].includes(String(r.id)));
|
||||
|
||||
return (
|
||||
<TabsContent
|
||||
key={tab.key}
|
||||
value={tab.key}
|
||||
className="m-0 flex flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"
|
||||
>
|
||||
{/* 액션 바 */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-bold">{tab.label} 목록</h2>
|
||||
<Badge className="bg-primary/10 font-mono text-[11px] text-primary">
|
||||
{tab.key === activeTab ? displayData.length : tabData[tab.key]?.length || 0}건
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" className="h-8 text-xs" onClick={handleOpenAdd}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
등록
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
disabled={tabChecked[tab.key].length !== 1}
|
||||
onClick={() => {
|
||||
const row = tabData[tab.key].find(
|
||||
(r: any) => String(r.id) === tabChecked[tab.key][0]
|
||||
);
|
||||
if (row) handleOpenEdit(row);
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs text-destructive hover:bg-destructive/10"
|
||||
disabled={tabChecked[tab.key].length === 0}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
삭제
|
||||
</Button>
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={handleExcelDownload}
|
||||
>
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
엑셀
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => fetchTabData(tab.key)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
tabLoading[tab.key] && "animate-spin"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => activeTs.setOpen(true)}
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{tabLoading[tab.key] ? (
|
||||
<div className="flex h-40 items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">불러오는 중...</span>
|
||||
</div>
|
||||
) : displayData.length === 0 ? (
|
||||
<div className="flex h-40 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted/50">
|
||||
<Inbox className="h-6 w-6 opacity-40" />
|
||||
</div>
|
||||
<span className="text-sm">등록된 {tab.label} 정보가 없어요</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
등록 버튼을 눌러 새 항목을 추가해주세요
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] p-2">
|
||||
<Checkbox
|
||||
checked={isAllChecked}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAllCheck(tab.key, !!checked)
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
{getVisibleColumns(tab.key).map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"p-2 text-[11px] font-semibold uppercase tracking-wide",
|
||||
col.align === "right" && "text-right",
|
||||
col.align === "center" && "text-center"
|
||||
)}
|
||||
style={
|
||||
col.width
|
||||
? { width: col.width, minWidth: col.width }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{displayData.map((row: any, idx: number) => {
|
||||
const rowId = String(row.id);
|
||||
const isChecked = tabChecked[tab.key].includes(rowId);
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id ?? idx}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors hover:bg-accent/50",
|
||||
isChecked && "bg-primary/5 hover:bg-primary/10"
|
||||
)}
|
||||
onClick={() => toggleRowCheck(tab.key, rowId)}
|
||||
onDoubleClick={() => handleOpenEdit(row)}
|
||||
>
|
||||
<TableCell className="w-[40px] p-2">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleRowCheck(tab.key, rowId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
{getVisibleColumns(tab.key).map((col) => {
|
||||
const val = row[col.key];
|
||||
const display =
|
||||
col.formatNumber && val != null && val !== ""
|
||||
? Number(val).toLocaleString()
|
||||
: val ?? "";
|
||||
return (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"max-w-[240px] truncate p-2 text-sm",
|
||||
col.align === "right" && "text-right",
|
||||
col.align === "center" && "text-center"
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[680px]">
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
||||
<DialogTitle>
|
||||
{activeConfig.label} {editMode ? "수정" : "등록"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editMode
|
||||
? `${activeConfig.label} 정보를 수정해주세요.`
|
||||
: `새 ${activeConfig.label} 정보를 입력해주세요.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{activeConfig.formFields.map((field) => (
|
||||
<div key={field.key} className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="ml-0.5 text-destructive">*</span>
|
||||
)}
|
||||
</Label>
|
||||
{renderFormField(field)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 border-t">
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-3">
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{editMode ? "수정" : "등록"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
<TableSettingsModal
|
||||
open={activeTs.open}
|
||||
onOpenChange={activeTs.setOpen}
|
||||
tableName={activeTs.tableName}
|
||||
settingsId={activeTs.settingsId}
|
||||
defaultVisibleKeys={activeTs.defaultVisibleKeys}
|
||||
onSave={activeTs.applySettings}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 재고현황 — 하드코딩 페이지 (Type B 마스터-디테일)
|
||||
*
|
||||
* 좌측: 재고 목록 (inventory_stock, item_info JOIN)
|
||||
* 우측: 선택 품목의 재고 이동 이력 (inventory_history)
|
||||
*
|
||||
* ★ 재고 직접 등록/삭제 불가 — 입출고를 통해서만 변동, 조정만 가능
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import {
|
||||
Package,
|
||||
Loader2,
|
||||
Download,
|
||||
ClipboardEdit,
|
||||
History,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { toast } from "sonner";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
const STOCK_TABLE = "inventory_stock";
|
||||
|
||||
const STOCK_COLUMNS = [
|
||||
{ key: "item_number", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "warehouse_name", label: "창고" },
|
||||
{ key: "location_name", label: "위치" },
|
||||
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
||||
{ key: "safety_qty", label: "안전재고", align: "right" as const },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
const HISTORY_TABLE = "inventory_history";
|
||||
|
||||
const getStatusVariant = (
|
||||
status: string
|
||||
): "default" | "secondary" | "outline" | "destructive" => {
|
||||
switch (status) {
|
||||
case "정상":
|
||||
return "default";
|
||||
case "부족":
|
||||
return "destructive";
|
||||
case "과잉":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
const getHistoryTypeVariant = (
|
||||
type: string
|
||||
): "default" | "secondary" | "outline" | "destructive" => {
|
||||
switch (type) {
|
||||
case "입고":
|
||||
return "default";
|
||||
case "출고":
|
||||
return "secondary";
|
||||
case "조정":
|
||||
return "outline";
|
||||
case "이동":
|
||||
return "destructive";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
export default function InventoryStatusPage() {
|
||||
const { user } = useAuth();
|
||||
const ts = useTableSettings("c16-inventory", STOCK_TABLE, STOCK_COLUMNS);
|
||||
|
||||
// 좌측: 재고 목록
|
||||
const [stockItems, setStockItems] = useState<any[]>([]);
|
||||
const [stockLoading, setStockLoading] = useState(false);
|
||||
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||
|
||||
// 검색 필터
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 우측: 이동 이력
|
||||
const [historyItems, setHistoryItems] = useState<any[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
|
||||
// 조정 모달
|
||||
const [adjustModalOpen, setAdjustModalOpen] = useState(false);
|
||||
const [adjustForm, setAdjustForm] = useState<{
|
||||
adjust_type: string;
|
||||
adjust_qty: string;
|
||||
reason: string;
|
||||
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
const [adjustSaving, setAdjustSaving] = useState(false);
|
||||
|
||||
// 카테고리 옵션
|
||||
const [categoryOptions, setCategoryOptions] = useState<
|
||||
Record<string, { code: string; label: string }[]>
|
||||
>({});
|
||||
|
||||
// 카테고리 로드
|
||||
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 ["status", "unit"]) {
|
||||
try {
|
||||
const res = await apiClient.get(
|
||||
`/table-categories/${STOCK_TABLE}/${col}/values`
|
||||
);
|
||||
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
setCategoryOptions(optMap);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
// 재고 목록 조회
|
||||
const fetchStock = useCallback(async () => {
|
||||
setStockLoading(true);
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(
|
||||
`/table-management/tables/${STOCK_TABLE}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "item_number", order: "asc" },
|
||||
}
|
||||
);
|
||||
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) => ({
|
||||
...r,
|
||||
status: resolve("status", r.status),
|
||||
unit: resolve("unit", r.unit),
|
||||
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
||||
}));
|
||||
setStockItems(data);
|
||||
} catch {
|
||||
toast.error("재고 목록을 불러오지 못했어요");
|
||||
} finally {
|
||||
setStockLoading(false);
|
||||
}
|
||||
}, [categoryOptions, searchFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStock();
|
||||
}, [fetchStock]);
|
||||
|
||||
// 선택된 재고
|
||||
const selectedStock = stockItems.find((s) => s.id === selectedStockId);
|
||||
|
||||
// 이력 조회
|
||||
const fetchHistory = useCallback(async () => {
|
||||
if (!selectedStock?.item_number) {
|
||||
setHistoryItems([]);
|
||||
return;
|
||||
}
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const historyFilters: any[] = [
|
||||
{
|
||||
columnName: "item_number",
|
||||
operator: "equals",
|
||||
value: selectedStock.item_number,
|
||||
},
|
||||
];
|
||||
if (selectedStock.warehouse_code) {
|
||||
historyFilters.push({
|
||||
columnName: "warehouse_code",
|
||||
operator: "equals",
|
||||
value: selectedStock.warehouse_code,
|
||||
});
|
||||
}
|
||||
const res = await apiClient.post(
|
||||
`/table-management/tables/${HISTORY_TABLE}/data`,
|
||||
{
|
||||
page: 1,
|
||||
size: 500,
|
||||
dataFilter: { enabled: true, filters: historyFilters },
|
||||
autoFilter: true,
|
||||
sort: { columnName: "history_date", order: "desc" },
|
||||
}
|
||||
);
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setHistoryItems(raw);
|
||||
} catch {
|
||||
toast.error("재고 이력을 불러오지 못했어요");
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}, [selectedStock?.item_number, selectedStock?.warehouse_code]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHistory();
|
||||
}, [fetchHistory]);
|
||||
|
||||
// 재고 조정 저장
|
||||
const handleAdjustSave = async () => {
|
||||
if (!selectedStock) return;
|
||||
const qty = Number(adjustForm.adjust_qty);
|
||||
if (!qty || qty <= 0) {
|
||||
toast.error("조정 수량을 입력해주세요");
|
||||
return;
|
||||
}
|
||||
if (!adjustForm.reason.trim()) {
|
||||
toast.error("조정 사유를 입력해주세요");
|
||||
return;
|
||||
}
|
||||
setAdjustSaving(true);
|
||||
try {
|
||||
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
|
||||
const afterQty = Number(selectedStock.current_qty || 0) + changeQty;
|
||||
|
||||
await apiClient.post(
|
||||
`/table-management/tables/${HISTORY_TABLE}/add`,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
item_number: selectedStock.item_number,
|
||||
warehouse_code: selectedStock.warehouse_code || "",
|
||||
location_code: selectedStock.location_code || "",
|
||||
history_type: "조정",
|
||||
history_date: new Date().toISOString().slice(0, 10),
|
||||
change_qty: changeQty,
|
||||
after_qty: afterQty,
|
||||
reason: adjustForm.reason.trim(),
|
||||
created_by: user?.userId || "",
|
||||
}
|
||||
);
|
||||
|
||||
await apiClient.put(
|
||||
`/table-management/tables/${STOCK_TABLE}/edit`,
|
||||
{
|
||||
originalData: { id: selectedStock.id },
|
||||
updatedData: { current_qty: afterQty },
|
||||
}
|
||||
);
|
||||
|
||||
toast.success("재고가 조정되었어요");
|
||||
setAdjustModalOpen(false);
|
||||
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
||||
fetchStock();
|
||||
} catch {
|
||||
toast.error("재고 조정에 실패했어요");
|
||||
} finally {
|
||||
setAdjustSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 엑셀 내보내기
|
||||
const handleExcelExport = () => {
|
||||
if (stockItems.length === 0) {
|
||||
toast.error("내보낼 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
exportToExcel(
|
||||
stockItems.map((r) => ({
|
||||
품목코드: r.item_number,
|
||||
품명: r.item_name,
|
||||
창고: r.warehouse_name,
|
||||
위치: r.location_name,
|
||||
현재수량: r.current_qty,
|
||||
안전재고: r.safety_qty,
|
||||
단위: r.unit,
|
||||
상태: r.status,
|
||||
})),
|
||||
"재고현황"
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3 p-3">
|
||||
{/* 검색 바 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={STOCK_TABLE}
|
||||
filterId="c16-inventory"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={stockItems.length}
|
||||
extraActions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs"
|
||||
onClick={handleExcelExport}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
엑셀
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 마스터-디테일 패널 */}
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="flex-1 rounded-lg border bg-card"
|
||||
>
|
||||
{/* 좌측: 재고 목록 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-bold">재고 목록</span>
|
||||
<Badge variant="default" className="rounded-full text-[11px]">
|
||||
{stockItems.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{stockLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : stockItems.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
|
||||
등록된 재고가 없어요
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-8 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={cn(col.align === "right" && "text-right")}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{stockItems.map((item, idx) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs",
|
||||
selectedStockId === item.id && "bg-primary/10",
|
||||
item._isLow && "bg-destructive/5"
|
||||
)}
|
||||
onClick={() => setSelectedStockId(item.id)}
|
||||
>
|
||||
<TableCell className="text-center text-muted-foreground">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
if (col.key === "current_qty") {
|
||||
return (
|
||||
<TableCell key={col.key} className="text-right font-mono">
|
||||
<span className={cn(item._isLow && "text-destructive font-bold")}>
|
||||
{Number(item.current_qty || 0).toLocaleString()}
|
||||
</span>
|
||||
{item._isLow && (
|
||||
<AlertTriangle className="inline h-3 w-3 text-destructive ml-1" />
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (col.key === "safety_qty") {
|
||||
return (
|
||||
<TableCell key={col.key} className="text-right font-mono">
|
||||
{Number(item.safety_qty || 0).toLocaleString()}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (col.key === "status") {
|
||||
return (
|
||||
<TableCell key={col.key}>
|
||||
<Badge variant={getStatusVariant(item.status)} className="text-[10px]">
|
||||
{item.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TableCell key={col.key} className="truncate max-w-[150px]">
|
||||
{item[col.key] ?? ""}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 상세 이력 */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
{!selectedStock ? (
|
||||
<div className="flex flex-col items-center justify-center flex-1 m-5 border-2 border-dashed rounded-lg border-border">
|
||||
<Package className="h-12 w-12 text-muted-foreground/40 mb-4" />
|
||||
<p className="text-sm font-semibold text-muted-foreground">
|
||||
품목을 선택해주세요
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||
좌측에서 품목을 선택하면 재고 이력이 표시돼요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-[13px] font-bold">
|
||||
{selectedStock.item_name || selectedStock.item_number}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="rounded-full text-[11px] font-mono"
|
||||
>
|
||||
{selectedStock.item_number}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">현재:</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-bold font-mono",
|
||||
selectedStock._isLow
|
||||
? "text-destructive"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{Number(selectedStock.current_qty || 0).toLocaleString()}
|
||||
</span>
|
||||
{selectedStock._isLow && (
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={() => {
|
||||
setAdjustForm({
|
||||
adjust_type: "증가",
|
||||
adjust_qty: "",
|
||||
reason: "",
|
||||
});
|
||||
setAdjustModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<ClipboardEdit className="h-3.5 w-3.5" />
|
||||
재고 조정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 재고 요약 카드 */}
|
||||
<div className="grid grid-cols-4 gap-2 px-4 py-3 border-b">
|
||||
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
|
||||
<span className="text-[10px] text-muted-foreground">현재수량</span>
|
||||
<span className="text-sm font-bold font-mono">
|
||||
{Number(selectedStock.current_qty || 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
|
||||
<span className="text-[10px] text-muted-foreground">안전재고</span>
|
||||
<span className="text-sm font-bold font-mono">
|
||||
{Number(selectedStock.safety_qty || 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
|
||||
<span className="text-[10px] text-muted-foreground">창고</span>
|
||||
<span className="text-sm font-bold truncate max-w-full">
|
||||
{selectedStock.warehouse_name || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
|
||||
<span className="text-[10px] text-muted-foreground">상태</span>
|
||||
<Badge
|
||||
variant={getStatusVariant(selectedStock.status)}
|
||||
className="text-[10px] mt-0.5"
|
||||
>
|
||||
{selectedStock.status || "-"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이력 서브헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
재고 이동 이력
|
||||
</span>
|
||||
<Badge variant="secondary" className="rounded-full text-[10px]">
|
||||
{historyItems.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs"
|
||||
onClick={fetchHistory}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 이력 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{historyLoading ? (
|
||||
<div className="flex items-center justify-center h-20">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : historyItems.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-20 text-xs text-muted-foreground">
|
||||
재고 이동 이력이 없어요
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-8 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">일자</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">유형</TableHead>
|
||||
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">변동수량</TableHead>
|
||||
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이후수량</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">참조번호</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사유</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">처리자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{historyItems.map((h, idx) => (
|
||||
<TableRow key={h.id || idx} className="text-xs">
|
||||
<TableCell className="text-center text-muted-foreground">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">
|
||||
{h.history_date}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={getHistoryTypeVariant(h.history_type)}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{h.history_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-right font-mono",
|
||||
Number(h.change_qty) > 0
|
||||
? "text-primary"
|
||||
: "text-destructive"
|
||||
)}
|
||||
>
|
||||
{Number(h.change_qty) > 0 ? "+" : ""}
|
||||
{Number(h.change_qty || 0).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono">
|
||||
{Number(h.after_qty || 0).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono truncate max-w-[120px]">
|
||||
{h.reference_no}
|
||||
</TableCell>
|
||||
<TableCell className="truncate max-w-[150px]">
|
||||
{h.reason}
|
||||
</TableCell>
|
||||
<TableCell>{h.created_by}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{/* 재고 조정 Dialog */}
|
||||
<Dialog open={adjustModalOpen} onOpenChange={setAdjustModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>재고 조정</DialogTitle>
|
||||
<DialogDescription>
|
||||
{selectedStock
|
||||
? `${selectedStock.item_name || selectedStock.item_number} — 현재 수량: ${Number(selectedStock.current_qty || 0).toLocaleString()}`
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 유형
|
||||
</Label>
|
||||
<Select
|
||||
value={adjustForm.adjust_type}
|
||||
onValueChange={(v) =>
|
||||
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="조정 유형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="증가">증가 (입고 보정)</SelectItem>
|
||||
<SelectItem value="감소">감소 (출고 보정)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 수량
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="수량을 입력해주세요"
|
||||
value={adjustForm.adjust_qty}
|
||||
onChange={(e) =>
|
||||
setAdjustForm((prev) => ({
|
||||
...prev,
|
||||
adjust_qty: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{adjustForm.adjust_qty && selectedStock && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
조정 후 수량:{" "}
|
||||
<span className="font-mono font-bold">
|
||||
{(
|
||||
Number(selectedStock.current_qty || 0) +
|
||||
(adjustForm.adjust_type === "증가" ? 1 : -1) *
|
||||
Number(adjustForm.adjust_qty || 0)
|
||||
).toLocaleString()}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
조정 사유 *
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder="조정 사유를 입력해주세요"
|
||||
rows={3}
|
||||
value={adjustForm.reason}
|
||||
onChange={(e) =>
|
||||
setAdjustForm((prev) => ({
|
||||
...prev,
|
||||
reason: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAdjustModalOpen(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleAdjustSave} disabled={adjustSaving}>
|
||||
{adjustSaving && (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
||||
)}
|
||||
조정하기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
// Card 제거 — rounded-lg border bg-card 패턴 사용
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import {
|
||||
Search,
|
||||
RotateCcw,
|
||||
Package,
|
||||
ClipboardList,
|
||||
Factory,
|
||||
MapPin,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Inbox,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getWorkOrders,
|
||||
getMaterialStatus,
|
||||
getWarehouses,
|
||||
type WorkOrder,
|
||||
type MaterialData,
|
||||
type WarehouseData,
|
||||
} from "@/lib/api/materialStatus";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "plan_no", label: "계획번호" },
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품목명" },
|
||||
{ key: "plan_qty", label: "수량" },
|
||||
{ key: "plan_date", label: "일자" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
return map[status] || status;
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
return map[status] || "bg-muted text-muted-foreground border-border";
|
||||
};
|
||||
|
||||
export default function MaterialStatusPage() {
|
||||
const ts = useTableSettings("c16-material-status", "work_instruction", GRID_COLUMNS);
|
||||
const today = new Date();
|
||||
const monthAgo = new Date(today);
|
||||
monthAgo.setMonth(today.getMonth() - 1);
|
||||
|
||||
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(monthAgo));
|
||||
const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
|
||||
const [searchItemCode, setSearchItemCode] = useState("");
|
||||
const [searchItemName, setSearchItemName] = useState("");
|
||||
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
|
||||
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
|
||||
const [selectedWoId, setSelectedWoId] = useState<string | null>(null);
|
||||
|
||||
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
|
||||
const [warehouse, setWarehouse] = useState("");
|
||||
const [materialSearch, setMaterialSearch] = useState("");
|
||||
const [showShortageOnly, setShowShortageOnly] = useState(false);
|
||||
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
||||
const [materialsLoading, setMaterialsLoading] = useState(false);
|
||||
|
||||
// 창고 목록 초기 로드
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await getWarehouses();
|
||||
if (res.success && res.data) {
|
||||
setWarehouses(res.data);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// 작업지시 검색
|
||||
const handleSearch = useCallback(async () => {
|
||||
setWorkOrdersLoading(true);
|
||||
try {
|
||||
const res = await getWorkOrders({
|
||||
dateFrom: searchDateFrom,
|
||||
dateTo: searchDateTo,
|
||||
itemCode: searchItemCode || undefined,
|
||||
itemName: searchItemName || undefined,
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
setWorkOrders(res.data);
|
||||
setCheckedWoIds([]);
|
||||
setSelectedWoId(null);
|
||||
setMaterials([]);
|
||||
}
|
||||
} finally {
|
||||
setWorkOrdersLoading(false);
|
||||
}
|
||||
}, [searchDateFrom, searchDateTo, searchItemCode, searchItemName]);
|
||||
|
||||
// 초기 로드
|
||||
useEffect(() => {
|
||||
handleSearch();
|
||||
}, []);
|
||||
|
||||
const isAllChecked =
|
||||
workOrders.length > 0 && checkedWoIds.length === workOrders.length;
|
||||
|
||||
const handleCheckAll = useCallback(
|
||||
(checked: boolean) => {
|
||||
setCheckedWoIds(checked ? workOrders.map((wo) => wo.id) : []);
|
||||
},
|
||||
[workOrders]
|
||||
);
|
||||
|
||||
const handleCheckWo = useCallback((id: string, checked: boolean) => {
|
||||
setCheckedWoIds((prev) =>
|
||||
checked ? [...prev, id] : prev.filter((i) => i !== id)
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSelectWo = useCallback((id: string) => {
|
||||
setSelectedWoId((prev) => (prev === id ? null : id));
|
||||
}, []);
|
||||
|
||||
// 선택된 작업지시의 자재 조회
|
||||
const handleLoadSelectedMaterials = useCallback(async () => {
|
||||
if (checkedWoIds.length === 0) {
|
||||
alert("자재를 조회할 작업지시를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setMaterialsLoading(true);
|
||||
try {
|
||||
const res = await getMaterialStatus({
|
||||
planIds: checkedWoIds,
|
||||
warehouseCode: warehouse || undefined,
|
||||
});
|
||||
if (res.success && res.data) {
|
||||
setMaterials(res.data);
|
||||
}
|
||||
} finally {
|
||||
setMaterialsLoading(false);
|
||||
}
|
||||
}, [checkedWoIds, warehouse]);
|
||||
|
||||
const handleResetSearch = useCallback(() => {
|
||||
const t = new Date();
|
||||
const m = new Date(t);
|
||||
m.setMonth(t.getMonth() - 1);
|
||||
setSearchDateFrom(formatDate(m));
|
||||
setSearchDateTo(formatDate(t));
|
||||
setSearchItemCode("");
|
||||
setSearchItemName("");
|
||||
setMaterialSearch("");
|
||||
setShowShortageOnly(false);
|
||||
}, []);
|
||||
|
||||
const filteredMaterials = useMemo(() => {
|
||||
return materials.filter((m) => {
|
||||
const searchLower = materialSearch.toLowerCase();
|
||||
const matchesSearch =
|
||||
!materialSearch ||
|
||||
m.code.toLowerCase().includes(searchLower) ||
|
||||
m.name.toLowerCase().includes(searchLower);
|
||||
const matchesShortage = !showShortageOnly || m.current < m.required;
|
||||
return matchesSearch && matchesShortage;
|
||||
});
|
||||
}, [materials, materialSearch, showShortageOnly]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3">
|
||||
{/* 검색 영역 */}
|
||||
<div className="shrink-0 flex flex-wrap items-end gap-3 rounded-lg border bg-card p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">기간</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-[140px]"
|
||||
value={searchDateFrom}
|
||||
onChange={(e) => setSearchDateFrom(e.target.value)}
|
||||
/>
|
||||
<span className="text-muted-foreground/50 text-xs">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9 w-[140px]"
|
||||
value={searchDateTo}
|
||||
onChange={(e) => setSearchDateTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">품목코드</span>
|
||||
<Input
|
||||
placeholder="품목코드"
|
||||
className="h-9 w-[140px]"
|
||||
value={searchItemCode}
|
||||
onChange={(e) => setSearchItemCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">품목명</span>
|
||||
<Input
|
||||
placeholder="품목명"
|
||||
className="h-9 w-[140px]"
|
||||
value={searchItemName}
|
||||
onChange={(e) => setSearchItemName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={handleResetSearch}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={handleSearch}
|
||||
disabled={workOrdersLoading}
|
||||
>
|
||||
{workOrdersLoading ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 (좌우 분할) */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 왼쪽: 작업지시 리스트 */}
|
||||
<ResizablePanel defaultSize={35} minSize={25}>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-2.5 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isAllChecked}
|
||||
onCheckedChange={handleCheckAll}
|
||||
/>
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">작업지시 리스트</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
|
||||
{workOrders.length}건
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={handleLoadSelectedMaterials}
|
||||
disabled={materialsLoading}
|
||||
>
|
||||
{materialsLoading ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Search className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
자재조회
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 작업지시 목록 */}
|
||||
<div className="flex-1 space-y-2 overflow-auto p-3">
|
||||
{workOrdersLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
작업지시를 조회하고 있어요...
|
||||
</p>
|
||||
</div>
|
||||
) : workOrders.length === 0 ? (
|
||||
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
|
||||
<Inbox className="h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
조회된 작업지시가 없어요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
workOrders.map((wo) => (
|
||||
<div
|
||||
key={wo.id}
|
||||
className={cn(
|
||||
"flex gap-3 rounded-lg border p-3 transition-all cursor-pointer",
|
||||
"hover:border-primary/50 hover:shadow-sm",
|
||||
selectedWoId === wo.id
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-border"
|
||||
)}
|
||||
onClick={() => handleSelectWo(wo.id)}
|
||||
>
|
||||
<div
|
||||
className="flex items-start pt-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checkedWoIds.includes(wo.id)}
|
||||
onCheckedChange={(c) =>
|
||||
handleCheckWo(wo.id, c as boolean)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<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/30 px-3 py-2.5 shrink-0">
|
||||
<Factory className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">원자재 재고 현황</span>
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-3 border-b bg-muted/30 px-4 py-2.5 shrink-0">
|
||||
<Input
|
||||
placeholder="원자재 검색"
|
||||
className="h-8 min-w-[150px] flex-1 text-xs"
|
||||
value={materialSearch}
|
||||
onChange={(e) => setMaterialSearch(e.target.value)}
|
||||
/>
|
||||
<Select value={warehouse} onValueChange={setWarehouse}>
|
||||
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||
<SelectValue placeholder="전체 창고" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">전체 창고</SelectItem>
|
||||
{warehouses.map((wh) => (
|
||||
<SelectItem
|
||||
key={wh.warehouse_code}
|
||||
value={wh.warehouse_code}
|
||||
>
|
||||
{wh.warehouse_name}
|
||||
{wh.warehouse_type
|
||||
? ` (${wh.warehouse_type})`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium">
|
||||
<Checkbox
|
||||
checked={showShortageOnly}
|
||||
onCheckedChange={(c) => setShowShortageOnly(c as boolean)}
|
||||
/>
|
||||
<span>부족한 것만 보기</span>
|
||||
</label>
|
||||
<span className="ml-auto rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
|
||||
{filteredMaterials.length}개 품목
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 원자재 목록 */}
|
||||
<div className="flex-1 space-y-2 overflow-auto p-3">
|
||||
{materialsLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
자재현황을 조회하고 있어요...
|
||||
</p>
|
||||
</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
|
||||
<Inbox className="h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
작업지시를 선택하고 자재조회 버튼을 클릭해주세요
|
||||
</p>
|
||||
</div>
|
||||
) : filteredMaterials.length === 0 ? (
|
||||
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
|
||||
<Package className="h-10 w-10 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
조회된 원자재가 없어요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredMaterials.map((material) => {
|
||||
const shortage = material.required - material.current;
|
||||
const isShortage = shortage > 0;
|
||||
const percentage =
|
||||
material.required > 0
|
||||
? Math.min(
|
||||
(material.current / material.required) * 100,
|
||||
100
|
||||
)
|
||||
: 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={material.code}
|
||||
className={cn(
|
||||
"rounded-lg border p-3 transition-all hover:shadow-sm",
|
||||
isShortage
|
||||
? "border-destructive/30 bg-destructive/5"
|
||||
: "border-primary/15 bg-primary/5"
|
||||
)}
|
||||
>
|
||||
{/* 메인 정보 라인 */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-bold">
|
||||
{material.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({material.code})
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
필요:
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-primary">
|
||||
{material.required.toLocaleString()}
|
||||
{material.unit}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
현재:
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-semibold",
|
||||
isShortage
|
||||
? "text-destructive"
|
||||
: "text-foreground"
|
||||
)}
|
||||
>
|
||||
{material.current.toLocaleString()}
|
||||
{material.unit}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isShortage ? "부족:" : "여유:"}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-semibold",
|
||||
isShortage
|
||||
? "text-destructive"
|
||||
: "text-primary"
|
||||
)}
|
||||
>
|
||||
{Math.abs(shortage).toLocaleString()}
|
||||
{material.unit}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
({percentage.toFixed(0)}%)
|
||||
</span>
|
||||
|
||||
{isShortage ? (
|
||||
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-destructive bg-destructive/10 px-2 py-0.5 text-[11px] font-semibold text-destructive">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
부족
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-primary bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
충분
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 위치별 재고 */}
|
||||
{material.locations.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{material.locations.map((loc, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
|
||||
>
|
||||
<span className="font-semibold font-mono text-primary">
|
||||
{loc.location || loc.warehouse}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
{loc.qty.toLocaleString()}
|
||||
{material.unit}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,867 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 회사관리 — Type D 탭 멀티뷰 (2탭)
|
||||
*
|
||||
* Tab 1: 회사정보 (company_mng 단일 레코드 폼)
|
||||
* Tab 2: 부서관리 (dept_info 트리 + user_info 목록)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } 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 { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import {
|
||||
Building2, Users, Pencil, Save, Loader2, Plus, Trash2,
|
||||
Upload, X, Image as ImageIcon, ChevronRight, FolderOpen, Folder,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import * as departmentAPI from "@/lib/api/department";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
|
||||
|
||||
const COMPANY_TABLE = "company_mng";
|
||||
const DEPT_TABLE = "dept_info";
|
||||
const USER_TABLE = "user_info";
|
||||
|
||||
/* ── 트리 노드 타입 ── */
|
||||
interface DeptNode {
|
||||
dept_code: string;
|
||||
dept_name: string;
|
||||
parent_dept_code: string | null;
|
||||
status?: string;
|
||||
children: DeptNode[];
|
||||
}
|
||||
|
||||
export default function CompanyPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
/* ===================== Tab 1: 회사정보 ===================== */
|
||||
const [companyData, setCompanyData] = useState<Record<string, any>>({});
|
||||
const [companyForm, setCompanyForm] = useState<Record<string, any>>({});
|
||||
const [companyLoading, setCompanyLoading] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 이미지 업로드 refs
|
||||
const imageRef = useRef<HTMLInputElement>(null);
|
||||
const logoRef = useRef<HTMLInputElement>(null);
|
||||
const sealRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 이미지 미리보기
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null);
|
||||
const [sealPreview, setSealPreview] = useState<string | null>(null);
|
||||
|
||||
const fetchCompany = useCallback(async () => {
|
||||
setCompanyLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${COMPANY_TABLE}/data`, {
|
||||
page: 1, size: 1, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
if (rows.length > 0) {
|
||||
setCompanyData(rows[0]);
|
||||
setCompanyForm(rows[0]);
|
||||
if (rows[0].company_image) setImagePreview(rows[0].company_image);
|
||||
if (rows[0].company_logo) setLogoPreview(rows[0].company_logo);
|
||||
if (rows[0].company_seal) setSealPreview(rows[0].company_seal);
|
||||
}
|
||||
} catch {
|
||||
toast.error("회사 정보를 불러오는데 실패했어요.");
|
||||
} finally {
|
||||
setCompanyLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchCompany(); }, [fetchCompany]);
|
||||
|
||||
const handleImageUpload = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
field: string,
|
||||
setPreview: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
setPreview(result);
|
||||
setCompanyForm((prev) => ({ ...prev, [field]: result }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleCompanySave = async () => {
|
||||
if (!companyForm.company_name) {
|
||||
toast.error("회사명은 필수예요.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const { id, created_at, updated_at, writer, created_date, updated_date, regdate, company_code, ...updatedData } = companyForm;
|
||||
|
||||
if (companyData.company_code) {
|
||||
await apiClient.put(`/table-management/tables/${COMPANY_TABLE}/edit`, {
|
||||
originalData: { company_code: companyData.company_code },
|
||||
updatedData,
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${COMPANY_TABLE}/add`, { company_code, ...updatedData });
|
||||
}
|
||||
toast.success("회사 정보가 저장되었어요.");
|
||||
setEditMode(false);
|
||||
fetchCompany();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || "저장에 실패했어요.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setCompanyForm(companyData);
|
||||
setImagePreview(companyData.company_image || null);
|
||||
setLogoPreview(companyData.company_logo || null);
|
||||
setSealPreview(companyData.company_seal || null);
|
||||
setEditMode(false);
|
||||
};
|
||||
|
||||
/* ===================== Tab 2: 부서관리 ===================== */
|
||||
const [depts, setDepts] = useState<any[]>([]);
|
||||
const [deptTree, setDeptTree] = useState<DeptNode[]>([]);
|
||||
const [deptLoading, setDeptLoading] = useState(false);
|
||||
const [selectedDeptCode, setSelectedDeptCode] = useState<string | null>(null);
|
||||
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
|
||||
|
||||
// 사원
|
||||
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 [deptSaving, setDeptSaving] = useState(false);
|
||||
|
||||
// 채번
|
||||
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
// 사원 모달
|
||||
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 buildTree = (flatDepts: any[]): DeptNode[] => {
|
||||
const map: Record<string, DeptNode> = {};
|
||||
const roots: DeptNode[] = [];
|
||||
flatDepts.forEach((d) => {
|
||||
map[d.dept_code] = { ...d, children: [] };
|
||||
});
|
||||
flatDepts.forEach((d) => {
|
||||
const node = map[d.dept_code];
|
||||
if (d.parent_dept_code && map[d.parent_dept_code]) {
|
||||
map[d.parent_dept_code].children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
return roots;
|
||||
};
|
||||
|
||||
const fetchDepts = useCallback(async () => {
|
||||
setDeptLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setDepts(raw);
|
||||
setDeptTree(buildTree(raw));
|
||||
// 전부 펼치기
|
||||
setExpandedDepts(new Set(raw.map((d: any) => d.dept_code)));
|
||||
} catch {
|
||||
toast.error("부서 목록을 불러오는데 실패했어요.");
|
||||
} finally {
|
||||
setDeptLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchDepts(); }, [fetchDepts]);
|
||||
|
||||
const selectedDept = depts.find((d) => d.dept_code === selectedDeptCode);
|
||||
|
||||
// 사원 조회
|
||||
const fetchMembers = useCallback(async () => {
|
||||
if (!selectedDeptCode) { setMembers([]); return; }
|
||||
setMemberLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setMembers([]); } finally { setMemberLoading(false); }
|
||||
}, [selectedDeptCode]);
|
||||
|
||||
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
||||
|
||||
// 트리 토글
|
||||
const toggleExpand = (code: string) => {
|
||||
setExpandedDepts((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(code)) next.delete(code); else next.add(code);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 부서 등록
|
||||
const openDeptRegister = async () => {
|
||||
setDeptForm({});
|
||||
setDeptEditMode(false);
|
||||
setPreviewCode(null);
|
||||
setNumberingRuleId(null);
|
||||
setDeptModalOpen(true);
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`);
|
||||
const ruleData = ruleRes.data;
|
||||
if (ruleData?.success && ruleData?.data?.ruleId) {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
setNumberingRuleId(ruleId);
|
||||
const previewRes = await previewNumberingCode(ruleId);
|
||||
if (previewRes.success && previewRes.data?.generatedCode) {
|
||||
setPreviewCode(previewRes.data.generatedCode);
|
||||
}
|
||||
}
|
||||
} catch { /* 채번 규칙 없으면 무시 */ }
|
||||
};
|
||||
|
||||
const openDeptEdit = () => {
|
||||
if (!selectedDept) return;
|
||||
setDeptForm({ ...selectedDept });
|
||||
setDeptEditMode(true);
|
||||
setDeptModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeptSave = async () => {
|
||||
if (!deptForm.dept_name) { toast.error("부서명은 필수예요."); return; }
|
||||
const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null;
|
||||
setDeptSaving(true);
|
||||
try {
|
||||
if (deptEditMode && deptForm.dept_code) {
|
||||
const response = await departmentAPI.updateDepartment(deptForm.dept_code, {
|
||||
dept_name: deptForm.dept_name,
|
||||
parent_dept_code: parentCode,
|
||||
});
|
||||
if (!response.success) { toast.error((response as any).error || "수정에 실패했어요."); return; }
|
||||
toast.success("수정되었어요.");
|
||||
} else {
|
||||
const companyCode = user?.companyCode || "";
|
||||
let allocatedCode: string | undefined;
|
||||
if (numberingRuleId) {
|
||||
const allocRes = await allocateNumberingCode(numberingRuleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
allocatedCode = allocRes.data.generatedCode;
|
||||
} else { toast.error("채번 코드 할당에 실패했어요."); return; }
|
||||
}
|
||||
const response = await departmentAPI.createDepartment(companyCode, {
|
||||
dept_name: deptForm.dept_name,
|
||||
parent_dept_code: parentCode,
|
||||
dept_code: allocatedCode,
|
||||
});
|
||||
if (!response.success) { toast.error((response as any).error || "등록에 실패했어요."); return; }
|
||||
toast.success("등록되었어요.");
|
||||
}
|
||||
setDeptModalOpen(false);
|
||||
fetchDepts();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || "저장에 실패했어요.");
|
||||
} finally {
|
||||
setDeptSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeptDelete = async () => {
|
||||
if (!selectedDeptCode) return;
|
||||
const ok = await confirm("부서를 삭제할까요?", {
|
||||
description: "해당 부서에 소속된 사원 정보는 유지돼요.",
|
||||
variant: "destructive", confirmText: "삭제",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const response = await departmentAPI.deleteDepartment(selectedDeptCode);
|
||||
if (!response.success) { toast.error((response as any).error || "삭제에 실패했어요."); return; }
|
||||
toast.success((response as any).message || "삭제되었어요.");
|
||||
setSelectedDeptCode(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; }
|
||||
if (!userForm.dept_code) { toast.error("부서는 필수예요."); return; }
|
||||
const errors = validateForm(userForm, ["cell_phone", "email"]);
|
||||
setFormErrors(errors);
|
||||
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
|
||||
setDeptSaving(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: userEditMode ? (userForm.email || null) : (userForm.email || undefined),
|
||||
tel: userForm.tel || undefined,
|
||||
cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined),
|
||||
sabun: userEditMode ? (userForm.sabun || null) : (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 {
|
||||
setDeptSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 트리 렌더 ── */
|
||||
const renderTree = (nodes: DeptNode[], depth = 0) => {
|
||||
return nodes.map((node) => {
|
||||
const isExpanded = expandedDepts.has(node.dept_code);
|
||||
const isSelected = selectedDeptCode === node.dept_code;
|
||||
const hasChildren = node.children.length > 0;
|
||||
return (
|
||||
<div key={node.dept_code}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-2 cursor-pointer text-sm transition-colors hover:bg-accent",
|
||||
isSelected && "bg-primary/10 text-primary font-semibold border-l-2 border-primary",
|
||||
!isSelected && "border-l-2 border-transparent",
|
||||
)}
|
||||
style={{ paddingLeft: `${12 + depth * 20}px` }}
|
||||
onClick={() => setSelectedDeptCode(isSelected ? null : node.dept_code)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
className="p-0.5 rounded hover:bg-accent"
|
||||
onClick={(e) => { e.stopPropagation(); toggleExpand(node.dept_code); }}
|
||||
>
|
||||
<ChevronRight className={cn("w-3.5 h-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-4.5" />
|
||||
)}
|
||||
{isExpanded && hasChildren
|
||||
? <FolderOpen className="w-4 h-4 text-muted-foreground" />
|
||||
: <Folder className="w-4 h-4 text-muted-foreground" />
|
||||
}
|
||||
<span className="truncate">{node.dept_name}</span>
|
||||
{node.status === "inactive" && <Badge variant="outline" className="text-[10px] px-1 py-0">비활성</Badge>}
|
||||
</div>
|
||||
{isExpanded && hasChildren && renderTree(node.children, depth + 1)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/* ── 이미지 업로드 박스 ── */
|
||||
const ImageUploadBox = ({
|
||||
label, preview, inputRef, field, setPreview,
|
||||
}: {
|
||||
label: string;
|
||||
preview: string | null;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
field: string;
|
||||
setPreview: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
}) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-sm font-medium">{label}</Label>
|
||||
<div className="relative w-40 h-40 rounded-lg border-2 border-dashed border-border bg-muted/50 flex items-center justify-center overflow-hidden group">
|
||||
{preview ? (
|
||||
<>
|
||||
<img src={preview} alt={label} className="w-full h-full object-contain" />
|
||||
{editMode && (
|
||||
<div className="absolute inset-0 bg-foreground/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<Button size="sm" variant="secondary" className="h-7 text-xs" onClick={() => inputRef.current?.click()}>
|
||||
<Upload className="w-3 h-3 mr-1" /> 변경
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => {
|
||||
setPreview(null);
|
||||
setCompanyForm((prev) => ({ ...prev, [field]: null }));
|
||||
}}>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="flex flex-col items-center gap-1.5 text-muted-foreground"
|
||||
onClick={() => editMode && inputRef.current?.click()}
|
||||
disabled={!editMode}
|
||||
>
|
||||
<ImageIcon className="w-8 h-8" />
|
||||
<span className="text-xs">{editMode ? "이미지 업로드" : "이미지 없음"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImageUpload(e, field, setPreview)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col overflow-hidden">
|
||||
{/* 탭 컨테이너 */}
|
||||
<Tabs defaultValue="company" className="flex flex-col h-full gap-0 min-h-0">
|
||||
{/* 탭 헤더 — border-b 스타일 */}
|
||||
<div className="shrink-0 border-b bg-background px-4">
|
||||
<TabsList className="h-12 bg-transparent gap-1">
|
||||
<TabsTrigger
|
||||
value="company"
|
||||
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 gap-1.5"
|
||||
>
|
||||
<Building2 className="w-4 h-4" /> 회사정보
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="department"
|
||||
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 gap-1.5"
|
||||
>
|
||||
<Users className="w-4 h-4" /> 부서관리
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* ===================== Tab 1: 회사정보 ===================== */}
|
||||
<TabsContent value="company" className="flex-1 overflow-auto mt-0 p-4">
|
||||
<div className="border rounded-lg bg-card">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b bg-muted/30">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Building2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span>회사 기본정보</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{editMode ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={cancelEdit}>취소</Button>
|
||||
<Button size="sm" onClick={handleCompanySave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Save className="w-3.5 h-3.5 mr-1" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => setEditMode(true)}>
|
||||
<Pencil className="w-3.5 h-3.5 mr-1" /> 수정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{companyLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 기본 정보 섹션 제목 */}
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
기본 정보
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 그리드 (2열) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">회사코드</Label>
|
||||
<Input value={companyForm.company_code || ""} className="h-9 bg-muted/50" disabled readOnly />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
회사명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={companyForm.company_name || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, company_name: e.target.value }))}
|
||||
placeholder="회사명을 입력해주세요"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">사업자등록번호</Label>
|
||||
<Input
|
||||
value={companyForm.business_registration_number || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, business_registration_number: e.target.value }))}
|
||||
placeholder="000-00-00000"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">대표자명</Label>
|
||||
<Input
|
||||
value={companyForm.representative_name || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, representative_name: e.target.value }))}
|
||||
placeholder="대표자명"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">대표전화</Label>
|
||||
<Input
|
||||
value={companyForm.representative_phone || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, representative_phone: e.target.value }))}
|
||||
placeholder="02-0000-0000"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">팩스</Label>
|
||||
<Input
|
||||
value={companyForm.fax || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, fax: e.target.value }))}
|
||||
placeholder="02-0000-0001"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">이메일</Label>
|
||||
<Input
|
||||
value={companyForm.email || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, email: e.target.value }))}
|
||||
placeholder="example@company.com"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">홈페이지</Label>
|
||||
<Input
|
||||
value={companyForm.website || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, website: e.target.value }))}
|
||||
placeholder="https://www.company.com"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">주소</Label>
|
||||
<Input
|
||||
value={companyForm.address || ""}
|
||||
onChange={(e) => setCompanyForm((p) => ({ ...p, address: e.target.value }))}
|
||||
placeholder="회사 주소를 입력해주세요"
|
||||
className="h-9" disabled={!editMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이미지 섹션 */}
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
이미지 관리
|
||||
<div className="flex-1 h-px bg-border" />
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
<ImageUploadBox label="회사 이미지" preview={imagePreview} inputRef={imageRef} field="company_image" setPreview={setImagePreview} />
|
||||
<ImageUploadBox label="회사 로고" preview={logoPreview} inputRef={logoRef} field="company_logo" setPreview={setLogoPreview} />
|
||||
<ImageUploadBox label="직인" preview={sealPreview} inputRef={sealRef} field="company_seal" setPreview={setSealPreview} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===================== Tab 2: 부서관리 ===================== */}
|
||||
<TabsContent value="department" className="flex-1 overflow-hidden mt-0">
|
||||
<div className="h-full overflow-hidden border rounded-none bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 좌측: 부서 트리 */}
|
||||
<ResizablePanel defaultSize={30} minSize={20}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Building2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span>부서</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{depts.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" className="h-8" onClick={openDeptRegister}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 등록
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8" disabled={!selectedDeptCode} onClick={openDeptEdit}>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" className="h-8" disabled={!selectedDeptCode} onClick={handleDeptDelete}>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{deptLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : deptTree.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<Building2 className="w-8 h-8 mb-2" />
|
||||
<span className="text-sm">등록된 부서가 없어요</span>
|
||||
</div>
|
||||
) : (
|
||||
renderTree(deptTree)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 사원 목록 */}
|
||||
<ResizablePanel defaultSize={70} minSize={40}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/30 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
<span>{selectedDept ? "부서 인원" : "부서를 선택해주세요"}</span>
|
||||
{selectedDept && <Badge variant="outline" className="font-mono text-xs">{selectedDept.dept_name}</Badge>}
|
||||
{members.length > 0 && <Badge variant="secondary" className="font-mono text-xs">{members.length}명</Badge>}
|
||||
</div>
|
||||
{selectedDeptCode && (
|
||||
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 사원 추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{selectedDeptCode ? (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{memberLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : members.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
||||
<Users className="w-8 h-8 mb-2" />
|
||||
<span className="text-sm">소속 사원이 없어요</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사번</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이름</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용자ID</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">직급</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">휴대폰</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이메일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((row) => (
|
||||
<TableRow
|
||||
key={row.user_id || row.id}
|
||||
className="cursor-pointer"
|
||||
onDoubleClick={() => openUserModal(row)}
|
||||
>
|
||||
<TableCell className="text-[13px]">{row.sabun || "-"}</TableCell>
|
||||
<TableCell className="text-sm font-medium">{row.user_name}</TableCell>
|
||||
<TableCell className="text-[13px] font-mono">{row.user_id}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.position_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.cell_phone || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{row.email || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Users className="w-10 h-10 mb-3" />
|
||||
<span className="text-sm">좌측에서 부서를 선택해주세요</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ── 부서 등록/수정 모달 ── */}
|
||||
<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={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
|
||||
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성됩니다")}
|
||||
className="h-9" disabled readOnly
|
||||
/>
|
||||
</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={deptSaving}>
|
||||
{deptSaving ? <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" autoComplete="off" />
|
||||
</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" autoComplete="new-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">부서 <span className="text-destructive">*</span></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>
|
||||
<Input type="date" value={userForm.regdate || ""} onChange={(e) => setUserForm((p) => ({ ...p, regdate: e.target.value }))} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">퇴사일</Label>
|
||||
<Input type="date" value={userForm.end_date || ""} onChange={(e) => setUserForm((p) => ({ ...p, end_date: e.target.value }))} className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUserModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleUserSave} disabled={deptSaving}>
|
||||
{deptSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
"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 { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||
Users, Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import * as departmentAPI from "@/lib/api/department";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const DEPT_TABLE = "dept_info";
|
||||
const USER_TABLE = "user_info";
|
||||
const DEPT_COLUMNS = [
|
||||
{ key: "parent_dept_code", label: "상위부서" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
|
||||
export default function DepartmentPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
// 검색 필터 (DynamicSearchFilter)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 좌측: 부서
|
||||
const [depts, setDepts] = useState<any[]>([]);
|
||||
const [deptLoading, setDeptLoading] = useState(false);
|
||||
const [deptCount, setDeptCount] = useState(0);
|
||||
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
|
||||
|
||||
// 우측: 사원
|
||||
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 [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
|
||||
const [previewCode, setPreviewCode] = useState<string | null>(null);
|
||||
|
||||
// 사원 모달
|
||||
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 [memberTab, setMemberTab] = useState<"active" | "resigned">("active");
|
||||
|
||||
// 엑셀
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
// 테이블 설정
|
||||
const ts = useTableSettings("c16-department", DEPT_TABLE, DEPT_COLUMNS);
|
||||
|
||||
// 부서 조회
|
||||
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) {
|
||||
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 = async () => {
|
||||
setDeptForm({});
|
||||
setDeptEditMode(false);
|
||||
setPreviewCode(null);
|
||||
setNumberingRuleId(null);
|
||||
setDeptModalOpen(true);
|
||||
|
||||
// 채번 규칙 조회 (dept_info.dept_code) — path params로 직접 호출
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`);
|
||||
const ruleData = ruleRes.data;
|
||||
if (ruleData?.success && ruleData?.data?.ruleId) {
|
||||
const ruleId = ruleData.data.ruleId;
|
||||
setNumberingRuleId(ruleId);
|
||||
const previewRes = await previewNumberingCode(ruleId);
|
||||
if (previewRes.success && previewRes.data?.generatedCode) {
|
||||
setPreviewCode(previewRes.data.generatedCode);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 채번 규칙 없으면 무시
|
||||
}
|
||||
};
|
||||
|
||||
const openDeptEdit = () => {
|
||||
if (!selectedDept) return;
|
||||
setDeptForm({ ...selectedDept });
|
||||
setDeptEditMode(true);
|
||||
setDeptModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeptSave = async () => {
|
||||
if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; }
|
||||
const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (deptEditMode && deptForm.dept_code) {
|
||||
const response = await departmentAPI.updateDepartment(deptForm.dept_code, {
|
||||
dept_name: deptForm.dept_name,
|
||||
parent_dept_code: parentCode,
|
||||
});
|
||||
if (!response.success) { toast.error((response as any).error || "수정에 실패했습니다."); return; }
|
||||
toast.success("수정되었습니다.");
|
||||
} else {
|
||||
const companyCode = user?.companyCode || "";
|
||||
|
||||
// 채번 규칙이 있으면 allocate로 실제 코드 할당
|
||||
let allocatedCode: string | undefined;
|
||||
if (numberingRuleId) {
|
||||
const allocRes = await allocateNumberingCode(numberingRuleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
allocatedCode = allocRes.data.generatedCode;
|
||||
} else {
|
||||
toast.error("채번 코드 할당에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await departmentAPI.createDepartment(companyCode, {
|
||||
dept_name: deptForm.dept_name,
|
||||
parent_dept_code: parentCode,
|
||||
dept_code: allocatedCode,
|
||||
});
|
||||
if (!response.success) {
|
||||
toast.error((response as any).error || "등록에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
const response = await departmentAPI.deleteDepartment(selectedDeptCode);
|
||||
if (!response.success) { toast.error((response as any).error || "삭제에 실패했습니다."); return; }
|
||||
toast.success(response.message || "삭제되었습니다.");
|
||||
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; }
|
||||
if (!userForm.dept_code) { 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: userEditMode ? (userForm.email || null) : (userForm.email || undefined),
|
||||
tel: userForm.tel || undefined,
|
||||
cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined),
|
||||
sabun: userEditMode ? (userForm.sabun || null) : (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("다운로드 완료");
|
||||
};
|
||||
|
||||
// 퇴사일 기반 재직/퇴사 분리
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const activeMembers = members.filter((m) => !m.end_date || m.end_date.substring(0, 10) >= today);
|
||||
const resignedMembers = members.filter((m) => m.end_date && m.end_date.substring(0, 10) < today);
|
||||
|
||||
const isColVisible = (key: string) => ts.isVisible(key);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-4">
|
||||
{/* 검색 필터 바 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={DEPT_TABLE}
|
||||
filterId="c16-department"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={deptCount}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
|
||||
엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => void 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 px-4 py-3 border-b bg-muted shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-[13px] font-bold">부서 목록</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/[0.08] px-2 py-0.5 rounded-full">
|
||||
{deptCount}건
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={() => void 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={() => void handleDeptDelete()}>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => ts.setOpen(true)}>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부서 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table noWrapper>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center px-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">부서코드</TableHead>
|
||||
<TableHead className="min-w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">부서명</TableHead>
|
||||
{isColVisible("parent_dept_code") && <TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상위부서</TableHead>}
|
||||
{isColVisible("status") && <TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{deptLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : depts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground text-sm">
|
||||
등록된 부서가 없어요
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : depts.map((dept, idx) => (
|
||||
<TableRow
|
||||
key={dept.id}
|
||||
className={cn(
|
||||
"cursor-pointer select-none",
|
||||
selectedDeptId === dept.id ? "bg-primary/10 hover:bg-primary/10" : "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => setSelectedDeptId((prev) => prev === dept.id ? null : dept.id)}
|
||||
onDoubleClick={openDeptEdit}
|
||||
>
|
||||
<TableCell className="text-center text-[13px] text-muted-foreground px-2">{idx + 1}</TableCell>
|
||||
<TableCell className="text-[13px] font-mono text-muted-foreground">{dept.dept_code}</TableCell>
|
||||
<TableCell className="text-sm font-medium">{dept.dept_name}</TableCell>
|
||||
{isColVisible("parent_dept_code") && <TableCell className="text-[13px] text-muted-foreground">{dept.parent_dept_code || "—"}</TableCell>}
|
||||
{isColVisible("status") && (
|
||||
<TableCell className="text-[13px]">
|
||||
{dept.status && (
|
||||
<Badge
|
||||
variant={dept.status === "active" ? "default" : "outline"}
|
||||
className="text-[10px] px-1.5 py-0 h-5"
|
||||
>
|
||||
{dept.status === "active" ? "활성" : (dept.status || "—")}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 사원 목록 */}
|
||||
<ResizablePanel defaultSize={60} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
{!selectedDeptId ? (
|
||||
/* 빈 상태 */
|
||||
<div className="flex-1 flex items-center justify-center p-5">
|
||||
<div className="flex flex-col items-center justify-center text-center border-2 border-dashed border-border rounded-lg px-10 py-16">
|
||||
<Users className="w-12 h-12 text-muted-foreground/40 mb-4" />
|
||||
<div className="text-sm font-semibold text-muted-foreground mb-1.5">부서를 선택해주세요</div>
|
||||
<div className="text-xs text-muted-foreground">좌측에서 부서를 선택하면 소속 사원 목록이 표시돼요</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 디테일 헤더 */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b bg-muted shrink-0">
|
||||
<span className="text-[13px] font-bold">{selectedDept?.dept_name || "-"}</span>
|
||||
<span className="font-mono text-[11px] font-semibold text-primary bg-primary/[0.08] px-2 py-0.5 rounded-full">
|
||||
{selectedDept?.dept_code || "-"}
|
||||
</span>
|
||||
<div className="ml-auto">
|
||||
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
사원 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 재직/퇴사 탭 */}
|
||||
<div className="flex border-b border-border px-4 shrink-0 bg-muted">
|
||||
<button
|
||||
onClick={() => setMemberTab("active")}
|
||||
className={cn("px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
|
||||
memberTab === "active" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
재직중
|
||||
{activeMembers.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1.5 py-0">{activeMembers.length}</Badge>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMemberTab("resigned")}
|
||||
className={cn("px-4 py-2.5 text-xs font-semibold border-b-2 transition-colors",
|
||||
memberTab === "resigned" ? "border-primary text-foreground" : "border-transparent text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
퇴사
|
||||
{resignedMembers.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 text-[10px] px-1.5 py-0">{resignedMembers.length}</Badge>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{memberLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" />
|
||||
</div>
|
||||
) : memberTab === "active" ? (
|
||||
activeMembers.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">재직중인 사원이 없어요</div>
|
||||
) : (
|
||||
<Table noWrapper>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center px-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사번</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이름</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용자ID</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">직급</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">휴대폰</TableHead>
|
||||
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이메일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{activeMembers.map((member, idx) => (
|
||||
<TableRow
|
||||
key={member.id || member.user_id}
|
||||
className="cursor-pointer select-none hover:bg-muted/50"
|
||||
onDoubleClick={() => openUserModal(member)}
|
||||
>
|
||||
<TableCell className="text-center text-[13px] text-muted-foreground px-2">{idx + 1}</TableCell>
|
||||
<TableCell className="font-mono text-[13px] text-muted-foreground">{member.sabun || "—"}</TableCell>
|
||||
<TableCell className="text-sm font-medium">{member.user_name}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{member.user_id}</TableCell>
|
||||
<TableCell className="text-[13px]">{member.position_name || "—"}</TableCell>
|
||||
<TableCell className="text-[13px]">{member.cell_phone || "—"}</TableCell>
|
||||
<TableCell className="text-[13px]">{member.email || "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
) : (
|
||||
resignedMembers.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">퇴사한 사원이 없어요</div>
|
||||
) : (
|
||||
<Table noWrapper>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center px-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사번</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이름</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용자ID</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">직급</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">휴대폰</TableHead>
|
||||
<TableHead className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이메일</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">퇴사일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{resignedMembers.map((member, idx) => (
|
||||
<TableRow
|
||||
key={member.id || member.user_id}
|
||||
className="cursor-pointer select-none hover:bg-muted/50"
|
||||
onDoubleClick={() => openUserModal(member)}
|
||||
>
|
||||
<TableCell className="text-center text-[13px] text-muted-foreground px-2">{idx + 1}</TableCell>
|
||||
<TableCell className="font-mono text-[13px] text-muted-foreground">{member.sabun || "—"}</TableCell>
|
||||
<TableCell className="text-sm font-medium">{member.user_name}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{member.user_id}</TableCell>
|
||||
<TableCell className="text-[13px]">{member.position_name || "—"}</TableCell>
|
||||
<TableCell className="text-[13px]">{member.cell_phone || "—"}</TableCell>
|
||||
<TableCell className="text-[13px]">{member.email || "—"}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{member.end_date ? member.end_date.substring(0, 10) : "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">부서코드</span>
|
||||
<Input
|
||||
value={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
|
||||
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성돼요")}
|
||||
className="h-9"
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
부서명 <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<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">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">상위부서</span>
|
||||
<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={() => void 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-2">
|
||||
<div className="space-y-1.5">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
사용자 ID <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<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">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
이름 <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<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">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">사번</span>
|
||||
<Input
|
||||
value={userForm.sabun || ""}
|
||||
onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
|
||||
placeholder="사번"
|
||||
className="h-9"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">비밀번호</span>
|
||||
<Input
|
||||
value={userForm.user_password || ""}
|
||||
onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
|
||||
placeholder={userEditMode ? "변경 시에만 입력해 주세요" : "미입력 시 기본값이 설정돼요"}
|
||||
className="h-9"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">직급</span>
|
||||
<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">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
부서 <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<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">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">휴대폰</span>
|
||||
<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">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">이메일</span>
|
||||
<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">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">입사일</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={userForm.regdate ? userForm.regdate.substring(0, 10) : ""}
|
||||
onChange={(e) => setUserForm((p) => ({ ...p, regdate: e.target.value }))}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">퇴사일</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={userForm.end_date ? userForm.end_date.substring(0, 10) : ""}
|
||||
onChange={(e) => setUserForm((p) => ({ ...p, end_date: e.target.value }))}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUserModalOpen(false)}>취소</Button>
|
||||
<Button onClick={() => void 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>
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{/* 엑셀 업로드 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
onOpenChange={setExcelUploadOpen}
|
||||
tableName={DEPT_TABLE}
|
||||
userId={user?.userId}
|
||||
onSuccess={() => fetchDepts()}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
"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 { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download,
|
||||
Pencil, Copy, Settings2,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const TABLE_NAME = "item_info";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_number", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "division", label: "관리품목" },
|
||||
{ key: "type", label: "품목구분" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "material", label: "재질" },
|
||||
{ key: "status", label: "상태" },
|
||||
{ key: "selling_price", label: "판매가격", align: "right" as const },
|
||||
{ key: "standard_price", label: "기준단가", align: "right" as const },
|
||||
{ key: "weight", label: "중량", align: "right" as const },
|
||||
{ key: "inventory_unit", label: "재고단위" },
|
||||
{ key: "user_type01", label: "대분류" },
|
||||
{ key: "user_type02", label: "중분류" },
|
||||
];
|
||||
|
||||
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", placeholder: "숫자 입력 (예: 3.5)" },
|
||||
{ key: "volum", label: "부피", type: "text", placeholder: "숫자 입력 (예: 100)" },
|
||||
{ key: "specific_gravity", label: "비중", type: "text", placeholder: "숫자 입력 (예: 7.85)" },
|
||||
{ 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 CATEGORY_COLUMNS = [
|
||||
"division", "type", "unit", "material", "status",
|
||||
"inventory_unit", "currency_code", "user_type01", "user_type02",
|
||||
];
|
||||
|
||||
export default function ItemInfoPage() {
|
||||
const { user } = useAuth();
|
||||
const ts = useTableSettings("c16-item-info", TABLE_NAME, GRID_COLUMNS);
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 검색 필터 (DynamicSearchFilter)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 모달
|
||||
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);
|
||||
|
||||
// 카테고리 옵션 로드
|
||||
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 = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
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);
|
||||
} catch (err) {
|
||||
console.error("품목 조회 실패:", err);
|
||||
toast.error("품목 목록을 불러오는데 실패했어요.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [categoryOptions, searchFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
// 등록 모달 열기
|
||||
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`, { id: crypto.randomUUID(), ...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 GRID_COLUMNS) {
|
||||
row[col.label] = item[col.key] || "";
|
||||
}
|
||||
return row;
|
||||
});
|
||||
await exportToExcel(exportData, "품목정보.xlsx", "품목정보");
|
||||
toast.success("엑셀 다운로드 완료");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-0">
|
||||
{/* 검색 필터 바 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={TABLE_NAME}
|
||||
filterId="c16-item-info"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
{/* 액션 바 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">품목 관리</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{items.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 variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||
<Download className="w-4 h-4 mr-1.5" /> 엑셀 다운로드
|
||||
</Button>
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
<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>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
등록된 품목이 없어요
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"whitespace-nowrap text-xs",
|
||||
col.align === "right" && "text-right"
|
||||
)}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, idx) => (
|
||||
<TableRow
|
||||
key={item.id ?? idx}
|
||||
className={cn(
|
||||
"cursor-pointer text-sm",
|
||||
selectedId === item.id && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
onDoubleClick={() => openEditModal(item)}
|
||||
>
|
||||
<TableCell className="text-center text-[13px] text-muted-foreground">{idx + 1}</TableCell>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"whitespace-nowrap max-w-[160px] truncate",
|
||||
col.align === "right" && "text-right tabular-nums"
|
||||
)}
|
||||
>
|
||||
{item[col.key] ?? ""}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="sm:max-w-[600px] w-[95vw] max-h-[90vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="shrink-0 px-6 pt-5 pb-3 border-b">
|
||||
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditMode ? "품목 정보를 수정해요." : "새로운 품목을 등록해요."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-4 p-6">
|
||||
{FORM_FIELDS.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className={cn("space-y-1.5", field.type === "textarea" && "col-span-2")}
|
||||
>
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.type === "category" ? (
|
||||
<Select
|
||||
value={formData[field.key] || ""}
|
||||
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full">
|
||||
<SelectValue placeholder={`${field.label} 선택`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions[field.key] || []).map((opt) => (
|
||||
<SelectItem key={opt.code} value={opt.code}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : 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.placeholder || (field.disabled ? "자동 채번" : field.label)}
|
||||
disabled={field.disabled && !isEditMode}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t px-6 py-3">
|
||||
<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>
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{/* 엑셀 업로드 모달 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
onOpenChange={setExcelUploadOpen}
|
||||
tableName={TABLE_NAME}
|
||||
userId={user?.userId}
|
||||
onSuccess={() => {
|
||||
fetchItems();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Settings2, Tags, Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { NumberingRuleDesigner } from "@/components/numbering-rule/NumberingRuleDesigner";
|
||||
|
||||
const TABS = [
|
||||
{ id: "category", label: "카테고리 설정", icon: Tags },
|
||||
{ id: "numbering", label: "코드 설정", icon: Hash },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
export default function OptionsSettingPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>("category");
|
||||
|
||||
const [selectedColumn, setSelectedColumn] = useState<string | null>(null);
|
||||
const [selectedColumnLabel, setSelectedColumnLabel] = useState("");
|
||||
const [selectedTableName, setSelectedTableName] = useState("");
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(340);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
setIsDragging(true);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setLeftWidth(Math.max(260, Math.min(500, e.clientX - rect.left)));
|
||||
};
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col p-3 gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-primary" />
|
||||
<h1 className="text-sm font-semibold">옵션 설정</h1>
|
||||
</div>
|
||||
<div className="flex bg-muted rounded-md p-0.5 gap-0.5">
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-all",
|
||||
activeTab === tab.id
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{activeTab === "category" && (
|
||||
<div ref={containerRef} className="flex h-full">
|
||||
<div
|
||||
style={{ width: leftWidth }}
|
||||
className="shrink-0 border rounded-lg bg-card overflow-hidden"
|
||||
>
|
||||
<CategoryColumnList
|
||||
tableName=""
|
||||
selectedColumn={selectedColumn}
|
||||
onColumnSelect={(uniqueKey, label, tableName) => {
|
||||
setSelectedColumn(uniqueKey);
|
||||
setSelectedColumnLabel(label);
|
||||
setSelectedTableName(tableName);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn(
|
||||
"w-1.5 mx-0.5 cursor-col-resize rounded-full transition-colors shrink-0",
|
||||
isDragging ? "bg-primary" : "bg-border hover:bg-primary/50"
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0 border rounded-lg bg-card overflow-hidden">
|
||||
{selectedColumn && selectedTableName ? (
|
||||
<CategoryValueManager
|
||||
tableName={selectedTableName}
|
||||
columnName={selectedColumn.includes("__") ? selectedColumn.split("__").pop()! : selectedColumn}
|
||||
columnLabel={selectedColumnLabel}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
<Tags className="h-8 w-8 mx-auto text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">좌측에서 카테고리 컬럼을 선택해주세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "numbering" && (
|
||||
<div className="h-full border rounded-lg bg-card overflow-auto">
|
||||
<NumberingRuleDesigner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,597 @@
|
||||
"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, Inbox, Search, RotateCcw, 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 { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const ITEM_TABLE = "item_info";
|
||||
const MAPPING_TABLE = "subcontractor_item_mapping";
|
||||
const SUBCONTRACTOR_TABLE = "subcontractor_mng";
|
||||
|
||||
const formatNum = (v: any) => (v == null || v === "" ? "-" : Number(v).toLocaleString());
|
||||
|
||||
const GRID_COLUMNS_CONFIG = [
|
||||
{ key: "item_number", label: "품번" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "standard_price", label: "기준단가" },
|
||||
{ key: "selling_price", label: "판매가격" },
|
||||
{ key: "currency_code", label: "통화" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
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 [inputKeyword, setInputKeyword] = useState("");
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
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 ts = useTableSettings("c16-subcontractor-item", ITEM_TABLE, GRID_COLUMNS_CONFIG);
|
||||
|
||||
// 카테고리 로드
|
||||
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[] = [];
|
||||
if (outsourcingDivisionCode) {
|
||||
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
|
||||
}
|
||||
if (searchKeyword) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
|
||||
}
|
||||
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);
|
||||
}
|
||||
}, [searchKeyword, 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 {
|
||||
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 || [];
|
||||
|
||||
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`, {
|
||||
id: crypto.randomUUID(),
|
||||
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("다운로드 완료");
|
||||
};
|
||||
|
||||
const handleSearch = () => setSearchKeyword(inputKeyword);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 브레드크럼 */}
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
<span>외주관리</span>
|
||||
<span className="text-muted-foreground/50">/</span>
|
||||
<span className="text-foreground font-medium">외주품목정보</span>
|
||||
</div>
|
||||
|
||||
{/* 검색 바 */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-card border rounded-lg shrink-0">
|
||||
<span className="text-[11px] font-semibold text-muted-foreground whitespace-nowrap">품명</span>
|
||||
<Input
|
||||
className="h-9 w-[200px]"
|
||||
placeholder="품명 검색"
|
||||
value={inputKeyword}
|
||||
onChange={(e) => setInputKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => { setInputKeyword(""); setSearchKeyword(""); }}>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1" /> 초기화
|
||||
</Button>
|
||||
<Button size="sm" className="h-9" onClick={handleSearch}>
|
||||
<Search className="w-3.5 h-3.5 mr-1" /> 조회
|
||||
</Button>
|
||||
<div className="ml-auto flex gap-1.5">
|
||||
<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>
|
||||
|
||||
{/* 분할 패널 */}
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-card">
|
||||
<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/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[13px] font-bold">외주품목 목록</h3>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{itemCount}건</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
|
||||
<Pencil className="w-3.5 h-3.5 mr-1" /> 수정
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)}>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{itemLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Inbox className="w-8 h-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">등록된 외주품목이 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
{ts.isVisible("item_number") && <TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>}
|
||||
{ts.isVisible("item_name") && <TableHead className="min-w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>}
|
||||
{ts.isVisible("size") && <TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
||||
{ts.isVisible("unit") && <TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>}
|
||||
{ts.isVisible("standard_price") && <TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기준단가</TableHead>}
|
||||
{ts.isVisible("selling_price") && <TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">판매가격</TableHead>}
|
||||
{ts.isVisible("currency_code") && <TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">통화</TableHead>}
|
||||
{ts.isVisible("status") && <TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
</TableRow>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn("cursor-pointer", selectedItemId === item.id && "border-l-2 border-l-primary bg-primary/[0.08]")}
|
||||
onClick={() => setSelectedItemId(item.id)}
|
||||
onDoubleClick={openEditItem}
|
||||
>
|
||||
{ts.isVisible("item_number") && <TableCell className="text-[13px] font-mono">{item.item_number}</TableCell>}
|
||||
{ts.isVisible("item_name") && <TableCell className="text-sm max-w-[150px] truncate" title={item.item_name}>{item.item_name || "-"}</TableCell>}
|
||||
{ts.isVisible("size") && <TableCell className="text-[13px]">{item.size || "-"}</TableCell>}
|
||||
{ts.isVisible("unit") && <TableCell className="text-[13px]">{item.unit || "-"}</TableCell>}
|
||||
{ts.isVisible("standard_price") && <TableCell className="text-[13px] text-right font-mono">{formatNum(item.standard_price)}</TableCell>}
|
||||
{ts.isVisible("selling_price") && <TableCell className="text-[13px] text-right font-mono">{formatNum(item.selling_price)}</TableCell>}
|
||||
{ts.isVisible("currency_code") && <TableCell className="text-[13px]">{item.currency_code || "-"}</TableCell>}
|
||||
{ts.isVisible("status") && <TableCell className="text-[13px]">{item.status || "-"}</TableCell>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</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/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[13px] font-bold">외주업체 정보</h3>
|
||||
{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 flex-col items-center justify-center gap-3 m-3 border-2 border-dashed rounded-lg text-center">
|
||||
<Inbox className="w-12 h-12 text-muted-foreground/40" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-muted-foreground">품목을 선택해주세요</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">좌측에서 품목을 선택하면 외주업체 정보가 표시돼요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : subcontractorLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : subcontractorItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Inbox className="w-8 h-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">등록된 외주업체가 없어요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">외주업체코드</TableHead>
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">외주업체명</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">외주품번</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">외주품명</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기준가</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">통화</TableHead>
|
||||
</TableRow>
|
||||
</thead>
|
||||
<TableBody>
|
||||
{subcontractorItems.map((item, idx) => (
|
||||
<TableRow key={item.id || idx}>
|
||||
<TableCell className="text-[13px] font-mono">{item.subcontractor_code || "-"}</TableCell>
|
||||
<TableCell className="text-sm">{item.subcontractor_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.subcontractor_item_code || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.subcontractor_item_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{formatNum(item.base_price)}</TableCell>
|
||||
<TableCell className="text-[13px] text-right font-mono">{formatNum(item.calculated_price)}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.currency_code || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* 품목 수정 모달 */}
|
||||
<Dialog open={editItemOpen} onOpenChange={setEditItemOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>외주품목 수정</DialogTitle>
|
||||
<DialogDescription>{editItemForm.item_number || ""} — {editItemForm.item_name || ""}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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>
|
||||
<DialogFooter>
|
||||
<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>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 외주업체 추가 모달 */}
|
||||
<Dialog open={subSelectOpen} onOpenChange={setSubSelectOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>외주업체 선택</DialogTitle>
|
||||
<DialogDescription>품목에 추가할 외주업체를 선택해주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<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="flex-1 overflow-auto border rounded-lg">
|
||||
<Table noWrapper>
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<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] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">외주업체코드</TableHead>
|
||||
<TableHead className="min-w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">외주업체명</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">거래유형</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">담당자</TableHead>
|
||||
</TableRow>
|
||||
</thead>
|
||||
<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-[13px]">{s.subcontractor_code}</TableCell>
|
||||
<TableCell className="text-sm">{s.subcontractor_name}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.division}</TableCell>
|
||||
<TableCell className="text-[13px]">{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={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{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
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,698 @@
|
||||
"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 { 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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
getProcessList,
|
||||
createProcess,
|
||||
updateProcess,
|
||||
deleteProcesses,
|
||||
getProcessEquipments,
|
||||
addProcessEquipment,
|
||||
removeProcessEquipment,
|
||||
getEquipmentList,
|
||||
type ProcessMaster,
|
||||
type ProcessEquipment,
|
||||
type Equipment,
|
||||
} from "@/lib/api/processInfo"; // API: /process-info/*
|
||||
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 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;
|
||||
|
||||
// 표시용 데이터
|
||||
const processGridData = useMemo(
|
||||
() =>
|
||||
processes.map((p) => ({
|
||||
...p,
|
||||
process_type_display: getProcessTypeLabel(p.process_type),
|
||||
use_yn_display: p.use_yn === "Y" ? "사용" : "미사용",
|
||||
})),
|
||||
[processes, getProcessTypeLabel]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
|
||||
{/* 좌측: 공정 목록 */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">공정 마스터</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{processes.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 필터 바 */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
|
||||
<Input value={filterCode} onChange={(e) => setFilterCode(e.target.value)} placeholder="공정코드" className="h-8 w-[110px] text-xs" />
|
||||
<Input value={filterName} onChange={(e) => setFilterName(e.target.value)} placeholder="공정명" className="h-8 w-[130px] text-xs" />
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="h-8 w-[110px] text-xs">
|
||||
<SelectValue placeholder="유형 전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>전체</SelectItem>
|
||||
{processTypeOptions.map((o, idx) => (
|
||||
<SelectItem key={`pt-filter-${idx}`} value={o.valueCode}>{o.valueLabel}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterUseYn} onValueChange={setFilterUseYn}>
|
||||
<SelectTrigger className="h-8 w-[100px] text-xs">
|
||||
<SelectValue placeholder="사용 전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE}>전체</SelectItem>
|
||||
<SelectItem value="Y">사용</SelectItem>
|
||||
<SelectItem value="N">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex-1" />
|
||||
{listBusy && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
|
||||
<Button size="sm" className="h-8 gap-1 text-xs" onClick={handleSearch} disabled={listBusy}>
|
||||
<Search className="h-3 w-3" />조회
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 gap-1 text-xs text-muted-foreground" onClick={handleResetFilters}>
|
||||
<RotateCcw className="h-3 w-3" />초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 액션 바 */}
|
||||
<div className="flex items-center justify-end gap-2 border-b bg-muted/30 px-4 py-2">
|
||||
<Button size="sm" onClick={openAdd}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
공정 추가
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={openEdit}>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={openDelete}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 공정 목록 테이블 */}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{listBusy ? (
|
||||
<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-sm">불러오는 중...</p>
|
||||
</div>
|
||||
) : processGridData.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">조회된 공정이 없어요</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-10 text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={selectedIds.size === processGridData.length && processGridData.length > 0}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) setSelectedIds(new Set(processGridData.map((r) => r.id)));
|
||||
else setSelectedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[130px] text-muted-foreground">공정코드</TableHead>
|
||||
<TableHead className="text-muted-foreground">공정명</TableHead>
|
||||
<TableHead className="w-[120px] text-muted-foreground">공정유형</TableHead>
|
||||
<TableHead className="w-[110px] text-right text-muted-foreground">표준시간(분)</TableHead>
|
||||
<TableHead className="w-[90px] text-right text-muted-foreground">작업인원</TableHead>
|
||||
<TableHead className="w-[90px] text-center text-muted-foreground">사용여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processGridData.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={selectedProcess?.id === row.id ? "selected" : undefined}
|
||||
className="cursor-pointer hover:bg-muted/30"
|
||||
onClick={() => {
|
||||
const proc = processes.find((p) => p.id === row.id);
|
||||
setSelectedProcess(proc || null);
|
||||
}}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(row.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(row.id);
|
||||
else next.delete(row.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{row.process_code}</TableCell>
|
||||
<TableCell>{row.process_name}</TableCell>
|
||||
<TableCell>{row.process_type_display}</TableCell>
|
||||
<TableCell className="text-right">{row.standard_time}</TableCell>
|
||||
<TableCell className="text-right">{row.worker_count}</TableCell>
|
||||
<TableCell className="text-center">{row.use_yn_display}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 공정별 설비 */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center gap-2 border-b bg-muted/30 px-4 py-2">
|
||||
<Wrench className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">공정별 사용설비</p>
|
||||
{selectedProcess ? (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{selectedProcess.process_name} ({selectedProcess.process_code})
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">공정을 선택해주세요</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedProcess ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 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">
|
||||
목록 행을 클릭하면 이 공정에 연결된 설비를 관리할 수 있어요
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-4">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">설비 선택</Label>
|
||||
<Select
|
||||
key={selectedProcess.id}
|
||||
value={equipmentPick || undefined}
|
||||
onValueChange={setEquipmentPick}
|
||||
disabled={addingEquipment || availableEquipments.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue placeholder="설비를 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableEquipments.map((eq) => (
|
||||
<SelectItem key={eq.id} value={eq.equipment_code}>
|
||||
{eq.equipment_code} · {eq.equipment_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
size="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 overflow-auto">
|
||||
{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-sm">설비 목록을 불러오고 있어요...</p>
|
||||
</div>
|
||||
) : processEquipments.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
등록된 설비가 없어요. 상단에서 설비를 추가해주세요.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{processEquipments.map((pe) => (
|
||||
<li key={pe.id} className="flex items-center gap-3 rounded-lg border p-3 transition-colors hover:bg-muted/30">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{pe.equipment_code}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{pe.equipment_name || "설비명 없음"}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => void handleRemoveEquipment(pe)}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
제거
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
{/* 공정 등록/수정 모달 */}
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formMode === "add" ? "공정 추가" : "공정 수정"}</DialogTitle>
|
||||
<DialogDescription>공정 마스터 정보를 입력해주세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
공정명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={formProcessName}
|
||||
onChange={(e) => setFormProcessName(e.target.value)}
|
||||
placeholder="공정명을 입력해주세요"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">공정유형</Label>
|
||||
<Select value={formProcessType} onValueChange={setFormProcessType}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{processTypeOptions.map((o, idx) => (
|
||||
<SelectItem key={`pt-form-${idx}`} value={o.valueCode}>{o.valueLabel}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">표준작업시간(분)</Label>
|
||||
<Input
|
||||
value={formStandardTime}
|
||||
onChange={(e) => setFormStandardTime(e.target.value)}
|
||||
placeholder="0"
|
||||
inputMode="numeric"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">작업인원수</Label>
|
||||
<Input
|
||||
value={formWorkerCount}
|
||||
onChange={(e) => setFormWorkerCount(e.target.value)}
|
||||
placeholder="0"
|
||||
inputMode="numeric"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">사용여부</Label>
|
||||
<Select value={formUseYn} onValueChange={setFormUseYn}>
|
||||
<SelectTrigger className="h-9" size="sm">
|
||||
<SelectValue placeholder="선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y">사용</SelectItem>
|
||||
<SelectItem value="N">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)} disabled={savingForm}>취소</Button>
|
||||
<Button onClick={() => void submitForm()} disabled={savingForm}>
|
||||
{savingForm && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>공정 삭제</DialogTitle>
|
||||
<DialogDescription>
|
||||
선택한 {selectedIds.size}건의 공정을 삭제해요. 연결된 공정-설비 매핑도 함께 삭제돼요. 이 작업은 되돌릴 수 없어요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(false)} disabled={deleting}>취소</Button>
|
||||
<Button variant="destructive" onClick={() => void confirmDelete()} disabled={deleting}>
|
||||
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
삭제해요
|
||||
</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,223 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Settings,
|
||||
GitBranch,
|
||||
ClipboardList,
|
||||
ChevronRight,
|
||||
Factory,
|
||||
Keyboard,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
List,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { ProcessMasterTab } from "./ProcessMasterTab";
|
||||
import { ItemRoutingTab } from "./ItemRoutingTab";
|
||||
import { ProcessWorkStandardTab } from "./ProcessWorkStandardTab";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "process_code", label: "공정코드" },
|
||||
{ key: "process_name", label: "공정명" },
|
||||
{ key: "process_type", label: "공정유형" },
|
||||
{ key: "standard_time", label: "표준시간(분)" },
|
||||
{ key: "worker_count", label: "작업인원" },
|
||||
{ key: "use_yn", label: "사용여부" },
|
||||
];
|
||||
|
||||
const TAB_META = [
|
||||
{
|
||||
value: "process",
|
||||
label: "공정 마스터",
|
||||
shortLabel: "공정",
|
||||
description: "공정코드/유형/표준시간/사용설비 관리",
|
||||
detailDesc: "공정코드, 공정유형, 표준시간을 등록하고 사용 설비를 매핑합니다.",
|
||||
icon: Settings,
|
||||
color: "text-blue-500",
|
||||
badgeColor: "bg-blue-50 text-blue-700 ring-blue-600/20",
|
||||
shortcut: "1",
|
||||
actions: ["공정 등록", "공정 수정", "공정 삭제", "설비 연결"],
|
||||
},
|
||||
{
|
||||
value: "routing",
|
||||
label: "품목별 라우팅",
|
||||
shortLabel: "라우팅",
|
||||
description: "품목 라우팅 버전 및 공정 순서 관리",
|
||||
detailDesc: "품목별 생산 라우팅 버전을 관리하고 공정 순서 및 소요시간을 설정합니다.",
|
||||
icon: GitBranch,
|
||||
color: "text-emerald-500",
|
||||
badgeColor: "bg-emerald-50 text-emerald-700 ring-emerald-600/20",
|
||||
shortcut: "2",
|
||||
actions: ["버전 생성", "공정 순서 설정", "품목 등록", "품목 해제"],
|
||||
},
|
||||
{
|
||||
value: "workstandard",
|
||||
label: "공정 작업기준",
|
||||
shortLabel: "작업기준",
|
||||
description: "공정별 작업기준서 및 작업 표준 관리",
|
||||
detailDesc: "공정별 작업기준서를 등록하고 작업 표준을 문서화하여 품질을 관리합니다.",
|
||||
icon: ClipboardList,
|
||||
color: "text-violet-500",
|
||||
badgeColor: "bg-violet-50 text-violet-700 ring-violet-600/20",
|
||||
shortcut: "3",
|
||||
actions: ["기준서 등록", "기준서 수정", "기준서 삭제", "작업 표준 관리"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
type TabValue = (typeof TAB_META)[number]["value"];
|
||||
|
||||
const ACTION_ICONS = [Plus, Pencil, Trash2, List] as const;
|
||||
|
||||
export default function ProcessInfoPage() {
|
||||
const ts = useTableSettings("c16-process-info", "process_mst", GRID_COLUMNS);
|
||||
const [activeTab, setActiveTab] = useState<TabValue>("process");
|
||||
const [showShortcutHint, setShowShortcutHint] = useState(false);
|
||||
|
||||
const activeMeta = TAB_META.find((t) => t.value === activeTab)!;
|
||||
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setActiveTab(value as TabValue);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!e.altKey) return;
|
||||
const tabByShortcut = TAB_META.find((t) => t.shortcut === e.key);
|
||||
if (tabByShortcut) {
|
||||
e.preventDefault();
|
||||
setActiveTab(tabByShortcut.value);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col bg-muted/30">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="shrink-0 border-b bg-background px-6 py-3">
|
||||
<nav
|
||||
className="mb-1 flex items-center gap-1 text-xs text-muted-foreground"
|
||||
aria-label="breadcrumb"
|
||||
>
|
||||
<Factory className="h-3 w-3" />
|
||||
<span>생산관리</span>
|
||||
<ChevronRight className="h-3 w-3 opacity-50" />
|
||||
<span className="font-medium text-foreground">공정정보관리</span>
|
||||
</nav>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h1 className="text-base font-semibold text-foreground">공정정보관리</h1>
|
||||
<span className="text-xs text-muted-foreground">{activeMeta.description}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => setShowShortcutHint((v) => !v)}
|
||||
aria-label="키보드 단축키 보기"
|
||||
>
|
||||
<Keyboard className="h-3 w-3" />
|
||||
<span>단축키</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showShortcutHint && (
|
||||
<div className="mt-2 flex items-center gap-3 rounded-md bg-muted px-3 py-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">탭 전환:</span>
|
||||
{TAB_META.map((t) => (
|
||||
<span key={t.value} className="flex items-center gap-1">
|
||||
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px]">
|
||||
Alt+{t.shortcut}
|
||||
</kbd>
|
||||
<span>{t.shortLabel}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{/* 탭 네비게이션 */}
|
||||
<div className="shrink-0 border-b bg-background px-4">
|
||||
<TabsList className="h-12 bg-transparent gap-1">
|
||||
{TAB_META.map(({ value, label, icon: Icon, shortcut }) => (
|
||||
<TabsTrigger
|
||||
key={value}
|
||||
value={value}
|
||||
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 gap-1.5"
|
||||
aria-label={`${label} (Alt+${shortcut})`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* 탭 설명 배너 */}
|
||||
<div className="shrink-0 border-b bg-background/60 px-6 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${activeMeta.badgeColor}`}
|
||||
>
|
||||
{activeMeta.shortLabel}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{activeMeta.detailDesc}</span>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 sm:flex">
|
||||
{activeMeta.actions.map((action, i) => {
|
||||
const ActionIcon = ACTION_ICONS[i % ACTION_ICONS.length];
|
||||
return (
|
||||
<span
|
||||
key={action}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground/70"
|
||||
>
|
||||
<ActionIcon className="h-3 w-3" />
|
||||
{action}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<TabsContent value="process" className="min-h-0 flex-1 overflow-hidden mt-0">
|
||||
<ProcessMasterTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="routing" className="min-h-0 flex-1 overflow-hidden mt-0">
|
||||
<ItemRoutingTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="workstandard" className="min-h-0 flex-1 overflow-hidden mt-0">
|
||||
<ProcessWorkStandardTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1007
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,872 @@
|
||||
"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 { 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, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown, ClipboardCheck, Inbox, Settings2 } 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";
|
||||
// API: /work-instruction/*
|
||||
import {
|
||||
getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions,
|
||||
getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList,
|
||||
getRoutingVersions, RoutingVersionData,
|
||||
} from "@/lib/api/workInstruction";
|
||||
import { WorkStandardEditModal } from "./WorkStandardEditModal";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "work_instruction_no", label: "작업지시번호" },
|
||||
{ key: "status", label: "상태" },
|
||||
{ key: "progress", label: "진행현황" },
|
||||
{ key: "item_name", label: "품목명" },
|
||||
{ key: "spec", label: "규격" },
|
||||
{ key: "qty", label: "수량" },
|
||||
{ key: "equipment", label: "설비" },
|
||||
{ key: "routing", label: "라우팅" },
|
||||
{ key: "work_team", label: "작업조" },
|
||||
{ key: "worker", label: "작업자" },
|
||||
{ key: "start_date", label: "시작일" },
|
||||
{ key: "end_date", label: "완료일" },
|
||||
{ key: "actions", label: "작업" },
|
||||
];
|
||||
|
||||
type SourceType = "production" | "order" | "item";
|
||||
|
||||
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
"일반": { label: "일반", cls: "bg-primary/10 text-primary border-primary/20" },
|
||||
"긴급": { label: "긴급", cls: "bg-destructive/10 text-destructive border-destructive/20" },
|
||||
};
|
||||
const PROGRESS_BADGE: Record<string, { label: string; cls: string }> = {
|
||||
"대기": { label: "대기", cls: "bg-warning/10 text-warning" },
|
||||
"진행중": { label: "진행중", cls: "bg-primary/10 text-primary" },
|
||||
"완료": { label: "완료", cls: "bg-success/10 text-success" },
|
||||
};
|
||||
|
||||
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;
|
||||
routing?: string; routingOptions?: RoutingVersionData[];
|
||||
}
|
||||
|
||||
export default function WorkInstructionPage() {
|
||||
const ts = useTableSettings("c16-work-instruction", "work_instruction", GRID_COLUMNS);
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [equipmentOptions, setEquipmentOptions] = useState<EquipmentOption[]>([]);
|
||||
const [employeeOptions, setEmployeeOptions] = useState<EmployeeOption[]>([]);
|
||||
|
||||
// 검색
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 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 [confirmAddQty, setConfirmAddQty] = useState("");
|
||||
const [confirmAddWorkerOpen, setConfirmAddWorkerOpen] = 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(() => {
|
||||
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 = {};
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "start_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split("|");
|
||||
if (from) params.dateFrom = from;
|
||||
if (to) params.dateTo = to;
|
||||
} else if (f.columnName === "status" && f.value) {
|
||||
params.status = f.value;
|
||||
} else if (f.columnName === "progress" && f.value) {
|
||||
params.progressStatus = f.value;
|
||||
} else if (f.columnName === "work_instruction_no" && f.value) {
|
||||
params.keyword = f.value;
|
||||
} else if (f.columnName === "item_name" && f.value) {
|
||||
params.keyword = f.value;
|
||||
}
|
||||
}
|
||||
const r = await getWorkInstructionList(params);
|
||||
if (r.success) setOrders(r.data || []);
|
||||
} catch {} finally { setLoading(false); }
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
||||
|
||||
// ─── 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 finalItems = regMergeSameItem ? Array.from(new Map(items.map(i => [i.itemCode, i])).values()) : items;
|
||||
const uniqueItemCodes = [...new Set(finalItems.map(i => i.itemCode).filter(Boolean))];
|
||||
for (const ic of uniqueItemCodes) {
|
||||
getRoutingVersions("__new__", ic).then(r => {
|
||||
if (r.success && r.data) {
|
||||
setConfirmItems(prev => prev.map(it => {
|
||||
if (it.itemCode !== ic) return it;
|
||||
const defaultRv = r.data.find(rv => rv.is_default);
|
||||
return { ...it, routingOptions: r.data, routing: defaultRv?.id || "" };
|
||||
}));
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
setIsRegModalOpen(false); setIsConfirmModalOpen(true);
|
||||
};
|
||||
|
||||
// 등록 확인 모달 — 인라인 품목 추가
|
||||
const addConfirmItem = () => {
|
||||
if (!confirmAddQty || Number(confirmAddQty) <= 0) { alert("수량을 입력해주세요."); return; }
|
||||
const firstItem = confirmItems[0];
|
||||
setConfirmItems(prev => [...prev, {
|
||||
itemCode: firstItem?.itemCode || "", itemName: firstItem?.itemName || "", spec: firstItem?.spec || "",
|
||||
qty: Number(confirmAddQty), remark: "",
|
||||
sourceType: "item" as SourceType, sourceTable: "item_info", sourceId: firstItem?.itemCode || "",
|
||||
}]);
|
||||
setConfirmAddQty("");
|
||||
};
|
||||
|
||||
// ─── 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, routing: i.routing || null })),
|
||||
};
|
||||
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 || "");
|
||||
const items: SelectedItem[] = 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 || "",
|
||||
routing: d.detail_routing_version_id || order.routing_version_id || "",
|
||||
routingOptions: [],
|
||||
}));
|
||||
setEditItems(items);
|
||||
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
|
||||
setEditRouting(order.routing_version_id || "");
|
||||
setEditRoutingOptions([]);
|
||||
|
||||
// 품목별 라우팅 옵션 로드
|
||||
const uniqueItemCodes = [...new Set(items.map(i => i.itemCode).filter(Boolean))];
|
||||
for (const ic of uniqueItemCodes) {
|
||||
getRoutingVersions(wiNo, ic).then(r => {
|
||||
if (r.success && r.data) {
|
||||
setEditItems(prev => prev.map(it => {
|
||||
if (it.itemCode !== ic) return it;
|
||||
const opts = r.data;
|
||||
const hasRouting = it.routing && opts.some(rv => rv.id === it.routing);
|
||||
return {
|
||||
...it,
|
||||
routingOptions: opts,
|
||||
routing: hasRouting ? it.routing : (opts.find(rv => rv.is_default)?.id || it.routing || ""),
|
||||
};
|
||||
}));
|
||||
}
|
||||
}).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, routing: i.routing || null })),
|
||||
};
|
||||
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-[calc(100vh-4rem)] p-3 gap-3">
|
||||
{/* 검색 필터 바 */}
|
||||
<DynamicSearchFilter
|
||||
tableName="work_instruction"
|
||||
filterId="c16-work-instruction"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={new Set(orders.map(o => o.work_instruction_no)).size}
|
||||
/>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<div className="flex-1 overflow-hidden border bg-card rounded-lg flex flex-col">
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/30 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-[13px] font-bold text-foreground">작업지시 목록</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{new Set(orders.map(o => o.work_instruction_no)).size}건 ({orders.length}행)
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openRegModal}>
|
||||
<Plus className="w-3.5 h-3.5" /> 작업지시 등록
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
{ts.isVisible("work_instruction_no") && <TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업지시번호</TableHead>}
|
||||
{ts.isVisible("status") && <TableHead className="w-[70px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
{ts.isVisible("progress") && <TableHead className="w-[100px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">진행현황</TableHead>}
|
||||
{ts.isVisible("item_name") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>}
|
||||
{ts.isVisible("spec") && <TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
||||
{ts.isVisible("qty") && <TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>}
|
||||
{ts.isVisible("equipment") && <TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비</TableHead>}
|
||||
{ts.isVisible("routing") && <TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>}
|
||||
{ts.isVisible("work_team") && <TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업조</TableHead>}
|
||||
{ts.isVisible("worker") && <TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업자</TableHead>}
|
||||
{ts.isVisible("start_date") && <TableHead className="w-[100px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">시작일</TableHead>}
|
||||
{ts.isVisible("end_date") && <TableHead className="w-[100px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">완료일</TableHead>}
|
||||
{ts.isVisible("actions") && <TableHead className="w-[150px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={13} className="text-center py-16"><Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : orders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} className="py-16">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<div className="rounded-full border-2 border-dashed border-muted-foreground/20 w-12 h-12 flex items-center justify-center mb-4">
|
||||
<Inbox className="w-6 h-6 text-muted-foreground/40" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">등록된 작업지시가 없어요</p>
|
||||
<p className="text-xs text-muted-foreground/60">새로운 작업지시를 등록해주세요</p>
|
||||
</div>
|
||||
</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/30">
|
||||
{ts.isVisible("work_instruction_no") && <TableCell className="font-mono text-[13px] font-medium">{getDisplayNo(o)}</TableCell>}
|
||||
{ts.isVisible("status") && <TableCell className="text-center"><Badge variant="outline" className={cn("text-[10px]", sBadge.cls)}>{sBadge.label}</Badge></TableCell>}
|
||||
{ts.isVisible("progress") && <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-success" : pct > 0 ? "bg-primary" : "bg-muted-foreground/30")} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">{pct}%</span>
|
||||
</div>
|
||||
) : <span className="text-[10px] text-muted-foreground">↑</span>}
|
||||
</TableCell>}
|
||||
{ts.isVisible("item_name") && <TableCell className="text-sm">{o.item_name || o.item_number || "-"}</TableCell>}
|
||||
{ts.isVisible("spec") && <TableCell className="text-[13px] text-muted-foreground">{o.item_spec || "-"}</TableCell>}
|
||||
{ts.isVisible("qty") && <TableCell className="text-right text-[13px] font-mono font-medium">{Number(o.detail_qty || 0).toLocaleString()}</TableCell>}
|
||||
{ts.isVisible("equipment") && <TableCell className="text-[13px]">{isFirstOfGroup ? (o.equipment_name || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("routing") && <TableCell className="text-[13px]">
|
||||
{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>}
|
||||
{ts.isVisible("work_team") && <TableCell className="text-center text-[13px]">{isFirstOfGroup ? (o.work_team || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("worker") && <TableCell className="text-[13px]">{isFirstOfGroup ? getWorkerName(o.worker) : ""}</TableCell>}
|
||||
{ts.isVisible("start_date") && <TableCell className="text-center text-[13px] font-mono">{isFirstOfGroup ? (o.start_date || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("end_date") && <TableCell className="text-center text-[13px] font-mono">{isFirstOfGroup ? (o.end_date || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("actions") && <TableCell className="text-center">
|
||||
{isFirstOfGroup && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs px-2" onClick={() => openEditModal(o)}><Pencil className="w-3 h-3" /> 수정</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs px-2 text-destructive hover:text-destructive" onClick={() => handleDelete(o.wi_id)}><Trash2 className="w-3 h-3" /></Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 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 text-muted-foreground">근거를 선택하고 품목을 체크한 후 "작업지시 적용" 버튼을 눌러주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-6 py-3 border-b bg-muted/30 flex items-center gap-3 flex-wrap shrink-0">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground whitespace-nowrap">근거</span>
|
||||
<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-xs font-semibold text-foreground">동일품목 합산</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-center">
|
||||
<div className="rounded-full border-2 border-dashed border-muted-foreground/20 w-12 h-12 flex items-center justify-center mb-4">
|
||||
<Search className="w-6 h-6 text-muted-foreground/40" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">근거를 선택해주세요</p>
|
||||
<p className="text-xs text-muted-foreground/60">근거를 선택하고 검색하면 품목이 표시돼요</p>
|
||||
</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-center">
|
||||
<div className="rounded-full border-2 border-dashed border-muted-foreground/20 w-12 h-12 flex items-center justify-center mb-4">
|
||||
<Inbox className="w-6 h-6 text-muted-foreground/40" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">검색 결과가 없어요</p>
|
||||
<p className="text-xs text-muted-foreground/60">다른 키워드로 검색해주세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
|
||||
{regSourceType === "item" && <><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead></>}
|
||||
{regSourceType === "order" && <><TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수주번호</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] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획번호</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/30", 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-[13px] font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell></>}
|
||||
{regSourceType === "order" && <><TableCell className="text-[13px]">{item.order_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-[13px]">{item.spec || "-"}</TableCell><TableCell className="text-right text-[13px]">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.due_date || "-"}</TableCell></>}
|
||||
{regSourceType === "production" && <><TableCell className="text-[13px]">{item.plan_no}</TableCell><TableCell className="text-[13px]">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-[13px]">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-[13px]">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-[13px]">{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="ghost" 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="ghost" 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-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>작업지시 적용 확인</DialogTitle>
|
||||
<DialogDescription>기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-5">
|
||||
<div className="bg-muted/30 border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground">작업지시 기본 정보</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">작업지시번호</Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">상태</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"><Label className="text-[11px] font-semibold text-muted-foreground">시작일</Label><Input type="date" value={confirmStartDate} onChange={(e) => setConfirmStartDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">완료예정일</Label><Input type="date" value={confirmEndDate} onChange={(e) => setConfirmEndDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">설비</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"><Label className="text-[11px] font-semibold text-muted-foreground">작업조</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"><Label className="text-[11px] font-semibold text-muted-foreground">작업자</Label>
|
||||
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">비고</Label><Input className="h-9" placeholder="비고를 입력해주세요" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3">품목 목록</h4>
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">순번</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{confirmItems.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
|
||||
<TableCell><Input type="number" className="h-7 text-[13px] w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={nv(item.routing || "")}
|
||||
onValueChange={v => {
|
||||
const val = fromNv(v);
|
||||
setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, routing: val } : it));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="라우팅" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안 함</SelectItem>
|
||||
{(item.routingOptions || []).map((rv: RoutingVersionData) => (
|
||||
<SelectItem key={rv.id} value={rv.id}>
|
||||
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell><Input className="h-7 text-[13px]" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(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={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<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={(v) => { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}>
|
||||
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`}</DialogTitle>
|
||||
<DialogDescription>품목을 추가/삭제하고 정보를 수정해주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-5">
|
||||
<div className="bg-muted/30 border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground">기본 정보</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">상태</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"><Label className="text-[11px] font-semibold text-muted-foreground">시작일</Label><Input type="date" value={editStartDate} onChange={(e) => setEditStartDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">완료예정일</Label><Input type="date" value={editEndDate} onChange={(e) => setEditEndDate(e.target.value)} className="h-9" /></div>
|
||||
<div className="space-y-1"><Label className="text-[11px] font-semibold text-muted-foreground">설비</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"><Label className="text-[11px] font-semibold text-muted-foreground">작업조</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"><Label className="text-[11px] font-semibold text-muted-foreground">작업자</Label>
|
||||
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
|
||||
</div>
|
||||
<div className="space-y-1 col-span-2"><Label className="text-[11px] font-semibold text-muted-foreground">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고를 입력해주세요" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-muted/30 border-b">
|
||||
<span className="text-[13px] font-bold text-foreground">작업지시 항목</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{editItems.length}건</span>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">순번</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[180px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공정작업기준</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{editItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center py-8 text-sm text-muted-foreground">등록된 품목이 없어요</TableCell></TableRow>
|
||||
) : editItems.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-[13px] max-w-[150px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-right"><Input type="number" className="h-7 text-[13px] 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>
|
||||
<Select
|
||||
value={nv(item.routing || "")}
|
||||
onValueChange={v => {
|
||||
const val = fromNv(v);
|
||||
setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, routing: val } : it));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="라우팅" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안 함</SelectItem>
|
||||
{(item.routingOptions || []).map((rv: RoutingVersionData) => (
|
||||
<SelectItem key={rv.id} value={rv.id}>
|
||||
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={!item.routing}
|
||||
onClick={() => {
|
||||
if (!editOrder || !item.routing) return;
|
||||
const rv = (item.routingOptions || []).find((r: RoutingVersionData) => r.id === item.routing);
|
||||
openWorkStandardModal(
|
||||
editOrder.work_instruction_no,
|
||||
item.routing,
|
||||
rv?.version_name || "",
|
||||
item.itemName || item.itemCode || "",
|
||||
item.itemCode || ""
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ClipboardCheck className="w-3 h-3 mr-1" /> 수정
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell><Input className="h-7 text-[13px]" 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="px-4 py-2.5 border-t bg-muted/30 flex items-center justify-between">
|
||||
<span className="text-[13px] font-bold text-foreground">총 수량</span>
|
||||
<span className="text-lg font-bold font-mono text-primary">{editItems.reduce((s, i) => s + i.qty, 0).toLocaleString()} EA</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<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}
|
||||
/>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,755 @@
|
||||
"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 { 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, Truck, 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 { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const ITEM_TABLE = "item_info";
|
||||
const MAPPING_TABLE = "supplier_item_mapping";
|
||||
const SUPPLIER_TABLE = "supplier_mng";
|
||||
|
||||
const ITEM_COLUMNS = [
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "standard_price", label: "기준단가" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
|
||||
export default function PurchaseItemPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
// 검색 필터 (DynamicSearchFilter)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 좌측: 품목
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [itemLoading, setItemLoading] = useState(false);
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||
|
||||
// 우측: 공급업체
|
||||
const [supplierItems, setSupplierItems] = useState<any[]>([]);
|
||||
const [supplierLoading, setSupplierLoading] = useState(false);
|
||||
const [supplierCheckedIds, setSupplierCheckedIds] = useState<string[]>([]);
|
||||
|
||||
// 카테고리
|
||||
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// 공급업체 추가 모달
|
||||
const [suppSelectOpen, setSuppSelectOpen] = useState(false);
|
||||
const [suppSearchKeyword, setSuppSearchKeyword] = useState("");
|
||||
const [suppSearchResults, setSuppSearchResults] = useState<any[]>([]);
|
||||
const [suppSearchLoading, setSuppSearchLoading] = useState(false);
|
||||
const [suppCheckedIds, setSuppCheckedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 공급업체 상세 입력 모달
|
||||
const [suppDetailOpen, setSuppDetailOpen] = useState(false);
|
||||
const [selectedSuppsForDetail, setSelectedSuppsForDetail] = useState<any[]>([]);
|
||||
const [suppMappings, setSuppMappings] = useState<Record<string, {
|
||||
supplier_item_code: string; supplier_item_name: string;
|
||||
base_price: string; discount_type: string; discount_value: string; calculated_price: string;
|
||||
currency_code: string; start_date: string; end_date: string;
|
||||
lead_time_days: string; min_order_qty: string;
|
||||
}>>({});
|
||||
const [editSuppData, setEditSuppData] = useState<any>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 품목 수정 모달
|
||||
const [editItemOpen, setEditItemOpen] = useState(false);
|
||||
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
|
||||
|
||||
// 엑셀
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
// 테이블 설정
|
||||
const ts = useTableSettings("c16-purchase-item", ITEM_TABLE, ITEM_COLUMNS);
|
||||
|
||||
// 카테고리 로드
|
||||
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 ["currency_code", "status"]) {
|
||||
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 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,
|
||||
});
|
||||
setItems(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch {
|
||||
toast.error("품목 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setItemLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchItems(); }, [fetchItems]);
|
||||
|
||||
const selectedItem = items.find((i) => i.id === selectedItemId);
|
||||
const isColVisible = (key: string) => ts.isVisible(key);
|
||||
const itemColSpan = 2 + ITEM_COLUMNS.filter((c) => isColVisible(c.key)).length;
|
||||
|
||||
// 우측: 공급업체 매핑 조회
|
||||
useEffect(() => {
|
||||
if (!selectedItem?.item_number) { setSupplierItems([]); setSupplierCheckedIds([]); return; }
|
||||
setSupplierCheckedIds([]);
|
||||
const itemKey = selectedItem.item_number;
|
||||
const fetchSupplierMappings = async () => {
|
||||
setSupplierLoading(true);
|
||||
try {
|
||||
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 || [];
|
||||
const suppIds = [...new Set(mappings.map((m: any) => m.supplier_id).filter(Boolean))];
|
||||
let suppMap: Record<string, any> = {};
|
||||
if (suppIds.length > 0) {
|
||||
try {
|
||||
const suppRes = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, {
|
||||
page: 1, size: suppIds.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "supplier_code", operator: "in", value: suppIds }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
for (const s of (suppRes.data?.data?.data || suppRes.data?.data?.rows || [])) {
|
||||
suppMap[s.supplier_code] = s;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setSupplierItems(mappings.map((m: any) => ({
|
||||
...m,
|
||||
supplier_code: m.supplier_id || "",
|
||||
supplier_name: suppMap[m.supplier_id]?.supplier_name || "",
|
||||
})));
|
||||
} catch {
|
||||
toast.error("공급업체 정보를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setSupplierLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSupplierMappings();
|
||||
}, [selectedItem?.item_number]);
|
||||
|
||||
// 공급업체 검색
|
||||
const searchSuppliers = async () => {
|
||||
setSuppSearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (suppSearchKeyword) filters.push({ columnName: "supplier_name", operator: "contains", value: suppSearchKeyword });
|
||||
const res = await apiClient.post(`/table-management/tables/${SUPPLIER_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(supplierItems.map((s: any) => s.supplier_id));
|
||||
setSuppSearchResults(all.filter((s: any) => !existing.has(s.supplier_code)));
|
||||
} catch { /* skip */ } finally { setSuppSearchLoading(false); }
|
||||
};
|
||||
|
||||
// 단가 자동 계산
|
||||
const calcPrice = (base: string, discType: string, discVal: string): string => {
|
||||
const bp = Number(base) || 0;
|
||||
const dv = Number(discVal) || 0;
|
||||
if (discType === "rate") return String(Math.round(bp * (1 - dv / 100)));
|
||||
if (discType === "amount") return String(Math.round(bp - dv));
|
||||
return String(bp);
|
||||
};
|
||||
|
||||
const goToSuppDetail = () => {
|
||||
const selected = suppSearchResults.filter((s) => suppCheckedIds.has(s.id));
|
||||
if (selected.length === 0) { toast.error("공급업체를 선택해주세요."); return; }
|
||||
setSelectedSuppsForDetail(selected);
|
||||
const mappings: typeof suppMappings = {};
|
||||
for (const supp of selected) {
|
||||
const key = supp.supplier_code || supp.id;
|
||||
mappings[key] = {
|
||||
supplier_item_code: "", supplier_item_name: "",
|
||||
base_price: selectedItem?.standard_price || "", discount_type: "none",
|
||||
discount_value: "", calculated_price: selectedItem?.standard_price || "",
|
||||
currency_code: "", start_date: "", end_date: "",
|
||||
lead_time_days: "", min_order_qty: "",
|
||||
};
|
||||
}
|
||||
setSuppMappings(mappings);
|
||||
setSuppSelectOpen(false);
|
||||
setEditSuppData(null);
|
||||
setSuppDetailOpen(true);
|
||||
};
|
||||
|
||||
const updateMapping = (suppKey: string, field: string, value: string) => {
|
||||
setSuppMappings((prev) => {
|
||||
const cur = prev[suppKey] || {} as any;
|
||||
const updated = { ...cur, [field]: value };
|
||||
if (["base_price", "discount_type", "discount_value"].includes(field)) {
|
||||
updated.calculated_price = calcPrice(updated.base_price, updated.discount_type, updated.discount_value);
|
||||
}
|
||||
return { ...prev, [suppKey]: updated };
|
||||
});
|
||||
};
|
||||
|
||||
const openEditSupp = (row: any) => {
|
||||
const suppKey = row.supplier_id || row.supplier_code;
|
||||
setSelectedSuppsForDetail([{ supplier_code: suppKey, supplier_name: row.supplier_name || "" }]);
|
||||
setSuppMappings({
|
||||
[suppKey]: {
|
||||
supplier_item_code: row.supplier_item_code || "",
|
||||
supplier_item_name: row.supplier_item_name || "",
|
||||
base_price: row.base_price ? String(row.base_price) : "",
|
||||
discount_type: row.discount_type || "none",
|
||||
discount_value: row.discount_value ? String(row.discount_value) : "",
|
||||
calculated_price: row.calculated_price ? String(row.calculated_price) : "",
|
||||
currency_code: row.currency_code || "",
|
||||
start_date: row.start_date ? String(row.start_date).split("T")[0] : "",
|
||||
end_date: row.end_date ? String(row.end_date).split("T")[0] : "",
|
||||
lead_time_days: row.lead_time_days ? String(row.lead_time_days) : "",
|
||||
min_order_qty: row.min_order_qty ? String(row.min_order_qty) : "",
|
||||
},
|
||||
});
|
||||
setEditSuppData(row);
|
||||
setSuppDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleSuppDetailSave = async () => {
|
||||
if (!selectedItem) return;
|
||||
const isEdit = !!editSuppData;
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const supp of selectedSuppsForDetail) {
|
||||
const suppKey = supp.supplier_code || supp.id;
|
||||
const m = suppMappings[suppKey];
|
||||
if (!m) continue;
|
||||
const fields: Record<string, any> = {
|
||||
supplier_id: suppKey, item_id: selectedItem.item_number,
|
||||
supplier_item_code: m.supplier_item_code || null,
|
||||
supplier_item_name: m.supplier_item_name || null,
|
||||
base_price: m.base_price ? Number(m.base_price) : null,
|
||||
discount_type: m.discount_type === "none" ? null : m.discount_type || null,
|
||||
discount_value: m.discount_value ? Number(m.discount_value) : null,
|
||||
calculated_price: m.calculated_price ? Number(m.calculated_price) : null,
|
||||
currency_code: m.currency_code || null,
|
||||
start_date: m.start_date || null,
|
||||
end_date: m.end_date || null,
|
||||
lead_time_days: m.lead_time_days ? Number(m.lead_time_days) : null,
|
||||
min_order_qty: m.min_order_qty ? Number(m.min_order_qty) : null,
|
||||
};
|
||||
if (isEdit && editSuppData?.id) {
|
||||
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
|
||||
originalData: { id: editSuppData.id }, updatedData: fields,
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { id: crypto.randomUUID(), ...fields });
|
||||
}
|
||||
}
|
||||
toast.success(isEdit ? "수정되었습니다." : `${selectedSuppsForDetail.length}개 공급업체가 추가되었습니다.`);
|
||||
setSuppDetailOpen(false);
|
||||
setEditSuppData(null);
|
||||
setSuppCheckedIds(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: {
|
||||
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 handleSupplierMappingDelete = async () => {
|
||||
if (supplierCheckedIds.length === 0) return;
|
||||
const ok = await confirm(`선택한 ${supplierCheckedIds.length}개 공급업체 매핑을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
|
||||
data: supplierCheckedIds.map((id) => ({ id })),
|
||||
});
|
||||
toast.success(`${supplierCheckedIds.length}개 공급업체 매핑이 삭제되었습니다.`);
|
||||
setSupplierCheckedIds([]);
|
||||
const sid = selectedItemId;
|
||||
setSelectedItemId(null);
|
||||
setTimeout(() => setSelectedItemId(sid), 50);
|
||||
} catch { toast.error("삭제에 실패했습니다."); }
|
||||
};
|
||||
|
||||
const handleExcelDownload = async () => {
|
||||
if (items.length === 0) return;
|
||||
await exportToExcel(items.map((i) => ({
|
||||
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
|
||||
기준단가: i.standard_price, 통화: i.currency_code, 상태: i.status,
|
||||
})), "구매품목정보.xlsx", "구매품목");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 바 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={ITEM_TABLE}
|
||||
filterId="c16-purchase-item"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={items.length}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
extraActions={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5" /> 엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||
<Download className="w-3.5 h-3.5" /> 엑셀 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 분할 패널 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border bg-card">
|
||||
{/* 좌측: 구매품목 */}
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[13px] font-bold">구매품목 목록</h3>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{items.length}건</span>
|
||||
{itemLoading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<Button size="sm" variant="ghost" onClick={() => ts.setOpen(true)}>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
{isColVisible("size") && <TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
||||
{isColVisible("unit") && <TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>}
|
||||
{isColVisible("standard_price") && <TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기준단가</TableHead>}
|
||||
{isColVisible("status") && <TableHead className="w-[60px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemLoading ? (
|
||||
<TableRow><TableCell colSpan={itemColSpan} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
|
||||
) : items.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={itemColSpan} className="h-32 text-center text-muted-foreground text-sm">등록된 구매품목이 없어요</TableCell></TableRow>
|
||||
) : items.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs border-l-2",
|
||||
selectedItemId === item.id ? "border-l-primary bg-primary/5" : "border-l-transparent"
|
||||
)}
|
||||
onClick={() => setSelectedItemId(item.id)}
|
||||
onDoubleClick={openEditItem}
|
||||
>
|
||||
<TableCell className="p-2 font-medium truncate max-w-[110px]">{item.item_number}</TableCell>
|
||||
<TableCell className="p-2 truncate max-w-[160px]">{item.item_name}</TableCell>
|
||||
{isColVisible("size") && <TableCell className="p-2 truncate">{item.size || "-"}</TableCell>}
|
||||
{isColVisible("unit") && <TableCell className="p-2">{item.unit || "-"}</TableCell>}
|
||||
{isColVisible("standard_price") && <TableCell className="p-2 text-right">{item.standard_price ? Number(item.standard_price).toLocaleString() : "-"}</TableCell>}
|
||||
{isColVisible("status") && (
|
||||
<TableCell className="p-2 text-center">
|
||||
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded",
|
||||
item.status === "ACTIVE" || item.status === "사용" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}>{item.status || "-"}</span>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 공급업체 정보 */}
|
||||
<ResizablePanel defaultSize={50} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
{!selectedItemId ? (
|
||||
<div className="flex-1 flex items-center justify-center p-5">
|
||||
<div className="flex flex-col items-center gap-3 border-2 border-dashed border-border rounded-lg p-10 text-center">
|
||||
<Truck className="w-12 h-12 text-muted-foreground/40" />
|
||||
<div className="text-sm font-semibold text-muted-foreground">품목을 선택해주세요</div>
|
||||
<div className="text-xs text-muted-foreground">좌측에서 품목을 선택하면 공급업체 정보가 표시돼요</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="text-[13px] font-bold truncate">{selectedItem?.item_name || "-"}</h3>
|
||||
<span className="text-[11px] font-mono text-muted-foreground bg-muted-foreground/10 px-2 py-0.5 rounded-full shrink-0">{selectedItem?.item_number || ""}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={openEditItem}><Pencil className="w-3.5 h-3.5" /> 수정</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground">공급업체별 단가</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-1.5 py-0.5 rounded-full font-mono">{supplierItems.length}건</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={() => { setSuppCheckedIds(new Set()); setSuppSelectOpen(true); searchSuppliers(); }}>
|
||||
<Plus className="w-3.5 h-3.5" /> 공급업체 추가
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={supplierCheckedIds.length === 0}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={handleSupplierMappingDelete}>
|
||||
<Trash2 className="w-3.5 h-3.5" /> 삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="p-2 w-10">
|
||||
<Checkbox
|
||||
checked={supplierItems.length > 0 && supplierCheckedIds.length === supplierItems.length}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) setSupplierCheckedIds(supplierItems.map((s) => s.id));
|
||||
else setSupplierCheckedIds([]);
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체코드</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체명</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체품번</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기준가</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">통화</TableHead>
|
||||
<TableHead className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">리드타임</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{supplierLoading ? (
|
||||
<TableRow><TableCell colSpan={8} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
|
||||
) : supplierItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8} className="h-32 text-center text-muted-foreground text-[13px]">등록된 공급업체가 없어요</TableCell></TableRow>
|
||||
) : supplierItems.map((s) => (
|
||||
<TableRow
|
||||
key={s.id}
|
||||
className={cn("text-xs cursor-pointer", supplierCheckedIds.includes(s.id) && "bg-primary/5")}
|
||||
onDoubleClick={() => openEditSupp(s)}
|
||||
onClick={() => setSupplierCheckedIds((prev) => {
|
||||
const next = [...prev];
|
||||
const idx = next.indexOf(s.id);
|
||||
if (idx >= 0) next.splice(idx, 1); else next.push(s.id);
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
<TableCell className="p-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={supplierCheckedIds.includes(s.id)}
|
||||
onCheckedChange={(checked) => setSupplierCheckedIds((prev) =>
|
||||
checked ? [...prev, s.id] : prev.filter((id) => id !== s.id)
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-2 font-medium truncate max-w-[110px]">{s.supplier_code}</TableCell>
|
||||
<TableCell className="p-2 truncate max-w-[120px]">{s.supplier_name}</TableCell>
|
||||
<TableCell className="p-2 truncate">{s.supplier_item_code || "-"}</TableCell>
|
||||
<TableCell className="p-2 text-right">{s.base_price ? Number(s.base_price).toLocaleString() : "-"}</TableCell>
|
||||
<TableCell className="p-2 text-right font-medium">{s.calculated_price ? Number(s.calculated_price).toLocaleString() : "-"}</TableCell>
|
||||
<TableCell className="p-2">{s.currency_code || "-"}</TableCell>
|
||||
<TableCell className="p-2 text-right">{s.lead_time_days ? `${s.lead_time_days}일` : "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* 품목 수정 모달 */}
|
||||
<Dialog open={editItemOpen} onOpenChange={setEditItemOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>구매품목 수정</DialogTitle>
|
||||
<DialogDescription>{editItemForm.item_number} — {editItemForm.item_name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{([
|
||||
{ key: "item_number", label: "품목코드" }, { key: "item_name", label: "품명" },
|
||||
{ key: "size", label: "규격" }, { key: "unit", label: "단위" },
|
||||
{ key: "material", label: "재질" }, { key: "status", label: "상태" },
|
||||
] as { key: string; label: string }[]).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-1" />
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">기준단가</Label>
|
||||
<Input type="number" 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>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditItemOpen(false)}>취소</Button>
|
||||
<Button onClick={handleEditSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />} 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 공급업체 선택 모달 */}
|
||||
<Dialog open={suppSelectOpen} onOpenChange={setSuppSelectOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>공급업체 선택</DialogTitle>
|
||||
<DialogDescription>품목에 추가할 공급업체를 선택하세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="공급업체명 검색" value={suppSearchKeyword}
|
||||
onChange={(e) => setSuppSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchSuppliers()}
|
||||
className="h-9 flex-1" />
|
||||
<Button size="sm" onClick={searchSuppliers} disabled={suppSearchLoading} className="h-9">
|
||||
{suppSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4" /> 조회</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10 text-center">
|
||||
<Checkbox
|
||||
checked={suppSearchResults.length > 0 && suppCheckedIds.size === suppSearchResults.length}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) setSuppCheckedIds(new Set(suppSearchResults.map((s) => s.id)));
|
||||
else setSuppCheckedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체명</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">담당자</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">연락처</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{suppSearchResults.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8 text-sm">검색 결과가 없어요</TableCell></TableRow>
|
||||
) : suppSearchResults.map((s) => (
|
||||
<TableRow key={s.id} className={cn("cursor-pointer", suppCheckedIds.has(s.id) && "bg-primary/5")}
|
||||
onClick={() => setSuppCheckedIds((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"><Checkbox checked={suppCheckedIds.has(s.id)} onCheckedChange={() => {}} /></TableCell>
|
||||
<TableCell className="text-[13px]">{s.supplier_code}</TableCell>
|
||||
<TableCell className="text-sm">{s.supplier_name}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.contact_person}</TableCell>
|
||||
<TableCell className="text-[13px]">{s.contact_phone}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2 w-full justify-between">
|
||||
<span className="text-sm text-muted-foreground">{suppCheckedIds.size}개 선택됨</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setSuppSelectOpen(false)}>취소</Button>
|
||||
<Button onClick={goToSuppDetail} disabled={suppCheckedIds.size === 0}>
|
||||
<Plus className="w-4 h-4" /> {suppCheckedIds.size}개 다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 공급업체 상세 입력/수정 모달 */}
|
||||
<Dialog open={suppDetailOpen} onOpenChange={setSuppDetailOpen}>
|
||||
<DialogContent className="max-w-[900px] overflow-y-auto" style={{ maxHeight: "90vh" }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>공급업체 매핑 {editSuppData ? "수정" : "등록"} — {selectedItem?.item_name || ""}</DialogTitle>
|
||||
<DialogDescription>{editSuppData ? "공급업체 품번/단가 정보를 수정합니다." : "공급업체별 품번과 단가를 입력합니다."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
{selectedSuppsForDetail.map((supp, idx) => {
|
||||
const suppKey = supp.supplier_code || supp.id;
|
||||
const m = suppMappings[suppKey] || {} as any;
|
||||
return (
|
||||
<div key={suppKey} className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center gap-2.5 px-4 py-2.5 bg-muted/50 border-b">
|
||||
<span className="text-[13px] font-bold">{idx + 1}. {supp.supplier_name || suppKey}</span>
|
||||
<span className="text-[11px] font-mono text-muted-foreground bg-muted-foreground/10 px-2 py-0.5 rounded-full">{suppKey}</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">공급업체 품번</Label>
|
||||
<Input value={m.supplier_item_code || ""} onChange={(e) => updateMapping(suppKey, "supplier_item_code", e.target.value)} placeholder="공급업체 자체 품번" className="h-9 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">공급업체 품명</Label>
|
||||
<Input value={m.supplier_item_name || ""} onChange={(e) => updateMapping(suppKey, "supplier_item_name", e.target.value)} placeholder="공급업체 자체 품명" className="h-9 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-3 bg-muted/30 space-y-3">
|
||||
<span className="text-xs font-semibold text-muted-foreground">단가 정보</span>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">기준단가</Label>
|
||||
<Input type="number" value={m.base_price || ""} onChange={(e) => updateMapping(suppKey, "base_price", e.target.value)} className="h-8 text-xs text-right" placeholder="0" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">할인유형</Label>
|
||||
<Select value={m.discount_type || "none"} onValueChange={(v) => updateMapping(suppKey, "discount_type", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">할인없음</SelectItem>
|
||||
<SelectItem value="rate">할인율(%)</SelectItem>
|
||||
<SelectItem value="amount">할인금액</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">할인값</Label>
|
||||
<Input type="number" value={m.discount_value || ""} onChange={(e) => updateMapping(suppKey, "discount_value", e.target.value)} className="h-8 text-xs text-right" placeholder="0" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">계산단가</Label>
|
||||
<Input value={m.calculated_price ? Number(m.calculated_price).toLocaleString() : "-"} className="h-8 text-[13px] text-right bg-muted/50 font-bold" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">통화</Label>
|
||||
<Input value={m.currency_code || ""} onChange={(e) => updateMapping(suppKey, "currency_code", e.target.value)} className="h-8 text-xs" placeholder="KRW" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">적용시작일</Label>
|
||||
<Input type="date" value={m.start_date || ""} onChange={(e) => updateMapping(suppKey, "start_date", e.target.value)} className="h-8 text-xs" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">적용종료일</Label>
|
||||
<Input type="date" value={m.end_date || ""} onChange={(e) => updateMapping(suppKey, "end_date", e.target.value)} className="h-8 text-xs" />
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">리드타임(일)</Label>
|
||||
<Input type="number" value={m.lead_time_days || ""} onChange={(e) => updateMapping(suppKey, "lead_time_days", e.target.value)} className="h-8 text-xs" placeholder="0" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">최소주문수량</Label>
|
||||
<Input type="number" value={m.min_order_qty || ""} onChange={(e) => updateMapping(suppKey, "min_order_qty", e.target.value)} className="h-8 text-xs" placeholder="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setSuppDetailOpen(false);
|
||||
if (!editSuppData) setSuppSelectOpen(true);
|
||||
setEditSuppData(null);
|
||||
}}>{editSuppData ? "취소" : "← 이전"}</Button>
|
||||
<Button onClick={handleSuppDetailSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />} 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ExcelUploadModal open={excelUploadOpen} onOpenChange={setExcelUploadOpen} tableName={ITEM_TABLE} userId={user?.userId} onSuccess={fetchItems} />
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
"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 { 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, Truck, 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 { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const SUPPLIER_TABLE = "supplier_mng";
|
||||
const MAPPING_TABLE = "supplier_item_mapping";
|
||||
|
||||
const SUPPLIER_COLUMNS = [
|
||||
{ key: "contact_person", label: "담당자" },
|
||||
{ key: "contact_phone", label: "연락처" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
|
||||
export default function SupplierManagementPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
// 검색 필터 (DynamicSearchFilter)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 좌측: 공급업체 목록
|
||||
const [suppliers, setSuppliers] = useState<any[]>([]);
|
||||
const [supplierLoading, setSupplierLoading] = useState(false);
|
||||
const [selectedSupplierId, setSelectedSupplierId] = useState<string | null>(null);
|
||||
|
||||
// 우측: 품목 매핑
|
||||
const [mappingItems, setMappingItems] = useState<any[]>([]);
|
||||
const [mappingLoading, setMappingLoading] = useState(false);
|
||||
const [mappingCheckedIds, setMappingCheckedIds] = useState<string[]>([]);
|
||||
|
||||
// 공급업체 등록/수정 모달
|
||||
const [supplierModalOpen, setSupplierModalOpen] = useState(false);
|
||||
const [supplierEditMode, setSupplierEditMode] = useState(false);
|
||||
const [supplierForm, setSupplierForm] = useState<Record<string, any>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 품목 추가 모달 (1단계: 검색/선택)
|
||||
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());
|
||||
|
||||
// 품목 상세 입력 모달 (2단계)
|
||||
const [itemDetailOpen, setItemDetailOpen] = useState(false);
|
||||
const [selectedItemsForDetail, setSelectedItemsForDetail] = useState<any[]>([]);
|
||||
const [itemMappings, setItemMappings] = useState<Record<string, {
|
||||
supplier_item_code: string; supplier_item_name: string;
|
||||
base_price: string; discount_type: string; discount_value: string; calculated_price: string;
|
||||
currency_code: string; start_date: string; end_date: string;
|
||||
lead_time_days: string; min_order_qty: string;
|
||||
}>>({});
|
||||
const [editItemData, setEditItemData] = useState<any>(null);
|
||||
|
||||
// 엑셀
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
// 테이블 설정
|
||||
const ts = useTableSettings("c16-supplier", SUPPLIER_TABLE, SUPPLIER_COLUMNS);
|
||||
|
||||
// 좌측: 공급업체 조회
|
||||
const fetchSuppliers = useCallback(async () => {
|
||||
setSupplierLoading(true);
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
setSuppliers(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch {
|
||||
toast.error("공급업체 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setSupplierLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchSuppliers(); }, [fetchSuppliers]);
|
||||
|
||||
const selectedSupplier = suppliers.find((s) => s.id === selectedSupplierId);
|
||||
const isColVisible = (key: string) => ts.isVisible(key);
|
||||
const supplierColSpan = 2 + SUPPLIER_COLUMNS.filter((c) => isColVisible(c.key)).length;
|
||||
|
||||
// 우측: 품목 매핑 조회
|
||||
useEffect(() => {
|
||||
if (!selectedSupplier?.supplier_code) { setMappingItems([]); setMappingCheckedIds([]); return; }
|
||||
setMappingCheckedIds([]);
|
||||
const fetchMappings = async () => {
|
||||
setMappingLoading(true);
|
||||
try {
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "supplier_id", operator: "equals", value: selectedSupplier.supplier_code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
const itemIds = [...new Set(mappings.map((m: any) => m.item_id).filter(Boolean))];
|
||||
let itemMap: Record<string, any> = {};
|
||||
if (itemIds.length > 0) {
|
||||
try {
|
||||
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: itemIds.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemIds }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
for (const item of (itemRes.data?.data?.data || itemRes.data?.data?.rows || [])) {
|
||||
itemMap[item.item_number] = item;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setMappingItems(mappings.map((m: any) => ({
|
||||
...m,
|
||||
item_number: m.item_id || "",
|
||||
item_name: itemMap[m.item_id]?.item_name || "",
|
||||
})));
|
||||
} catch {
|
||||
toast.error("품목 정보를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setMappingLoading(false);
|
||||
}
|
||||
};
|
||||
fetchMappings();
|
||||
}, [selectedSupplier?.supplier_code]);
|
||||
|
||||
// 단가 자동 계산
|
||||
const calcPrice = (base: string, discType: string, discVal: string): string => {
|
||||
const bp = Number(base) || 0;
|
||||
const dv = Number(discVal) || 0;
|
||||
if (discType === "rate") return String(Math.round(bp * (1 - dv / 100)));
|
||||
if (discType === "amount") return String(Math.round(bp - dv));
|
||||
return String(bp);
|
||||
};
|
||||
|
||||
const openSupplierRegister = () => { setSupplierForm({}); setSupplierEditMode(false); setSupplierModalOpen(true); };
|
||||
const openSupplierEdit = () => {
|
||||
if (!selectedSupplier) return;
|
||||
setSupplierForm({ ...selectedSupplier });
|
||||
setSupplierEditMode(true);
|
||||
setSupplierModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSupplierSave = async () => {
|
||||
if (!supplierForm.supplier_name) { toast.error("공급업체명은 필수입니다."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const { id, created_date, updated_date, writer, company_code, status: _s, ...fields } = supplierForm;
|
||||
const cleanFields: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(fields)) cleanFields[key] = value === "" ? null : value;
|
||||
if (supplierEditMode && id) {
|
||||
await apiClient.put(`/table-management/tables/${SUPPLIER_TABLE}/edit`, { originalData: { id }, updatedData: cleanFields });
|
||||
toast.success("수정되었습니다.");
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${SUPPLIER_TABLE}/add`, { id: crypto.randomUUID(), ...cleanFields });
|
||||
toast.success("등록되었습니다.");
|
||||
}
|
||||
setSupplierModalOpen(false);
|
||||
fetchSuppliers();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleSupplierDelete = async () => {
|
||||
if (!selectedSupplierId) return;
|
||||
const ok = await confirm("공급업체를 삭제하시겠습니까?", { description: "관련된 품목 매핑 정보도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${SUPPLIER_TABLE}/delete`, { data: [{ id: selectedSupplierId }] });
|
||||
toast.success("삭제되었습니다.");
|
||||
setSelectedSupplierId(null);
|
||||
fetchSuppliers();
|
||||
} catch { toast.error("삭제에 실패했습니다."); }
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
const allItems = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const existingItemIds = new Set(mappingItems.map((m: any) => m.item_id));
|
||||
setItemSearchResults(allItems.filter((item: any) => !existingItemIds.has(item.item_number)));
|
||||
} catch { /* skip */ } finally { setItemSearchLoading(false); }
|
||||
};
|
||||
|
||||
const goToItemDetail = () => {
|
||||
const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id));
|
||||
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
|
||||
setSelectedItemsForDetail(selected);
|
||||
const mappings: typeof itemMappings = {};
|
||||
for (const item of selected) {
|
||||
const key = item.item_number || item.id;
|
||||
mappings[key] = {
|
||||
supplier_item_code: "", supplier_item_name: "",
|
||||
base_price: item.standard_price || "", discount_type: "none",
|
||||
discount_value: "", calculated_price: item.standard_price || "",
|
||||
currency_code: "", start_date: "", end_date: "",
|
||||
lead_time_days: "", min_order_qty: "",
|
||||
};
|
||||
}
|
||||
setItemMappings(mappings);
|
||||
setItemSelectOpen(false);
|
||||
setEditItemData(null);
|
||||
setItemDetailOpen(true);
|
||||
};
|
||||
|
||||
const updateMapping = (itemKey: string, field: string, value: string) => {
|
||||
setItemMappings((prev) => {
|
||||
const cur = prev[itemKey] || {} as any;
|
||||
const updated = { ...cur, [field]: value };
|
||||
if (["base_price", "discount_type", "discount_value"].includes(field)) {
|
||||
updated.calculated_price = calcPrice(updated.base_price, updated.discount_type, updated.discount_value);
|
||||
}
|
||||
return { ...prev, [itemKey]: updated };
|
||||
});
|
||||
};
|
||||
|
||||
const openEditItem = (row: any) => {
|
||||
const itemKey = row.item_id || row.item_number;
|
||||
setSelectedItemsForDetail([{ item_number: itemKey, item_name: row.item_name || "" }]);
|
||||
setItemMappings({
|
||||
[itemKey]: {
|
||||
supplier_item_code: row.supplier_item_code || "",
|
||||
supplier_item_name: row.supplier_item_name || "",
|
||||
base_price: row.base_price ? String(row.base_price) : "",
|
||||
discount_type: row.discount_type || "none",
|
||||
discount_value: row.discount_value ? String(row.discount_value) : "",
|
||||
calculated_price: row.calculated_price ? String(row.calculated_price) : "",
|
||||
currency_code: row.currency_code || "",
|
||||
start_date: row.start_date ? String(row.start_date).split("T")[0] : "",
|
||||
end_date: row.end_date ? String(row.end_date).split("T")[0] : "",
|
||||
lead_time_days: row.lead_time_days ? String(row.lead_time_days) : "",
|
||||
min_order_qty: row.min_order_qty ? String(row.min_order_qty) : "",
|
||||
},
|
||||
});
|
||||
setEditItemData(row);
|
||||
setItemDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleItemDetailSave = async () => {
|
||||
if (!selectedSupplier) return;
|
||||
const isEdit = !!editItemData;
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const item of selectedItemsForDetail) {
|
||||
const itemKey = item.item_number || item.id;
|
||||
const m = itemMappings[itemKey];
|
||||
if (!m) continue;
|
||||
const fields: Record<string, any> = {
|
||||
supplier_id: selectedSupplier.supplier_code, item_id: itemKey,
|
||||
supplier_item_code: m.supplier_item_code || null,
|
||||
supplier_item_name: m.supplier_item_name || null,
|
||||
base_price: m.base_price ? Number(m.base_price) : null,
|
||||
discount_type: m.discount_type === "none" ? null : m.discount_type || null,
|
||||
discount_value: m.discount_value ? Number(m.discount_value) : null,
|
||||
calculated_price: m.calculated_price ? Number(m.calculated_price) : null,
|
||||
currency_code: m.currency_code || null,
|
||||
start_date: m.start_date || null,
|
||||
end_date: m.end_date || null,
|
||||
lead_time_days: m.lead_time_days ? Number(m.lead_time_days) : null,
|
||||
min_order_qty: m.min_order_qty ? Number(m.min_order_qty) : null,
|
||||
};
|
||||
if (isEdit && editItemData?.id) {
|
||||
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, { originalData: { id: editItemData.id }, updatedData: fields });
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { id: crypto.randomUUID(), ...fields });
|
||||
}
|
||||
}
|
||||
toast.success(isEdit ? "수정되었습니다." : `${selectedItemsForDetail.length}개 품목이 추가되었습니다.`);
|
||||
setItemDetailOpen(false);
|
||||
setEditItemData(null);
|
||||
setItemCheckedIds(new Set());
|
||||
const sid = selectedSupplierId;
|
||||
setSelectedSupplierId(null);
|
||||
setTimeout(() => setSelectedSupplierId(sid), 50);
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleMappingDelete = async () => {
|
||||
if (mappingCheckedIds.length === 0) return;
|
||||
const ok = await confirm(`선택한 ${mappingCheckedIds.length}개 품목 매핑을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, { data: mappingCheckedIds.map((id) => ({ id })) });
|
||||
toast.success(`${mappingCheckedIds.length}개 품목 매핑이 삭제되었습니다.`);
|
||||
setMappingCheckedIds([]);
|
||||
const sid = selectedSupplierId;
|
||||
setSelectedSupplierId(null);
|
||||
setTimeout(() => setSelectedSupplierId(sid), 50);
|
||||
} catch { toast.error("삭제에 실패했습니다."); }
|
||||
};
|
||||
|
||||
const handleExcelDownload = async () => {
|
||||
if (suppliers.length === 0) return;
|
||||
await exportToExcel(suppliers.map((s) => ({
|
||||
공급업체코드: s.supplier_code, 공급업체명: s.supplier_name,
|
||||
담당자: s.contact_person, 연락처: s.contact_phone,
|
||||
사업자번호: s.business_number, 이메일: s.email, 상태: s.status,
|
||||
})), "공급업체관리.xlsx", "공급업체");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 바 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={SUPPLIER_TABLE}
|
||||
filterId="c16-supplier"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={suppliers.length}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
extraActions={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5" /> 엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||
<Download className="w-3.5 h-3.5" /> 엑셀 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 분할 패널 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border bg-card">
|
||||
{/* 좌측: 공급업체 목록 */}
|
||||
<ResizablePanel defaultSize={45} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[13px] font-bold">공급업체 목록</h3>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{suppliers.length}건</span>
|
||||
{supplierLoading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={openSupplierRegister}><Plus className="w-3.5 h-3.5" /> 등록</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedSupplierId} onClick={openSupplierEdit}><Pencil className="w-3.5 h-3.5" /> 수정</Button>
|
||||
<Button variant="destructive" size="sm" disabled={!selectedSupplierId} onClick={handleSupplierDelete}><Trash2 className="w-3.5 h-3.5" /> 삭제</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => ts.setOpen(true)}>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체코드</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체명</TableHead>
|
||||
{isColVisible("contact_person") && <TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">담당자</TableHead>}
|
||||
{isColVisible("contact_phone") && <TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">연락처</TableHead>}
|
||||
{isColVisible("status") && <TableHead className="w-[70px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{supplierLoading ? (
|
||||
<TableRow><TableCell colSpan={supplierColSpan} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
|
||||
) : suppliers.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={supplierColSpan} className="h-32 text-center text-muted-foreground text-sm">등록된 공급업체가 없어요</TableCell></TableRow>
|
||||
) : suppliers.map((s) => (
|
||||
<TableRow
|
||||
key={s.id}
|
||||
className={cn(
|
||||
"cursor-pointer text-xs border-l-2",
|
||||
selectedSupplierId === s.id ? "border-l-primary bg-primary/5" : "border-l-transparent"
|
||||
)}
|
||||
onClick={() => setSelectedSupplierId(s.id)}
|
||||
onDoubleClick={openSupplierEdit}
|
||||
>
|
||||
<TableCell className="p-2 font-medium truncate max-w-[120px]">{s.supplier_code}</TableCell>
|
||||
<TableCell className="p-2 truncate max-w-[160px]">{s.supplier_name}</TableCell>
|
||||
{isColVisible("contact_person") && <TableCell className="p-2 truncate">{s.contact_person || "-"}</TableCell>}
|
||||
{isColVisible("contact_phone") && <TableCell className="p-2 truncate">{s.contact_phone || "-"}</TableCell>}
|
||||
{isColVisible("status") && (
|
||||
<TableCell className="p-2 text-center">
|
||||
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded",
|
||||
s.status === "ACTIVE" || s.status === "사용" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||
)}>{s.status || "-"}</span>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 품목 매핑 */}
|
||||
<ResizablePanel defaultSize={55} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
{!selectedSupplierId ? (
|
||||
<div className="flex-1 flex items-center justify-center p-5">
|
||||
<div className="flex flex-col items-center gap-3 border-2 border-dashed border-border rounded-lg p-10 text-center">
|
||||
<Truck className="w-12 h-12 text-muted-foreground/40" />
|
||||
<div className="text-sm font-semibold text-muted-foreground">공급업체를 선택해주세요</div>
|
||||
<div className="text-xs text-muted-foreground">좌측에서 공급업체를 선택하면 품목 정보가 표시돼요</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||
<h3 className="text-[13px] font-bold">{selectedSupplier?.supplier_name || "-"}</h3>
|
||||
<span className="font-mono text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full">{selectedSupplier?.supplier_code || "-"}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground">등록 품목</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-1.5 py-0.5 rounded-full font-mono">{mappingItems.length}건</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
|
||||
<Plus className="w-3.5 h-3.5" /> 품목 추가
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={mappingCheckedIds.length === 0}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={handleMappingDelete}>
|
||||
<Trash2 className="w-3.5 h-3.5" /> 삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="p-2 w-10">
|
||||
<Checkbox
|
||||
checked={mappingItems.length > 0 && mappingCheckedIds.length === mappingItems.length}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) setMappingCheckedIds(mappingItems.map((m) => m.id));
|
||||
else setMappingCheckedIds([]);
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체품번</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기준가</TableHead>
|
||||
<TableHead className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">통화</TableHead>
|
||||
<TableHead className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">리드타임</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mappingLoading ? (
|
||||
<TableRow><TableCell colSpan={8} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
|
||||
) : mappingItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={8} className="h-32 text-center text-muted-foreground text-[13px]">등록된 품목이 없어요</TableCell></TableRow>
|
||||
) : mappingItems.map((m) => (
|
||||
<TableRow
|
||||
key={m.id}
|
||||
className={cn("text-xs cursor-pointer", mappingCheckedIds.includes(m.id) && "bg-primary/5")}
|
||||
onDoubleClick={() => openEditItem(m)}
|
||||
onClick={() => setMappingCheckedIds((prev) => {
|
||||
const next = [...prev];
|
||||
const idx = next.indexOf(m.id);
|
||||
if (idx >= 0) next.splice(idx, 1); else next.push(m.id);
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
<TableCell className="p-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={mappingCheckedIds.includes(m.id)}
|
||||
onCheckedChange={(checked) => setMappingCheckedIds((prev) =>
|
||||
checked ? [...prev, m.id] : prev.filter((id) => id !== m.id)
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-2 font-medium truncate max-w-[100px]">{m.item_number}</TableCell>
|
||||
<TableCell className="p-2 truncate max-w-[120px]">{m.item_name || "-"}</TableCell>
|
||||
<TableCell className="p-2 truncate">{m.supplier_item_code || "-"}</TableCell>
|
||||
<TableCell className="p-2 text-right">{m.base_price ? Number(m.base_price).toLocaleString() : "-"}</TableCell>
|
||||
<TableCell className="p-2 text-right font-medium">{m.calculated_price ? Number(m.calculated_price).toLocaleString() : "-"}</TableCell>
|
||||
<TableCell className="p-2">{m.currency_code || "-"}</TableCell>
|
||||
<TableCell className="p-2 text-right">{m.lead_time_days ? `${m.lead_time_days}일` : "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* 공급업체 등록/수정 모달 */}
|
||||
<Dialog open={supplierModalOpen} onOpenChange={setSupplierModalOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{supplierEditMode ? "공급업체 수정" : "공급업체 등록"}</DialogTitle>
|
||||
<DialogDescription>{supplierEditMode ? "공급업체 정보를 수정합니다." : "새로운 공급업체를 등록합니다."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">공급업체 코드</Label>
|
||||
<Input value={supplierForm.supplier_code || ""} onChange={(e) => setSupplierForm((p) => ({ ...p, supplier_code: e.target.value }))} placeholder="공급업체 코드" className="h-9" disabled={supplierEditMode} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">공급업체명 <span className="text-destructive">*</span></Label>
|
||||
<Input value={supplierForm.supplier_name || ""} onChange={(e) => setSupplierForm((p) => ({ ...p, supplier_name: e.target.value }))} placeholder="공급업체명" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">담당자명</Label>
|
||||
<Input value={supplierForm.contact_person || ""} onChange={(e) => setSupplierForm((p) => ({ ...p, contact_person: e.target.value }))} placeholder="담당자명" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">담당자 연락처</Label>
|
||||
<Input value={supplierForm.contact_phone || ""} onChange={(e) => setSupplierForm((p) => ({ ...p, contact_phone: e.target.value }))} placeholder="010-0000-0000" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사업자번호</Label>
|
||||
<Input value={supplierForm.business_number || ""} onChange={(e) => setSupplierForm((p) => ({ ...p, business_number: e.target.value }))} placeholder="000-00-00000" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이메일</Label>
|
||||
<Input value={supplierForm.email || ""} onChange={(e) => setSupplierForm((p) => ({ ...p, email: e.target.value }))} placeholder="example@email.com" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-sm">주소</Label>
|
||||
<Input value={supplierForm.address || ""} onChange={(e) => setSupplierForm((p) => ({ ...p, address: e.target.value }))} placeholder="사업장 주소" className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSupplierModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSupplierSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />} 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 품목 선택 모달 */}
|
||||
<Dialog open={itemSelectOpen} onOpenChange={setItemSelectOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>품목 선택</DialogTitle>
|
||||
<DialogDescription>공급업체에 추가할 품목을 선택하세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<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" /> 조회</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10 text-center">
|
||||
<Checkbox
|
||||
checked={itemSearchResults.length > 0 && itemCheckedIds.size === itemSearchResults.length}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id)));
|
||||
else setItemCheckedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기준단가</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemSearchResults.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8 text-sm">검색 결과가 없어요</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"><Checkbox checked={itemCheckedIds.has(item.id)} onCheckedChange={() => {}} /></TableCell>
|
||||
<TableCell className="text-[13px]">{item.item_number}</TableCell>
|
||||
<TableCell className="text-sm">{item.item_name}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.size || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.unit || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-right">{item.standard_price ? Number(item.standard_price).toLocaleString() : "-"}</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={() => setItemSelectOpen(false)}>취소</Button>
|
||||
<Button onClick={goToItemDetail} disabled={itemCheckedIds.size === 0}>
|
||||
<Plus className="w-4 h-4" /> {itemCheckedIds.size}개 다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 품목 상세 입력/수정 모달 */}
|
||||
<Dialog open={itemDetailOpen} onOpenChange={setItemDetailOpen}>
|
||||
<DialogContent className="max-w-[900px] overflow-y-auto" style={{ maxHeight: "90vh" }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>품목 매핑 {editItemData ? "수정" : "등록"} — {selectedSupplier?.supplier_name || ""}</DialogTitle>
|
||||
<DialogDescription>{editItemData ? "공급업체 품번/단가 정보를 수정합니다." : "품목별 공급업체 품번과 단가를 입력합니다."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6">
|
||||
{selectedItemsForDetail.map((item, idx) => {
|
||||
const itemKey = item.item_number || item.id;
|
||||
const m = itemMappings[itemKey] || {} as any;
|
||||
return (
|
||||
<div key={itemKey} className="border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center gap-2.5 px-4 py-2.5 bg-muted/50 border-b">
|
||||
<span className="text-[13px] font-bold">{idx + 1}. {item.item_name || itemKey}</span>
|
||||
<span className="text-[11px] font-mono text-muted-foreground bg-muted-foreground/10 px-2 py-0.5 rounded-full">{itemKey}</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">공급업체 품번</Label>
|
||||
<Input value={m.supplier_item_code || ""} onChange={(e) => updateMapping(itemKey, "supplier_item_code", e.target.value)} placeholder="공급업체 자체 품번" className="h-9 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">공급업체 품명</Label>
|
||||
<Input value={m.supplier_item_name || ""} onChange={(e) => updateMapping(itemKey, "supplier_item_name", e.target.value)} placeholder="공급업체 자체 품명" className="h-9 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-3 bg-muted/30 space-y-3">
|
||||
<span className="text-xs font-semibold text-muted-foreground">단가 정보</span>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">기준단가</Label>
|
||||
<Input type="number" value={m.base_price || ""} onChange={(e) => updateMapping(itemKey, "base_price", e.target.value)} className="h-8 text-xs text-right" placeholder="0" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">할인유형</Label>
|
||||
<Select value={m.discount_type || "none"} onValueChange={(v) => updateMapping(itemKey, "discount_type", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">할인없음</SelectItem>
|
||||
<SelectItem value="rate">할인율(%)</SelectItem>
|
||||
<SelectItem value="amount">할인금액</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">할인값</Label>
|
||||
<Input type="number" value={m.discount_value || ""} onChange={(e) => updateMapping(itemKey, "discount_value", e.target.value)} className="h-8 text-xs text-right" placeholder="0" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">계산단가</Label>
|
||||
<Input value={m.calculated_price ? Number(m.calculated_price).toLocaleString() : "-"} className="h-8 text-[13px] text-right bg-muted/50 font-bold" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">통화</Label>
|
||||
<Input value={m.currency_code || ""} onChange={(e) => updateMapping(itemKey, "currency_code", e.target.value)} className="h-8 text-xs" placeholder="KRW" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">적용시작일</Label>
|
||||
<Input type="date" value={m.start_date || ""} onChange={(e) => updateMapping(itemKey, "start_date", e.target.value)} className="h-8 text-xs" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">적용종료일</Label>
|
||||
<Input type="date" value={m.end_date || ""} onChange={(e) => updateMapping(itemKey, "end_date", e.target.value)} className="h-8 text-xs" />
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">리드타임(일)</Label>
|
||||
<Input type="number" value={m.lead_time_days || ""} onChange={(e) => updateMapping(itemKey, "lead_time_days", e.target.value)} className="h-8 text-xs" placeholder="0" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">최소주문수량</Label>
|
||||
<Input type="number" value={m.min_order_qty || ""} onChange={(e) => updateMapping(itemKey, "min_order_qty", e.target.value)} className="h-8 text-xs" placeholder="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setItemDetailOpen(false);
|
||||
if (!editItemData) setItemSelectOpen(true);
|
||||
setEditItemData(null);
|
||||
}}>{editItemData ? "취소" : "← 이전"}</Button>
|
||||
<Button onClick={handleItemDetailSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />} 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ExcelUploadModal open={excelUploadOpen} onOpenChange={setExcelUploadOpen} tableName={SUPPLIER_TABLE} userId={user?.userId} onSuccess={fetchSuppliers} />
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
"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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil,
|
||||
ClipboardCheck, AlertTriangle, Wrench, Search, Inbox, Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
|
||||
/* ───── 테이블명 ───── */
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
|
||||
const INSPECTION_COLUMNS = [
|
||||
{ key: "inspection_type", label: "검사유형" },
|
||||
{ key: "inspection_standard", label: "검사기준" },
|
||||
{ key: "inspection_item_name", label: "검사항목명" },
|
||||
{ key: "inspection_method", label: "검사방법" },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "apply_type", label: "적용유형" },
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
const DEFECT_TABLE = "defect_standard_mng";
|
||||
const EQUIPMENT_TABLE = "inspection_equipment_mng";
|
||||
|
||||
/* ───── 카테고리 flatten ───── */
|
||||
const flattenCategories = (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(...flattenCategories(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export default function InspectionManagementPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const ts = useTableSettings("c16-inspection", INSPECTION_TABLE, INSPECTION_COLUMNS);
|
||||
|
||||
const [activeTab, setActiveTab] = useState("inspection");
|
||||
|
||||
/* ───── 검사기준 ───── */
|
||||
const [inspections, setInspections] = useState<any[]>([]);
|
||||
const [inspLoading, setInspLoading] = useState(false);
|
||||
const [inspCount, setInspCount] = useState(0);
|
||||
const [inspChecked, setInspChecked] = useState<string[]>([]);
|
||||
const [inspModalOpen, setInspModalOpen] = useState(false);
|
||||
const [inspEditMode, setInspEditMode] = useState(false);
|
||||
const [inspForm, setInspForm] = useState<Record<string, any>>({});
|
||||
const [inspSaving, setInspSaving] = useState(false);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
/* ───── 불량관리 ───── */
|
||||
const [defects, setDefects] = useState<any[]>([]);
|
||||
const [defLoading, setDefLoading] = useState(false);
|
||||
const [defCount, setDefCount] = useState(0);
|
||||
const [defChecked, setDefChecked] = useState<string[]>([]);
|
||||
const [defModalOpen, setDefModalOpen] = useState(false);
|
||||
const [defEditMode, setDefEditMode] = useState(false);
|
||||
const [defForm, setDefForm] = useState<Record<string, any>>({});
|
||||
const [defSaving, setDefSaving] = useState(false);
|
||||
const [defKeyword, setDefKeyword] = useState("");
|
||||
|
||||
/* ───── 검사장비 ───── */
|
||||
const [equipments, setEquipments] = useState<any[]>([]);
|
||||
const [eqLoading, setEqLoading] = useState(false);
|
||||
const [eqCount, setEqCount] = useState(0);
|
||||
const [eqChecked, setEqChecked] = useState<string[]>([]);
|
||||
const [eqModalOpen, setEqModalOpen] = useState(false);
|
||||
const [eqEditMode, setEqEditMode] = useState(false);
|
||||
const [eqForm, setEqForm] = useState<Record<string, any>>({});
|
||||
const [eqSaving, setEqSaving] = useState(false);
|
||||
const [eqKeyword, setEqKeyword] = useState("");
|
||||
|
||||
/* ───── 카테고리 옵션 ───── */
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const catList = [
|
||||
{ table: INSPECTION_TABLE, col: "inspection_type" },
|
||||
{ table: INSPECTION_TABLE, col: "apply_type" },
|
||||
{ table: DEFECT_TABLE, col: "defect_type" },
|
||||
{ table: EQUIPMENT_TABLE, col: "equipment_status" },
|
||||
];
|
||||
await Promise.all(
|
||||
catList.map(async ({ table, col }) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${table}/${col}/values`);
|
||||
if (res.data?.data?.length > 0) {
|
||||
optMap[`${table}.${col}`] = flattenCategories(res.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
})
|
||||
);
|
||||
setCatOptions(optMap);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const getCatLabel = (table: string, col: string, code: string) => {
|
||||
const opts = catOptions[`${table}.${col}`];
|
||||
if (!opts) return code;
|
||||
return opts.find(o => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchInspections = useCallback(async () => {
|
||||
setInspLoading(true);
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setInspections(rows);
|
||||
setInspCount(rows.length);
|
||||
} catch {
|
||||
toast.error("검사기준 조회에 실패했어요");
|
||||
} finally {
|
||||
setInspLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
const fetchDefects = useCallback(async () => {
|
||||
setDefLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setDefects(rows);
|
||||
setDefCount(rows.length);
|
||||
} catch {
|
||||
toast.error("불량관리 조회에 실패했어요");
|
||||
} finally {
|
||||
setDefLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchEquipments = useCallback(async () => {
|
||||
setEqLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEquipments(rows);
|
||||
setEqCount(rows.length);
|
||||
} catch {
|
||||
toast.error("검사장비 조회에 실패했어요");
|
||||
} finally {
|
||||
setEqLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchInspections(); }, [fetchInspections]);
|
||||
useEffect(() => { fetchDefects(); fetchEquipments(); }, []);
|
||||
|
||||
/* ───── 클라이언트 필터 ───── */
|
||||
const filteredDefects = defKeyword.trim()
|
||||
? defects.filter(r => (r.defect_name || "").toLowerCase().includes(defKeyword.toLowerCase()) || (r.defect_type || "").toLowerCase().includes(defKeyword.toLowerCase()))
|
||||
: defects;
|
||||
|
||||
const filteredEquipments = eqKeyword.trim()
|
||||
? equipments.filter(r => (r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) || (r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()))
|
||||
: equipments;
|
||||
|
||||
/* ═══════════════════ 검사기준 CRUD ═══════════════════ */
|
||||
const openInspCreate = () => { setInspForm({}); setInspEditMode(false); setInspModalOpen(true); };
|
||||
const openInspEdit = (row: any) => { setInspForm({ ...row }); setInspEditMode(true); setInspModalOpen(true); };
|
||||
const saveInspection = async () => {
|
||||
if (!inspForm.inspection_standard) { toast.error("검사기준은 필수 입력이에요"); return; }
|
||||
setInspSaving(true);
|
||||
try {
|
||||
if (inspEditMode) {
|
||||
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
|
||||
originalData: { id: inspForm.id }, updatedData: inspForm,
|
||||
});
|
||||
toast.success("검사기준을 수정했어요");
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, { id: crypto.randomUUID(), ...inspForm });
|
||||
toast.success("검사기준을 등록했어요");
|
||||
}
|
||||
setInspModalOpen(false);
|
||||
fetchInspections();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
finally { setInspSaving(false); }
|
||||
};
|
||||
const deleteInspections = async () => {
|
||||
if (inspChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("검사기준 삭제", { description: `선택한 ${inspChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, {
|
||||
data: inspChecked.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${inspChecked.length}건을 삭제했어요`);
|
||||
setInspChecked([]);
|
||||
fetchInspections();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
|
||||
const openDefCreate = () => { setDefForm({}); setDefEditMode(false); setDefModalOpen(true); };
|
||||
const openDefEdit = (row: any) => { setDefForm({ ...row }); setDefEditMode(true); setDefModalOpen(true); };
|
||||
const saveDefect = async () => {
|
||||
if (!defForm.defect_name) { toast.error("불량명은 필수 입력이에요"); return; }
|
||||
setDefSaving(true);
|
||||
try {
|
||||
if (defEditMode) {
|
||||
await apiClient.put(`/table-management/tables/${DEFECT_TABLE}/edit`, {
|
||||
originalData: { id: defForm.id }, updatedData: defForm,
|
||||
});
|
||||
toast.success("불량유형을 수정했어요");
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, { id: crypto.randomUUID(), ...defForm });
|
||||
toast.success("불량유형을 등록했어요");
|
||||
}
|
||||
setDefModalOpen(false);
|
||||
fetchDefects();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
finally { setDefSaving(false); }
|
||||
};
|
||||
const deleteDefects = async () => {
|
||||
if (defChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("불량유형 삭제", { description: `선택한 ${defChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${DEFECT_TABLE}/delete`, {
|
||||
data: defChecked.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${defChecked.length}건을 삭제했어요`);
|
||||
setDefChecked([]);
|
||||
fetchDefects();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ 검사장비 CRUD ═══════════════════ */
|
||||
const openEqCreate = () => { setEqForm({}); setEqEditMode(false); setEqModalOpen(true); };
|
||||
const openEqEdit = (row: any) => { setEqForm({ ...row }); setEqEditMode(true); setEqModalOpen(true); };
|
||||
const saveEquipment = async () => {
|
||||
if (!eqForm.equipment_name) { toast.error("장비명은 필수 입력이에요"); return; }
|
||||
setEqSaving(true);
|
||||
try {
|
||||
if (eqEditMode) {
|
||||
await apiClient.put(`/table-management/tables/${EQUIPMENT_TABLE}/edit`, {
|
||||
originalData: { id: eqForm.id }, updatedData: eqForm,
|
||||
});
|
||||
toast.success("검사장비를 수정했어요");
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, { id: crypto.randomUUID(), ...eqForm });
|
||||
toast.success("검사장비를 등록했어요");
|
||||
}
|
||||
setEqModalOpen(false);
|
||||
fetchEquipments();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
finally { setEqSaving(false); }
|
||||
};
|
||||
const deleteEquipments = async () => {
|
||||
if (eqChecked.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("검사장비 삭제", { description: `선택한 ${eqChecked.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.` });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${EQUIPMENT_TABLE}/delete`, {
|
||||
data: eqChecked.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${eqChecked.length}건을 삭제했어요`);
|
||||
setEqChecked([]);
|
||||
fetchEquipments();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<div className="border-b px-3">
|
||||
<TabsList className="bg-transparent h-auto p-0 gap-0">
|
||||
<TabsTrigger
|
||||
value="inspection"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
||||
>
|
||||
<ClipboardCheck className="w-4 h-4 mr-2" />
|
||||
검사기준
|
||||
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{inspCount}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="defect"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 mr-2" />
|
||||
불량관리
|
||||
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{defCount}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="equipment"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-3 text-sm font-medium"
|
||||
>
|
||||
<Wrench className="w-4 h-4 mr-2" />
|
||||
검사장비
|
||||
<Badge variant="secondary" className="ml-2 bg-primary/10 text-primary text-xs">{eqCount}</Badge>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* ──── 검사기준 탭 ──── */}
|
||||
<TabsContent value="inspection" className="p-3 mt-0">
|
||||
<div className="mb-3">
|
||||
<DynamicSearchFilter
|
||||
tableName={INSPECTION_TABLE}
|
||||
filterId="c16-inspection"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={inspCount}
|
||||
extraActions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openInspCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
const sel = inspections.find(r => inspChecked.includes(r.id));
|
||||
if (sel) openInspEdit(sel);
|
||||
else toast.error("수정할 항목을 선택해주세요");
|
||||
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" onClick={deleteInspections}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={inspections.length > 0 && inspChecked.length === inspections.length}
|
||||
onCheckedChange={(v) => setInspChecked(v ? inspections.map(r => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{inspLoading ? (
|
||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : inspections.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 검사기준이 없어요</p></TableCell></TableRow>
|
||||
) : inspections.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn("cursor-pointer", inspChecked.includes(row.id) && "bg-primary/5")}
|
||||
onClick={() => setInspChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
||||
onDoubleClick={() => openInspEdit(row)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={inspChecked.includes(row.id)} onCheckedChange={(v) => setInspChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
||||
</TableCell>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
if (col.key === "inspection_type") return <TableCell key={col.key}>{getCatLabel(INSPECTION_TABLE, "inspection_type", row.inspection_type)}</TableCell>;
|
||||
if (col.key === "apply_type") return <TableCell key={col.key}>{getCatLabel(INSPECTION_TABLE, "apply_type", row.apply_type)}</TableCell>;
|
||||
if (col.key === "is_active") return <TableCell key={col.key} className="text-center"><Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge></TableCell>;
|
||||
return <TableCell key={col.key}>{row[col.key] ?? ""}</TableCell>;
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ──── 불량관리 탭 ──── */}
|
||||
<TabsContent value="defect" className="p-3 mt-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 w-56 text-sm"
|
||||
placeholder="불량명 검색..."
|
||||
value={defKeyword}
|
||||
onChange={(e) => setDefKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">{filteredDefects.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openDefCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
const sel = defects.find(r => defChecked.includes(r.id));
|
||||
if (sel) openDefEdit(sel);
|
||||
else toast.error("수정할 항목을 선택해주세요");
|
||||
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" onClick={deleteDefects}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={filteredDefects.length > 0 && defChecked.length === filteredDefects.length}
|
||||
onCheckedChange={(v) => setDefChecked(v ? filteredDefects.map(r => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">불량유형</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">불량명</TableHead>
|
||||
<TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">심각도</TableHead>
|
||||
<TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{defLoading ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : filteredDefects.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 불량유형이 없어요</p></TableCell></TableRow>
|
||||
) : filteredDefects.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn("cursor-pointer", defChecked.includes(row.id) && "bg-primary/5")}
|
||||
onClick={() => setDefChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
||||
onDoubleClick={() => openDefEdit(row)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={defChecked.includes(row.id)} onCheckedChange={(v) => setDefChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
||||
</TableCell>
|
||||
<TableCell>{getCatLabel(DEFECT_TABLE, "defect_type", row.defect_type)}</TableCell>
|
||||
<TableCell>{row.defect_name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={row.severity === "Critical" ? "destructive" : "secondary"} className="text-xs">{row.severity}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ──── 검사장비 탭 ──── */}
|
||||
<TabsContent value="equipment" className="p-3 mt-0">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="h-8 pl-8 w-56 text-sm"
|
||||
placeholder="장비명 검색..."
|
||||
value={eqKeyword}
|
||||
onChange={(e) => setEqKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-primary/10 text-primary">{filteredEquipments.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openEqCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
const sel = equipments.find(r => eqChecked.includes(r.id));
|
||||
if (sel) openEqEdit(sel);
|
||||
else toast.error("수정할 항목을 선택해주세요");
|
||||
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" onClick={deleteEquipments}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={filteredEquipments.length > 0 && eqChecked.length === filteredEquipments.length}
|
||||
onCheckedChange={(v) => setEqChecked(v ? filteredEquipments.map(r => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">장비명</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">모델명</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">제조사</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">교정주기</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">최종교정일</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">장비상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{eqLoading ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : filteredEquipments.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 검사장비가 없어요</p></TableCell></TableRow>
|
||||
) : filteredEquipments.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn("cursor-pointer", eqChecked.includes(row.id) && "bg-primary/5")}
|
||||
onClick={() => setEqChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
||||
onDoubleClick={() => openEqEdit(row)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={eqChecked.includes(row.id)} onCheckedChange={(v) => setEqChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
||||
</TableCell>
|
||||
<TableCell>{row.equipment_name}</TableCell>
|
||||
<TableCell>{row.model_name}</TableCell>
|
||||
<TableCell>{row.manufacturer}</TableCell>
|
||||
<TableCell>{row.calibration_cycle}</TableCell>
|
||||
<TableCell>{row.last_calibration_date}</TableCell>
|
||||
<TableCell>{getCatLabel(EQUIPMENT_TABLE, "equipment_status", row.equipment_status)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════ 검사기준 모달 ═══════════════════ */}
|
||||
<Dialog open={inspModalOpen} onOpenChange={setInspModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{inspEditMode ? "검사기준 수정" : "검사기준 등록"}</DialogTitle>
|
||||
<DialogDescription>검사기준 정보를 입력해주세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">검사유형</Label>
|
||||
<Select value={inspForm.inspection_type || ""} onValueChange={(v) => setInspForm(p => ({ ...p, inspection_type: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(catOptions[`${INSPECTION_TABLE}.inspection_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 font-semibold text-muted-foreground">적용유형</Label>
|
||||
<Select value={inspForm.apply_type || ""} onValueChange={(v) => setInspForm(p => ({ ...p, apply_type: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(catOptions[`${INSPECTION_TABLE}.apply_type`] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">검사기준 <span className="text-destructive">*</span></Label>
|
||||
<Input className="h-9" value={inspForm.inspection_standard || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_standard: e.target.value }))} placeholder="검사기준을 입력해주세요" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">검사항목명</Label>
|
||||
<Input className="h-9" value={inspForm.inspection_item_name || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_item_name: e.target.value }))} placeholder="검사항목명을 입력해주세요" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">검사방법</Label>
|
||||
<Input className="h-9" value={inspForm.inspection_method || ""} onChange={(e) => setInspForm(p => ({ ...p, inspection_method: e.target.value }))} placeholder="검사방법" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">단위</Label>
|
||||
<Input className="h-9" value={inspForm.unit || ""} onChange={(e) => setInspForm(p => ({ ...p, unit: e.target.value }))} placeholder="단위" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={inspForm.is_active ?? true} onCheckedChange={(v) => setInspForm(p => ({ ...p, is_active: !!v }))} />
|
||||
<Label className="text-sm">사용</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setInspModalOpen(false)}>취소</Button>
|
||||
<Button onClick={saveInspection} disabled={inspSaving}>
|
||||
{inspSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 불량관리 모달 ═══════════════════ */}
|
||||
<Dialog open={defModalOpen} onOpenChange={setDefModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{defEditMode ? "불량유형 수정" : "불량유형 등록"}</DialogTitle>
|
||||
<DialogDescription>불량유형 정보를 입력해주세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">불량유형</Label>
|
||||
<Select value={defForm.defect_type || ""} onValueChange={(v) => setDefForm(p => ({ ...p, defect_type: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(catOptions[`${DEFECT_TABLE}.defect_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 font-semibold text-muted-foreground">심각도 <span className="text-destructive">*</span></Label>
|
||||
<Select value={defForm.severity || ""} onValueChange={(v) => setDefForm(p => ({ ...p, severity: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Critical">Critical</SelectItem>
|
||||
<SelectItem value="Major">Major</SelectItem>
|
||||
<SelectItem value="Minor">Minor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">불량명 <span className="text-destructive">*</span></Label>
|
||||
<Input className="h-9" value={defForm.defect_name || ""} onChange={(e) => setDefForm(p => ({ ...p, defect_name: e.target.value }))} placeholder="불량명을 입력해주세요" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={defForm.is_active ?? true} onCheckedChange={(v) => setDefForm(p => ({ ...p, is_active: !!v }))} />
|
||||
<Label className="text-sm">사용</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDefModalOpen(false)}>취소</Button>
|
||||
<Button onClick={saveDefect} disabled={defSaving}>
|
||||
{defSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 검사장비 모달 ═══════════════════ */}
|
||||
<Dialog open={eqModalOpen} onOpenChange={setEqModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{eqEditMode ? "검사장비 수정" : "검사장비 등록"}</DialogTitle>
|
||||
<DialogDescription>검사장비 정보를 입력해주세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">장비명 <span className="text-destructive">*</span></Label>
|
||||
<Input className="h-9" value={eqForm.equipment_name || ""} onChange={(e) => setEqForm(p => ({ ...p, equipment_name: e.target.value }))} placeholder="장비명을 입력해주세요" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">모델명</Label>
|
||||
<Input className="h-9" value={eqForm.model_name || ""} onChange={(e) => setEqForm(p => ({ ...p, model_name: e.target.value }))} placeholder="모델명" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">제조사</Label>
|
||||
<Input className="h-9" value={eqForm.manufacturer || ""} onChange={(e) => setEqForm(p => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">교정주기</Label>
|
||||
<Input className="h-9" value={eqForm.calibration_cycle || ""} onChange={(e) => setEqForm(p => ({ ...p, calibration_cycle: e.target.value }))} placeholder="예: 12개월" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">최종교정일</Label>
|
||||
<Input type="date" className="h-9" value={eqForm.last_calibration_date || ""} onChange={(e) => setEqForm(p => ({ ...p, last_calibration_date: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">장비상태</Label>
|
||||
<Select value={eqForm.equipment_status || ""} onValueChange={(v) => setEqForm(p => ({ ...p, equipment_status: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(catOptions[`${EQUIPMENT_TABLE}.equipment_status`] || []).map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEqModalOpen(false)}>취소</Button>
|
||||
<Button onClick={saveEquipment} disabled={eqSaving}>
|
||||
{eqSaving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, Pencil, Inbox, Settings2,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
|
||||
const TABLE_NAME = "item_inspection_info";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "item_code", label: "품목코드" },
|
||||
{ key: "item_name", label: "품목명" },
|
||||
{ key: "inspection_standard_id", label: "검사기준ID" },
|
||||
{ key: "inspection_standard_name", label: "검사기준명" },
|
||||
{ key: "inspection_level", label: "검사수준" },
|
||||
{ key: "sampling_method", label: "샘플링방법" },
|
||||
{ key: "is_active", label: "사용여부" },
|
||||
];
|
||||
const ITEM_TABLE = "item_info";
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
|
||||
export default function ItemInspectionInfoPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const ts = useTableSettings("c16-item-inspection", TABLE_NAME, GRID_COLUMNS);
|
||||
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [form, setForm] = useState<Record<string, any>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
/* FK 옵션 */
|
||||
const [itemOptions, setItemOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
const [inspOptions, setInspOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ═══════════════════ FK 옵션 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
const [itemRes, inspRes] = await Promise.all([
|
||||
apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { page: 1, size: 500, autoFilter: true }),
|
||||
]);
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
setItemOptions(items.map((r: any) => ({ code: r.item_code, label: `${r.item_code} - ${r.item_name || ""}` })));
|
||||
|
||||
const insps = inspRes.data?.data?.data || inspRes.data?.data?.rows || [];
|
||||
setInspOptions(insps.map((r: any) => ({ code: r.id, label: r.inspection_standard || r.id })));
|
||||
} catch { /* skip */ }
|
||||
};
|
||||
loadOptions();
|
||||
}, []);
|
||||
|
||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
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 rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setData(rows);
|
||||
setTotalCount(rows.length);
|
||||
} catch {
|
||||
toast.error("품목검사정보 조회에 실패했어요");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
/* ═══════════════════ CRUD ═══════════════════ */
|
||||
const openCreate = () => { setForm({}); setEditMode(false); setModalOpen(true); };
|
||||
const openEdit = (row: any) => { setForm({ ...row }); setEditMode(true); setModalOpen(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.item_code) { toast.error("품목코드는 필수 입력이에요"); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editMode) {
|
||||
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
||||
originalData: { id: form.id }, updatedData: form,
|
||||
});
|
||||
toast.success("품목검사정보를 수정했어요");
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, { id: crypto.randomUUID(), ...form });
|
||||
toast.success("품목검사정보를 등록했어요");
|
||||
}
|
||||
setModalOpen(false);
|
||||
fetchData();
|
||||
} catch { toast.error("저장에 실패했어요"); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요"); return; }
|
||||
const ok = await confirm("품목검사정보 삭제", {
|
||||
description: `선택한 ${checkedIds.length}건을 삭제할까요? 이 작업은 되돌릴 수 없어요.`,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
||||
data: checkedIds.map(id => ({ id })),
|
||||
});
|
||||
toast.success(`${checkedIds.length}건을 삭제했어요`);
|
||||
setCheckedIds([]);
|
||||
fetchData();
|
||||
} catch { toast.error("삭제에 실패했어요"); }
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="px-3 py-2.5 border-b bg-muted/50">
|
||||
<DynamicSearchFilter
|
||||
tableName={TABLE_NAME}
|
||||
filterId="c16-item-inspection"
|
||||
onFilterChange={setSearchFilters}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
dataCount={totalCount}
|
||||
extraActions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" />등록</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => {
|
||||
const sel = data.find(r => checkedIds.includes(r.id));
|
||||
if (sel) openEdit(sel);
|
||||
else toast.error("수정할 항목을 선택해주세요");
|
||||
}}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
||||
<Button size="sm" variant="destructive" onClick={handleDelete}><Trash2 className="w-4 h-4 mr-1" />삭제</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={data.length > 0 && checkedIds.length === data.length}
|
||||
onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
||||
) : data.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 품목검사정보가 없어요</p></TableCell></TableRow>
|
||||
) : data.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn("cursor-pointer", checkedIds.includes(row.id) && "bg-primary/5")}
|
||||
onClick={() => setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
||||
onDoubleClick={() => openEdit(row)}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={(v) => setCheckedIds(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
||||
</TableCell>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableCell key={col.key}>
|
||||
{col.key === "is_active"
|
||||
? <Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
||||
: row[col.key] ?? ""}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════ 등록/수정 모달 ═══════════════════ */}
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editMode ? "품목검사정보 수정" : "품목검사정보 등록"}</DialogTitle>
|
||||
<DialogDescription>품목과 검사기준을 연결해주세요</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">품목코드 <span className="text-destructive">*</span></Label>
|
||||
<Select value={form.item_code || ""} onValueChange={(v) => {
|
||||
const opt = itemOptions.find(o => o.code === v);
|
||||
const name = opt ? opt.label.split(" - ").slice(1).join(" - ") : "";
|
||||
setForm(p => ({ ...p, item_code: v, item_name: name }));
|
||||
}}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="품목을 선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{itemOptions.map(o => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">품목명</Label>
|
||||
<Input className="h-9 bg-muted" value={form.item_name || ""} readOnly placeholder="품목 선택 시 자동입력" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">검사기준</Label>
|
||||
<Select value={form.inspection_standard_id || ""} onValueChange={(v) => {
|
||||
const opt = inspOptions.find(o => o.code === v);
|
||||
setForm(p => ({ ...p, inspection_standard_id: v, inspection_standard_name: opt?.label || "" }));
|
||||
}}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="검사기준을 선택해주세요" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{inspOptions.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 font-semibold text-muted-foreground">검사수준</Label>
|
||||
<Input className="h-9" value={form.inspection_level || ""} onChange={(e) => setForm(p => ({ ...p, inspection_level: e.target.value }))} placeholder="검사수준" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">샘플링방법</Label>
|
||||
<Input className="h-9" value={form.sampling_method || ""} onChange={(e) => setForm(p => ({ ...p, sampling_method: e.target.value }))} placeholder="샘플링방법" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox checked={form.is_active ?? true} onCheckedChange={(v) => setForm(p => ({ ...p, is_active: !!v }))} />
|
||||
<Label className="text-sm">사용</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin mr-1" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
저장해요
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
"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 { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Download,
|
||||
Plus,
|
||||
Save,
|
||||
ClipboardList,
|
||||
Inbox,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Loader2,
|
||||
FileSpreadsheet,
|
||||
Trash2,
|
||||
Pencil,
|
||||
FileText,
|
||||
Wrench,
|
||||
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 { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
// --- 상수 ---
|
||||
const TABLE_NAME = "claim_mng";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "claim_no", label: "클레임번호" },
|
||||
{ key: "claim_date", label: "접수일자" },
|
||||
{ key: "claim_type", label: "유형" },
|
||||
{ key: "claim_status", label: "상태" },
|
||||
{ key: "customer_name", label: "거래처명" },
|
||||
{ key: "manager_name", label: "담당자" },
|
||||
{ key: "claim_content", label: "클레임 내용" },
|
||||
];
|
||||
|
||||
type ClaimType = "불량" | "교환" | "반품" | "배송지연" | "기타";
|
||||
type ClaimStatus = "접수" | "처리중" | "완료" | "취소";
|
||||
|
||||
interface ClaimRow {
|
||||
id: number;
|
||||
claim_no: string;
|
||||
claim_date: string;
|
||||
claim_type: string;
|
||||
claim_status: string;
|
||||
customer_code: string;
|
||||
customer_name: string;
|
||||
manager_name: string;
|
||||
order_no: string;
|
||||
claim_content: string;
|
||||
process_content: string;
|
||||
company_code?: string;
|
||||
writer?: string;
|
||||
created_date?: string;
|
||||
updated_date?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface CustomerOption {
|
||||
customerCode: string;
|
||||
customerName: string;
|
||||
}
|
||||
|
||||
interface SalesOrderOption {
|
||||
orderNo: string;
|
||||
partnerName: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// Badge variant 매핑 (CSS 변수 기반)
|
||||
const getClaimStatusVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "접수": return "default" as const;
|
||||
case "처리중": return "secondary" as const;
|
||||
case "완료": return "outline" as const;
|
||||
case "취소": return "destructive" as const;
|
||||
default: return "secondary" as const;
|
||||
}
|
||||
};
|
||||
|
||||
const getClaimTypeVariant = (type: string) => {
|
||||
switch (type) {
|
||||
case "불량": return "destructive" as const;
|
||||
case "교환": return "warning" as const;
|
||||
case "반품": return "default" as const;
|
||||
case "배송지연": return "secondary" as const;
|
||||
case "기타": return "outline" as const;
|
||||
default: return "outline" as const;
|
||||
}
|
||||
};
|
||||
|
||||
const CLAIM_TYPES: ClaimType[] = ["불량", "교환", "반품", "배송지연", "기타"];
|
||||
const CLAIM_STATUSES: ClaimStatus[] = ["접수", "처리중", "완료", "취소"];
|
||||
|
||||
export default function ClaimManagementPage() {
|
||||
useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const ts = useTableSettings("c16-claim", TABLE_NAME, GRID_COLUMNS);
|
||||
|
||||
const [data, setData] = useState<ClaimRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [selectedClaimNo, setSelectedClaimNo] = useState<string | null>(null);
|
||||
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formData, setFormData] = useState<Partial<ClaimRow>>({});
|
||||
|
||||
// Combobox 상태
|
||||
const [customerOpen, setCustomerOpen] = useState(false);
|
||||
const [orderOpen, setOrderOpen] = useState(false);
|
||||
|
||||
// DB 데이터
|
||||
const [customers, setCustomers] = useState<CustomerOption[]>([]);
|
||||
const [salesOrders, setSalesOrders] = useState<SalesOrderOption[]>([]);
|
||||
const [customersLoading, setCustomersLoading] = useState(false);
|
||||
const [ordersLoading, setOrdersLoading] = useState(false);
|
||||
|
||||
// --- 데이터 조회 (서버사이드 필터 + autoFilter 멀티테넌시) ---
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters = searchFilters.map(f => ({
|
||||
columnName: f.columnName,
|
||||
operator: f.operator,
|
||||
value: f.value,
|
||||
}));
|
||||
|
||||
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,
|
||||
sort: { columnName: "claim_date", order: "desc" },
|
||||
});
|
||||
|
||||
const rows: ClaimRow[] = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setData(rows);
|
||||
setTotalCount(res.data?.data?.total || rows.length);
|
||||
} catch {
|
||||
toast.error("클레임 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
|
||||
// 거래처 목록 조회 (autoFilter로 멀티테넌시 적용)
|
||||
const fetchCustomers = useCallback(async (force = false) => {
|
||||
if (!force && customers.length > 0) return;
|
||||
setCustomersLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post("/table-management/tables/customer_mng/data", {
|
||||
page: 1,
|
||||
size: 9999,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows;
|
||||
if (res.data?.success && Array.isArray(rows)) {
|
||||
const list: CustomerOption[] = rows.map((row: any) => ({
|
||||
customerCode: row.customer_code || row.id || "",
|
||||
customerName: row.customer_name || "",
|
||||
}));
|
||||
setCustomers(list);
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
} finally {
|
||||
setCustomersLoading(false);
|
||||
}
|
||||
}, [customers.length]);
|
||||
|
||||
// 수주 목록 조회 (autoFilter로 멀티테넌시 적용)
|
||||
const fetchSalesOrders = useCallback(async (force = false) => {
|
||||
if (!force && salesOrders.length > 0) return;
|
||||
setOrdersLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post("/table-management/tables/sales_order_mng/data", {
|
||||
page: 1,
|
||||
size: 9999,
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows;
|
||||
if (res.data?.success && Array.isArray(rows)) {
|
||||
const seen = new Set<string>();
|
||||
const list: SalesOrderOption[] = [];
|
||||
for (const row of rows) {
|
||||
const orderNo = row.order_no || "";
|
||||
if (!orderNo || seen.has(orderNo)) continue;
|
||||
seen.add(orderNo);
|
||||
list.push({
|
||||
orderNo,
|
||||
partnerName: row.partner_id || "",
|
||||
status: row.status || "",
|
||||
});
|
||||
}
|
||||
setSalesOrders(list);
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
} finally {
|
||||
setOrdersLoading(false);
|
||||
}
|
||||
}, [salesOrders.length]);
|
||||
|
||||
// 상태별 카운트
|
||||
const statusCounts = useMemo(() => {
|
||||
const counts = { 접수: 0, 처리중: 0, 완료: 0, 취소: 0 };
|
||||
data.forEach((claim) => {
|
||||
if (counts[claim.claim_status as keyof typeof counts] !== undefined) {
|
||||
counts[claim.claim_status as keyof typeof counts]++;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [data]);
|
||||
|
||||
// 클레임번호 자동 생성
|
||||
const generateClaimNo = useCallback(() => {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `CLM-${year}-`;
|
||||
const existingNumbers = data
|
||||
.filter((c) => c.claim_no?.startsWith(prefix))
|
||||
.map((c) => parseInt(c.claim_no.replace(prefix, ""), 10))
|
||||
.filter((n) => !isNaN(n));
|
||||
const maxNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) : 0;
|
||||
return `${prefix}${String(maxNumber + 1).padStart(3, "0")}`;
|
||||
}, [data]);
|
||||
|
||||
const handleRowClick = (claimNo: string) => {
|
||||
setSelectedClaimNo(claimNo);
|
||||
};
|
||||
|
||||
const openRegisterModal = () => {
|
||||
setIsEditMode(false);
|
||||
setFormData({
|
||||
claim_no: generateClaimNo(),
|
||||
claim_date: new Date().toISOString().split("T")[0],
|
||||
claim_type: undefined,
|
||||
claim_status: "접수",
|
||||
customer_code: "",
|
||||
customer_name: "",
|
||||
manager_name: "",
|
||||
order_no: "",
|
||||
claim_content: "",
|
||||
process_content: "",
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
fetchCustomers(true);
|
||||
fetchSalesOrders(true);
|
||||
};
|
||||
|
||||
const openEditModal = (claimNo: string) => {
|
||||
const claim = data.find((c) => c.claim_no === claimNo);
|
||||
if (!claim) return;
|
||||
setIsEditMode(true);
|
||||
setFormData({ ...claim });
|
||||
setIsModalOpen(true);
|
||||
fetchCustomers(true);
|
||||
fetchSalesOrders(true);
|
||||
};
|
||||
|
||||
const handleFormChange = (field: string, value: string) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// --- 저장 (table-management API, company_code 자동 주입) ---
|
||||
const handleSave = async () => {
|
||||
if (!formData.claim_type || !formData.customer_name || !formData.claim_content) {
|
||||
toast.error("필수 항목을 모두 입력해주세요. (클레임유형, 거래처명, 클레임내용)");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const { id, company_code, writer, created_date, updated_date, created_by, updated_by, ...saveFields } = formData as any;
|
||||
|
||||
if (isEditMode && id) {
|
||||
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
||||
originalData: { id },
|
||||
updatedData: saveFields,
|
||||
});
|
||||
toast.success("클레임이 수정되었어요.");
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, saveFields);
|
||||
toast.success("클레임이 등록되었어요.");
|
||||
}
|
||||
|
||||
setIsModalOpen(false);
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
console.error("클레임 저장 실패:", err);
|
||||
toast.error(err.response?.data?.message || "저장에 실패했어요.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- 삭제 ---
|
||||
const handleDelete = async (claimNo: string) => {
|
||||
const claim = data.find((c) => c.claim_no === claimNo);
|
||||
if (!claim) return;
|
||||
|
||||
const ok = await confirm(`클레임 ${claimNo}을(를) 삭제하시겠습니까?`, {
|
||||
variant: "destructive",
|
||||
confirmText: "삭제",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
||||
data: [{ id: claim.id }],
|
||||
});
|
||||
toast.success("클레임이 삭제되었어요.");
|
||||
if (selectedClaimNo === claimNo) setSelectedClaimNo(null);
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
console.error("클레임 삭제 실패:", err);
|
||||
toast.error(err.response?.data?.message || "삭제에 실패했어요.");
|
||||
}
|
||||
};
|
||||
|
||||
// 엑셀 다운로드
|
||||
const handleExcelDownload = async () => {
|
||||
if (data.length === 0) {
|
||||
toast.error("다운로드할 데이터가 없어요.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const exportData = data.map((row) => ({
|
||||
클레임번호: row.claim_no,
|
||||
접수일자: row.claim_date,
|
||||
클레임유형: row.claim_type,
|
||||
처리상태: row.claim_status,
|
||||
거래처코드: row.customer_code,
|
||||
거래처명: row.customer_name,
|
||||
담당자: row.manager_name,
|
||||
수주번호: row.order_no,
|
||||
클레임내용: row.claim_content,
|
||||
처리내용: row.process_content,
|
||||
}));
|
||||
await exportToExcel(exportData, "클레임관리.xlsx", "클레임");
|
||||
toast.success("엑셀 다운로드 완료");
|
||||
} catch (err) {
|
||||
console.error("엑셀 다운로드 실패:", err);
|
||||
toast.error("엑셀 다운로드에 실패했어요.");
|
||||
}
|
||||
};
|
||||
|
||||
const selectedClaim = useMemo(
|
||||
() => data.find((c) => c.claim_no === selectedClaimNo),
|
||||
[data, selectedClaimNo]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
|
||||
{/* 검색 필터 (DynamicSearchFilter) */}
|
||||
<DynamicSearchFilter
|
||||
tableName={TABLE_NAME}
|
||||
filterId="c16-claim"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={data.length}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
{/* ───── 메인 분할 레이아웃 ───── */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full">
|
||||
{/* ── 좌측: 클레임 목록 (65%) ── */}
|
||||
<ResizablePanel defaultSize={65} minSize={35}>
|
||||
<div className="flex flex-col h-full bg-card">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/40 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-[13px] font-bold text-foreground">클레임 목록</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{data.length}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5" />
|
||||
업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
다운로드
|
||||
</Button>
|
||||
<Button size="sm" onClick={openRegisterModal}>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
등록
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px] font-bold uppercase tracking-wide">#</TableHead>
|
||||
{ts.visibleColumns.map((col) => (
|
||||
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide">
|
||||
{col.label}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length + 1} className="h-32 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm">불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={ts.visibleColumns.length + 1} className="h-32 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Inbox className="w-8 h-8 opacity-30" />
|
||||
<span className="text-sm">등록된 클레임이 없어요</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((claim, idx) => (
|
||||
<TableRow
|
||||
key={claim.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
selectedClaimNo === claim.claim_no
|
||||
? "bg-primary/8 border-l-2 border-l-primary"
|
||||
: "hover:bg-muted/50"
|
||||
)}
|
||||
onClick={() => handleRowClick(claim.claim_no)}
|
||||
onDoubleClick={() => openEditModal(claim.claim_no)}
|
||||
>
|
||||
<TableCell className="text-center text-[11px] text-muted-foreground py-2">
|
||||
{idx + 1}
|
||||
</TableCell>
|
||||
{ts.visibleColumns.map((col) => {
|
||||
if (col.key === "claim_type") {
|
||||
return (
|
||||
<TableCell key={col.key} className="text-center py-2">
|
||||
<Badge variant={getClaimTypeVariant(claim.claim_type)} className="text-[10px] px-1.5 py-0">
|
||||
{claim.claim_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (col.key === "claim_status") {
|
||||
return (
|
||||
<TableCell key={col.key} className="text-center py-2">
|
||||
<Badge variant={getClaimStatusVariant(claim.claim_status)} className="text-[10px] px-1.5 py-0">
|
||||
{claim.claim_status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
if (col.key === "claim_content") {
|
||||
return (
|
||||
<TableCell key={col.key} className="text-sm text-muted-foreground py-2 max-w-[200px] truncate">
|
||||
{claim.claim_content}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TableCell key={col.key} className="text-sm py-2">
|
||||
{claim[col.key] ?? "-"}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* ── 우측: 클레임 상세 (35%) ── */}
|
||||
<ResizablePanel defaultSize={35} minSize={20}>
|
||||
<div className="flex flex-col h-full bg-card border-l">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/40 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-[13px] font-bold text-foreground">클레임 상세</span>
|
||||
{selectedClaim && (
|
||||
<span className="text-[11px] font-mono text-muted-foreground">{selectedClaim.claim_no}</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedClaim && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditModal(selectedClaim.claim_no)}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(selectedClaim.claim_no)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상태별 요약 카운트 */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b bg-muted/20 shrink-0">
|
||||
{([
|
||||
{ label: "접수", count: statusCounts["접수"], variant: "default" as const },
|
||||
{ label: "처리중", count: statusCounts["처리중"], variant: "secondary" as const },
|
||||
{ label: "완료", count: statusCounts["완료"], variant: "outline" as const },
|
||||
{ label: "취소", count: statusCounts["취소"], variant: "destructive" as const },
|
||||
]).map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-1">
|
||||
<Badge variant={item.variant} className="text-[10px] px-1.5 py-0">
|
||||
{item.label}
|
||||
</Badge>
|
||||
<span className="text-sm font-bold tabular-nums text-foreground">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 상세 컨텐츠 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{selectedClaim ? (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 기본 정보 그리드 */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">접수일자</p>
|
||||
<p className="text-sm text-foreground">{selectedClaim.claim_date}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">담당자</p>
|
||||
<p className="text-sm text-foreground">{selectedClaim.manager_name || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">유형</p>
|
||||
<Badge variant={getClaimTypeVariant(selectedClaim.claim_type)} className="text-[11px]">
|
||||
{selectedClaim.claim_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">상태</p>
|
||||
<Badge variant={getClaimStatusVariant(selectedClaim.claim_status)} className="text-[11px]">
|
||||
{selectedClaim.claim_status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">거래처명</p>
|
||||
<p className="text-sm font-medium text-foreground">{selectedClaim.customer_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">수주번호</p>
|
||||
<p className="text-sm font-mono text-muted-foreground">{selectedClaim.order_no || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
{/* 클레임 내용 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<FileText className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">클레임 내용</p>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-sm text-foreground whitespace-pre-wrap min-h-[60px]">
|
||||
{selectedClaim.claim_content || "-"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 처리 내용 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<Wrench className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">처리 내용</p>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-sm text-foreground whitespace-pre-wrap min-h-[60px]">
|
||||
{selectedClaim.process_content || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||
<div className="rounded-full border-2 border-dashed border-muted-foreground/20 w-14 h-14 flex items-center justify-center mb-4">
|
||||
<Inbox className="w-7 h-7 text-muted-foreground/30" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">
|
||||
좌측에서 클레임을 선택해주세요
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
클레임을 선택하면 상세 정보가 표시돼요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* ───── 클레임 등록/수정 모달 ───── */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[900px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "클레임 수정" : "클레임 등록"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditMode ? "클레임 정보를 수정해요." : "새로운 클레임을 등록해요."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 py-2">
|
||||
{/* 왼쪽: 기본 정보 */}
|
||||
<div className="md:w-[320px] shrink-0 space-y-3 bg-muted/30 p-4 rounded-lg border">
|
||||
<h3 className="text-[13px] font-bold pb-2 border-b text-foreground">기본 정보</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="claim_no" className="text-[11px] font-semibold text-muted-foreground">클레임번호</Label>
|
||||
<Input
|
||||
id="claim_no"
|
||||
value={formData.claim_no || ""}
|
||||
readOnly
|
||||
className="h-9 text-sm bg-muted cursor-not-allowed font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="claim_date" className="text-[11px] font-semibold text-muted-foreground">접수일자</Label>
|
||||
<Input
|
||||
id="claim_date"
|
||||
type="date"
|
||||
value={formData.claim_date || ""}
|
||||
onChange={(e) => handleFormChange("claim_date", e.target.value)}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="claim_type" className="text-[11px] font-semibold text-muted-foreground">
|
||||
클레임 유형 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.claim_type || ""}
|
||||
onValueChange={(v) => handleFormChange("claim_type", v)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="유형을 선택해주세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CLAIM_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="claim_status" className="text-[11px] font-semibold text-muted-foreground">처리 상태</Label>
|
||||
<Select
|
||||
value={formData.claim_status || "접수"}
|
||||
onValueChange={(v) => handleFormChange("claim_status", v)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CLAIM_STATUSES.map((s) => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
거래처명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Popover open={customerOpen} onOpenChange={setCustomerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={customerOpen}
|
||||
className="h-9 w-full justify-between font-normal"
|
||||
onClick={() => fetchCustomers(false)}
|
||||
>
|
||||
{formData.customer_name || "거래처를 선택해주세요"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="거래처 검색..." />
|
||||
<CommandList>
|
||||
{customersLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-xs text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty className="py-4 text-center text-sm">
|
||||
거래처를 찾을 수 없어요.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{customers.map((cust) => (
|
||||
<CommandItem
|
||||
key={cust.customerCode}
|
||||
value={`${cust.customerCode} ${cust.customerName}`}
|
||||
onSelect={() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
customer_code: cust.customerCode,
|
||||
customer_name: cust.customerName,
|
||||
}));
|
||||
setCustomerOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.customer_code === cust.customerCode
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium truncate">{cust.customerName}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{cust.customerCode}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="manager_name" className="text-[11px] font-semibold text-muted-foreground">담당자</Label>
|
||||
<Input
|
||||
id="manager_name"
|
||||
value={formData.manager_name || ""}
|
||||
onChange={(e) => handleFormChange("manager_name", e.target.value)}
|
||||
placeholder="담당자를 입력해주세요"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">수주번호</Label>
|
||||
<Popover open={orderOpen} onOpenChange={setOrderOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={orderOpen}
|
||||
className="h-9 w-full justify-between font-normal"
|
||||
onClick={() => fetchSalesOrders(false)}
|
||||
>
|
||||
{formData.order_no || "수주번호를 선택해주세요"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="수주번호 검색..." />
|
||||
<CommandList>
|
||||
{ordersLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-xs text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty className="py-4 text-center text-sm">
|
||||
수주번호를 찾을 수 없어요.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{salesOrders.map((order) => (
|
||||
<CommandItem
|
||||
key={order.orderNo}
|
||||
value={`${order.orderNo} ${order.partnerName}`}
|
||||
onSelect={() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
order_no: order.orderNo,
|
||||
}));
|
||||
setOrderOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.order_no === order.orderNo
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium truncate">{order.orderNo}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{order.status}
|
||||
{order.partnerName ? ` | ${order.partnerName}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 상세 내용 */}
|
||||
<div className="flex-1 space-y-3 bg-muted/30 p-4 rounded-lg border min-w-0">
|
||||
<h3 className="text-[13px] font-bold pb-2 border-b text-foreground">클레임 상세 내용</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="claim_content" className="text-[11px] font-semibold text-muted-foreground">
|
||||
클레임 내용 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="claim_content"
|
||||
value={formData.claim_content || ""}
|
||||
onChange={(e) => handleFormChange("claim_content", e.target.value)}
|
||||
placeholder="클레임 내용을 상세히 입력해주세요"
|
||||
className="min-h-[200px] resize-y text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="process_content" className="text-[11px] font-semibold text-muted-foreground">처리 내용</Label>
|
||||
<Textarea
|
||||
id="process_content"
|
||||
value={formData.process_content || ""}
|
||||
onChange={(e) => handleFormChange("process_content", e.target.value)}
|
||||
placeholder="처리 내용을 입력해주세요"
|
||||
className="min-h-[150px] resize-y text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4" />
|
||||
)}
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 엑셀 업로드 모달 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
onOpenChange={setExcelUploadOpen}
|
||||
tableName={TABLE_NAME}
|
||||
onSuccess={() => {
|
||||
fetchData();
|
||||
toast.success("엑셀 업로드가 완료되었어요.");
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</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,963 @@
|
||||
"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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
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 { Plus, Trash2, Save, X, ChevronDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Search, Loader2, FileSpreadsheet, Inbox, Settings2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getShippingOrderList,
|
||||
saveShippingOrder,
|
||||
deleteShippingOrders,
|
||||
previewShippingOrderNo,
|
||||
getShipmentPlanSource,
|
||||
getSalesOrderSource,
|
||||
getItemSource,
|
||||
} from "@/lib/api/shipping";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "instruction_no", label: "출하지시번호" },
|
||||
{ key: "ship_date", label: "출하일자" },
|
||||
{ key: "customer_name", label: "거래처명" },
|
||||
{ key: "transport_company", label: "운송업체" },
|
||||
{ key: "vehicle_no", label: "차량번호" },
|
||||
{ key: "driver_name", label: "기사명" },
|
||||
{ key: "status", label: "상태" },
|
||||
{ key: "item_code", label: "품번" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "qty", label: "수량" },
|
||||
{ key: "source_type", label: "소스" },
|
||||
{ key: "remark", label: "비고" },
|
||||
];
|
||||
|
||||
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-warning/10 text-warning";
|
||||
case "IN_PROGRESS": return "bg-primary/10 text-primary";
|
||||
case "COMPLETED": return "bg-success/10 text-success";
|
||||
default: return "bg-muted text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceBadge = (s: string) => {
|
||||
switch (s) {
|
||||
case "shipmentPlan": return { label: "출하계획", cls: "bg-primary/10 text-primary" };
|
||||
case "salesOrder": return { label: "수주", cls: "bg-success/10 text-success" };
|
||||
case "itemInfo": return { label: "품목", cls: "bg-secondary text-secondary-foreground" };
|
||||
default: return { label: s, cls: "bg-muted text-muted-foreground" };
|
||||
}
|
||||
};
|
||||
|
||||
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 ts = useTableSettings("c16-shipping-order", "shipment_instruction", GRID_COLUMNS);
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
||||
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
|
||||
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 엑셀 업로드
|
||||
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, setSourcePageSize] = useState(20);
|
||||
const [sourceTotalCount, setSourceTotalCount] = useState(0);
|
||||
|
||||
|
||||
// 데이터 조회
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "ship_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
if (from) params.dateFrom = from;
|
||||
if (to) params.dateTo = to;
|
||||
} else if (f.columnName === "status") {
|
||||
params.status = f.value;
|
||||
} else if (f.columnName === "customer_name") {
|
||||
params.customer = f.value;
|
||||
} else {
|
||||
params.keyword = f.value;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getShippingOrderList(params);
|
||||
if (result.success) setOrders(result.data || []);
|
||||
} catch (err) {
|
||||
console.error("출하지시 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
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 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";
|
||||
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 => {
|
||||
if (s.sourceType === dataSource) {
|
||||
if (dataSource === "itemInfo") return s.itemCode === key;
|
||||
return String(s.id) === String(key);
|
||||
}
|
||||
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-full p-4 gap-3">
|
||||
{/* 브레드크럼 */}
|
||||
<nav className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
<span>영업관리</span>
|
||||
<span className="text-muted-foreground/40">/</span>
|
||||
<span className="font-semibold text-foreground">출하지시</span>
|
||||
</nav>
|
||||
|
||||
{/* 검색 필터 (DynamicSearchFilter) */}
|
||||
<DynamicSearchFilter
|
||||
tableName={ts.tableName}
|
||||
filterId="c16-shipping-order"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={orders.length}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
{/* 액션 바 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-[15px] font-bold text-foreground">출하지시 관리</h2>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{orders.length}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5" />
|
||||
엑셀 업로드
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => openModal()}>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
출하지시 등록
|
||||
</Button>
|
||||
<div className="w-px h-5 bg-border" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive border-destructive/20 bg-destructive/5 hover:bg-destructive/10"
|
||||
disabled={checkedIds.length === 0}
|
||||
onClick={handleDeleteSelected}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
선택삭제 {checkedIds.length > 0 && `(${checkedIds.length})`}
|
||||
</Button>
|
||||
<div className="w-px h-5 bg-border" />
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border bg-card flex flex-col">
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={orders.length > 0 && checkedIds.length === orders.length}
|
||||
onCheckedChange={handleCheckAll}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.isVisible("instruction_no") && <TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하지시번호</TableHead>}
|
||||
{ts.isVisible("ship_date") && <TableHead className="w-[100px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하일자</TableHead>}
|
||||
{ts.isVisible("customer_name") && <TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">거래처명</TableHead>}
|
||||
{ts.isVisible("transport_company") && <TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">운송업체</TableHead>}
|
||||
{ts.isVisible("vehicle_no") && <TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">차량번호</TableHead>}
|
||||
{ts.isVisible("driver_name") && <TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기사명</TableHead>}
|
||||
{ts.isVisible("status") && <TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
{ts.isVisible("item_code") && <TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>}
|
||||
{ts.isVisible("item_name") && <TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>}
|
||||
{ts.isVisible("qty") && <TableHead className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>}
|
||||
{ts.isVisible("source_type") && <TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소스</TableHead>}
|
||||
{ts.isVisible("remark") && <TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} className="h-40 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<div className="w-12 h-12 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center">
|
||||
<Inbox className="w-5 h-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">등록된 출하지시가 없어요</p>
|
||||
<p className="text-xs text-muted-foreground/60">출하지시 등록 버튼으로 등록해주세요</p>
|
||||
</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={cn("cursor-pointer transition-colors", selectedOrderId === order.id && "bg-primary/5")}
|
||||
onClick={() => setSelectedOrderId(order.id)}
|
||||
onDoubleClick={() => 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>
|
||||
{ts.isVisible("instruction_no") && <TableCell className="font-medium text-sm">{order.instruction_no}</TableCell>}
|
||||
{ts.isVisible("ship_date") && <TableCell className="text-center text-sm">{formatDate(order.instruction_date)}</TableCell>}
|
||||
{ts.isVisible("customer_name") && <TableCell className="text-sm">{order.customer_name || "-"}</TableCell>}
|
||||
{ts.isVisible("transport_company") && <TableCell className="text-sm">{order.carrier_name || "-"}</TableCell>}
|
||||
{ts.isVisible("vehicle_no") && <TableCell className="text-sm">{order.vehicle_no || "-"}</TableCell>}
|
||||
{ts.isVisible("driver_name") && <TableCell className="text-sm">{order.driver_name || "-"}</TableCell>}
|
||||
{ts.isVisible("status") && <TableCell className="text-center">
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(order.status))}>
|
||||
{getStatusLabel(order.status)}
|
||||
</span>
|
||||
</TableCell>}
|
||||
{ts.isVisible("item_code") && <TableCell className="text-sm">-</TableCell>}
|
||||
{ts.isVisible("item_name") && <TableCell className="text-sm">-</TableCell>}
|
||||
{ts.isVisible("qty") && <TableCell className="text-right text-sm">0</TableCell>}
|
||||
{ts.isVisible("source_type") && <TableCell className="text-center text-sm">-</TableCell>}
|
||||
{ts.isVisible("remark") && <TableCell className="text-[13px] 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={cn("cursor-pointer transition-colors", selectedOrderId === order.id && "bg-primary/5")}
|
||||
onClick={() => setSelectedOrderId(order.id)}
|
||||
onDoubleClick={() => 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>
|
||||
{ts.isVisible("instruction_no") && <TableCell className="font-medium text-sm">{itemIdx === 0 ? order.instruction_no : ""}</TableCell>}
|
||||
{ts.isVisible("ship_date") && <TableCell className="text-center text-sm">{itemIdx === 0 ? formatDate(order.instruction_date) : ""}</TableCell>}
|
||||
{ts.isVisible("customer_name") && <TableCell className="text-sm">{itemIdx === 0 ? (order.customer_name || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("transport_company") && <TableCell className="text-sm">{itemIdx === 0 ? (order.carrier_name || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("vehicle_no") && <TableCell className="text-sm">{itemIdx === 0 ? (order.vehicle_no || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("driver_name") && <TableCell className="text-sm">{itemIdx === 0 ? (order.driver_name || "-") : ""}</TableCell>}
|
||||
{ts.isVisible("status") && <TableCell className="text-center">
|
||||
{itemIdx === 0 && (
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(order.status))}>
|
||||
{getStatusLabel(order.status)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>}
|
||||
{ts.isVisible("item_code") && <TableCell className="text-[13px] text-muted-foreground">{item.item_code}</TableCell>}
|
||||
{ts.isVisible("item_name") && <TableCell className="font-medium text-sm">{item.item_name}</TableCell>}
|
||||
{ts.isVisible("qty") && <TableCell className="text-right text-sm">{Number(item.order_qty || 0).toLocaleString()}</TableCell>}
|
||||
{ts.isVisible("source_type") && <TableCell className="text-center">
|
||||
{(() => {
|
||||
const b = getSourceBadge(item.source_type || "");
|
||||
return <span className={cn("px-2 py-0.5 rounded-full text-[10px] font-medium", b.cls)}>{b.label}</span>;
|
||||
})()}
|
||||
</TableCell>}
|
||||
{ts.isVisible("remark") && <TableCell className="text-[13px] text-muted-foreground truncate max-w-[100px]">
|
||||
{itemIdx === 0 ? (order.memo || "-") : ""}
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
));
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[90vw] w-[1400px] h-[90vh] flex flex-col overflow-hidden p-0 gap-0">
|
||||
<DialogHeader className="px-4 py-3 border-b shrink-0">
|
||||
<DialogTitle className="text-[15px] font-bold">
|
||||
{isEditMode ? "출하지시 수정" : "출하지시 등록"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
|
||||
{isEditMode ? "출하지시 정보를 수정해요." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력해요."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 왼쪽: 데이터 소스 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 소스 검색 헤더 */}
|
||||
<div className="px-4 py-2 border-b bg-muted/50 flex flex-wrap items-center gap-2 shrink-0">
|
||||
<Select value={dataSource} onValueChange={(v) => setDataSource(v as DataSourceType)}>
|
||||
<SelectTrigger className="w-[120px] h-9 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-9 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-3 py-1.5 flex items-center gap-2 border-b bg-muted/30 shrink-0">
|
||||
<span className="text-[13px] font-bold text-foreground">{dataSourceTitle[dataSource]}</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
선택 {selectedItems.length}개
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 소스 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{sourceLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : sourceData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-2">
|
||||
<div className="w-12 h-12 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center">
|
||||
<Search className="w-5 h-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<p className="text-sm">조회 버튼을 눌러 데이터를 불러와주세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">선택</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">거래처</TableHead>
|
||||
<TableHead className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
{dataSource === "shipmentPlan" && <TableHead className="w-[70px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sourceData.map((item: any, idx: number) => {
|
||||
const itemId = dataSource === "itemInfo" ? item.item_code : item.id;
|
||||
const isSelected = selectedItems.some(s => {
|
||||
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 transition-colors", 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-[13px]">{item.item_code || "-"}</TableCell>
|
||||
<TableCell className="text-sm font-medium">{item.item_name || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.customer_name || "-"}</TableCell>
|
||||
<TableCell className="text-right text-[13px]">
|
||||
{Number(item.plan_qty || item.qty || item.balance_qty || 0).toLocaleString()}
|
||||
</TableCell>
|
||||
{dataSource === "shipmentPlan" && (
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[10px] font-medium", getStatusColor(item.status))}>
|
||||
{getStatusLabel(item.status)}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이징 */}
|
||||
{sourceTotalCount > 0 && (
|
||||
<div className="px-3 py-1.5 border-t bg-muted/30 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-[11px]">표시:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={sourcePageSize}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (v > 0) { setSourcePageSize(v); setSourcePage(1); fetchSourceData(1); }
|
||||
}}
|
||||
className="h-7 w-[60px] text-center text-[11px]"
|
||||
/>
|
||||
<span className="text-muted-foreground text-[11px]">총 {sourceTotalCount}건</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
|
||||
onClick={() => { setSourcePage(1); fetchSourceData(1); }}>
|
||||
<ChevronsLeft className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<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>
|
||||
<Button variant="outline" size="icon" className="h-7 w-7"
|
||||
disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
|
||||
onClick={() => { const p = Math.ceil(sourceTotalCount / sourcePageSize); setSourcePage(p); fetchSourceData(p); }}>
|
||||
<ChevronsRight className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle onPointerDown={(e) => e.stopPropagation()} />
|
||||
|
||||
{/* 오른쪽: 폼 */}
|
||||
<ResizablePanel defaultSize={45} minSize={30}>
|
||||
<div className="flex flex-col h-full overflow-auto p-4 gap-4">
|
||||
{/* 기본 정보 */}
|
||||
<div className="border rounded-lg p-4 shrink-0">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide mb-3">기본 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">출하지시번호</Label>
|
||||
<Input value={formOrderNumber} readOnly className="h-9 bg-muted/50 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">
|
||||
출하지시일 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9"
|
||||
value={formOrderDate}
|
||||
onChange={(e) => setFormOrderDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">거래처</Label>
|
||||
<Input value={formCustomer} readOnly placeholder="품목 선택 시 자동" className="h-9 bg-muted/50" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">상태</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-muted/50 border rounded-lg overflow-hidden shrink-0">
|
||||
<button
|
||||
className="w-full px-4 py-2.5 flex items-center justify-between text-left"
|
||||
onClick={() => setIsTransportCollapsed(!isTransportCollapsed)}
|
||||
>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
운송 정보 <span className="text-[10px] font-normal">(선택사항)</span>
|
||||
</p>
|
||||
{isTransportCollapsed
|
||||
? <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
: <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
}
|
||||
</button>
|
||||
{!isTransportCollapsed && (
|
||||
<div className="px-4 pb-3 grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">운송업체</Label>
|
||||
<Input value={formCarrier} onChange={(e) => setFormCarrier(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">차량번호</Label>
|
||||
<Input value={formVehicle} onChange={(e) => setFormVehicle(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">기사명</Label>
|
||||
<Input value={formDriver} onChange={(e) => setFormDriver(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">연락처</Label>
|
||||
<Input value={formDriverPhone} onChange={(e) => setFormDriverPhone(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">도착예정일시</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
className="h-9"
|
||||
value={formArrival}
|
||||
onChange={(e) => setFormArrival(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">배송지</Label>
|
||||
<Input value={formAddress} onChange={(e) => setFormAddress(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 선택된 품목 */}
|
||||
<div className="border rounded-lg p-4 flex-1 flex flex-col min-h-[200px]">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide mb-2 flex items-center gap-2">
|
||||
선택된 품목
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{selectedItems.length}
|
||||
</span>
|
||||
</p>
|
||||
<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 border-2 border-dashed rounded-lg gap-2">
|
||||
<div className="w-10 h-10 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center">
|
||||
<Inbox className="w-4 h-4 text-muted-foreground/30" />
|
||||
</div>
|
||||
<p className="text-sm">왼쪽에서 데이터를 선택해주세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소스</TableHead>
|
||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
<TableHead className="w-[90px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하수량</TableHead>
|
||||
<TableHead className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획수량</TableHead>
|
||||
<TableHead className="w-[40px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">삭제</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] font-medium", b.cls)}>
|
||||
{b.label.charAt(0)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px]">{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-[13px]">
|
||||
{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="border rounded-lg p-4 shrink-0">
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">메모</p>
|
||||
<Textarea
|
||||
value={formMemo}
|
||||
onChange={(e) => setFormMemo(e.target.value)}
|
||||
placeholder="출하지시 관련 메모를 입력해주세요"
|
||||
rows={2}
|
||||
className="resize-y"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* 모달 하단 버튼 */}
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t shrink-0">
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 엑셀 업로드 모달 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
onOpenChange={setExcelUploadOpen}
|
||||
tableName="shipping-order"
|
||||
onSuccess={() => { fetchOrders(); }}
|
||||
/>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
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 { X, Save, Loader2, Inbox, Settings2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getShipmentPlanList,
|
||||
updateShipmentPlan,
|
||||
type ShipmentPlanListItem,
|
||||
} from "@/lib/api/shipping";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "order_no", label: "수주번호" },
|
||||
{ key: "due_date", label: "납기일" },
|
||||
{ key: "customer_name", label: "거래처" },
|
||||
{ key: "part_code", label: "품목코드" },
|
||||
{ key: "part_name", label: "품목명" },
|
||||
{ key: "order_qty", label: "수주수량" },
|
||||
{ key: "plan_qty", label: "계획수량" },
|
||||
{ key: "plan_date", label: "계획일" },
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
|
||||
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-primary/10 text-primary";
|
||||
case "CONFIRMED": return "bg-secondary text-secondary-foreground";
|
||||
case "SHIPPING": return "bg-warning/10 text-warning";
|
||||
case "COMPLETED": return "bg-success/10 text-success";
|
||||
case "CANCEL_REQUEST": return "bg-destructive/10 text-destructive";
|
||||
case "CANCELLED": return "bg-muted text-muted-foreground";
|
||||
default: return "bg-muted text-muted-foreground";
|
||||
}
|
||||
};
|
||||
|
||||
export default function ShippingPlanPage() {
|
||||
const ts = useTableSettings("c16-shipping-plan", "shipment_plan", GRID_COLUMNS);
|
||||
const [data, setData] = useState<ShipmentPlanListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
||||
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 상세 패널 편집
|
||||
const [editPlanQty, setEditPlanQty] = useState("");
|
||||
const [editPlanDate, setEditPlanDate] = useState("");
|
||||
const [editMemo, setEditMemo] = useState("");
|
||||
const [isDetailChanged, setIsDetailChanged] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
|
||||
// 데이터 조회
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
for (const f of searchFilters) {
|
||||
if (f.columnName === "plan_date" && f.operator === "between" && f.value) {
|
||||
const [from, to] = f.value.split(",");
|
||||
if (from) params.dateFrom = from;
|
||||
if (to) params.dateTo = to;
|
||||
} else if (f.columnName === "status") {
|
||||
params.status = f.value;
|
||||
} else if (f.columnName === "customer_name") {
|
||||
params.customer = f.value;
|
||||
} else {
|
||||
params.keyword = f.value;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getShipmentPlanList(params);
|
||||
if (result.success) {
|
||||
setData(result.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하계획 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
// searchFilters 변경 시 자동 조회
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
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 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-full p-4 gap-3">
|
||||
{/* 브레드크럼 */}
|
||||
<nav className="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
|
||||
<span>영업관리</span>
|
||||
<span className="text-muted-foreground/40">/</span>
|
||||
<span className="font-semibold text-foreground">출하계획</span>
|
||||
</nav>
|
||||
|
||||
{/* 검색 필터 (DynamicSearchFilter) */}
|
||||
<DynamicSearchFilter
|
||||
tableName="shipment_plan"
|
||||
filterId="c16-shipping-plan"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={data.length}
|
||||
externalFilterConfig={ts.filterConfig}
|
||||
/>
|
||||
|
||||
{/* 마스터-디테일 */}
|
||||
<div className="flex-1 overflow-hidden rounded-lg border bg-card">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 좌측: 출하계획 목록 */}
|
||||
<ResizablePanel defaultSize={60} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-bold text-foreground">출하계획 목록</span>
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/10 border border-primary/15 px-2 py-0.5 rounded-full font-mono">
|
||||
{data.length}건
|
||||
</span>
|
||||
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={data.length > 0 && checkedIds.length === data.filter(p => p.status !== "CANCELLED").length}
|
||||
onCheckedChange={handleCheckAll}
|
||||
/>
|
||||
</TableHead>
|
||||
{ts.isVisible("order_no") && <TableHead className="w-[10%] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수주번호</TableHead>}
|
||||
{ts.isVisible("due_date") && <TableHead className="w-[8%] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>}
|
||||
{ts.isVisible("customer_name") && <TableHead className="w-[12%] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">거래처</TableHead>}
|
||||
{ts.isVisible("part_code") && <TableHead className="w-[18%] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>}
|
||||
{ts.isVisible("part_name") && <TableHead className="w-[18%] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>}
|
||||
{ts.isVisible("order_qty") && <TableHead className="w-[7%] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수주수량</TableHead>}
|
||||
{ts.isVisible("plan_qty") && <TableHead className="w-[7%] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획수량</TableHead>}
|
||||
{ts.isVisible("plan_date") && <TableHead className="w-[8%] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획일</TableHead>}
|
||||
{ts.isVisible("status") && <TableHead className="w-[6%] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="h-40 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<div className="w-12 h-12 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center">
|
||||
<Inbox className="w-5 h-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">출하계획이 없어요</p>
|
||||
<p className="text-xs text-muted-foreground/60">조건을 변경해서 다시 조회해주세요</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groupedData.map(group =>
|
||||
group.plans.map((plan, planIdx) => (
|
||||
<TableRow
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
selectedId === plan.id && "bg-primary/5",
|
||||
plan.status === "CANCELLED" && "opacity-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>
|
||||
{ts.isVisible("order_no") && <TableCell className="font-medium text-sm">
|
||||
{planIdx === 0 ? (plan.order_no || "-") : ""}
|
||||
</TableCell>}
|
||||
{ts.isVisible("due_date") && <TableCell className="text-center text-sm">
|
||||
{planIdx === 0 ? formatDate(plan.due_date) : ""}
|
||||
</TableCell>}
|
||||
{ts.isVisible("customer_name") && <TableCell className="text-sm">
|
||||
{planIdx === 0 ? (plan.customer_name || "-") : ""}
|
||||
</TableCell>}
|
||||
{ts.isVisible("part_code") && <TableCell className="text-muted-foreground text-[13px]">{plan.part_code || "-"}</TableCell>}
|
||||
{ts.isVisible("part_name") && <TableCell className="font-medium text-sm">{plan.part_name || "-"}</TableCell>}
|
||||
{ts.isVisible("order_qty") && <TableCell className="text-right text-sm">{formatNumber(plan.order_qty)}</TableCell>}
|
||||
{ts.isVisible("plan_qty") && <TableCell className="text-right font-semibold text-primary text-sm">{formatNumber(plan.plan_qty)}</TableCell>}
|
||||
{ts.isVisible("plan_date") && <TableCell className="text-center text-sm">{formatDate(plan.plan_date)}</TableCell>}
|
||||
{ts.isVisible("status") && <TableCell className="text-center">
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(plan.status))}>
|
||||
{getStatusLabel(plan.status)}
|
||||
</span>
|
||||
</TableCell>}
|
||||
</TableRow>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 상세 패널 */}
|
||||
<ResizablePanel defaultSize={40} minSize={20}>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-bold text-foreground">출하계획 상세</span>
|
||||
{selectedPlan && (
|
||||
<span className="text-[11px] font-mono text-muted-foreground">{selectedPlan.shipment_plan_no}</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedPlan && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveDetail}
|
||||
disabled={!isDetailChanged || saving}
|
||||
variant={isDetailChanged ? "default" : "secondary"}
|
||||
>
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
저장
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setSelectedId(null)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상세 컨텐츠 */}
|
||||
{selectedPlan ? (
|
||||
<div className="flex-1 overflow-auto p-4 space-y-4">
|
||||
{/* 기본 정보 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">상태</p>
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium inline-block", getStatusColor(selectedPlan.status))}>
|
||||
{getStatusLabel(selectedPlan.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">수주번호</p>
|
||||
<p className="text-sm font-mono text-muted-foreground">{selectedPlan.order_no || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">거래처</p>
|
||||
<p className="text-sm font-medium text-foreground">{selectedPlan.customer_name || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">납기일</p>
|
||||
<p className="text-sm text-foreground">{formatDate(selectedPlan.due_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
{/* 품목 정보 */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-2">품목 정보</p>
|
||||
<div className="grid grid-cols-2 gap-3 bg-muted/30 border rounded-md p-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">품목코드</p>
|
||||
<p className="text-sm font-mono text-muted-foreground">{selectedPlan.part_code || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">품목명</p>
|
||||
<p className="text-sm font-medium text-foreground">{selectedPlan.part_name || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">규격</p>
|
||||
<p className="text-sm text-foreground">{selectedPlan.spec || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">재질</p>
|
||||
<p className="text-sm text-foreground">{selectedPlan.material || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
{/* 수량 정보 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">수주수량</p>
|
||||
<p className="text-sm text-foreground">{formatNumber(selectedPlan.order_qty)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1 block">계획수량</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-9"
|
||||
value={editPlanQty}
|
||||
onChange={(e) => { setEditPlanQty(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">출하수량</p>
|
||||
<p className="text-sm text-foreground">{formatNumber(selectedPlan.shipped_qty)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">잔여수량</p>
|
||||
<p className={cn(
|
||||
"font-semibold text-sm",
|
||||
(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty)) > 0
|
||||
? "text-destructive"
|
||||
: "text-success"
|
||||
)}>
|
||||
{formatNumber(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
{/* 출하 정보 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1 block">출하계획일</Label>
|
||||
<Input
|
||||
type="date"
|
||||
className="h-9"
|
||||
value={editPlanDate}
|
||||
onChange={(e) => { setEditPlanDate(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1 block">비고</Label>
|
||||
<Textarea
|
||||
className="min-h-[80px] resize-y text-sm"
|
||||
value={editMemo}
|
||||
onChange={(e) => { setEditMemo(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
placeholder="비고를 입력해주세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t" />
|
||||
|
||||
{/* 등록 정보 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">등록자</p>
|
||||
<p className="text-sm text-foreground">{selectedPlan.created_by || "-"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground mb-1">등록일시</p>
|
||||
<p className="text-sm text-foreground">
|
||||
{selectedPlan.created_date ? new Date(selectedPlan.created_date).toLocaleString("ko-KR") : "-"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8 text-muted-foreground">
|
||||
<div className="w-12 h-12 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center mb-4">
|
||||
<Inbox className="w-6 h-6 text-muted-foreground/30" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">좌측에서 출하계획을 선택해주세요</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">선택하면 상세 정보가 표시돼요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
<TableSettingsModal
|
||||
open={ts.open}
|
||||
onOpenChange={ts.setOpen}
|
||||
tableName={ts.tableName}
|
||||
settingsId={ts.settingsId}
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -510,7 +510,7 @@ select {
|
||||
font-family: "Gaegu", cursive;
|
||||
}
|
||||
|
||||
/* ===== 전체 폰트 사이즈 16px 통일 (버튼 제외) =====
|
||||
/* ===== 전체 폰트 사이즈 16px 통일 (버튼 제외) 작업자 김주석 =====
|
||||
body *:not(button, [role="button"], .kpi-dynamic-font) {
|
||||
font-size: 16px !important;
|
||||
} */
|
||||
@@ -1033,3 +1033,50 @@ body span.messenger-time {
|
||||
}
|
||||
|
||||
/* ===== End Dark Mode Compatibility Layer ===== */
|
||||
|
||||
/* ===== Table Readability Enhancement ===== */
|
||||
/* 셀 패딩 + 폰트 크기 확대 */
|
||||
[data-slot="table-cell"] {
|
||||
padding: 12px 16px !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
[data-slot="table-head"] {
|
||||
padding: 12px 16px !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: 0.03em !important;
|
||||
}
|
||||
|
||||
/* 다크모드 체크박스 — input[type=checkbox] + shadcn Checkbox 모두 커버 */
|
||||
.dark [data-slot="table-cell"] input[type="checkbox"],
|
||||
.dark [data-slot="table-head"] input[type="checkbox"] {
|
||||
background-color: transparent !important;
|
||||
border: 2px solid hsl(0 0% 85%) !important;
|
||||
accent-color: hsl(var(--primary)) !important;
|
||||
}
|
||||
.dark [data-slot="table-cell"] button[role="checkbox"],
|
||||
.dark [data-slot="table-head"] button[role="checkbox"] {
|
||||
border: 2px solid hsl(0 0% 85%) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.dark [data-slot="table-cell"] button[role="checkbox"][data-state="checked"],
|
||||
.dark [data-slot="table-head"] button[role="checkbox"][data-state="checked"] {
|
||||
border-color: hsl(var(--primary)) !important;
|
||||
background-color: hsl(var(--primary)) !important;
|
||||
}
|
||||
|
||||
/* 짝수 행 stripe — 행 구분 */
|
||||
[data-slot="table-body"] [data-slot="table-row"]:nth-child(even) {
|
||||
background-color: hsl(var(--muted) / 0.35);
|
||||
}
|
||||
.dark [data-slot="table-body"] [data-slot="table-row"]:nth-child(even) {
|
||||
background-color: hsl(var(--muted) / 0.18);
|
||||
}
|
||||
|
||||
/* hover */
|
||||
[data-slot="table-body"] [data-slot="table-row"]:hover {
|
||||
background-color: hsl(var(--accent)) !important;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ export interface TableSettingsModalProps {
|
||||
onSave?: (settings: TableSettings) => void;
|
||||
/** 초기 탭 */
|
||||
initialTab?: "columns" | "filters" | "groups";
|
||||
/** 기본 표시 컬럼 키 목록 (GRID_COLUMNS 기준). 미지정 시 전체 표시 */
|
||||
defaultVisibleKeys?: string[];
|
||||
}
|
||||
|
||||
// ===== 상수 =====
|
||||
@@ -204,6 +206,7 @@ export function TableSettingsModal({
|
||||
settingsId,
|
||||
onSave,
|
||||
initialTab = "columns",
|
||||
defaultVisibleKeys,
|
||||
}: TableSettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -241,7 +244,7 @@ export function TableSettingsModal({
|
||||
.map((t) => ({
|
||||
columnName: t.columnName,
|
||||
displayName: t.displayName || t.columnLabel || t.columnName,
|
||||
visible: true,
|
||||
visible: defaultVisibleKeys ? defaultVisibleKeys.includes(t.columnName) : true,
|
||||
width: 120,
|
||||
}));
|
||||
|
||||
@@ -391,7 +394,7 @@ export function TableSettingsModal({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>테이블 설정</DialogTitle>
|
||||
<DialogDescription>테이블의 컬럼, 필터, 그룹화를 설정합니다</DialogDescription>
|
||||
@@ -416,9 +419,9 @@ export function TableSettingsModal({
|
||||
</TabsList>
|
||||
|
||||
{/* ===== 탭 1: 컬럼 설정 ===== */}
|
||||
<TabsContent value="columns" className="flex-1 overflow-auto mt-0 pt-3">
|
||||
<TabsContent value="columns" className="mt-0 pt-3 flex flex-col min-h-0 max-h-[calc(80vh-220px)]">
|
||||
{/* 헤더: 표시 수 / 틀고정 / 초기화 */}
|
||||
<div className="flex items-center justify-between px-2 pb-3 border-b mb-2">
|
||||
<div className="flex items-center justify-between px-2 pb-3 border-b mb-2 shrink-0">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span>
|
||||
{visibleCount}/{tempColumns.length}개 컬럼 표시 중
|
||||
@@ -447,27 +450,29 @@ export function TableSettingsModal({
|
||||
</div>
|
||||
|
||||
{/* 컬럼 목록 (드래그 순서 변경 가능) */}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={tempColumns.map((c) => c.columnName)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
{tempColumns.map((col, idx) => (
|
||||
<SortableColumnRow
|
||||
key={col.columnName}
|
||||
col={{ ...col, _idx: idx }}
|
||||
onToggleVisible={toggleColumnVisible}
|
||||
onWidthChange={changeColumnWidth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={tempColumns.map((c) => c.columnName)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
{tempColumns.map((col, idx) => (
|
||||
<SortableColumnRow
|
||||
key={col.columnName}
|
||||
col={{ ...col, _idx: idx }}
|
||||
onToggleVisible={toggleColumnVisible}
|
||||
onWidthChange={changeColumnWidth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 탭 2: 필터 설정 ===== */}
|
||||
<TabsContent value="filters" className="flex-1 overflow-auto mt-0 pt-3">
|
||||
<TabsContent value="filters" className="mt-0 pt-3 overflow-y-auto max-h-[calc(80vh-220px)]">
|
||||
{/* 전체 선택 */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 pb-3 border-b mb-2 cursor-pointer"
|
||||
@@ -530,7 +535,7 @@ export function TableSettingsModal({
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 탭 3: 그룹 설정 ===== */}
|
||||
<TabsContent value="groups" className="flex-1 overflow-auto mt-0 pt-3">
|
||||
<TabsContent value="groups" className="mt-0 pt-3 overflow-y-auto max-h-[calc(80vh-220px)]">
|
||||
<div className="px-2 pb-3 border-b mb-2">
|
||||
<span className="text-sm font-medium">사용 가능한 컬럼</span>
|
||||
</div>
|
||||
|
||||
@@ -117,6 +117,43 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/COMPANY_7/design/my-work": dynamic(() => import("@/app/(main)/COMPANY_7/design/my-work/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/design/design-request": dynamic(() => import("@/app/(main)/COMPANY_7/design/design-request/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_7/design/task-management": dynamic(() => import("@/app/(main)/COMPANY_7/design/task-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
// === COMPANY_16 (하이큐마그) ===
|
||||
"/COMPANY_16/master-data/options": dynamic(() => import("@/app/(main)/COMPANY_16/master-data/options/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/master-data/item-info": dynamic(() => import("@/app/(main)/COMPANY_16/master-data/item-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/master-data/department": dynamic(() => import("@/app/(main)/COMPANY_16/master-data/department/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/master-data/company": dynamic(() => import("@/app/(main)/COMPANY_16/master-data/company/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/sales/order": dynamic(() => import("@/app/(main)/COMPANY_16/sales/order/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/sales/customer": dynamic(() => import("@/app/(main)/COMPANY_16/sales/customer/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/sales/sales-item": dynamic(() => import("@/app/(main)/COMPANY_16/sales/sales-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/sales/shipping-order": dynamic(() => import("@/app/(main)/COMPANY_16/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_16/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_16/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_16/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/production/bom": dynamic(() => import("@/app/(main)/COMPANY_16/production/bom/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_16/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_16/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/logistics/inventory": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/inventory/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/logistics/warehouse": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/warehouse/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/logistics/info": dynamic(() => import("@/app/(main)/COMPANY_16/logistics/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/outsourcing/subcontractor": dynamic(() => import("@/app/(main)/COMPANY_16/outsourcing/subcontractor/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/outsourcing/subcontractor-item": dynamic(() => import("@/app/(main)/COMPANY_16/outsourcing/subcontractor-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/purchase/order": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/order/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/purchase/purchase-item": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/purchase-item/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/purchase/supplier": dynamic(() => import("@/app/(main)/COMPANY_16/purchase/supplier/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/quality/inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/quality/item-inspection": dynamic(() => import("@/app/(main)/COMPANY_16/quality/item-inspection/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/mold/info": dynamic(() => import("@/app/(main)/COMPANY_16/mold/info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/design/project": dynamic(() => import("@/app/(main)/COMPANY_16/design/project/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/design/change-management": dynamic(() => import("@/app/(main)/COMPANY_16/design/change-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/design/my-work": dynamic(() => import("@/app/(main)/COMPANY_16/design/my-work/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/design/design-request": dynamic(() => import("@/app/(main)/COMPANY_16/design/design-request/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_16/design/task-management": dynamic(() => import("@/app/(main)/COMPANY_16/design/task-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
// === COMPANY_10 (큐앤씨) ===
|
||||
"/COMPANY_10/master-data/item-info": dynamic(() => import("@/app/(main)/COMPANY_10/master-data/item-info/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/COMPANY_10/master-data/department": dynamic(() => import("@/app/(main)/COMPANY_10/master-data/department/page"), { ssr: false, loading: LoadingFallback }),
|
||||
@@ -440,6 +477,9 @@ const COMPANY_PAGE_PREFIXES = [
|
||||
"/logistics/",
|
||||
"/outsourcing/",
|
||||
"/design/",
|
||||
"/purchase/",
|
||||
"/quality/",
|
||||
"/mold/",
|
||||
];
|
||||
|
||||
function isCompanyPage(url: string): boolean {
|
||||
|
||||
@@ -54,7 +54,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
"text-foreground h-10 px-3.5 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -67,7 +67,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
"px-3.5 py-2.5 align-middle whitespace-nowrap text-[13px] [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* useTableSettings — 날코딩 페이지용 테이블 설정 훅
|
||||
*
|
||||
* TableSettingsModal과 함께 사용하여 컬럼 표시/숨김, 순서, 너비를 관리합니다.
|
||||
* 설정은 localStorage에 자동 저장/복원됩니다.
|
||||
*
|
||||
* @example
|
||||
* const ts = useTableSettings("item-info", TABLE_NAME, GRID_COLUMNS);
|
||||
*
|
||||
* // 툴바 버튼
|
||||
* <Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)}>
|
||||
* <Settings2 className="h-4 w-4" />
|
||||
* </Button>
|
||||
*
|
||||
* // 테이블 헤더 — GRID_COLUMNS 대신 ts.visibleColumns 사용
|
||||
* {ts.visibleColumns.map(col => <TableHead key={col.key}>{col.label}</TableHead>)}
|
||||
*
|
||||
* // 모달 (JSX 하단)
|
||||
* <TableSettingsModal
|
||||
* open={ts.open}
|
||||
* onOpenChange={ts.setOpen}
|
||||
* tableName={ts.tableName}
|
||||
* settingsId={ts.settingsId}
|
||||
* onSave={ts.applySettings}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { loadTableSettings, type TableSettings } from "@/components/common/TableSettingsModal";
|
||||
|
||||
export function useTableSettings<T extends { key: string }>(
|
||||
settingsId: string,
|
||||
tableName: string,
|
||||
defaultColumns: T[],
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [visibleKeys, setVisibleKeys] = useState<Set<string>>(
|
||||
() => new Set(defaultColumns.map((c) => c.key)),
|
||||
);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const [orderedKeys, setOrderedKeys] = useState<string[]>(
|
||||
() => defaultColumns.map((c) => c.key),
|
||||
);
|
||||
// 초기 filterConfig: GRID_COLUMNS에 있는 컬럼만 필터 가능 (전부 비활성)
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"]>(
|
||||
() =>
|
||||
defaultColumns.map((c) => ({
|
||||
columnName: c.key,
|
||||
displayName: (c as any).label || c.key,
|
||||
enabled: false,
|
||||
filterType: "text" as const,
|
||||
width: 25,
|
||||
})),
|
||||
);
|
||||
|
||||
/** TableSettingsModal onSave에 전달할 콜백 */
|
||||
const applySettings = useCallback(
|
||||
(settings: TableSettings) => {
|
||||
const visible = new Set<string>();
|
||||
const widths: Record<string, number> = {};
|
||||
const order: string[] = [];
|
||||
|
||||
for (const cs of settings.columns) {
|
||||
if (cs.visible) {
|
||||
visible.add(cs.columnName);
|
||||
widths[cs.columnName] = cs.width;
|
||||
order.push(cs.columnName);
|
||||
}
|
||||
}
|
||||
|
||||
// settings에 없는 새 컬럼은 보이도록 추가
|
||||
for (const col of defaultColumns) {
|
||||
if (!settings.columns.find((c) => c.columnName === col.key)) {
|
||||
visible.add(col.key);
|
||||
order.push(col.key);
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleKeys(visible);
|
||||
setColumnWidths(widths);
|
||||
setOrderedKeys(order);
|
||||
|
||||
// 화면에 표시된 컬럼만 필터 가능하도록 제한
|
||||
setFilterConfig(
|
||||
settings.filters?.filter((f) => visible.has(f.columnName)),
|
||||
);
|
||||
},
|
||||
[defaultColumns],
|
||||
);
|
||||
|
||||
// 마운트 시 저장된 설정 복원
|
||||
useEffect(() => {
|
||||
const saved = loadTableSettings(settingsId);
|
||||
if (saved) applySettings(saved);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/** 설정이 적용된 컬럼 목록 (순서 + 표시 필터 적용) */
|
||||
const visibleColumns = useMemo((): T[] => {
|
||||
const colMap = new Map(defaultColumns.map((c) => [c.key, c]));
|
||||
const result: T[] = [];
|
||||
|
||||
// 저장된 순서대로
|
||||
for (const key of orderedKeys) {
|
||||
if (visibleKeys.has(key)) {
|
||||
const col = colMap.get(key);
|
||||
if (col) result.push(col);
|
||||
}
|
||||
}
|
||||
|
||||
// orderedKeys에 없는 컬럼 (새로 추가된 것)
|
||||
for (const col of defaultColumns) {
|
||||
if (!orderedKeys.includes(col.key) && visibleKeys.has(col.key)) {
|
||||
result.push(col);
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : defaultColumns;
|
||||
}, [defaultColumns, orderedKeys, visibleKeys]);
|
||||
|
||||
/** 컬럼 표시 여부 확인 */
|
||||
const isVisible = useCallback((key: string) => visibleKeys.has(key), [visibleKeys]);
|
||||
|
||||
/** 컬럼 너비 가져오기 (설정값 or undefined) */
|
||||
const getWidth = useCallback(
|
||||
(key: string): number | undefined => columnWidths[key],
|
||||
[columnWidths],
|
||||
);
|
||||
|
||||
return {
|
||||
/** 모달 open 상태 */
|
||||
open,
|
||||
/** 모달 open 상태 setter */
|
||||
setOpen,
|
||||
/** web-types API 호출용 테이블명 */
|
||||
tableName,
|
||||
/** localStorage 키 */
|
||||
settingsId,
|
||||
/** TableSettingsModal onSave 콜백 */
|
||||
applySettings,
|
||||
/** 설정 적용된 컬럼 배열 (순서 + 표시 필터) */
|
||||
visibleColumns,
|
||||
/** 특정 컬럼 표시 여부 */
|
||||
isVisible,
|
||||
/** 특정 컬럼 너비 (px) */
|
||||
getWidth,
|
||||
/** 필터 설정 */
|
||||
filterConfig,
|
||||
/** GRID_COLUMNS 기본 컬럼 키 목록 (TableSettingsModal defaultVisibleKeys용) */
|
||||
defaultVisibleKeys: defaultColumns.map((c) => c.key),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user