feat: Update equipment handling in process management
- Enhanced the `getProcessEquipments` function to support matching both legacy equipment codes and new IDs, improving data retrieval accuracy. - Updated the `availableEquipments` logic in the `ProcessMasterTab` component to handle both equipment codes and IDs, ensuring a seamless user experience when adding equipment. - Improved error handling for equipment selection, providing user feedback when a selected equipment cannot be found. - Refactored the display of equipment names to ensure accurate representation, even when equipment codes are not available.
This commit is contained in:
@@ -67,7 +67,7 @@ export async function getProductionReportData(req: any, res: Response): Promise<
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(wi.start_date, wi.created_date::date::text) as date,
|
||||
COALESCE(wi.routing, '미지정') as process,
|
||||
COALESCE(NULLIF(rv.version_name, ''), '미지정') as process,
|
||||
COALESCE(ei.equipment_name, wi.equipment_id, '미지정') as equipment,
|
||||
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
|
||||
COALESCE(wi.worker, '미지정') as worker,
|
||||
@@ -79,6 +79,8 @@ export async function getProductionReportData(req: any, res: Response): Promise<
|
||||
wi.status,
|
||||
wi.company_code
|
||||
FROM work_instruction wi
|
||||
LEFT JOIN item_routing_version rv
|
||||
ON wi.routing = rv.id AND wi.company_code = rv.company_code
|
||||
LEFT JOIN (
|
||||
SELECT wo_id, company_code,
|
||||
SUM(CAST(COALESCE(NULLIF(production_qty, ''), '0') AS numeric)) as production_qty,
|
||||
|
||||
@@ -154,10 +154,13 @@ export async function getProcessEquipments(req: AuthenticatedRequest, res: Respo
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { processCode } = req.params;
|
||||
|
||||
// equipment_code 컬럼에 코드(legacy) 또는 id(신규)가 들어올 수 있어 두 경우 모두 매칭
|
||||
const result = await pool.query(
|
||||
`SELECT pe.*, em.equipment_name
|
||||
FROM process_equipment pe
|
||||
LEFT JOIN equipment_mng em ON pe.equipment_code = em.equipment_code AND pe.company_code = em.company_code
|
||||
LEFT JOIN equipment_mng em
|
||||
ON pe.company_code = em.company_code
|
||||
AND (pe.equipment_code = em.equipment_code OR pe.equipment_code = em.id)
|
||||
WHERE pe.process_code = $1 AND pe.company_code = $2
|
||||
ORDER BY pe.equipment_code`,
|
||||
[processCode, companyCode]
|
||||
|
||||
@@ -324,8 +324,15 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const availableEquipments = useMemo(() => {
|
||||
const used = new Set(processEquipments.map((e) => e.equipment_code));
|
||||
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
|
||||
const used = new Set<string>();
|
||||
for (const pe of processEquipments) {
|
||||
if (pe.equipment_code) used.add(pe.equipment_code);
|
||||
}
|
||||
return equipmentMaster.filter((e) => {
|
||||
if (e.equipment_code && used.has(e.equipment_code)) return false;
|
||||
if (e.id && used.has(e.id)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
@@ -334,11 +341,16 @@ export function ProcessMasterTab() {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: equipmentPick,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
@@ -515,8 +527,8 @@ export function ProcessMasterTab() {
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.equipment_code,
|
||||
label: `${eq.equipment_code} · ${eq.equipment_name}`,
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
@@ -553,8 +565,11 @@ export function ProcessMasterTab() {
|
||||
{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>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{pe.equipment_name
|
||||
|| equipmentMaster.find((e) => e.id === pe.equipment_code || e.equipment_code === pe.equipment_code)?.equipment_name
|
||||
|| "설비명 없음"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => void handleRemoveEquipment(pe)}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Search,
|
||||
Inbox,
|
||||
Settings2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
@@ -41,6 +42,8 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload";
|
||||
import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload";
|
||||
|
||||
/* ───── 테이블명 ───── */
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
@@ -118,6 +121,11 @@ export default function InspectionManagementPage() {
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ───── 엑셀업로드 모달 오픈 상태 ───── */
|
||||
const [inspExcelOpen, setInspExcelOpen] = useState(false);
|
||||
const [defExcelOpen, setDefExcelOpen] = useState(false);
|
||||
const [eqExcelOpen, setEqExcelOpen] = useState(false);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
@@ -128,6 +136,7 @@ export default function InspectionManagementPage() {
|
||||
{ table: INSPECTION_TABLE, col: "inspection_method" },
|
||||
{ table: INSPECTION_TABLE, col: "judgment_criteria" },
|
||||
{ table: INSPECTION_TABLE, col: "unit" },
|
||||
{ table: INSPECTION_TABLE, col: "is_active" },
|
||||
{ table: DEFECT_TABLE, col: "defect_type" },
|
||||
{ table: DEFECT_TABLE, col: "severity" },
|
||||
{ table: DEFECT_TABLE, col: "inspection_type" },
|
||||
@@ -395,7 +404,7 @@ export default function InspectionManagementPage() {
|
||||
|
||||
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
|
||||
const openDefCreate = async () => {
|
||||
setDefForm({ is_active: "사용" });
|
||||
setDefForm({ is_active: "CAT_DA_01" });
|
||||
setDefEditMode(false);
|
||||
setNumberingRuleId(null);
|
||||
setPreviewCode(null);
|
||||
@@ -606,6 +615,617 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 엑셀 업로드 공통 헬퍼 ═══════════════════ */
|
||||
// 라벨 배열 추출 (비어있는 카테고리는 빈 배열 반환)
|
||||
const catLabels = useCallback(
|
||||
(table: string, col: string): string[] => {
|
||||
return (catOptions[`${table}.${col}`] || []).map((o) => o.label);
|
||||
},
|
||||
[catOptions],
|
||||
);
|
||||
// 라벨→코드 단일 맵 생성
|
||||
const catLabelToCode = useCallback(
|
||||
(table: string, col: string): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
(catOptions[`${table}.${col}`] || []).forEach((o) => {
|
||||
map[o.label] = o.code;
|
||||
});
|
||||
return map;
|
||||
},
|
||||
[catOptions],
|
||||
);
|
||||
|
||||
/* ═══════════════════ 탭 1: 검사기준 엑셀업로드 ═══════════════════ */
|
||||
const inspExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "검사기준",
|
||||
sheets: [
|
||||
{
|
||||
name: "검사기준",
|
||||
typeKey: "inspection_standard",
|
||||
columns: [
|
||||
{ key: "inspection_code", label: "검사코드", type: "text", width: 14 },
|
||||
{ key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 },
|
||||
{ key: "inspection_criteria", label: "검사기준", required: true, type: "text", width: 20 },
|
||||
{ key: "criteria_detail", label: "기준상세", type: "text", width: 20 },
|
||||
{ key: "inspection_item", label: "검사항목", required: true, type: "text", width: 18 },
|
||||
{
|
||||
key: "inspection_method",
|
||||
label: "검사방법",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "inspection_method") },
|
||||
width: 14,
|
||||
},
|
||||
{
|
||||
key: "judgment_criteria",
|
||||
label: "판단기준",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "judgment_criteria") },
|
||||
width: 14,
|
||||
},
|
||||
{
|
||||
key: "unit",
|
||||
label: "단위",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "unit") },
|
||||
width: 10,
|
||||
},
|
||||
{
|
||||
key: "apply_type",
|
||||
label: "적용구분",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "apply_type") },
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "is_active") },
|
||||
width: 10,
|
||||
},
|
||||
{ key: "selection_options", label: "선택옵션", type: "text", width: 22 },
|
||||
{
|
||||
key: "manager",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remark", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
conditionalRules: [
|
||||
{ when: { column: "judgment_criteria", equals: "선택형" }, require: ["selection_options"], ignore: [] },
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const inspDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
inspection_method: catLabels(INSPECTION_TABLE, "inspection_method"),
|
||||
judgment_criteria: catLabels(INSPECTION_TABLE, "judgment_criteria"),
|
||||
unit: catLabels(INSPECTION_TABLE, "unit"),
|
||||
apply_type: catLabels(INSPECTION_TABLE, "apply_type"),
|
||||
is_active: catLabels(INSPECTION_TABLE, "is_active"),
|
||||
manager: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const inspLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
inspection_method: catLabelToCode(INSPECTION_TABLE, "inspection_method"),
|
||||
judgment_criteria: catLabelToCode(INSPECTION_TABLE, "judgment_criteria"),
|
||||
unit: catLabelToCode(INSPECTION_TABLE, "unit"),
|
||||
apply_type: catLabelToCode(INSPECTION_TABLE, "apply_type"),
|
||||
is_active: catLabelToCode(INSPECTION_TABLE, "is_active"),
|
||||
manager: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleInspExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
// 채번 규칙 사전 조회 (코드 미입력 건에만 사용)
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${INSPECTION_TABLE}/inspection_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* 채번 규칙 없으면 기존 코드로만 처리 */
|
||||
}
|
||||
|
||||
// templateParser가 dropdown 컬럼은 labelToCodeMap으로 이미 label→code 자동변환 완료.
|
||||
// row.{key}는 코드, row.{key}_label은 원본 라벨.
|
||||
// inspection_type은 text 타입이라 자동변환 제외 → 수동 처리 + 카테고리 라벨 검증.
|
||||
const inspTypeMap = catLabelToCode(INSPECTION_TABLE, "inspection_type");
|
||||
const inspTypeLabels = catLabels(INSPECTION_TABLE, "inspection_type");
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// inspection_type 다중값: ,로 split → 각 라벨이 카테고리에 있는지 검증 → 코드로 변환
|
||||
const rawTypes = String(row.inspection_type || "").trim();
|
||||
const typeLabelsArr = rawTypes
|
||||
? rawTypes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const invalidTypes = typeLabelsArr.filter((label) => !inspTypeLabels.includes(label));
|
||||
if (invalidTypes.length > 0) {
|
||||
failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
const typeCodes = typeLabelsArr.map((label) => inspTypeMap[label] || label).join(",");
|
||||
|
||||
// selection_options 조건부 검증 (판단기준 라벨이 "선택형"일 때 필수)
|
||||
const judgmentOrigLabel = String(row.judgment_criteria_label || row.judgment_criteria || "").trim();
|
||||
if (judgmentOrigLabel === "선택형" && !String(row.selection_options || "").trim()) {
|
||||
failList.push(`${i + 2}행: 선택형은 옵션을 입력해주세요`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// row.{key}는 이미 코드 (dropdown 컬럼, templateParser가 labelToCodeMap 자동변환).
|
||||
const payload: Record<string, any> = {
|
||||
inspection_type: typeCodes,
|
||||
inspection_criteria: row.inspection_criteria || "",
|
||||
criteria_detail: row.criteria_detail || "",
|
||||
inspection_item: row.inspection_item || "",
|
||||
inspection_method: row.inspection_method || "",
|
||||
judgment_criteria: row.judgment_criteria || "",
|
||||
unit: row.unit || "",
|
||||
apply_type: row.apply_type || "",
|
||||
is_active: row.is_active || "CAT_IS_01",
|
||||
selection_options: row.selection_options || "",
|
||||
manager: row.manager || "",
|
||||
remark: row.remark || "",
|
||||
};
|
||||
|
||||
// 코드 있으면 update, 없으면 insert (채번 할당)
|
||||
const inspectionCode = String(row.inspection_code || "").trim();
|
||||
if (inspectionCode) {
|
||||
// 기존 행 조회 후 upsert
|
||||
const existRes = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "inspection_code", operator: "equals", value: inspectionCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, inspection_code: inspectionCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_code: inspectionCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 코드 자동 채번
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch (e) {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchInspections();
|
||||
};
|
||||
|
||||
/* ═══════════════════ 탭 2: 불량관리 엑셀업로드 ═══════════════════ */
|
||||
const defExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "불량관리",
|
||||
sheets: [
|
||||
{
|
||||
name: "불량관리",
|
||||
typeKey: "defect_standard_mng",
|
||||
columns: [
|
||||
{ key: "defect_code", label: "불량코드", type: "text", width: 14 },
|
||||
{
|
||||
key: "defect_type",
|
||||
label: "불량유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "defect_type") },
|
||||
width: 14,
|
||||
},
|
||||
{ key: "defect_name", label: "불량명", required: true, type: "text", width: 20 },
|
||||
{ key: "defect_content", label: "불량내용", required: true, type: "text", width: 24 },
|
||||
{
|
||||
key: "severity",
|
||||
label: "심각도",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "severity") },
|
||||
width: 12,
|
||||
},
|
||||
{ key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 },
|
||||
{ key: "apply_target", label: "적용대상", type: "text", width: 18 },
|
||||
{
|
||||
key: "is_active",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "is_active") },
|
||||
width: 10,
|
||||
},
|
||||
{
|
||||
key: "manager_id",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remarks", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const defDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
defect_type: catLabels(DEFECT_TABLE, "defect_type"),
|
||||
severity: catLabels(DEFECT_TABLE, "severity"),
|
||||
is_active: catLabels(DEFECT_TABLE, "is_active"),
|
||||
manager_id: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const defLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
defect_type: catLabelToCode(DEFECT_TABLE, "defect_type"),
|
||||
severity: catLabelToCode(DEFECT_TABLE, "severity"),
|
||||
is_active: catLabelToCode(DEFECT_TABLE, "is_active"),
|
||||
manager_id: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleDefExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${DEFECT_TABLE}/defect_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
// templateParser가 dropdown 컬럼은 이미 label→code 변환 완료. inspection_type/apply_target만 text라 수동 처리 + 계층 검증.
|
||||
const inspTypeMap = catLabelToCode(DEFECT_TABLE, "inspection_type");
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
// depth=1 부모(검사유형), depth=2 자식(적용대상)
|
||||
const parentLabels = defInspOpts.filter((o) => o.depth === 1).map((o) => o.label);
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// inspection_type 다중값: ,로 split → depth=1 라벨 검증 → 코드로 변환
|
||||
const rawTypes = String(row.inspection_type || "").trim();
|
||||
const typeLabelsArr = rawTypes
|
||||
? rawTypes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const invalidTypes = typeLabelsArr.filter((label) => !parentLabels.includes(label));
|
||||
if (invalidTypes.length > 0) {
|
||||
failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
const typeCodeList = typeLabelsArr.map((label) => inspTypeMap[label] || label);
|
||||
const typeCodes = typeCodeList.join(",");
|
||||
|
||||
// apply_target 다중값: 선택한 검사유형의 자식(depth=2)만 허용 → 자식 코드로 변환
|
||||
const rawTargets = String(row.apply_target || "").trim();
|
||||
const targetLabelsArr = rawTargets
|
||||
? rawTargets.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const targetCodeList: string[] = [];
|
||||
const invalidTargets: string[] = [];
|
||||
for (const label of targetLabelsArr) {
|
||||
const child = defInspOpts.find(
|
||||
(o) =>
|
||||
o.depth === 2 &&
|
||||
o.label === label &&
|
||||
o.parentCode &&
|
||||
typeCodeList.includes(o.parentCode),
|
||||
);
|
||||
if (child) targetCodeList.push(child.code);
|
||||
else invalidTargets.push(label);
|
||||
}
|
||||
if (invalidTargets.length > 0) {
|
||||
failList.push(
|
||||
`${i + 2}행: 적용대상이 선택한 검사유형의 하위가 아니거나 미등록(${invalidTargets.join(", ")})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const targetCodes = targetCodeList.join(",");
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
defect_type: row.defect_type || "",
|
||||
defect_name: row.defect_name || "",
|
||||
defect_content: row.defect_content || "",
|
||||
severity: row.severity || "",
|
||||
inspection_type: typeCodes,
|
||||
apply_target: targetCodes,
|
||||
is_active: row.is_active || "",
|
||||
manager_id: row.manager_id || "",
|
||||
remarks: row.remarks || "",
|
||||
};
|
||||
|
||||
const defectCode = String(row.defect_code || "").trim();
|
||||
if (defectCode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "defect_code", operator: "equals", value: defectCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${DEFECT_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, defect_code: defectCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
defect_code: defectCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
defect_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchDefects();
|
||||
};
|
||||
|
||||
/* ═══════════════════ 탭 3: 검사장비 엑셀업로드 ═══════════════════ */
|
||||
const eqExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "검사장비",
|
||||
sheets: [
|
||||
{
|
||||
name: "검사장비",
|
||||
typeKey: "inspection_equipment_mng",
|
||||
columns: [
|
||||
{ key: "equipment_code", label: "장비코드", type: "text", width: 14 },
|
||||
{ key: "equipment_name", label: "장비명", required: true, type: "text", width: 20 },
|
||||
{
|
||||
key: "equipment_type",
|
||||
label: "장비유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_type") },
|
||||
width: 14,
|
||||
},
|
||||
{ key: "model_name", label: "모델명", type: "text", width: 16 },
|
||||
{ key: "manufacturer", label: "제조사", type: "text", width: 16 },
|
||||
{ key: "serial_number", label: "시리얼번호", type: "text", width: 16 },
|
||||
{ key: "installation_location", label: "설치위치", type: "text", width: 18 },
|
||||
{ key: "last_calibration_date", label: "최종교정일", type: "date", width: 14 },
|
||||
{ key: "calibration_period", label: "교정주기(개월)", type: "number", width: 14 },
|
||||
{
|
||||
key: "equipment_status",
|
||||
label: "장비상태",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_status") },
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
key: "manager_id",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remarks", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const eqDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
equipment_type: catLabels(EQUIPMENT_TABLE, "equipment_type"),
|
||||
equipment_status: catLabels(EQUIPMENT_TABLE, "equipment_status"),
|
||||
manager_id: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const eqLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
equipment_type: catLabelToCode(EQUIPMENT_TABLE, "equipment_type"),
|
||||
equipment_status: catLabelToCode(EQUIPMENT_TABLE, "equipment_status"),
|
||||
manager_id: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleEqExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${EQUIPMENT_TABLE}/equipment_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// calibration_period 숫자 검증
|
||||
const periodRaw = String(row.calibration_period ?? "").trim();
|
||||
if (periodRaw && isNaN(Number(periodRaw))) {
|
||||
failList.push(`${i + 2}행: 교정주기는 숫자만 입력해주세요`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// last_calibration_date 포맷 검증 (YYYY-MM-DD 기대)
|
||||
let calibDate = String(row.last_calibration_date ?? "").trim();
|
||||
if (calibDate) {
|
||||
// Date 객체로 변환되어 왔을 수도 있어 ISO 추출
|
||||
const d = new Date(calibDate);
|
||||
if (isNaN(d.getTime())) {
|
||||
failList.push(`${i + 2}행: 최종교정일 포맷 오류(${calibDate})`);
|
||||
continue;
|
||||
}
|
||||
calibDate = d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// row.{key}는 이미 코드 (templateParser가 labelToCodeMap으로 자동변환 완료).
|
||||
const payload: Record<string, any> = {
|
||||
equipment_name: row.equipment_name || "",
|
||||
equipment_type: row.equipment_type || "",
|
||||
model_name: row.model_name || "",
|
||||
manufacturer: row.manufacturer || "",
|
||||
serial_number: row.serial_number || "",
|
||||
installation_location: row.installation_location || "",
|
||||
last_calibration_date: calibDate,
|
||||
calibration_period: periodRaw,
|
||||
equipment_status: row.equipment_status || "",
|
||||
manager_id: row.manager_id || "",
|
||||
remarks: row.remarks || "",
|
||||
};
|
||||
|
||||
const equipmentCode = String(row.equipment_code || "").trim();
|
||||
if (equipmentCode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "equipment_code", operator: "equals", value: equipmentCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${EQUIPMENT_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, equipment_code: equipmentCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
equipment_code: equipmentCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
equipment_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchEquipments();
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3 h-[calc(100vh-4rem)] overflow-auto">
|
||||
@@ -663,6 +1283,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setInspExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -725,6 +1349,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setDefExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -876,12 +1504,15 @@ export default function InspectionManagementPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{row.is_active === "사용" || row.is_active === "true" ? "사용" : row.is_active || "미사용"}
|
||||
</Badge>
|
||||
{(() => {
|
||||
const label = getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || row.is_active || "-";
|
||||
const isOn = row.is_active === "CAT_DA_01" || label === "사용";
|
||||
return (
|
||||
<Badge variant={isOn ? "default" : "secondary"} className="text-[10px]">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")}
|
||||
@@ -919,6 +1550,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEqExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1508,15 +2143,18 @@ export default function InspectionManagementPage() {
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">사용여부</Label>
|
||||
<Select
|
||||
value={defForm.is_active || "사용"}
|
||||
value={defForm.is_active || "CAT_DA_01"}
|
||||
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사용">사용</SelectItem>
|
||||
<SelectItem value="미사용">미사용</SelectItem>
|
||||
{(catOptions[`${DEFECT_TABLE}.is_active`] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1771,6 +2409,101 @@ export default function InspectionManagementPage() {
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{/* ═══════════════════ 엑셀업로드 모달 ═══════════════════ */}
|
||||
<SmartExcelUploadModal
|
||||
open={inspExcelOpen}
|
||||
onOpenChange={setInspExcelOpen}
|
||||
config={inspExcelConfig}
|
||||
dropdownOptions={inspDropdownOptions}
|
||||
labelToCodeMap={inspLabelToCodeMap}
|
||||
onUpload={handleInspExcelUpload}
|
||||
customValidator={(data) => {
|
||||
// inspection_type 다중값(text) 카테고리 라벨 검증
|
||||
const errors: any[] = [];
|
||||
const validLabels = catLabels(INSPECTION_TABLE, "inspection_type");
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
const raw = String(row.inspection_type || "").trim();
|
||||
if (!raw) return;
|
||||
const labels = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const invalid = labels.filter((l) => !validLabels.includes(l));
|
||||
if (invalid.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "검사유형",
|
||||
message: `등록되지 않은 값: ${invalid.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
<SmartExcelUploadModal
|
||||
open={defExcelOpen}
|
||||
onOpenChange={setDefExcelOpen}
|
||||
config={defExcelConfig}
|
||||
dropdownOptions={defDropdownOptions}
|
||||
labelToCodeMap={defLabelToCodeMap}
|
||||
onUpload={handleDefExcelUpload}
|
||||
customValidator={(data) => {
|
||||
const errors: any[] = [];
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
const parentByLabel = new Map(defInspOpts.filter((o) => o.depth === 1).map((o) => [o.label, o.code]));
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
// 검사유형(depth=1) 검증
|
||||
const rawType = String(row.inspection_type || "").trim();
|
||||
const typeLabels = rawType ? rawType.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const invalidTypes = typeLabels.filter((l) => !parentByLabel.has(l));
|
||||
if (invalidTypes.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "검사유형",
|
||||
message: `등록되지 않은 값: ${invalidTypes.join(", ")}`,
|
||||
});
|
||||
}
|
||||
// 적용대상(depth=2) 계층 검증 — 선택한 검사유형의 자식만 허용
|
||||
const rawTarget = String(row.apply_target || "").trim();
|
||||
if (!rawTarget) return;
|
||||
const targetLabels = rawTarget.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const selectedParentCodes = typeLabels
|
||||
.map((l) => parentByLabel.get(l))
|
||||
.filter(Boolean) as string[];
|
||||
const invalidTargets = targetLabels.filter((label) => {
|
||||
const child = defInspOpts.find(
|
||||
(o) =>
|
||||
o.depth === 2 &&
|
||||
o.label === label &&
|
||||
o.parentCode &&
|
||||
selectedParentCodes.includes(o.parentCode),
|
||||
);
|
||||
return !child;
|
||||
});
|
||||
if (invalidTargets.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "적용대상",
|
||||
message: `선택한 검사유형의 하위가 아니거나 미등록: ${invalidTargets.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
<SmartExcelUploadModal
|
||||
open={eqExcelOpen}
|
||||
onOpenChange={setEqExcelOpen}
|
||||
config={eqExcelConfig}
|
||||
dropdownOptions={eqDropdownOptions}
|
||||
labelToCodeMap={eqLabelToCodeMap}
|
||||
onUpload={handleEqExcelUpload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Search,
|
||||
RotateCcw,
|
||||
Wrench,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -62,6 +63,10 @@ import {
|
||||
type Equipment,
|
||||
} from "@/lib/api/processInfo"; // API: /process-info/*
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload";
|
||||
import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload";
|
||||
import { allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
const ALL_VALUE = "__all__";
|
||||
|
||||
@@ -69,6 +74,8 @@ export function ProcessMasterTab() {
|
||||
const [processes, setProcesses] = useState<ProcessMaster[]>([]);
|
||||
const [equipmentMaster, setEquipmentMaster] = useState<Equipment[]>([]);
|
||||
const [processTypeOptions, setProcessTypeOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
|
||||
const [useYnOptions, setUseYnOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
|
||||
const [excelOpen, setExcelOpen] = useState(false);
|
||||
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [loadingEquipments, setLoadingEquipments] = useState(false);
|
||||
@@ -154,6 +161,18 @@ export function ProcessMasterTab() {
|
||||
});
|
||||
setProcessTypeOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
|
||||
}
|
||||
// 사용여부 카테고리 (엑셀업로드 드롭다운용)
|
||||
const uyRes = await getCategoryValues("process_mng", "use_yn");
|
||||
if (uyRes.success && "data" in uyRes && Array.isArray(uyRes.data)) {
|
||||
const activeValues = uyRes.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;
|
||||
});
|
||||
setUseYnOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
|
||||
}
|
||||
} finally {
|
||||
setLoadingInitial(false);
|
||||
}
|
||||
@@ -324,8 +343,17 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const availableEquipments = useMemo(() => {
|
||||
const used = new Set(processEquipments.map((e) => e.equipment_code));
|
||||
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
|
||||
// equipment_code 컬럼에 code(legacy) 또는 id(신규)가 들어있을 수 있음
|
||||
// 빈 값은 used set에서 제외 (코드 없는 설비를 모두 가리는 것 방지)
|
||||
const used = new Set<string>();
|
||||
for (const pe of processEquipments) {
|
||||
if (pe.equipment_code) used.add(pe.equipment_code);
|
||||
}
|
||||
return equipmentMaster.filter((e) => {
|
||||
if (e.equipment_code && used.has(e.equipment_code)) return false;
|
||||
if (e.id && used.has(e.id)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
@@ -334,11 +362,17 @@ export function ProcessMasterTab() {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
// equipment_code가 비어있으면 id를 저장 (백엔드 JOIN이 양쪽 다 매칭)
|
||||
const res = await addProcessEquipment({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: equipmentPick,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
@@ -368,6 +402,175 @@ export function ProcessMasterTab() {
|
||||
|
||||
const listBusy = loadingInitial || loadingList;
|
||||
|
||||
/* ═══════════════════ 엑셀 업로드 설정 ═══════════════════ */
|
||||
const processTypeLabelToCode = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
processTypeOptions.forEach((o) => {
|
||||
m[o.valueLabel] = o.valueCode;
|
||||
});
|
||||
return m;
|
||||
}, [processTypeOptions]);
|
||||
|
||||
const useYnLabelToCode = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
useYnOptions.forEach((o) => {
|
||||
m[o.valueLabel] = o.valueCode;
|
||||
});
|
||||
return m;
|
||||
}, [useYnOptions]);
|
||||
|
||||
const excelConfig = useMemo<SmartExcelUploadConfig>(
|
||||
() => ({
|
||||
templateName: "공정 마스터",
|
||||
sheets: [
|
||||
{
|
||||
name: "공정 마스터",
|
||||
typeKey: "process_mng",
|
||||
columns: [
|
||||
{ key: "process_code", label: "공정코드", type: "text", width: 14 },
|
||||
{ key: "process_name", label: "공정명", required: true, type: "text", width: 20 },
|
||||
{
|
||||
key: "process_type",
|
||||
label: "공정유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: processTypeOptions.map((o) => o.valueLabel) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "standard_time", label: "표준시간(분)", type: "number", width: 12 },
|
||||
{ key: "worker_count", label: "작업인원", type: "number", width: 10 },
|
||||
{
|
||||
key: "use_yn",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: useYnOptions.map((o) => o.valueLabel) },
|
||||
width: 10,
|
||||
},
|
||||
// 설비명을 쉼표로 구분하여 입력 (예: "절단기1,포장기"). 매칭 실패한 이름은 스킵.
|
||||
{ key: "equipment_list", label: "사용설비(쉼표구분)", type: "text", width: 28 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
[processTypeOptions, useYnOptions],
|
||||
);
|
||||
|
||||
const excelDropdownOptions = useMemo<Record<string, string[]>>(
|
||||
() => ({
|
||||
process_type: processTypeOptions.map((o) => o.valueLabel),
|
||||
use_yn: useYnOptions.map((o) => o.valueLabel),
|
||||
}),
|
||||
[processTypeOptions, useYnOptions],
|
||||
);
|
||||
|
||||
const excelLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(
|
||||
() => ({
|
||||
process_type: processTypeLabelToCode,
|
||||
use_yn: useYnLabelToCode,
|
||||
}),
|
||||
[processTypeLabelToCode, useYnLabelToCode],
|
||||
);
|
||||
|
||||
const handleExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
// 채번 규칙 사전 조회 (공정코드 미입력 행에만 사용)
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const res = await apiClient.get(`/numbering-rules/by-column/process_mng/process_code`);
|
||||
if (res.data?.success && res.data?.data?.ruleId) {
|
||||
ruleId = res.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* 채번 규칙 없으면 스킵 */
|
||||
}
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// row.{key}는 templateParser가 이미 label→code로 자동변환한 값 (dropdown 컬럼)
|
||||
let processCode = String(row.process_code || "").trim();
|
||||
if (!processCode && ruleId) {
|
||||
const alloc = await allocateNumberingCode(ruleId);
|
||||
if (alloc.success && alloc.data?.generatedCode) {
|
||||
processCode = alloc.data.generatedCode;
|
||||
} else {
|
||||
failList.push(`${i + 2}행: 채번 실패`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!processCode) {
|
||||
failList.push(`${i + 2}행: 공정코드 없음 (채번 규칙 필요)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
process_code: processCode,
|
||||
process_name: row.process_name || "",
|
||||
process_type: row.process_type || "",
|
||||
standard_time: String(row.standard_time ?? ""),
|
||||
worker_count: String(row.worker_count ?? ""),
|
||||
use_yn: row.use_yn || "USE_Y",
|
||||
};
|
||||
|
||||
// 기존 공정 조회 후 upsert
|
||||
const existRes = await getProcessList({ processCode });
|
||||
const existing = existRes.success
|
||||
? (existRes.data ?? []).find((p) => p.process_code === processCode)
|
||||
: null;
|
||||
if (existing) {
|
||||
const up = await updateProcess(existing.id, payload);
|
||||
if (!up.success) {
|
||||
failList.push(`${i + 2}행: 수정 실패`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const cr = await createProcess(payload);
|
||||
if (!cr.success) {
|
||||
failList.push(`${i + 2}행: 등록 실패`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 사용설비 매핑 (쉼표 구분 설비명). 매칭 안 되는 이름은 무시 (사용자 책임).
|
||||
const rawEqs = String(row.equipment_list || "").trim();
|
||||
if (rawEqs) {
|
||||
const eqLabels = rawEqs.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
// 기존 매핑 조회 후 중복 방지
|
||||
const curRes = await getProcessEquipments(processCode);
|
||||
const currentSet = new Set((curRes.success ? curRes.data ?? [] : []).map((e) => e.equipment_code));
|
||||
const eqByName = new Map(equipmentMaster.map((e) => [e.equipment_name, e.equipment_code]));
|
||||
for (const label of eqLabels) {
|
||||
const eqCode = eqByName.get(label);
|
||||
if (!eqCode) continue; // 오타/미등록 설비 → 스킵
|
||||
if (currentSet.has(eqCode)) continue; // 이미 매핑됨
|
||||
await addProcessEquipment({ process_code: processCode, equipment_code: eqCode });
|
||||
currentSet.add(eqCode);
|
||||
}
|
||||
}
|
||||
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(
|
||||
`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`,
|
||||
);
|
||||
}
|
||||
void loadProcesses();
|
||||
};
|
||||
|
||||
// 표시용 데이터
|
||||
const processGridData = useMemo(
|
||||
() =>
|
||||
@@ -431,6 +634,10 @@ export function ProcessMasterTab() {
|
||||
|
||||
{/* 액션 바 */}
|
||||
<div className="flex items-center justify-end gap-2 border-b bg-muted/30 px-4 py-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-3.5 w-3.5" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button size="sm" onClick={openAdd}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
공정 추가
|
||||
@@ -515,8 +722,8 @@ export function ProcessMasterTab() {
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.equipment_code,
|
||||
label: `${eq.equipment_code} · ${eq.equipment_name}`,
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
@@ -553,8 +760,11 @@ export function ProcessMasterTab() {
|
||||
{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>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{pe.equipment_name
|
||||
|| equipmentMaster.find((e) => e.id === pe.equipment_code || e.equipment_code === pe.equipment_code)?.equipment_name
|
||||
|| "설비명 없음"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => void handleRemoveEquipment(pe)}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
@@ -664,6 +874,38 @@ export function ProcessMasterTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ═══════════════════ 엑셀업로드 모달 ═══════════════════ */}
|
||||
<SmartExcelUploadModal
|
||||
open={excelOpen}
|
||||
onOpenChange={setExcelOpen}
|
||||
config={excelConfig}
|
||||
dropdownOptions={excelDropdownOptions}
|
||||
labelToCodeMap={excelLabelToCodeMap}
|
||||
onUpload={handleExcelUpload}
|
||||
customValidator={(data) => {
|
||||
// 사용설비 컬럼 검증: 쉼표 구분 각 이름이 equipmentMaster에 등록된 설비명인지
|
||||
const errors: any[] = [];
|
||||
const validNames = new Set(equipmentMaster.map((e) => e.equipment_name));
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
const raw = String(row.equipment_list || "").trim();
|
||||
if (!raw) return;
|
||||
const names = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const invalid = names.filter((n) => !validNames.has(n));
|
||||
if (invalid.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "사용설비(쉼표구분)",
|
||||
message: `등록되지 않은 설비: ${invalid.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Search,
|
||||
Inbox,
|
||||
Settings2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
@@ -41,6 +42,8 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload";
|
||||
import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload";
|
||||
|
||||
/* ───── 테이블명 ───── */
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
@@ -118,6 +121,11 @@ export default function InspectionManagementPage() {
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ───── 엑셀업로드 모달 오픈 상태 ───── */
|
||||
const [inspExcelOpen, setInspExcelOpen] = useState(false);
|
||||
const [defExcelOpen, setDefExcelOpen] = useState(false);
|
||||
const [eqExcelOpen, setEqExcelOpen] = useState(false);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
@@ -128,6 +136,7 @@ export default function InspectionManagementPage() {
|
||||
{ table: INSPECTION_TABLE, col: "inspection_method" },
|
||||
{ table: INSPECTION_TABLE, col: "judgment_criteria" },
|
||||
{ table: INSPECTION_TABLE, col: "unit" },
|
||||
{ table: INSPECTION_TABLE, col: "is_active" },
|
||||
{ table: DEFECT_TABLE, col: "defect_type" },
|
||||
{ table: DEFECT_TABLE, col: "severity" },
|
||||
{ table: DEFECT_TABLE, col: "inspection_type" },
|
||||
@@ -395,7 +404,7 @@ export default function InspectionManagementPage() {
|
||||
|
||||
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
|
||||
const openDefCreate = async () => {
|
||||
setDefForm({ is_active: "사용" });
|
||||
setDefForm({ is_active: "CAT_DA_01" });
|
||||
setDefEditMode(false);
|
||||
setNumberingRuleId(null);
|
||||
setPreviewCode(null);
|
||||
@@ -606,6 +615,617 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 엑셀 업로드 공통 헬퍼 ═══════════════════ */
|
||||
// 라벨 배열 추출 (비어있는 카테고리는 빈 배열 반환)
|
||||
const catLabels = useCallback(
|
||||
(table: string, col: string): string[] => {
|
||||
return (catOptions[`${table}.${col}`] || []).map((o) => o.label);
|
||||
},
|
||||
[catOptions],
|
||||
);
|
||||
// 라벨→코드 단일 맵 생성
|
||||
const catLabelToCode = useCallback(
|
||||
(table: string, col: string): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
(catOptions[`${table}.${col}`] || []).forEach((o) => {
|
||||
map[o.label] = o.code;
|
||||
});
|
||||
return map;
|
||||
},
|
||||
[catOptions],
|
||||
);
|
||||
|
||||
/* ═══════════════════ 탭 1: 검사기준 엑셀업로드 ═══════════════════ */
|
||||
const inspExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "검사기준",
|
||||
sheets: [
|
||||
{
|
||||
name: "검사기준",
|
||||
typeKey: "inspection_standard",
|
||||
columns: [
|
||||
{ key: "inspection_code", label: "검사코드", type: "text", width: 14 },
|
||||
{ key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 },
|
||||
{ key: "inspection_criteria", label: "검사기준", required: true, type: "text", width: 20 },
|
||||
{ key: "criteria_detail", label: "기준상세", type: "text", width: 20 },
|
||||
{ key: "inspection_item", label: "검사항목", required: true, type: "text", width: 18 },
|
||||
{
|
||||
key: "inspection_method",
|
||||
label: "검사방법",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "inspection_method") },
|
||||
width: 14,
|
||||
},
|
||||
{
|
||||
key: "judgment_criteria",
|
||||
label: "판단기준",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "judgment_criteria") },
|
||||
width: 14,
|
||||
},
|
||||
{
|
||||
key: "unit",
|
||||
label: "단위",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "unit") },
|
||||
width: 10,
|
||||
},
|
||||
{
|
||||
key: "apply_type",
|
||||
label: "적용구분",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "apply_type") },
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "is_active") },
|
||||
width: 10,
|
||||
},
|
||||
{ key: "selection_options", label: "선택옵션", type: "text", width: 22 },
|
||||
{
|
||||
key: "manager",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remark", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
conditionalRules: [
|
||||
{ when: { column: "judgment_criteria", equals: "선택형" }, require: ["selection_options"], ignore: [] },
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const inspDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
inspection_method: catLabels(INSPECTION_TABLE, "inspection_method"),
|
||||
judgment_criteria: catLabels(INSPECTION_TABLE, "judgment_criteria"),
|
||||
unit: catLabels(INSPECTION_TABLE, "unit"),
|
||||
apply_type: catLabels(INSPECTION_TABLE, "apply_type"),
|
||||
is_active: catLabels(INSPECTION_TABLE, "is_active"),
|
||||
manager: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const inspLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
inspection_method: catLabelToCode(INSPECTION_TABLE, "inspection_method"),
|
||||
judgment_criteria: catLabelToCode(INSPECTION_TABLE, "judgment_criteria"),
|
||||
unit: catLabelToCode(INSPECTION_TABLE, "unit"),
|
||||
apply_type: catLabelToCode(INSPECTION_TABLE, "apply_type"),
|
||||
is_active: catLabelToCode(INSPECTION_TABLE, "is_active"),
|
||||
manager: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleInspExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
// 채번 규칙 사전 조회 (코드 미입력 건에만 사용)
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${INSPECTION_TABLE}/inspection_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* 채번 규칙 없으면 기존 코드로만 처리 */
|
||||
}
|
||||
|
||||
// templateParser가 dropdown 컬럼은 labelToCodeMap으로 이미 label→code 자동변환 완료.
|
||||
// row.{key}는 코드, row.{key}_label은 원본 라벨.
|
||||
// inspection_type은 text 타입이라 자동변환 제외 → 수동 처리 + 카테고리 라벨 검증.
|
||||
const inspTypeMap = catLabelToCode(INSPECTION_TABLE, "inspection_type");
|
||||
const inspTypeLabels = catLabels(INSPECTION_TABLE, "inspection_type");
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// inspection_type 다중값: ,로 split → 각 라벨이 카테고리에 있는지 검증 → 코드로 변환
|
||||
const rawTypes = String(row.inspection_type || "").trim();
|
||||
const typeLabelsArr = rawTypes
|
||||
? rawTypes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const invalidTypes = typeLabelsArr.filter((label) => !inspTypeLabels.includes(label));
|
||||
if (invalidTypes.length > 0) {
|
||||
failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
const typeCodes = typeLabelsArr.map((label) => inspTypeMap[label] || label).join(",");
|
||||
|
||||
// selection_options 조건부 검증 (판단기준 라벨이 "선택형"일 때 필수)
|
||||
const judgmentOrigLabel = String(row.judgment_criteria_label || row.judgment_criteria || "").trim();
|
||||
if (judgmentOrigLabel === "선택형" && !String(row.selection_options || "").trim()) {
|
||||
failList.push(`${i + 2}행: 선택형은 옵션을 입력해주세요`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// row.{key}는 이미 코드 (dropdown 컬럼, templateParser가 labelToCodeMap 자동변환).
|
||||
const payload: Record<string, any> = {
|
||||
inspection_type: typeCodes,
|
||||
inspection_criteria: row.inspection_criteria || "",
|
||||
criteria_detail: row.criteria_detail || "",
|
||||
inspection_item: row.inspection_item || "",
|
||||
inspection_method: row.inspection_method || "",
|
||||
judgment_criteria: row.judgment_criteria || "",
|
||||
unit: row.unit || "",
|
||||
apply_type: row.apply_type || "",
|
||||
is_active: row.is_active || "CAT_IS_01",
|
||||
selection_options: row.selection_options || "",
|
||||
manager: row.manager || "",
|
||||
remark: row.remark || "",
|
||||
};
|
||||
|
||||
// 코드 있으면 update, 없으면 insert (채번 할당)
|
||||
const inspectionCode = String(row.inspection_code || "").trim();
|
||||
if (inspectionCode) {
|
||||
// 기존 행 조회 후 upsert
|
||||
const existRes = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "inspection_code", operator: "equals", value: inspectionCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, inspection_code: inspectionCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_code: inspectionCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 코드 자동 채번
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch (e) {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchInspections();
|
||||
};
|
||||
|
||||
/* ═══════════════════ 탭 2: 불량관리 엑셀업로드 ═══════════════════ */
|
||||
const defExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "불량관리",
|
||||
sheets: [
|
||||
{
|
||||
name: "불량관리",
|
||||
typeKey: "defect_standard_mng",
|
||||
columns: [
|
||||
{ key: "defect_code", label: "불량코드", type: "text", width: 14 },
|
||||
{
|
||||
key: "defect_type",
|
||||
label: "불량유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "defect_type") },
|
||||
width: 14,
|
||||
},
|
||||
{ key: "defect_name", label: "불량명", required: true, type: "text", width: 20 },
|
||||
{ key: "defect_content", label: "불량내용", required: true, type: "text", width: 24 },
|
||||
{
|
||||
key: "severity",
|
||||
label: "심각도",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "severity") },
|
||||
width: 12,
|
||||
},
|
||||
{ key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 },
|
||||
{ key: "apply_target", label: "적용대상", type: "text", width: 18 },
|
||||
{
|
||||
key: "is_active",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "is_active") },
|
||||
width: 10,
|
||||
},
|
||||
{
|
||||
key: "manager_id",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remarks", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const defDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
defect_type: catLabels(DEFECT_TABLE, "defect_type"),
|
||||
severity: catLabels(DEFECT_TABLE, "severity"),
|
||||
is_active: catLabels(DEFECT_TABLE, "is_active"),
|
||||
manager_id: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const defLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
defect_type: catLabelToCode(DEFECT_TABLE, "defect_type"),
|
||||
severity: catLabelToCode(DEFECT_TABLE, "severity"),
|
||||
is_active: catLabelToCode(DEFECT_TABLE, "is_active"),
|
||||
manager_id: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleDefExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${DEFECT_TABLE}/defect_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
// templateParser가 dropdown 컬럼은 이미 label→code 변환 완료. inspection_type/apply_target만 text라 수동 처리 + 계층 검증.
|
||||
const inspTypeMap = catLabelToCode(DEFECT_TABLE, "inspection_type");
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
// depth=1 부모(검사유형), depth=2 자식(적용대상)
|
||||
const parentLabels = defInspOpts.filter((o) => o.depth === 1).map((o) => o.label);
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// inspection_type 다중값: ,로 split → depth=1 라벨 검증 → 코드로 변환
|
||||
const rawTypes = String(row.inspection_type || "").trim();
|
||||
const typeLabelsArr = rawTypes
|
||||
? rawTypes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const invalidTypes = typeLabelsArr.filter((label) => !parentLabels.includes(label));
|
||||
if (invalidTypes.length > 0) {
|
||||
failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
const typeCodeList = typeLabelsArr.map((label) => inspTypeMap[label] || label);
|
||||
const typeCodes = typeCodeList.join(",");
|
||||
|
||||
// apply_target 다중값: 선택한 검사유형의 자식(depth=2)만 허용 → 자식 코드로 변환
|
||||
const rawTargets = String(row.apply_target || "").trim();
|
||||
const targetLabelsArr = rawTargets
|
||||
? rawTargets.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const targetCodeList: string[] = [];
|
||||
const invalidTargets: string[] = [];
|
||||
for (const label of targetLabelsArr) {
|
||||
const child = defInspOpts.find(
|
||||
(o) =>
|
||||
o.depth === 2 &&
|
||||
o.label === label &&
|
||||
o.parentCode &&
|
||||
typeCodeList.includes(o.parentCode),
|
||||
);
|
||||
if (child) targetCodeList.push(child.code);
|
||||
else invalidTargets.push(label);
|
||||
}
|
||||
if (invalidTargets.length > 0) {
|
||||
failList.push(
|
||||
`${i + 2}행: 적용대상이 선택한 검사유형의 하위가 아니거나 미등록(${invalidTargets.join(", ")})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const targetCodes = targetCodeList.join(",");
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
defect_type: row.defect_type || "",
|
||||
defect_name: row.defect_name || "",
|
||||
defect_content: row.defect_content || "",
|
||||
severity: row.severity || "",
|
||||
inspection_type: typeCodes,
|
||||
apply_target: targetCodes,
|
||||
is_active: row.is_active || "",
|
||||
manager_id: row.manager_id || "",
|
||||
remarks: row.remarks || "",
|
||||
};
|
||||
|
||||
const defectCode = String(row.defect_code || "").trim();
|
||||
if (defectCode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "defect_code", operator: "equals", value: defectCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${DEFECT_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, defect_code: defectCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
defect_code: defectCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
defect_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchDefects();
|
||||
};
|
||||
|
||||
/* ═══════════════════ 탭 3: 검사장비 엑셀업로드 ═══════════════════ */
|
||||
const eqExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "검사장비",
|
||||
sheets: [
|
||||
{
|
||||
name: "검사장비",
|
||||
typeKey: "inspection_equipment_mng",
|
||||
columns: [
|
||||
{ key: "equipment_code", label: "장비코드", type: "text", width: 14 },
|
||||
{ key: "equipment_name", label: "장비명", required: true, type: "text", width: 20 },
|
||||
{
|
||||
key: "equipment_type",
|
||||
label: "장비유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_type") },
|
||||
width: 14,
|
||||
},
|
||||
{ key: "model_name", label: "모델명", type: "text", width: 16 },
|
||||
{ key: "manufacturer", label: "제조사", type: "text", width: 16 },
|
||||
{ key: "serial_number", label: "시리얼번호", type: "text", width: 16 },
|
||||
{ key: "installation_location", label: "설치위치", type: "text", width: 18 },
|
||||
{ key: "last_calibration_date", label: "최종교정일", type: "date", width: 14 },
|
||||
{ key: "calibration_period", label: "교정주기(개월)", type: "number", width: 14 },
|
||||
{
|
||||
key: "equipment_status",
|
||||
label: "장비상태",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_status") },
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
key: "manager_id",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remarks", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const eqDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
equipment_type: catLabels(EQUIPMENT_TABLE, "equipment_type"),
|
||||
equipment_status: catLabels(EQUIPMENT_TABLE, "equipment_status"),
|
||||
manager_id: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const eqLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
equipment_type: catLabelToCode(EQUIPMENT_TABLE, "equipment_type"),
|
||||
equipment_status: catLabelToCode(EQUIPMENT_TABLE, "equipment_status"),
|
||||
manager_id: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleEqExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${EQUIPMENT_TABLE}/equipment_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// calibration_period 숫자 검증
|
||||
const periodRaw = String(row.calibration_period ?? "").trim();
|
||||
if (periodRaw && isNaN(Number(periodRaw))) {
|
||||
failList.push(`${i + 2}행: 교정주기는 숫자만 입력해주세요`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// last_calibration_date 포맷 검증 (YYYY-MM-DD 기대)
|
||||
let calibDate = String(row.last_calibration_date ?? "").trim();
|
||||
if (calibDate) {
|
||||
// Date 객체로 변환되어 왔을 수도 있어 ISO 추출
|
||||
const d = new Date(calibDate);
|
||||
if (isNaN(d.getTime())) {
|
||||
failList.push(`${i + 2}행: 최종교정일 포맷 오류(${calibDate})`);
|
||||
continue;
|
||||
}
|
||||
calibDate = d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// row.{key}는 이미 코드 (templateParser가 labelToCodeMap으로 자동변환 완료).
|
||||
const payload: Record<string, any> = {
|
||||
equipment_name: row.equipment_name || "",
|
||||
equipment_type: row.equipment_type || "",
|
||||
model_name: row.model_name || "",
|
||||
manufacturer: row.manufacturer || "",
|
||||
serial_number: row.serial_number || "",
|
||||
installation_location: row.installation_location || "",
|
||||
last_calibration_date: calibDate,
|
||||
calibration_period: periodRaw,
|
||||
equipment_status: row.equipment_status || "",
|
||||
manager_id: row.manager_id || "",
|
||||
remarks: row.remarks || "",
|
||||
};
|
||||
|
||||
const equipmentCode = String(row.equipment_code || "").trim();
|
||||
if (equipmentCode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "equipment_code", operator: "equals", value: equipmentCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${EQUIPMENT_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, equipment_code: equipmentCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
equipment_code: equipmentCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
equipment_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchEquipments();
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3 h-[calc(100vh-4rem)] overflow-auto">
|
||||
@@ -663,6 +1283,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setInspExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -725,6 +1349,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setDefExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -876,12 +1504,15 @@ export default function InspectionManagementPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{row.is_active === "사용" || row.is_active === "true" ? "사용" : row.is_active || "미사용"}
|
||||
</Badge>
|
||||
{(() => {
|
||||
const label = getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || row.is_active || "-";
|
||||
const isOn = row.is_active === "CAT_DA_01" || label === "사용";
|
||||
return (
|
||||
<Badge variant={isOn ? "default" : "secondary"} className="text-[10px]">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")}
|
||||
@@ -919,6 +1550,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEqExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1504,19 +2139,20 @@ export default function InspectionManagementPage() {
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{/* 사용여부 */}
|
||||
{/* 사용여부 (카테고리 코드 저장) */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">사용여부</Label>
|
||||
<Select
|
||||
value={defForm.is_active || "사용"}
|
||||
value={defForm.is_active || "CAT_DA_01"}
|
||||
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사용">사용</SelectItem>
|
||||
<SelectItem value="미사용">미사용</SelectItem>
|
||||
{(catOptions[`${DEFECT_TABLE}.is_active`] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1771,6 +2407,101 @@ export default function InspectionManagementPage() {
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{/* ═══════════════════ 엑셀업로드 모달 ═══════════════════ */}
|
||||
<SmartExcelUploadModal
|
||||
open={inspExcelOpen}
|
||||
onOpenChange={setInspExcelOpen}
|
||||
config={inspExcelConfig}
|
||||
dropdownOptions={inspDropdownOptions}
|
||||
labelToCodeMap={inspLabelToCodeMap}
|
||||
onUpload={handleInspExcelUpload}
|
||||
customValidator={(data) => {
|
||||
// inspection_type 다중값(text) 카테고리 라벨 검증
|
||||
const errors: any[] = [];
|
||||
const validLabels = catLabels(INSPECTION_TABLE, "inspection_type");
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
const raw = String(row.inspection_type || "").trim();
|
||||
if (!raw) return;
|
||||
const labels = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const invalid = labels.filter((l) => !validLabels.includes(l));
|
||||
if (invalid.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "검사유형",
|
||||
message: `등록되지 않은 값: ${invalid.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
<SmartExcelUploadModal
|
||||
open={defExcelOpen}
|
||||
onOpenChange={setDefExcelOpen}
|
||||
config={defExcelConfig}
|
||||
dropdownOptions={defDropdownOptions}
|
||||
labelToCodeMap={defLabelToCodeMap}
|
||||
onUpload={handleDefExcelUpload}
|
||||
customValidator={(data) => {
|
||||
const errors: any[] = [];
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
const parentByLabel = new Map(defInspOpts.filter((o) => o.depth === 1).map((o) => [o.label, o.code]));
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
// 검사유형(depth=1) 검증
|
||||
const rawType = String(row.inspection_type || "").trim();
|
||||
const typeLabels = rawType ? rawType.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const invalidTypes = typeLabels.filter((l) => !parentByLabel.has(l));
|
||||
if (invalidTypes.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "검사유형",
|
||||
message: `등록되지 않은 값: ${invalidTypes.join(", ")}`,
|
||||
});
|
||||
}
|
||||
// 적용대상(depth=2) 계층 검증 — 선택한 검사유형의 자식만 허용
|
||||
const rawTarget = String(row.apply_target || "").trim();
|
||||
if (!rawTarget) return;
|
||||
const targetLabels = rawTarget.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const selectedParentCodes = typeLabels
|
||||
.map((l) => parentByLabel.get(l))
|
||||
.filter(Boolean) as string[];
|
||||
const invalidTargets = targetLabels.filter((label) => {
|
||||
const child = defInspOpts.find(
|
||||
(o) =>
|
||||
o.depth === 2 &&
|
||||
o.label === label &&
|
||||
o.parentCode &&
|
||||
selectedParentCodes.includes(o.parentCode),
|
||||
);
|
||||
return !child;
|
||||
});
|
||||
if (invalidTargets.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "적용대상",
|
||||
message: `선택한 검사유형의 하위가 아니거나 미등록: ${invalidTargets.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
<SmartExcelUploadModal
|
||||
open={eqExcelOpen}
|
||||
onOpenChange={setEqExcelOpen}
|
||||
config={eqExcelConfig}
|
||||
dropdownOptions={eqDropdownOptions}
|
||||
labelToCodeMap={eqLabelToCodeMap}
|
||||
onUpload={handleEqExcelUpload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,8 +324,15 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const availableEquipments = useMemo(() => {
|
||||
const used = new Set(processEquipments.map((e) => e.equipment_code));
|
||||
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
|
||||
const used = new Set<string>();
|
||||
for (const pe of processEquipments) {
|
||||
if (pe.equipment_code) used.add(pe.equipment_code);
|
||||
}
|
||||
return equipmentMaster.filter((e) => {
|
||||
if (e.equipment_code && used.has(e.equipment_code)) return false;
|
||||
if (e.id && used.has(e.id)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
@@ -334,11 +341,16 @@ export function ProcessMasterTab() {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: equipmentPick,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
@@ -515,8 +527,8 @@ export function ProcessMasterTab() {
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.equipment_code,
|
||||
label: `${eq.equipment_code} · ${eq.equipment_name}`,
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
@@ -553,8 +565,11 @@ export function ProcessMasterTab() {
|
||||
{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>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{pe.equipment_name
|
||||
|| equipmentMaster.find((e) => e.id === pe.equipment_code || e.equipment_code === pe.equipment_code)?.equipment_name
|
||||
|| "설비명 없음"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => void handleRemoveEquipment(pe)}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Search,
|
||||
Inbox,
|
||||
Settings2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
@@ -41,6 +42,8 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload";
|
||||
import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload";
|
||||
|
||||
/* ───── 테이블명 ───── */
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
@@ -118,6 +121,11 @@ export default function InspectionManagementPage() {
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ───── 엑셀업로드 모달 오픈 상태 ───── */
|
||||
const [inspExcelOpen, setInspExcelOpen] = useState(false);
|
||||
const [defExcelOpen, setDefExcelOpen] = useState(false);
|
||||
const [eqExcelOpen, setEqExcelOpen] = useState(false);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
@@ -128,6 +136,7 @@ export default function InspectionManagementPage() {
|
||||
{ table: INSPECTION_TABLE, col: "inspection_method" },
|
||||
{ table: INSPECTION_TABLE, col: "judgment_criteria" },
|
||||
{ table: INSPECTION_TABLE, col: "unit" },
|
||||
{ table: INSPECTION_TABLE, col: "is_active" },
|
||||
{ table: DEFECT_TABLE, col: "defect_type" },
|
||||
{ table: DEFECT_TABLE, col: "severity" },
|
||||
{ table: DEFECT_TABLE, col: "inspection_type" },
|
||||
@@ -395,7 +404,7 @@ export default function InspectionManagementPage() {
|
||||
|
||||
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
|
||||
const openDefCreate = async () => {
|
||||
setDefForm({ is_active: "사용" });
|
||||
setDefForm({ is_active: "CAT_DA_01" });
|
||||
setDefEditMode(false);
|
||||
setNumberingRuleId(null);
|
||||
setPreviewCode(null);
|
||||
@@ -606,6 +615,617 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 엑셀 업로드 공통 헬퍼 ═══════════════════ */
|
||||
// 라벨 배열 추출 (비어있는 카테고리는 빈 배열 반환)
|
||||
const catLabels = useCallback(
|
||||
(table: string, col: string): string[] => {
|
||||
return (catOptions[`${table}.${col}`] || []).map((o) => o.label);
|
||||
},
|
||||
[catOptions],
|
||||
);
|
||||
// 라벨→코드 단일 맵 생성
|
||||
const catLabelToCode = useCallback(
|
||||
(table: string, col: string): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
(catOptions[`${table}.${col}`] || []).forEach((o) => {
|
||||
map[o.label] = o.code;
|
||||
});
|
||||
return map;
|
||||
},
|
||||
[catOptions],
|
||||
);
|
||||
|
||||
/* ═══════════════════ 탭 1: 검사기준 엑셀업로드 ═══════════════════ */
|
||||
const inspExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "검사기준",
|
||||
sheets: [
|
||||
{
|
||||
name: "검사기준",
|
||||
typeKey: "inspection_standard",
|
||||
columns: [
|
||||
{ key: "inspection_code", label: "검사코드", type: "text", width: 14 },
|
||||
{ key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 },
|
||||
{ key: "inspection_criteria", label: "검사기준", required: true, type: "text", width: 20 },
|
||||
{ key: "criteria_detail", label: "기준상세", type: "text", width: 20 },
|
||||
{ key: "inspection_item", label: "검사항목", required: true, type: "text", width: 18 },
|
||||
{
|
||||
key: "inspection_method",
|
||||
label: "검사방법",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "inspection_method") },
|
||||
width: 14,
|
||||
},
|
||||
{
|
||||
key: "judgment_criteria",
|
||||
label: "판단기준",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "judgment_criteria") },
|
||||
width: 14,
|
||||
},
|
||||
{
|
||||
key: "unit",
|
||||
label: "단위",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "unit") },
|
||||
width: 10,
|
||||
},
|
||||
{
|
||||
key: "apply_type",
|
||||
label: "적용구분",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "apply_type") },
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "is_active") },
|
||||
width: 10,
|
||||
},
|
||||
{ key: "selection_options", label: "선택옵션", type: "text", width: 22 },
|
||||
{
|
||||
key: "manager",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remark", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
conditionalRules: [
|
||||
{ when: { column: "judgment_criteria", equals: "선택형" }, require: ["selection_options"], ignore: [] },
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const inspDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
inspection_method: catLabels(INSPECTION_TABLE, "inspection_method"),
|
||||
judgment_criteria: catLabels(INSPECTION_TABLE, "judgment_criteria"),
|
||||
unit: catLabels(INSPECTION_TABLE, "unit"),
|
||||
apply_type: catLabels(INSPECTION_TABLE, "apply_type"),
|
||||
is_active: catLabels(INSPECTION_TABLE, "is_active"),
|
||||
manager: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const inspLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
inspection_method: catLabelToCode(INSPECTION_TABLE, "inspection_method"),
|
||||
judgment_criteria: catLabelToCode(INSPECTION_TABLE, "judgment_criteria"),
|
||||
unit: catLabelToCode(INSPECTION_TABLE, "unit"),
|
||||
apply_type: catLabelToCode(INSPECTION_TABLE, "apply_type"),
|
||||
is_active: catLabelToCode(INSPECTION_TABLE, "is_active"),
|
||||
manager: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleInspExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
// 채번 규칙 사전 조회 (코드 미입력 건에만 사용)
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${INSPECTION_TABLE}/inspection_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* 채번 규칙 없으면 기존 코드로만 처리 */
|
||||
}
|
||||
|
||||
// templateParser가 dropdown 컬럼은 labelToCodeMap으로 이미 label→code 자동변환 완료.
|
||||
// row.{key}는 코드, row.{key}_label은 원본 라벨.
|
||||
// inspection_type은 text 타입이라 자동변환 제외 → 수동 처리 + 카테고리 라벨 검증.
|
||||
const inspTypeMap = catLabelToCode(INSPECTION_TABLE, "inspection_type");
|
||||
const inspTypeLabels = catLabels(INSPECTION_TABLE, "inspection_type");
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// inspection_type 다중값: ,로 split → 각 라벨이 카테고리에 있는지 검증 → 코드로 변환
|
||||
const rawTypes = String(row.inspection_type || "").trim();
|
||||
const typeLabelsArr = rawTypes
|
||||
? rawTypes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const invalidTypes = typeLabelsArr.filter((label) => !inspTypeLabels.includes(label));
|
||||
if (invalidTypes.length > 0) {
|
||||
failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
const typeCodes = typeLabelsArr.map((label) => inspTypeMap[label] || label).join(",");
|
||||
|
||||
// selection_options 조건부 검증 (판단기준 라벨이 "선택형"일 때 필수)
|
||||
const judgmentOrigLabel = String(row.judgment_criteria_label || row.judgment_criteria || "").trim();
|
||||
if (judgmentOrigLabel === "선택형" && !String(row.selection_options || "").trim()) {
|
||||
failList.push(`${i + 2}행: 선택형은 옵션을 입력해주세요`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// row.{key}는 이미 코드 (dropdown 컬럼, templateParser가 labelToCodeMap 자동변환).
|
||||
const payload: Record<string, any> = {
|
||||
inspection_type: typeCodes,
|
||||
inspection_criteria: row.inspection_criteria || "",
|
||||
criteria_detail: row.criteria_detail || "",
|
||||
inspection_item: row.inspection_item || "",
|
||||
inspection_method: row.inspection_method || "",
|
||||
judgment_criteria: row.judgment_criteria || "",
|
||||
unit: row.unit || "",
|
||||
apply_type: row.apply_type || "",
|
||||
is_active: row.is_active || "CAT_IS_01",
|
||||
selection_options: row.selection_options || "",
|
||||
manager: row.manager || "",
|
||||
remark: row.remark || "",
|
||||
};
|
||||
|
||||
// 코드 있으면 update, 없으면 insert (채번 할당)
|
||||
const inspectionCode = String(row.inspection_code || "").trim();
|
||||
if (inspectionCode) {
|
||||
// 기존 행 조회 후 upsert
|
||||
const existRes = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "inspection_code", operator: "equals", value: inspectionCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, inspection_code: inspectionCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_code: inspectionCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 코드 자동 채번
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch (e) {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchInspections();
|
||||
};
|
||||
|
||||
/* ═══════════════════ 탭 2: 불량관리 엑셀업로드 ═══════════════════ */
|
||||
const defExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "불량관리",
|
||||
sheets: [
|
||||
{
|
||||
name: "불량관리",
|
||||
typeKey: "defect_standard_mng",
|
||||
columns: [
|
||||
{ key: "defect_code", label: "불량코드", type: "text", width: 14 },
|
||||
{
|
||||
key: "defect_type",
|
||||
label: "불량유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "defect_type") },
|
||||
width: 14,
|
||||
},
|
||||
{ key: "defect_name", label: "불량명", required: true, type: "text", width: 20 },
|
||||
{ key: "defect_content", label: "불량내용", required: true, type: "text", width: 24 },
|
||||
{
|
||||
key: "severity",
|
||||
label: "심각도",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "severity") },
|
||||
width: 12,
|
||||
},
|
||||
{ key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 },
|
||||
{ key: "apply_target", label: "적용대상", type: "text", width: 18 },
|
||||
{
|
||||
key: "is_active",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "is_active") },
|
||||
width: 10,
|
||||
},
|
||||
{
|
||||
key: "manager_id",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remarks", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const defDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
defect_type: catLabels(DEFECT_TABLE, "defect_type"),
|
||||
severity: catLabels(DEFECT_TABLE, "severity"),
|
||||
is_active: catLabels(DEFECT_TABLE, "is_active"),
|
||||
manager_id: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const defLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
defect_type: catLabelToCode(DEFECT_TABLE, "defect_type"),
|
||||
severity: catLabelToCode(DEFECT_TABLE, "severity"),
|
||||
is_active: catLabelToCode(DEFECT_TABLE, "is_active"),
|
||||
manager_id: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleDefExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${DEFECT_TABLE}/defect_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
// templateParser가 dropdown 컬럼은 이미 label→code 변환 완료. inspection_type/apply_target만 text라 수동 처리 + 계층 검증.
|
||||
const inspTypeMap = catLabelToCode(DEFECT_TABLE, "inspection_type");
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
// depth=1 부모(검사유형), depth=2 자식(적용대상)
|
||||
const parentLabels = defInspOpts.filter((o) => o.depth === 1).map((o) => o.label);
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// inspection_type 다중값: ,로 split → depth=1 라벨 검증 → 코드로 변환
|
||||
const rawTypes = String(row.inspection_type || "").trim();
|
||||
const typeLabelsArr = rawTypes
|
||||
? rawTypes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const invalidTypes = typeLabelsArr.filter((label) => !parentLabels.includes(label));
|
||||
if (invalidTypes.length > 0) {
|
||||
failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
const typeCodeList = typeLabelsArr.map((label) => inspTypeMap[label] || label);
|
||||
const typeCodes = typeCodeList.join(",");
|
||||
|
||||
// apply_target 다중값: 선택한 검사유형의 자식(depth=2)만 허용 → 자식 코드로 변환
|
||||
const rawTargets = String(row.apply_target || "").trim();
|
||||
const targetLabelsArr = rawTargets
|
||||
? rawTargets.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const targetCodeList: string[] = [];
|
||||
const invalidTargets: string[] = [];
|
||||
for (const label of targetLabelsArr) {
|
||||
const child = defInspOpts.find(
|
||||
(o) =>
|
||||
o.depth === 2 &&
|
||||
o.label === label &&
|
||||
o.parentCode &&
|
||||
typeCodeList.includes(o.parentCode),
|
||||
);
|
||||
if (child) targetCodeList.push(child.code);
|
||||
else invalidTargets.push(label);
|
||||
}
|
||||
if (invalidTargets.length > 0) {
|
||||
failList.push(
|
||||
`${i + 2}행: 적용대상이 선택한 검사유형의 하위가 아니거나 미등록(${invalidTargets.join(", ")})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const targetCodes = targetCodeList.join(",");
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
defect_type: row.defect_type || "",
|
||||
defect_name: row.defect_name || "",
|
||||
defect_content: row.defect_content || "",
|
||||
severity: row.severity || "",
|
||||
inspection_type: typeCodes,
|
||||
apply_target: targetCodes,
|
||||
is_active: row.is_active || "",
|
||||
manager_id: row.manager_id || "",
|
||||
remarks: row.remarks || "",
|
||||
};
|
||||
|
||||
const defectCode = String(row.defect_code || "").trim();
|
||||
if (defectCode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "defect_code", operator: "equals", value: defectCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${DEFECT_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, defect_code: defectCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
defect_code: defectCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
defect_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchDefects();
|
||||
};
|
||||
|
||||
/* ═══════════════════ 탭 3: 검사장비 엑셀업로드 ═══════════════════ */
|
||||
const eqExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "검사장비",
|
||||
sheets: [
|
||||
{
|
||||
name: "검사장비",
|
||||
typeKey: "inspection_equipment_mng",
|
||||
columns: [
|
||||
{ key: "equipment_code", label: "장비코드", type: "text", width: 14 },
|
||||
{ key: "equipment_name", label: "장비명", required: true, type: "text", width: 20 },
|
||||
{
|
||||
key: "equipment_type",
|
||||
label: "장비유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_type") },
|
||||
width: 14,
|
||||
},
|
||||
{ key: "model_name", label: "모델명", type: "text", width: 16 },
|
||||
{ key: "manufacturer", label: "제조사", type: "text", width: 16 },
|
||||
{ key: "serial_number", label: "시리얼번호", type: "text", width: 16 },
|
||||
{ key: "installation_location", label: "설치위치", type: "text", width: 18 },
|
||||
{ key: "last_calibration_date", label: "최종교정일", type: "date", width: 14 },
|
||||
{ key: "calibration_period", label: "교정주기(개월)", type: "number", width: 14 },
|
||||
{
|
||||
key: "equipment_status",
|
||||
label: "장비상태",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_status") },
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
key: "manager_id",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remarks", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const eqDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
equipment_type: catLabels(EQUIPMENT_TABLE, "equipment_type"),
|
||||
equipment_status: catLabels(EQUIPMENT_TABLE, "equipment_status"),
|
||||
manager_id: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const eqLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
equipment_type: catLabelToCode(EQUIPMENT_TABLE, "equipment_type"),
|
||||
equipment_status: catLabelToCode(EQUIPMENT_TABLE, "equipment_status"),
|
||||
manager_id: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleEqExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${EQUIPMENT_TABLE}/equipment_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// calibration_period 숫자 검증
|
||||
const periodRaw = String(row.calibration_period ?? "").trim();
|
||||
if (periodRaw && isNaN(Number(periodRaw))) {
|
||||
failList.push(`${i + 2}행: 교정주기는 숫자만 입력해주세요`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// last_calibration_date 포맷 검증 (YYYY-MM-DD 기대)
|
||||
let calibDate = String(row.last_calibration_date ?? "").trim();
|
||||
if (calibDate) {
|
||||
// Date 객체로 변환되어 왔을 수도 있어 ISO 추출
|
||||
const d = new Date(calibDate);
|
||||
if (isNaN(d.getTime())) {
|
||||
failList.push(`${i + 2}행: 최종교정일 포맷 오류(${calibDate})`);
|
||||
continue;
|
||||
}
|
||||
calibDate = d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// row.{key}는 이미 코드 (templateParser가 labelToCodeMap으로 자동변환 완료).
|
||||
const payload: Record<string, any> = {
|
||||
equipment_name: row.equipment_name || "",
|
||||
equipment_type: row.equipment_type || "",
|
||||
model_name: row.model_name || "",
|
||||
manufacturer: row.manufacturer || "",
|
||||
serial_number: row.serial_number || "",
|
||||
installation_location: row.installation_location || "",
|
||||
last_calibration_date: calibDate,
|
||||
calibration_period: periodRaw,
|
||||
equipment_status: row.equipment_status || "",
|
||||
manager_id: row.manager_id || "",
|
||||
remarks: row.remarks || "",
|
||||
};
|
||||
|
||||
const equipmentCode = String(row.equipment_code || "").trim();
|
||||
if (equipmentCode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "equipment_code", operator: "equals", value: equipmentCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${EQUIPMENT_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, equipment_code: equipmentCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
equipment_code: equipmentCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
equipment_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchEquipments();
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3 h-[calc(100vh-4rem)] overflow-auto">
|
||||
@@ -663,6 +1283,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setInspExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -725,6 +1349,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setDefExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -876,12 +1504,15 @@ export default function InspectionManagementPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{row.is_active === "사용" || row.is_active === "true" ? "사용" : row.is_active || "미사용"}
|
||||
</Badge>
|
||||
{(() => {
|
||||
const label = getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || row.is_active || "-";
|
||||
const isOn = row.is_active === "CAT_DA_01" || label === "사용";
|
||||
return (
|
||||
<Badge variant={isOn ? "default" : "secondary"} className="text-[10px]">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")}
|
||||
@@ -919,6 +1550,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEqExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1508,15 +2143,18 @@ export default function InspectionManagementPage() {
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">사용여부</Label>
|
||||
<Select
|
||||
value={defForm.is_active || "사용"}
|
||||
value={defForm.is_active || "CAT_DA_01"}
|
||||
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사용">사용</SelectItem>
|
||||
<SelectItem value="미사용">미사용</SelectItem>
|
||||
{(catOptions[`${DEFECT_TABLE}.is_active`] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1771,6 +2409,101 @@ export default function InspectionManagementPage() {
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{/* ═══════════════════ 엑셀업로드 모달 ═══════════════════ */}
|
||||
<SmartExcelUploadModal
|
||||
open={inspExcelOpen}
|
||||
onOpenChange={setInspExcelOpen}
|
||||
config={inspExcelConfig}
|
||||
dropdownOptions={inspDropdownOptions}
|
||||
labelToCodeMap={inspLabelToCodeMap}
|
||||
onUpload={handleInspExcelUpload}
|
||||
customValidator={(data) => {
|
||||
// inspection_type 다중값(text) 카테고리 라벨 검증
|
||||
const errors: any[] = [];
|
||||
const validLabels = catLabels(INSPECTION_TABLE, "inspection_type");
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
const raw = String(row.inspection_type || "").trim();
|
||||
if (!raw) return;
|
||||
const labels = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const invalid = labels.filter((l) => !validLabels.includes(l));
|
||||
if (invalid.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "검사유형",
|
||||
message: `등록되지 않은 값: ${invalid.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
<SmartExcelUploadModal
|
||||
open={defExcelOpen}
|
||||
onOpenChange={setDefExcelOpen}
|
||||
config={defExcelConfig}
|
||||
dropdownOptions={defDropdownOptions}
|
||||
labelToCodeMap={defLabelToCodeMap}
|
||||
onUpload={handleDefExcelUpload}
|
||||
customValidator={(data) => {
|
||||
const errors: any[] = [];
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
const parentByLabel = new Map(defInspOpts.filter((o) => o.depth === 1).map((o) => [o.label, o.code]));
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
// 검사유형(depth=1) 검증
|
||||
const rawType = String(row.inspection_type || "").trim();
|
||||
const typeLabels = rawType ? rawType.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const invalidTypes = typeLabels.filter((l) => !parentByLabel.has(l));
|
||||
if (invalidTypes.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "검사유형",
|
||||
message: `등록되지 않은 값: ${invalidTypes.join(", ")}`,
|
||||
});
|
||||
}
|
||||
// 적용대상(depth=2) 계층 검증 — 선택한 검사유형의 자식만 허용
|
||||
const rawTarget = String(row.apply_target || "").trim();
|
||||
if (!rawTarget) return;
|
||||
const targetLabels = rawTarget.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const selectedParentCodes = typeLabels
|
||||
.map((l) => parentByLabel.get(l))
|
||||
.filter(Boolean) as string[];
|
||||
const invalidTargets = targetLabels.filter((label) => {
|
||||
const child = defInspOpts.find(
|
||||
(o) =>
|
||||
o.depth === 2 &&
|
||||
o.label === label &&
|
||||
o.parentCode &&
|
||||
selectedParentCodes.includes(o.parentCode),
|
||||
);
|
||||
return !child;
|
||||
});
|
||||
if (invalidTargets.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "적용대상",
|
||||
message: `선택한 검사유형의 하위가 아니거나 미등록: ${invalidTargets.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
<SmartExcelUploadModal
|
||||
open={eqExcelOpen}
|
||||
onOpenChange={setEqExcelOpen}
|
||||
config={eqExcelConfig}
|
||||
dropdownOptions={eqDropdownOptions}
|
||||
labelToCodeMap={eqLabelToCodeMap}
|
||||
onUpload={handleEqExcelUpload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,8 +324,15 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const availableEquipments = useMemo(() => {
|
||||
const used = new Set(processEquipments.map((e) => e.equipment_code));
|
||||
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
|
||||
const used = new Set<string>();
|
||||
for (const pe of processEquipments) {
|
||||
if (pe.equipment_code) used.add(pe.equipment_code);
|
||||
}
|
||||
return equipmentMaster.filter((e) => {
|
||||
if (e.equipment_code && used.has(e.equipment_code)) return false;
|
||||
if (e.id && used.has(e.id)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
@@ -334,11 +341,16 @@ export function ProcessMasterTab() {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: equipmentPick,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
@@ -515,8 +527,8 @@ export function ProcessMasterTab() {
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.equipment_code,
|
||||
label: `${eq.equipment_code} · ${eq.equipment_name}`,
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
@@ -553,8 +565,11 @@ export function ProcessMasterTab() {
|
||||
{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>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{pe.equipment_name
|
||||
|| equipmentMaster.find((e) => e.id === pe.equipment_code || e.equipment_code === pe.equipment_code)?.equipment_name
|
||||
|| "설비명 없음"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => void handleRemoveEquipment(pe)}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Search,
|
||||
Inbox,
|
||||
Settings2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
@@ -41,6 +42,8 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload";
|
||||
import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload";
|
||||
|
||||
/* ───── 테이블명 ───── */
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
@@ -118,6 +121,11 @@ export default function InspectionManagementPage() {
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ───── 엑셀업로드 모달 오픈 상태 ───── */
|
||||
const [inspExcelOpen, setInspExcelOpen] = useState(false);
|
||||
const [defExcelOpen, setDefExcelOpen] = useState(false);
|
||||
const [eqExcelOpen, setEqExcelOpen] = useState(false);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
@@ -128,6 +136,7 @@ export default function InspectionManagementPage() {
|
||||
{ table: INSPECTION_TABLE, col: "inspection_method" },
|
||||
{ table: INSPECTION_TABLE, col: "judgment_criteria" },
|
||||
{ table: INSPECTION_TABLE, col: "unit" },
|
||||
{ table: INSPECTION_TABLE, col: "is_active" },
|
||||
{ table: DEFECT_TABLE, col: "defect_type" },
|
||||
{ table: DEFECT_TABLE, col: "severity" },
|
||||
{ table: DEFECT_TABLE, col: "inspection_type" },
|
||||
@@ -395,7 +404,7 @@ export default function InspectionManagementPage() {
|
||||
|
||||
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
|
||||
const openDefCreate = async () => {
|
||||
setDefForm({ is_active: "사용" });
|
||||
setDefForm({ is_active: "CAT_DA_01" });
|
||||
setDefEditMode(false);
|
||||
setNumberingRuleId(null);
|
||||
setPreviewCode(null);
|
||||
@@ -606,6 +615,617 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 엑셀 업로드 공통 헬퍼 ═══════════════════ */
|
||||
// 라벨 배열 추출 (비어있는 카테고리는 빈 배열 반환)
|
||||
const catLabels = useCallback(
|
||||
(table: string, col: string): string[] => {
|
||||
return (catOptions[`${table}.${col}`] || []).map((o) => o.label);
|
||||
},
|
||||
[catOptions],
|
||||
);
|
||||
// 라벨→코드 단일 맵 생성
|
||||
const catLabelToCode = useCallback(
|
||||
(table: string, col: string): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
(catOptions[`${table}.${col}`] || []).forEach((o) => {
|
||||
map[o.label] = o.code;
|
||||
});
|
||||
return map;
|
||||
},
|
||||
[catOptions],
|
||||
);
|
||||
|
||||
/* ═══════════════════ 탭 1: 검사기준 엑셀업로드 ═══════════════════ */
|
||||
const inspExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "검사기준",
|
||||
sheets: [
|
||||
{
|
||||
name: "검사기준",
|
||||
typeKey: "inspection_standard",
|
||||
columns: [
|
||||
{ key: "inspection_code", label: "검사코드", type: "text", width: 14 },
|
||||
{ key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 },
|
||||
{ key: "inspection_criteria", label: "검사기준", required: true, type: "text", width: 20 },
|
||||
{ key: "criteria_detail", label: "기준상세", type: "text", width: 20 },
|
||||
{ key: "inspection_item", label: "검사항목", required: true, type: "text", width: 18 },
|
||||
{
|
||||
key: "inspection_method",
|
||||
label: "검사방법",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "inspection_method") },
|
||||
width: 14,
|
||||
},
|
||||
{
|
||||
key: "judgment_criteria",
|
||||
label: "판단기준",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "judgment_criteria") },
|
||||
width: 14,
|
||||
},
|
||||
{
|
||||
key: "unit",
|
||||
label: "단위",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "unit") },
|
||||
width: 10,
|
||||
},
|
||||
{
|
||||
key: "apply_type",
|
||||
label: "적용구분",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "apply_type") },
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "is_active") },
|
||||
width: 10,
|
||||
},
|
||||
{ key: "selection_options", label: "선택옵션", type: "text", width: 22 },
|
||||
{
|
||||
key: "manager",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remark", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
conditionalRules: [
|
||||
{ when: { column: "judgment_criteria", equals: "선택형" }, require: ["selection_options"], ignore: [] },
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const inspDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
inspection_method: catLabels(INSPECTION_TABLE, "inspection_method"),
|
||||
judgment_criteria: catLabels(INSPECTION_TABLE, "judgment_criteria"),
|
||||
unit: catLabels(INSPECTION_TABLE, "unit"),
|
||||
apply_type: catLabels(INSPECTION_TABLE, "apply_type"),
|
||||
is_active: catLabels(INSPECTION_TABLE, "is_active"),
|
||||
manager: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const inspLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
inspection_method: catLabelToCode(INSPECTION_TABLE, "inspection_method"),
|
||||
judgment_criteria: catLabelToCode(INSPECTION_TABLE, "judgment_criteria"),
|
||||
unit: catLabelToCode(INSPECTION_TABLE, "unit"),
|
||||
apply_type: catLabelToCode(INSPECTION_TABLE, "apply_type"),
|
||||
is_active: catLabelToCode(INSPECTION_TABLE, "is_active"),
|
||||
manager: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleInspExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
// 채번 규칙 사전 조회 (코드 미입력 건에만 사용)
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${INSPECTION_TABLE}/inspection_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* 채번 규칙 없으면 기존 코드로만 처리 */
|
||||
}
|
||||
|
||||
// templateParser가 dropdown 컬럼은 labelToCodeMap으로 이미 label→code 자동변환 완료.
|
||||
// row.{key}는 코드, row.{key}_label은 원본 라벨.
|
||||
// inspection_type은 text 타입이라 자동변환 제외 → 수동 처리 + 카테고리 라벨 검증.
|
||||
const inspTypeMap = catLabelToCode(INSPECTION_TABLE, "inspection_type");
|
||||
const inspTypeLabels = catLabels(INSPECTION_TABLE, "inspection_type");
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// inspection_type 다중값: ,로 split → 각 라벨이 카테고리에 있는지 검증 → 코드로 변환
|
||||
const rawTypes = String(row.inspection_type || "").trim();
|
||||
const typeLabelsArr = rawTypes
|
||||
? rawTypes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const invalidTypes = typeLabelsArr.filter((label) => !inspTypeLabels.includes(label));
|
||||
if (invalidTypes.length > 0) {
|
||||
failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
const typeCodes = typeLabelsArr.map((label) => inspTypeMap[label] || label).join(",");
|
||||
|
||||
// selection_options 조건부 검증 (판단기준 라벨이 "선택형"일 때 필수)
|
||||
const judgmentOrigLabel = String(row.judgment_criteria_label || row.judgment_criteria || "").trim();
|
||||
if (judgmentOrigLabel === "선택형" && !String(row.selection_options || "").trim()) {
|
||||
failList.push(`${i + 2}행: 선택형은 옵션을 입력해주세요`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// row.{key}는 이미 코드 (dropdown 컬럼, templateParser가 labelToCodeMap 자동변환).
|
||||
const payload: Record<string, any> = {
|
||||
inspection_type: typeCodes,
|
||||
inspection_criteria: row.inspection_criteria || "",
|
||||
criteria_detail: row.criteria_detail || "",
|
||||
inspection_item: row.inspection_item || "",
|
||||
inspection_method: row.inspection_method || "",
|
||||
judgment_criteria: row.judgment_criteria || "",
|
||||
unit: row.unit || "",
|
||||
apply_type: row.apply_type || "",
|
||||
is_active: row.is_active || "CAT_IS_01",
|
||||
selection_options: row.selection_options || "",
|
||||
manager: row.manager || "",
|
||||
remark: row.remark || "",
|
||||
};
|
||||
|
||||
// 코드 있으면 update, 없으면 insert (채번 할당)
|
||||
const inspectionCode = String(row.inspection_code || "").trim();
|
||||
if (inspectionCode) {
|
||||
// 기존 행 조회 후 upsert
|
||||
const existRes = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "inspection_code", operator: "equals", value: inspectionCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, inspection_code: inspectionCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_code: inspectionCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 코드 자동 채번
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch (e) {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchInspections();
|
||||
};
|
||||
|
||||
/* ═══════════════════ 탭 2: 불량관리 엑셀업로드 ═══════════════════ */
|
||||
const defExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "불량관리",
|
||||
sheets: [
|
||||
{
|
||||
name: "불량관리",
|
||||
typeKey: "defect_standard_mng",
|
||||
columns: [
|
||||
{ key: "defect_code", label: "불량코드", type: "text", width: 14 },
|
||||
{
|
||||
key: "defect_type",
|
||||
label: "불량유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "defect_type") },
|
||||
width: 14,
|
||||
},
|
||||
{ key: "defect_name", label: "불량명", required: true, type: "text", width: 20 },
|
||||
{ key: "defect_content", label: "불량내용", required: true, type: "text", width: 24 },
|
||||
{
|
||||
key: "severity",
|
||||
label: "심각도",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "severity") },
|
||||
width: 12,
|
||||
},
|
||||
{ key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 },
|
||||
{ key: "apply_target", label: "적용대상", type: "text", width: 18 },
|
||||
{
|
||||
key: "is_active",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "is_active") },
|
||||
width: 10,
|
||||
},
|
||||
{
|
||||
key: "manager_id",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remarks", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const defDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
defect_type: catLabels(DEFECT_TABLE, "defect_type"),
|
||||
severity: catLabels(DEFECT_TABLE, "severity"),
|
||||
is_active: catLabels(DEFECT_TABLE, "is_active"),
|
||||
manager_id: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const defLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
defect_type: catLabelToCode(DEFECT_TABLE, "defect_type"),
|
||||
severity: catLabelToCode(DEFECT_TABLE, "severity"),
|
||||
is_active: catLabelToCode(DEFECT_TABLE, "is_active"),
|
||||
manager_id: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleDefExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${DEFECT_TABLE}/defect_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
// templateParser가 dropdown 컬럼은 이미 label→code 변환 완료. inspection_type/apply_target만 text라 수동 처리 + 계층 검증.
|
||||
const inspTypeMap = catLabelToCode(DEFECT_TABLE, "inspection_type");
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
// depth=1 부모(검사유형), depth=2 자식(적용대상)
|
||||
const parentLabels = defInspOpts.filter((o) => o.depth === 1).map((o) => o.label);
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// inspection_type 다중값: ,로 split → depth=1 라벨 검증 → 코드로 변환
|
||||
const rawTypes = String(row.inspection_type || "").trim();
|
||||
const typeLabelsArr = rawTypes
|
||||
? rawTypes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const invalidTypes = typeLabelsArr.filter((label) => !parentLabels.includes(label));
|
||||
if (invalidTypes.length > 0) {
|
||||
failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
const typeCodeList = typeLabelsArr.map((label) => inspTypeMap[label] || label);
|
||||
const typeCodes = typeCodeList.join(",");
|
||||
|
||||
// apply_target 다중값: 선택한 검사유형의 자식(depth=2)만 허용 → 자식 코드로 변환
|
||||
const rawTargets = String(row.apply_target || "").trim();
|
||||
const targetLabelsArr = rawTargets
|
||||
? rawTargets.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const targetCodeList: string[] = [];
|
||||
const invalidTargets: string[] = [];
|
||||
for (const label of targetLabelsArr) {
|
||||
const child = defInspOpts.find(
|
||||
(o) =>
|
||||
o.depth === 2 &&
|
||||
o.label === label &&
|
||||
o.parentCode &&
|
||||
typeCodeList.includes(o.parentCode),
|
||||
);
|
||||
if (child) targetCodeList.push(child.code);
|
||||
else invalidTargets.push(label);
|
||||
}
|
||||
if (invalidTargets.length > 0) {
|
||||
failList.push(
|
||||
`${i + 2}행: 적용대상이 선택한 검사유형의 하위가 아니거나 미등록(${invalidTargets.join(", ")})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const targetCodes = targetCodeList.join(",");
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
defect_type: row.defect_type || "",
|
||||
defect_name: row.defect_name || "",
|
||||
defect_content: row.defect_content || "",
|
||||
severity: row.severity || "",
|
||||
inspection_type: typeCodes,
|
||||
apply_target: targetCodes,
|
||||
is_active: row.is_active || "",
|
||||
manager_id: row.manager_id || "",
|
||||
remarks: row.remarks || "",
|
||||
};
|
||||
|
||||
const defectCode = String(row.defect_code || "").trim();
|
||||
if (defectCode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "defect_code", operator: "equals", value: defectCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${DEFECT_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, defect_code: defectCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
defect_code: defectCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
defect_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchDefects();
|
||||
};
|
||||
|
||||
/* ═══════════════════ 탭 3: 검사장비 엑셀업로드 ═══════════════════ */
|
||||
const eqExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "검사장비",
|
||||
sheets: [
|
||||
{
|
||||
name: "검사장비",
|
||||
typeKey: "inspection_equipment_mng",
|
||||
columns: [
|
||||
{ key: "equipment_code", label: "장비코드", type: "text", width: 14 },
|
||||
{ key: "equipment_name", label: "장비명", required: true, type: "text", width: 20 },
|
||||
{
|
||||
key: "equipment_type",
|
||||
label: "장비유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_type") },
|
||||
width: 14,
|
||||
},
|
||||
{ key: "model_name", label: "모델명", type: "text", width: 16 },
|
||||
{ key: "manufacturer", label: "제조사", type: "text", width: 16 },
|
||||
{ key: "serial_number", label: "시리얼번호", type: "text", width: 16 },
|
||||
{ key: "installation_location", label: "설치위치", type: "text", width: 18 },
|
||||
{ key: "last_calibration_date", label: "최종교정일", type: "date", width: 14 },
|
||||
{ key: "calibration_period", label: "교정주기(개월)", type: "number", width: 14 },
|
||||
{
|
||||
key: "equipment_status",
|
||||
label: "장비상태",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_status") },
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
key: "manager_id",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remarks", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const eqDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
equipment_type: catLabels(EQUIPMENT_TABLE, "equipment_type"),
|
||||
equipment_status: catLabels(EQUIPMENT_TABLE, "equipment_status"),
|
||||
manager_id: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const eqLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
equipment_type: catLabelToCode(EQUIPMENT_TABLE, "equipment_type"),
|
||||
equipment_status: catLabelToCode(EQUIPMENT_TABLE, "equipment_status"),
|
||||
manager_id: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleEqExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${EQUIPMENT_TABLE}/equipment_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// calibration_period 숫자 검증
|
||||
const periodRaw = String(row.calibration_period ?? "").trim();
|
||||
if (periodRaw && isNaN(Number(periodRaw))) {
|
||||
failList.push(`${i + 2}행: 교정주기는 숫자만 입력해주세요`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// last_calibration_date 포맷 검증 (YYYY-MM-DD 기대)
|
||||
let calibDate = String(row.last_calibration_date ?? "").trim();
|
||||
if (calibDate) {
|
||||
// Date 객체로 변환되어 왔을 수도 있어 ISO 추출
|
||||
const d = new Date(calibDate);
|
||||
if (isNaN(d.getTime())) {
|
||||
failList.push(`${i + 2}행: 최종교정일 포맷 오류(${calibDate})`);
|
||||
continue;
|
||||
}
|
||||
calibDate = d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// row.{key}는 이미 코드 (templateParser가 labelToCodeMap으로 자동변환 완료).
|
||||
const payload: Record<string, any> = {
|
||||
equipment_name: row.equipment_name || "",
|
||||
equipment_type: row.equipment_type || "",
|
||||
model_name: row.model_name || "",
|
||||
manufacturer: row.manufacturer || "",
|
||||
serial_number: row.serial_number || "",
|
||||
installation_location: row.installation_location || "",
|
||||
last_calibration_date: calibDate,
|
||||
calibration_period: periodRaw,
|
||||
equipment_status: row.equipment_status || "",
|
||||
manager_id: row.manager_id || "",
|
||||
remarks: row.remarks || "",
|
||||
};
|
||||
|
||||
const equipmentCode = String(row.equipment_code || "").trim();
|
||||
if (equipmentCode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "equipment_code", operator: "equals", value: equipmentCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${EQUIPMENT_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, equipment_code: equipmentCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
equipment_code: equipmentCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
equipment_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchEquipments();
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3 h-[calc(100vh-4rem)] overflow-auto">
|
||||
@@ -663,6 +1283,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setInspExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -725,6 +1349,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setDefExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -876,12 +1504,15 @@ export default function InspectionManagementPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{row.is_active === "사용" || row.is_active === "true" ? "사용" : row.is_active || "미사용"}
|
||||
</Badge>
|
||||
{(() => {
|
||||
const label = getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || row.is_active || "-";
|
||||
const isOn = row.is_active === "CAT_DA_01" || label === "사용";
|
||||
return (
|
||||
<Badge variant={isOn ? "default" : "secondary"} className="text-[10px]">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")}
|
||||
@@ -919,6 +1550,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEqExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1508,15 +2143,18 @@ export default function InspectionManagementPage() {
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">사용여부</Label>
|
||||
<Select
|
||||
value={defForm.is_active || "사용"}
|
||||
value={defForm.is_active || "CAT_DA_01"}
|
||||
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사용">사용</SelectItem>
|
||||
<SelectItem value="미사용">미사용</SelectItem>
|
||||
{(catOptions[`${DEFECT_TABLE}.is_active`] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1771,6 +2409,101 @@ export default function InspectionManagementPage() {
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{/* ═══════════════════ 엑셀업로드 모달 ═══════════════════ */}
|
||||
<SmartExcelUploadModal
|
||||
open={inspExcelOpen}
|
||||
onOpenChange={setInspExcelOpen}
|
||||
config={inspExcelConfig}
|
||||
dropdownOptions={inspDropdownOptions}
|
||||
labelToCodeMap={inspLabelToCodeMap}
|
||||
onUpload={handleInspExcelUpload}
|
||||
customValidator={(data) => {
|
||||
// inspection_type 다중값(text) 카테고리 라벨 검증
|
||||
const errors: any[] = [];
|
||||
const validLabels = catLabels(INSPECTION_TABLE, "inspection_type");
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
const raw = String(row.inspection_type || "").trim();
|
||||
if (!raw) return;
|
||||
const labels = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const invalid = labels.filter((l) => !validLabels.includes(l));
|
||||
if (invalid.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "검사유형",
|
||||
message: `등록되지 않은 값: ${invalid.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
<SmartExcelUploadModal
|
||||
open={defExcelOpen}
|
||||
onOpenChange={setDefExcelOpen}
|
||||
config={defExcelConfig}
|
||||
dropdownOptions={defDropdownOptions}
|
||||
labelToCodeMap={defLabelToCodeMap}
|
||||
onUpload={handleDefExcelUpload}
|
||||
customValidator={(data) => {
|
||||
const errors: any[] = [];
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
const parentByLabel = new Map(defInspOpts.filter((o) => o.depth === 1).map((o) => [o.label, o.code]));
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
// 검사유형(depth=1) 검증
|
||||
const rawType = String(row.inspection_type || "").trim();
|
||||
const typeLabels = rawType ? rawType.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const invalidTypes = typeLabels.filter((l) => !parentByLabel.has(l));
|
||||
if (invalidTypes.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "검사유형",
|
||||
message: `등록되지 않은 값: ${invalidTypes.join(", ")}`,
|
||||
});
|
||||
}
|
||||
// 적용대상(depth=2) 계층 검증 — 선택한 검사유형의 자식만 허용
|
||||
const rawTarget = String(row.apply_target || "").trim();
|
||||
if (!rawTarget) return;
|
||||
const targetLabels = rawTarget.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const selectedParentCodes = typeLabels
|
||||
.map((l) => parentByLabel.get(l))
|
||||
.filter(Boolean) as string[];
|
||||
const invalidTargets = targetLabels.filter((label) => {
|
||||
const child = defInspOpts.find(
|
||||
(o) =>
|
||||
o.depth === 2 &&
|
||||
o.label === label &&
|
||||
o.parentCode &&
|
||||
selectedParentCodes.includes(o.parentCode),
|
||||
);
|
||||
return !child;
|
||||
});
|
||||
if (invalidTargets.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "적용대상",
|
||||
message: `선택한 검사유형의 하위가 아니거나 미등록: ${invalidTargets.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
<SmartExcelUploadModal
|
||||
open={eqExcelOpen}
|
||||
onOpenChange={setEqExcelOpen}
|
||||
config={eqExcelConfig}
|
||||
dropdownOptions={eqDropdownOptions}
|
||||
labelToCodeMap={eqLabelToCodeMap}
|
||||
onUpload={handleEqExcelUpload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,8 +324,15 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const availableEquipments = useMemo(() => {
|
||||
const used = new Set(processEquipments.map((e) => e.equipment_code));
|
||||
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
|
||||
const used = new Set<string>();
|
||||
for (const pe of processEquipments) {
|
||||
if (pe.equipment_code) used.add(pe.equipment_code);
|
||||
}
|
||||
return equipmentMaster.filter((e) => {
|
||||
if (e.equipment_code && used.has(e.equipment_code)) return false;
|
||||
if (e.id && used.has(e.id)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
@@ -334,11 +341,16 @@ export function ProcessMasterTab() {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: equipmentPick,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
@@ -515,8 +527,8 @@ export function ProcessMasterTab() {
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.equipment_code,
|
||||
label: `${eq.equipment_code} · ${eq.equipment_name}`,
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
@@ -553,8 +565,11 @@ export function ProcessMasterTab() {
|
||||
{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>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{pe.equipment_name
|
||||
|| equipmentMaster.find((e) => e.id === pe.equipment_code || e.equipment_code === pe.equipment_code)?.equipment_name
|
||||
|| "설비명 없음"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => void handleRemoveEquipment(pe)}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Search,
|
||||
Inbox,
|
||||
Settings2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
@@ -41,6 +42,8 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload";
|
||||
import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload";
|
||||
|
||||
/* ───── 테이블명 ───── */
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
@@ -119,6 +122,11 @@ export default function InspectionManagementPage() {
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ───── 엑셀업로드 모달 오픈 상태 ───── */
|
||||
const [inspExcelOpen, setInspExcelOpen] = useState(false);
|
||||
const [defExcelOpen, setDefExcelOpen] = useState(false);
|
||||
const [eqExcelOpen, setEqExcelOpen] = useState(false);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
@@ -129,6 +137,7 @@ export default function InspectionManagementPage() {
|
||||
{ table: INSPECTION_TABLE, col: "inspection_method" },
|
||||
{ table: INSPECTION_TABLE, col: "judgment_criteria" },
|
||||
{ table: INSPECTION_TABLE, col: "unit" },
|
||||
{ table: INSPECTION_TABLE, col: "is_active" },
|
||||
{ table: DEFECT_TABLE, col: "defect_type" },
|
||||
{ table: DEFECT_TABLE, col: "severity" },
|
||||
{ table: DEFECT_TABLE, col: "inspection_type" },
|
||||
@@ -396,7 +405,7 @@ export default function InspectionManagementPage() {
|
||||
|
||||
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
|
||||
const openDefCreate = async () => {
|
||||
setDefForm({ is_active: "사용" });
|
||||
setDefForm({ is_active: "CAT_DA_01" });
|
||||
setDefEditMode(false);
|
||||
setNumberingRuleId(null);
|
||||
setPreviewCode(null);
|
||||
@@ -607,6 +616,617 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 엑셀 업로드 공통 헬퍼 ═══════════════════ */
|
||||
// 라벨 배열 추출 (비어있는 카테고리는 빈 배열 반환)
|
||||
const catLabels = useCallback(
|
||||
(table: string, col: string): string[] => {
|
||||
return (catOptions[`${table}.${col}`] || []).map((o) => o.label);
|
||||
},
|
||||
[catOptions],
|
||||
);
|
||||
// 라벨→코드 단일 맵 생성
|
||||
const catLabelToCode = useCallback(
|
||||
(table: string, col: string): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
(catOptions[`${table}.${col}`] || []).forEach((o) => {
|
||||
map[o.label] = o.code;
|
||||
});
|
||||
return map;
|
||||
},
|
||||
[catOptions],
|
||||
);
|
||||
|
||||
/* ═══════════════════ 탭 1: 검사기준 엑셀업로드 ═══════════════════ */
|
||||
const inspExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "검사기준",
|
||||
sheets: [
|
||||
{
|
||||
name: "검사기준",
|
||||
typeKey: "inspection_standard",
|
||||
columns: [
|
||||
{ key: "inspection_code", label: "검사코드", type: "text", width: 14 },
|
||||
{ key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 },
|
||||
{ key: "inspection_criteria", label: "검사기준", required: true, type: "text", width: 20 },
|
||||
{ key: "criteria_detail", label: "기준상세", type: "text", width: 20 },
|
||||
{ key: "inspection_item", label: "검사항목", required: true, type: "text", width: 18 },
|
||||
{
|
||||
key: "inspection_method",
|
||||
label: "검사방법",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "inspection_method") },
|
||||
width: 14,
|
||||
},
|
||||
{
|
||||
key: "judgment_criteria",
|
||||
label: "판단기준",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "judgment_criteria") },
|
||||
width: 14,
|
||||
},
|
||||
{
|
||||
key: "unit",
|
||||
label: "단위",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "unit") },
|
||||
width: 10,
|
||||
},
|
||||
{
|
||||
key: "apply_type",
|
||||
label: "적용구분",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "apply_type") },
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "is_active") },
|
||||
width: 10,
|
||||
},
|
||||
{ key: "selection_options", label: "선택옵션", type: "text", width: 22 },
|
||||
{
|
||||
key: "manager",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remark", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
conditionalRules: [
|
||||
{ when: { column: "judgment_criteria", equals: "선택형" }, require: ["selection_options"], ignore: [] },
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const inspDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
inspection_method: catLabels(INSPECTION_TABLE, "inspection_method"),
|
||||
judgment_criteria: catLabels(INSPECTION_TABLE, "judgment_criteria"),
|
||||
unit: catLabels(INSPECTION_TABLE, "unit"),
|
||||
apply_type: catLabels(INSPECTION_TABLE, "apply_type"),
|
||||
is_active: catLabels(INSPECTION_TABLE, "is_active"),
|
||||
manager: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const inspLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
inspection_method: catLabelToCode(INSPECTION_TABLE, "inspection_method"),
|
||||
judgment_criteria: catLabelToCode(INSPECTION_TABLE, "judgment_criteria"),
|
||||
unit: catLabelToCode(INSPECTION_TABLE, "unit"),
|
||||
apply_type: catLabelToCode(INSPECTION_TABLE, "apply_type"),
|
||||
is_active: catLabelToCode(INSPECTION_TABLE, "is_active"),
|
||||
manager: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleInspExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
// 채번 규칙 사전 조회 (코드 미입력 건에만 사용)
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${INSPECTION_TABLE}/inspection_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* 채번 규칙 없으면 기존 코드로만 처리 */
|
||||
}
|
||||
|
||||
// templateParser가 dropdown 컬럼은 labelToCodeMap으로 이미 label→code 자동변환 완료.
|
||||
// row.{key}는 코드, row.{key}_label은 원본 라벨.
|
||||
// inspection_type은 text 타입이라 자동변환 제외 → 수동 처리 + 카테고리 라벨 검증.
|
||||
const inspTypeMap = catLabelToCode(INSPECTION_TABLE, "inspection_type");
|
||||
const inspTypeLabels = catLabels(INSPECTION_TABLE, "inspection_type");
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// inspection_type 다중값: ,로 split → 각 라벨이 카테고리에 있는지 검증 → 코드로 변환
|
||||
const rawTypes = String(row.inspection_type || "").trim();
|
||||
const typeLabelsArr = rawTypes
|
||||
? rawTypes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const invalidTypes = typeLabelsArr.filter((label) => !inspTypeLabels.includes(label));
|
||||
if (invalidTypes.length > 0) {
|
||||
failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
const typeCodes = typeLabelsArr.map((label) => inspTypeMap[label] || label).join(",");
|
||||
|
||||
// selection_options 조건부 검증 (판단기준 라벨이 "선택형"일 때 필수)
|
||||
const judgmentOrigLabel = String(row.judgment_criteria_label || row.judgment_criteria || "").trim();
|
||||
if (judgmentOrigLabel === "선택형" && !String(row.selection_options || "").trim()) {
|
||||
failList.push(`${i + 2}행: 선택형은 옵션을 입력해주세요`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// row.{key}는 이미 코드 (dropdown 컬럼, templateParser가 labelToCodeMap 자동변환).
|
||||
const payload: Record<string, any> = {
|
||||
inspection_type: typeCodes,
|
||||
inspection_criteria: row.inspection_criteria || "",
|
||||
criteria_detail: row.criteria_detail || "",
|
||||
inspection_item: row.inspection_item || "",
|
||||
inspection_method: row.inspection_method || "",
|
||||
judgment_criteria: row.judgment_criteria || "",
|
||||
unit: row.unit || "",
|
||||
apply_type: row.apply_type || "",
|
||||
is_active: row.is_active || "CAT_IS_01",
|
||||
selection_options: row.selection_options || "",
|
||||
manager: row.manager || "",
|
||||
remark: row.remark || "",
|
||||
};
|
||||
|
||||
// 코드 있으면 update, 없으면 insert (채번 할당)
|
||||
const inspectionCode = String(row.inspection_code || "").trim();
|
||||
if (inspectionCode) {
|
||||
// 기존 행 조회 후 upsert
|
||||
const existRes = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "inspection_code", operator: "equals", value: inspectionCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, inspection_code: inspectionCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_code: inspectionCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 코드 자동 채번
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch (e) {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchInspections();
|
||||
};
|
||||
|
||||
/* ═══════════════════ 탭 2: 불량관리 엑셀업로드 ═══════════════════ */
|
||||
const defExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "불량관리",
|
||||
sheets: [
|
||||
{
|
||||
name: "불량관리",
|
||||
typeKey: "defect_standard_mng",
|
||||
columns: [
|
||||
{ key: "defect_code", label: "불량코드", type: "text", width: 14 },
|
||||
{
|
||||
key: "defect_type",
|
||||
label: "불량유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "defect_type") },
|
||||
width: 14,
|
||||
},
|
||||
{ key: "defect_name", label: "불량명", required: true, type: "text", width: 20 },
|
||||
{ key: "defect_content", label: "불량내용", required: true, type: "text", width: 24 },
|
||||
{
|
||||
key: "severity",
|
||||
label: "심각도",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "severity") },
|
||||
width: 12,
|
||||
},
|
||||
{ key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 },
|
||||
{ key: "apply_target", label: "적용대상", type: "text", width: 18 },
|
||||
{
|
||||
key: "is_active",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "is_active") },
|
||||
width: 10,
|
||||
},
|
||||
{
|
||||
key: "manager_id",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remarks", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const defDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
defect_type: catLabels(DEFECT_TABLE, "defect_type"),
|
||||
severity: catLabels(DEFECT_TABLE, "severity"),
|
||||
is_active: catLabels(DEFECT_TABLE, "is_active"),
|
||||
manager_id: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const defLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
defect_type: catLabelToCode(DEFECT_TABLE, "defect_type"),
|
||||
severity: catLabelToCode(DEFECT_TABLE, "severity"),
|
||||
is_active: catLabelToCode(DEFECT_TABLE, "is_active"),
|
||||
manager_id: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleDefExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${DEFECT_TABLE}/defect_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
// templateParser가 dropdown 컬럼은 이미 label→code 변환 완료. inspection_type/apply_target만 text라 수동 처리 + 계층 검증.
|
||||
const inspTypeMap = catLabelToCode(DEFECT_TABLE, "inspection_type");
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
// depth=1 부모(검사유형), depth=2 자식(적용대상)
|
||||
const parentLabels = defInspOpts.filter((o) => o.depth === 1).map((o) => o.label);
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// inspection_type 다중값: ,로 split → depth=1 라벨 검증 → 코드로 변환
|
||||
const rawTypes = String(row.inspection_type || "").trim();
|
||||
const typeLabelsArr = rawTypes
|
||||
? rawTypes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const invalidTypes = typeLabelsArr.filter((label) => !parentLabels.includes(label));
|
||||
if (invalidTypes.length > 0) {
|
||||
failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
const typeCodeList = typeLabelsArr.map((label) => inspTypeMap[label] || label);
|
||||
const typeCodes = typeCodeList.join(",");
|
||||
|
||||
// apply_target 다중값: 선택한 검사유형의 자식(depth=2)만 허용 → 자식 코드로 변환
|
||||
const rawTargets = String(row.apply_target || "").trim();
|
||||
const targetLabelsArr = rawTargets
|
||||
? rawTargets.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const targetCodeList: string[] = [];
|
||||
const invalidTargets: string[] = [];
|
||||
for (const label of targetLabelsArr) {
|
||||
const child = defInspOpts.find(
|
||||
(o) =>
|
||||
o.depth === 2 &&
|
||||
o.label === label &&
|
||||
o.parentCode &&
|
||||
typeCodeList.includes(o.parentCode),
|
||||
);
|
||||
if (child) targetCodeList.push(child.code);
|
||||
else invalidTargets.push(label);
|
||||
}
|
||||
if (invalidTargets.length > 0) {
|
||||
failList.push(
|
||||
`${i + 2}행: 적용대상이 선택한 검사유형의 하위가 아니거나 미등록(${invalidTargets.join(", ")})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const targetCodes = targetCodeList.join(",");
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
defect_type: row.defect_type || "",
|
||||
defect_name: row.defect_name || "",
|
||||
defect_content: row.defect_content || "",
|
||||
severity: row.severity || "",
|
||||
inspection_type: typeCodes,
|
||||
apply_target: targetCodes,
|
||||
is_active: row.is_active || "",
|
||||
manager_id: row.manager_id || "",
|
||||
remarks: row.remarks || "",
|
||||
};
|
||||
|
||||
const defectCode = String(row.defect_code || "").trim();
|
||||
if (defectCode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "defect_code", operator: "equals", value: defectCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${DEFECT_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, defect_code: defectCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
defect_code: defectCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
defect_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchDefects();
|
||||
};
|
||||
|
||||
/* ═══════════════════ 탭 3: 검사장비 엑셀업로드 ═══════════════════ */
|
||||
const eqExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "검사장비",
|
||||
sheets: [
|
||||
{
|
||||
name: "검사장비",
|
||||
typeKey: "inspection_equipment_mng",
|
||||
columns: [
|
||||
{ key: "equipment_code", label: "장비코드", type: "text", width: 14 },
|
||||
{ key: "equipment_name", label: "장비명", required: true, type: "text", width: 20 },
|
||||
{
|
||||
key: "equipment_type",
|
||||
label: "장비유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_type") },
|
||||
width: 14,
|
||||
},
|
||||
{ key: "model_name", label: "모델명", type: "text", width: 16 },
|
||||
{ key: "manufacturer", label: "제조사", type: "text", width: 16 },
|
||||
{ key: "serial_number", label: "시리얼번호", type: "text", width: 16 },
|
||||
{ key: "installation_location", label: "설치위치", type: "text", width: 18 },
|
||||
{ key: "last_calibration_date", label: "최종교정일", type: "date", width: 14 },
|
||||
{ key: "calibration_period", label: "교정주기(개월)", type: "number", width: 14 },
|
||||
{
|
||||
key: "equipment_status",
|
||||
label: "장비상태",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_status") },
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
key: "manager_id",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remarks", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const eqDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
equipment_type: catLabels(EQUIPMENT_TABLE, "equipment_type"),
|
||||
equipment_status: catLabels(EQUIPMENT_TABLE, "equipment_status"),
|
||||
manager_id: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const eqLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
equipment_type: catLabelToCode(EQUIPMENT_TABLE, "equipment_type"),
|
||||
equipment_status: catLabelToCode(EQUIPMENT_TABLE, "equipment_status"),
|
||||
manager_id: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleEqExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${EQUIPMENT_TABLE}/equipment_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// calibration_period 숫자 검증
|
||||
const periodRaw = String(row.calibration_period ?? "").trim();
|
||||
if (periodRaw && isNaN(Number(periodRaw))) {
|
||||
failList.push(`${i + 2}행: 교정주기는 숫자만 입력해주세요`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// last_calibration_date 포맷 검증 (YYYY-MM-DD 기대)
|
||||
let calibDate = String(row.last_calibration_date ?? "").trim();
|
||||
if (calibDate) {
|
||||
// Date 객체로 변환되어 왔을 수도 있어 ISO 추출
|
||||
const d = new Date(calibDate);
|
||||
if (isNaN(d.getTime())) {
|
||||
failList.push(`${i + 2}행: 최종교정일 포맷 오류(${calibDate})`);
|
||||
continue;
|
||||
}
|
||||
calibDate = d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// row.{key}는 이미 코드 (templateParser가 labelToCodeMap으로 자동변환 완료).
|
||||
const payload: Record<string, any> = {
|
||||
equipment_name: row.equipment_name || "",
|
||||
equipment_type: row.equipment_type || "",
|
||||
model_name: row.model_name || "",
|
||||
manufacturer: row.manufacturer || "",
|
||||
serial_number: row.serial_number || "",
|
||||
installation_location: row.installation_location || "",
|
||||
last_calibration_date: calibDate,
|
||||
calibration_period: periodRaw,
|
||||
equipment_status: row.equipment_status || "",
|
||||
manager_id: row.manager_id || "",
|
||||
remarks: row.remarks || "",
|
||||
};
|
||||
|
||||
const equipmentCode = String(row.equipment_code || "").trim();
|
||||
if (equipmentCode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "equipment_code", operator: "equals", value: equipmentCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${EQUIPMENT_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, equipment_code: equipmentCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
equipment_code: equipmentCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
equipment_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchEquipments();
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3 h-[calc(100vh-4rem)] overflow-auto">
|
||||
@@ -664,6 +1284,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setInspExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -726,6 +1350,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setDefExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -877,12 +1505,15 @@ export default function InspectionManagementPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{row.is_active === "사용" || row.is_active === "true" ? "사용" : row.is_active || "미사용"}
|
||||
</Badge>
|
||||
{(() => {
|
||||
const label = getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || row.is_active || "-";
|
||||
const isOn = row.is_active === "CAT_DA_01" || label === "사용";
|
||||
return (
|
||||
<Badge variant={isOn ? "default" : "secondary"} className="text-[10px]">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")}
|
||||
@@ -920,6 +1551,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEqExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1509,15 +2144,18 @@ export default function InspectionManagementPage() {
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">사용여부</Label>
|
||||
<Select
|
||||
value={defForm.is_active || "사용"}
|
||||
value={defForm.is_active || "CAT_DA_01"}
|
||||
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사용">사용</SelectItem>
|
||||
<SelectItem value="미사용">미사용</SelectItem>
|
||||
{(catOptions[`${DEFECT_TABLE}.is_active`] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1772,6 +2410,101 @@ export default function InspectionManagementPage() {
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{/* ═══════════════════ 엑셀업로드 모달 ═══════════════════ */}
|
||||
<SmartExcelUploadModal
|
||||
open={inspExcelOpen}
|
||||
onOpenChange={setInspExcelOpen}
|
||||
config={inspExcelConfig}
|
||||
dropdownOptions={inspDropdownOptions}
|
||||
labelToCodeMap={inspLabelToCodeMap}
|
||||
onUpload={handleInspExcelUpload}
|
||||
customValidator={(data) => {
|
||||
// inspection_type 다중값(text) 카테고리 라벨 검증
|
||||
const errors: any[] = [];
|
||||
const validLabels = catLabels(INSPECTION_TABLE, "inspection_type");
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
const raw = String(row.inspection_type || "").trim();
|
||||
if (!raw) return;
|
||||
const labels = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const invalid = labels.filter((l) => !validLabels.includes(l));
|
||||
if (invalid.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "검사유형",
|
||||
message: `등록되지 않은 값: ${invalid.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
<SmartExcelUploadModal
|
||||
open={defExcelOpen}
|
||||
onOpenChange={setDefExcelOpen}
|
||||
config={defExcelConfig}
|
||||
dropdownOptions={defDropdownOptions}
|
||||
labelToCodeMap={defLabelToCodeMap}
|
||||
onUpload={handleDefExcelUpload}
|
||||
customValidator={(data) => {
|
||||
const errors: any[] = [];
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
const parentByLabel = new Map(defInspOpts.filter((o) => o.depth === 1).map((o) => [o.label, o.code]));
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
// 검사유형(depth=1) 검증
|
||||
const rawType = String(row.inspection_type || "").trim();
|
||||
const typeLabels = rawType ? rawType.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const invalidTypes = typeLabels.filter((l) => !parentByLabel.has(l));
|
||||
if (invalidTypes.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "검사유형",
|
||||
message: `등록되지 않은 값: ${invalidTypes.join(", ")}`,
|
||||
});
|
||||
}
|
||||
// 적용대상(depth=2) 계층 검증 — 선택한 검사유형의 자식만 허용
|
||||
const rawTarget = String(row.apply_target || "").trim();
|
||||
if (!rawTarget) return;
|
||||
const targetLabels = rawTarget.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const selectedParentCodes = typeLabels
|
||||
.map((l) => parentByLabel.get(l))
|
||||
.filter(Boolean) as string[];
|
||||
const invalidTargets = targetLabels.filter((label) => {
|
||||
const child = defInspOpts.find(
|
||||
(o) =>
|
||||
o.depth === 2 &&
|
||||
o.label === label &&
|
||||
o.parentCode &&
|
||||
selectedParentCodes.includes(o.parentCode),
|
||||
);
|
||||
return !child;
|
||||
});
|
||||
if (invalidTargets.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "적용대상",
|
||||
message: `선택한 검사유형의 하위가 아니거나 미등록: ${invalidTargets.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
<SmartExcelUploadModal
|
||||
open={eqExcelOpen}
|
||||
onOpenChange={setEqExcelOpen}
|
||||
config={eqExcelConfig}
|
||||
dropdownOptions={eqDropdownOptions}
|
||||
labelToCodeMap={eqLabelToCodeMap}
|
||||
onUpload={handleEqExcelUpload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -325,8 +325,15 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const availableEquipments = useMemo(() => {
|
||||
const used = new Set(processEquipments.map((e) => e.equipment_code));
|
||||
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
|
||||
const used = new Set<string>();
|
||||
for (const pe of processEquipments) {
|
||||
if (pe.equipment_code) used.add(pe.equipment_code);
|
||||
}
|
||||
return equipmentMaster.filter((e) => {
|
||||
if (e.equipment_code && used.has(e.equipment_code)) return false;
|
||||
if (e.id && used.has(e.id)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
@@ -335,11 +342,16 @@ export function ProcessMasterTab() {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: equipmentPick,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
@@ -516,8 +528,8 @@ export function ProcessMasterTab() {
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.equipment_code,
|
||||
label: `${eq.equipment_code} · ${eq.equipment_name}`,
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
@@ -554,8 +566,11 @@ export function ProcessMasterTab() {
|
||||
{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>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{pe.equipment_name
|
||||
|| equipmentMaster.find((e) => e.id === pe.equipment_code || e.equipment_code === pe.equipment_code)?.equipment_name
|
||||
|| "설비명 없음"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => void handleRemoveEquipment(pe)}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Search,
|
||||
Inbox,
|
||||
Settings2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
@@ -41,6 +42,8 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload";
|
||||
import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload";
|
||||
|
||||
/* ───── 테이블명 ───── */
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
@@ -118,6 +121,11 @@ export default function InspectionManagementPage() {
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ───── 엑셀업로드 모달 오픈 상태 ───── */
|
||||
const [inspExcelOpen, setInspExcelOpen] = useState(false);
|
||||
const [defExcelOpen, setDefExcelOpen] = useState(false);
|
||||
const [eqExcelOpen, setEqExcelOpen] = useState(false);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
@@ -128,6 +136,7 @@ export default function InspectionManagementPage() {
|
||||
{ table: INSPECTION_TABLE, col: "inspection_method" },
|
||||
{ table: INSPECTION_TABLE, col: "judgment_criteria" },
|
||||
{ table: INSPECTION_TABLE, col: "unit" },
|
||||
{ table: INSPECTION_TABLE, col: "is_active" },
|
||||
{ table: DEFECT_TABLE, col: "defect_type" },
|
||||
{ table: DEFECT_TABLE, col: "severity" },
|
||||
{ table: DEFECT_TABLE, col: "inspection_type" },
|
||||
@@ -395,7 +404,7 @@ export default function InspectionManagementPage() {
|
||||
|
||||
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
|
||||
const openDefCreate = async () => {
|
||||
setDefForm({ is_active: "사용" });
|
||||
setDefForm({ is_active: "CAT_DA_01" });
|
||||
setDefEditMode(false);
|
||||
setNumberingRuleId(null);
|
||||
setPreviewCode(null);
|
||||
@@ -606,6 +615,617 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 엑셀 업로드 공통 헬퍼 ═══════════════════ */
|
||||
// 라벨 배열 추출 (비어있는 카테고리는 빈 배열 반환)
|
||||
const catLabels = useCallback(
|
||||
(table: string, col: string): string[] => {
|
||||
return (catOptions[`${table}.${col}`] || []).map((o) => o.label);
|
||||
},
|
||||
[catOptions],
|
||||
);
|
||||
// 라벨→코드 단일 맵 생성
|
||||
const catLabelToCode = useCallback(
|
||||
(table: string, col: string): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
(catOptions[`${table}.${col}`] || []).forEach((o) => {
|
||||
map[o.label] = o.code;
|
||||
});
|
||||
return map;
|
||||
},
|
||||
[catOptions],
|
||||
);
|
||||
|
||||
/* ═══════════════════ 탭 1: 검사기준 엑셀업로드 ═══════════════════ */
|
||||
const inspExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "검사기준",
|
||||
sheets: [
|
||||
{
|
||||
name: "검사기준",
|
||||
typeKey: "inspection_standard",
|
||||
columns: [
|
||||
{ key: "inspection_code", label: "검사코드", type: "text", width: 14 },
|
||||
{ key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 },
|
||||
{ key: "inspection_criteria", label: "검사기준", required: true, type: "text", width: 20 },
|
||||
{ key: "criteria_detail", label: "기준상세", type: "text", width: 20 },
|
||||
{ key: "inspection_item", label: "검사항목", required: true, type: "text", width: 18 },
|
||||
{
|
||||
key: "inspection_method",
|
||||
label: "검사방법",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "inspection_method") },
|
||||
width: 14,
|
||||
},
|
||||
{
|
||||
key: "judgment_criteria",
|
||||
label: "판단기준",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "judgment_criteria") },
|
||||
width: 14,
|
||||
},
|
||||
{
|
||||
key: "unit",
|
||||
label: "단위",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "unit") },
|
||||
width: 10,
|
||||
},
|
||||
{
|
||||
key: "apply_type",
|
||||
label: "적용구분",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "apply_type") },
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "is_active") },
|
||||
width: 10,
|
||||
},
|
||||
{ key: "selection_options", label: "선택옵션", type: "text", width: 22 },
|
||||
{
|
||||
key: "manager",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remark", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
conditionalRules: [
|
||||
{ when: { column: "judgment_criteria", equals: "선택형" }, require: ["selection_options"], ignore: [] },
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const inspDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
inspection_method: catLabels(INSPECTION_TABLE, "inspection_method"),
|
||||
judgment_criteria: catLabels(INSPECTION_TABLE, "judgment_criteria"),
|
||||
unit: catLabels(INSPECTION_TABLE, "unit"),
|
||||
apply_type: catLabels(INSPECTION_TABLE, "apply_type"),
|
||||
is_active: catLabels(INSPECTION_TABLE, "is_active"),
|
||||
manager: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const inspLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
inspection_method: catLabelToCode(INSPECTION_TABLE, "inspection_method"),
|
||||
judgment_criteria: catLabelToCode(INSPECTION_TABLE, "judgment_criteria"),
|
||||
unit: catLabelToCode(INSPECTION_TABLE, "unit"),
|
||||
apply_type: catLabelToCode(INSPECTION_TABLE, "apply_type"),
|
||||
is_active: catLabelToCode(INSPECTION_TABLE, "is_active"),
|
||||
manager: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleInspExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
// 채번 규칙 사전 조회 (코드 미입력 건에만 사용)
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${INSPECTION_TABLE}/inspection_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* 채번 규칙 없으면 기존 코드로만 처리 */
|
||||
}
|
||||
|
||||
// templateParser가 dropdown 컬럼은 labelToCodeMap으로 이미 label→code 자동변환 완료.
|
||||
// row.{key}는 코드, row.{key}_label은 원본 라벨.
|
||||
// inspection_type은 text 타입이라 자동변환 제외 → 수동 처리 + 카테고리 라벨 검증.
|
||||
const inspTypeMap = catLabelToCode(INSPECTION_TABLE, "inspection_type");
|
||||
const inspTypeLabels = catLabels(INSPECTION_TABLE, "inspection_type");
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// inspection_type 다중값: ,로 split → 각 라벨이 카테고리에 있는지 검증 → 코드로 변환
|
||||
const rawTypes = String(row.inspection_type || "").trim();
|
||||
const typeLabelsArr = rawTypes
|
||||
? rawTypes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const invalidTypes = typeLabelsArr.filter((label) => !inspTypeLabels.includes(label));
|
||||
if (invalidTypes.length > 0) {
|
||||
failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
const typeCodes = typeLabelsArr.map((label) => inspTypeMap[label] || label).join(",");
|
||||
|
||||
// selection_options 조건부 검증 (판단기준 라벨이 "선택형"일 때 필수)
|
||||
const judgmentOrigLabel = String(row.judgment_criteria_label || row.judgment_criteria || "").trim();
|
||||
if (judgmentOrigLabel === "선택형" && !String(row.selection_options || "").trim()) {
|
||||
failList.push(`${i + 2}행: 선택형은 옵션을 입력해주세요`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// row.{key}는 이미 코드 (dropdown 컬럼, templateParser가 labelToCodeMap 자동변환).
|
||||
const payload: Record<string, any> = {
|
||||
inspection_type: typeCodes,
|
||||
inspection_criteria: row.inspection_criteria || "",
|
||||
criteria_detail: row.criteria_detail || "",
|
||||
inspection_item: row.inspection_item || "",
|
||||
inspection_method: row.inspection_method || "",
|
||||
judgment_criteria: row.judgment_criteria || "",
|
||||
unit: row.unit || "",
|
||||
apply_type: row.apply_type || "",
|
||||
is_active: row.is_active || "CAT_IS_01",
|
||||
selection_options: row.selection_options || "",
|
||||
manager: row.manager || "",
|
||||
remark: row.remark || "",
|
||||
};
|
||||
|
||||
// 코드 있으면 update, 없으면 insert (채번 할당)
|
||||
const inspectionCode = String(row.inspection_code || "").trim();
|
||||
if (inspectionCode) {
|
||||
// 기존 행 조회 후 upsert
|
||||
const existRes = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "inspection_code", operator: "equals", value: inspectionCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, inspection_code: inspectionCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_code: inspectionCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 코드 자동 채번
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch (e) {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchInspections();
|
||||
};
|
||||
|
||||
/* ═══════════════════ 탭 2: 불량관리 엑셀업로드 ═══════════════════ */
|
||||
const defExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "불량관리",
|
||||
sheets: [
|
||||
{
|
||||
name: "불량관리",
|
||||
typeKey: "defect_standard_mng",
|
||||
columns: [
|
||||
{ key: "defect_code", label: "불량코드", type: "text", width: 14 },
|
||||
{
|
||||
key: "defect_type",
|
||||
label: "불량유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "defect_type") },
|
||||
width: 14,
|
||||
},
|
||||
{ key: "defect_name", label: "불량명", required: true, type: "text", width: 20 },
|
||||
{ key: "defect_content", label: "불량내용", required: true, type: "text", width: 24 },
|
||||
{
|
||||
key: "severity",
|
||||
label: "심각도",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "severity") },
|
||||
width: 12,
|
||||
},
|
||||
{ key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 },
|
||||
{ key: "apply_target", label: "적용대상", type: "text", width: 18 },
|
||||
{
|
||||
key: "is_active",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "is_active") },
|
||||
width: 10,
|
||||
},
|
||||
{
|
||||
key: "manager_id",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remarks", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const defDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
defect_type: catLabels(DEFECT_TABLE, "defect_type"),
|
||||
severity: catLabels(DEFECT_TABLE, "severity"),
|
||||
is_active: catLabels(DEFECT_TABLE, "is_active"),
|
||||
manager_id: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const defLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
defect_type: catLabelToCode(DEFECT_TABLE, "defect_type"),
|
||||
severity: catLabelToCode(DEFECT_TABLE, "severity"),
|
||||
is_active: catLabelToCode(DEFECT_TABLE, "is_active"),
|
||||
manager_id: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleDefExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${DEFECT_TABLE}/defect_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
// templateParser가 dropdown 컬럼은 이미 label→code 변환 완료. inspection_type/apply_target만 text라 수동 처리 + 계층 검증.
|
||||
const inspTypeMap = catLabelToCode(DEFECT_TABLE, "inspection_type");
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
// depth=1 부모(검사유형), depth=2 자식(적용대상)
|
||||
const parentLabels = defInspOpts.filter((o) => o.depth === 1).map((o) => o.label);
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// inspection_type 다중값: ,로 split → depth=1 라벨 검증 → 코드로 변환
|
||||
const rawTypes = String(row.inspection_type || "").trim();
|
||||
const typeLabelsArr = rawTypes
|
||||
? rawTypes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const invalidTypes = typeLabelsArr.filter((label) => !parentLabels.includes(label));
|
||||
if (invalidTypes.length > 0) {
|
||||
failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
const typeCodeList = typeLabelsArr.map((label) => inspTypeMap[label] || label);
|
||||
const typeCodes = typeCodeList.join(",");
|
||||
|
||||
// apply_target 다중값: 선택한 검사유형의 자식(depth=2)만 허용 → 자식 코드로 변환
|
||||
const rawTargets = String(row.apply_target || "").trim();
|
||||
const targetLabelsArr = rawTargets
|
||||
? rawTargets.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const targetCodeList: string[] = [];
|
||||
const invalidTargets: string[] = [];
|
||||
for (const label of targetLabelsArr) {
|
||||
const child = defInspOpts.find(
|
||||
(o) =>
|
||||
o.depth === 2 &&
|
||||
o.label === label &&
|
||||
o.parentCode &&
|
||||
typeCodeList.includes(o.parentCode),
|
||||
);
|
||||
if (child) targetCodeList.push(child.code);
|
||||
else invalidTargets.push(label);
|
||||
}
|
||||
if (invalidTargets.length > 0) {
|
||||
failList.push(
|
||||
`${i + 2}행: 적용대상이 선택한 검사유형의 하위가 아니거나 미등록(${invalidTargets.join(", ")})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const targetCodes = targetCodeList.join(",");
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
defect_type: row.defect_type || "",
|
||||
defect_name: row.defect_name || "",
|
||||
defect_content: row.defect_content || "",
|
||||
severity: row.severity || "",
|
||||
inspection_type: typeCodes,
|
||||
apply_target: targetCodes,
|
||||
is_active: row.is_active || "",
|
||||
manager_id: row.manager_id || "",
|
||||
remarks: row.remarks || "",
|
||||
};
|
||||
|
||||
const defectCode = String(row.defect_code || "").trim();
|
||||
if (defectCode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "defect_code", operator: "equals", value: defectCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${DEFECT_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, defect_code: defectCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
defect_code: defectCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
defect_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchDefects();
|
||||
};
|
||||
|
||||
/* ═══════════════════ 탭 3: 검사장비 엑셀업로드 ═══════════════════ */
|
||||
const eqExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "검사장비",
|
||||
sheets: [
|
||||
{
|
||||
name: "검사장비",
|
||||
typeKey: "inspection_equipment_mng",
|
||||
columns: [
|
||||
{ key: "equipment_code", label: "장비코드", type: "text", width: 14 },
|
||||
{ key: "equipment_name", label: "장비명", required: true, type: "text", width: 20 },
|
||||
{
|
||||
key: "equipment_type",
|
||||
label: "장비유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_type") },
|
||||
width: 14,
|
||||
},
|
||||
{ key: "model_name", label: "모델명", type: "text", width: 16 },
|
||||
{ key: "manufacturer", label: "제조사", type: "text", width: 16 },
|
||||
{ key: "serial_number", label: "시리얼번호", type: "text", width: 16 },
|
||||
{ key: "installation_location", label: "설치위치", type: "text", width: 18 },
|
||||
{ key: "last_calibration_date", label: "최종교정일", type: "date", width: 14 },
|
||||
{ key: "calibration_period", label: "교정주기(개월)", type: "number", width: 14 },
|
||||
{
|
||||
key: "equipment_status",
|
||||
label: "장비상태",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_status") },
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
key: "manager_id",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remarks", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const eqDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
equipment_type: catLabels(EQUIPMENT_TABLE, "equipment_type"),
|
||||
equipment_status: catLabels(EQUIPMENT_TABLE, "equipment_status"),
|
||||
manager_id: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const eqLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
equipment_type: catLabelToCode(EQUIPMENT_TABLE, "equipment_type"),
|
||||
equipment_status: catLabelToCode(EQUIPMENT_TABLE, "equipment_status"),
|
||||
manager_id: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleEqExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${EQUIPMENT_TABLE}/equipment_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// calibration_period 숫자 검증
|
||||
const periodRaw = String(row.calibration_period ?? "").trim();
|
||||
if (periodRaw && isNaN(Number(periodRaw))) {
|
||||
failList.push(`${i + 2}행: 교정주기는 숫자만 입력해주세요`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// last_calibration_date 포맷 검증 (YYYY-MM-DD 기대)
|
||||
let calibDate = String(row.last_calibration_date ?? "").trim();
|
||||
if (calibDate) {
|
||||
// Date 객체로 변환되어 왔을 수도 있어 ISO 추출
|
||||
const d = new Date(calibDate);
|
||||
if (isNaN(d.getTime())) {
|
||||
failList.push(`${i + 2}행: 최종교정일 포맷 오류(${calibDate})`);
|
||||
continue;
|
||||
}
|
||||
calibDate = d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// row.{key}는 이미 코드 (templateParser가 labelToCodeMap으로 자동변환 완료).
|
||||
const payload: Record<string, any> = {
|
||||
equipment_name: row.equipment_name || "",
|
||||
equipment_type: row.equipment_type || "",
|
||||
model_name: row.model_name || "",
|
||||
manufacturer: row.manufacturer || "",
|
||||
serial_number: row.serial_number || "",
|
||||
installation_location: row.installation_location || "",
|
||||
last_calibration_date: calibDate,
|
||||
calibration_period: periodRaw,
|
||||
equipment_status: row.equipment_status || "",
|
||||
manager_id: row.manager_id || "",
|
||||
remarks: row.remarks || "",
|
||||
};
|
||||
|
||||
const equipmentCode = String(row.equipment_code || "").trim();
|
||||
if (equipmentCode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "equipment_code", operator: "equals", value: equipmentCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${EQUIPMENT_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, equipment_code: equipmentCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
equipment_code: equipmentCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
equipment_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchEquipments();
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3 h-[calc(100vh-4rem)] overflow-auto">
|
||||
@@ -663,6 +1283,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setInspExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -725,6 +1349,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setDefExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -876,12 +1504,15 @@ export default function InspectionManagementPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{row.is_active === "사용" || row.is_active === "true" ? "사용" : row.is_active || "미사용"}
|
||||
</Badge>
|
||||
{(() => {
|
||||
const label = getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || row.is_active || "-";
|
||||
const isOn = row.is_active === "CAT_DA_01" || label === "사용";
|
||||
return (
|
||||
<Badge variant={isOn ? "default" : "secondary"} className="text-[10px]">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")}
|
||||
@@ -919,6 +1550,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEqExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1508,15 +2143,18 @@ export default function InspectionManagementPage() {
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">사용여부</Label>
|
||||
<Select
|
||||
value={defForm.is_active || "사용"}
|
||||
value={defForm.is_active || "CAT_DA_01"}
|
||||
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사용">사용</SelectItem>
|
||||
<SelectItem value="미사용">미사용</SelectItem>
|
||||
{(catOptions[`${DEFECT_TABLE}.is_active`] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1771,6 +2409,101 @@ export default function InspectionManagementPage() {
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{/* ═══════════════════ 엑셀업로드 모달 ═══════════════════ */}
|
||||
<SmartExcelUploadModal
|
||||
open={inspExcelOpen}
|
||||
onOpenChange={setInspExcelOpen}
|
||||
config={inspExcelConfig}
|
||||
dropdownOptions={inspDropdownOptions}
|
||||
labelToCodeMap={inspLabelToCodeMap}
|
||||
onUpload={handleInspExcelUpload}
|
||||
customValidator={(data) => {
|
||||
// inspection_type 다중값(text) 카테고리 라벨 검증
|
||||
const errors: any[] = [];
|
||||
const validLabels = catLabels(INSPECTION_TABLE, "inspection_type");
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
const raw = String(row.inspection_type || "").trim();
|
||||
if (!raw) return;
|
||||
const labels = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const invalid = labels.filter((l) => !validLabels.includes(l));
|
||||
if (invalid.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "검사유형",
|
||||
message: `등록되지 않은 값: ${invalid.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
<SmartExcelUploadModal
|
||||
open={defExcelOpen}
|
||||
onOpenChange={setDefExcelOpen}
|
||||
config={defExcelConfig}
|
||||
dropdownOptions={defDropdownOptions}
|
||||
labelToCodeMap={defLabelToCodeMap}
|
||||
onUpload={handleDefExcelUpload}
|
||||
customValidator={(data) => {
|
||||
const errors: any[] = [];
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
const parentByLabel = new Map(defInspOpts.filter((o) => o.depth === 1).map((o) => [o.label, o.code]));
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
// 검사유형(depth=1) 검증
|
||||
const rawType = String(row.inspection_type || "").trim();
|
||||
const typeLabels = rawType ? rawType.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const invalidTypes = typeLabels.filter((l) => !parentByLabel.has(l));
|
||||
if (invalidTypes.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "검사유형",
|
||||
message: `등록되지 않은 값: ${invalidTypes.join(", ")}`,
|
||||
});
|
||||
}
|
||||
// 적용대상(depth=2) 계층 검증 — 선택한 검사유형의 자식만 허용
|
||||
const rawTarget = String(row.apply_target || "").trim();
|
||||
if (!rawTarget) return;
|
||||
const targetLabels = rawTarget.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const selectedParentCodes = typeLabels
|
||||
.map((l) => parentByLabel.get(l))
|
||||
.filter(Boolean) as string[];
|
||||
const invalidTargets = targetLabels.filter((label) => {
|
||||
const child = defInspOpts.find(
|
||||
(o) =>
|
||||
o.depth === 2 &&
|
||||
o.label === label &&
|
||||
o.parentCode &&
|
||||
selectedParentCodes.includes(o.parentCode),
|
||||
);
|
||||
return !child;
|
||||
});
|
||||
if (invalidTargets.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "적용대상",
|
||||
message: `선택한 검사유형의 하위가 아니거나 미등록: ${invalidTargets.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
<SmartExcelUploadModal
|
||||
open={eqExcelOpen}
|
||||
onOpenChange={setEqExcelOpen}
|
||||
config={eqExcelConfig}
|
||||
dropdownOptions={eqDropdownOptions}
|
||||
labelToCodeMap={eqLabelToCodeMap}
|
||||
onUpload={handleEqExcelUpload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,8 +324,15 @@ export function ProcessMasterTab() {
|
||||
};
|
||||
|
||||
const availableEquipments = useMemo(() => {
|
||||
const used = new Set(processEquipments.map((e) => e.equipment_code));
|
||||
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
|
||||
const used = new Set<string>();
|
||||
for (const pe of processEquipments) {
|
||||
if (pe.equipment_code) used.add(pe.equipment_code);
|
||||
}
|
||||
return equipmentMaster.filter((e) => {
|
||||
if (e.equipment_code && used.has(e.equipment_code)) return false;
|
||||
if (e.id && used.has(e.id)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
@@ -334,11 +341,16 @@ export function ProcessMasterTab() {
|
||||
toast.message("추가할 설비를 선택해주세요");
|
||||
return;
|
||||
}
|
||||
const picked = availableEquipments.find((e) => e.id === equipmentPick);
|
||||
if (!picked) {
|
||||
toast.error("선택한 설비를 찾을 수 없어요");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: equipmentPick,
|
||||
equipment_code: picked.equipment_code || picked.id,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했어요");
|
||||
@@ -515,8 +527,8 @@ export function ProcessMasterTab() {
|
||||
<SmartSelect
|
||||
key={selectedProcess.id}
|
||||
options={availableEquipments.map((eq) => ({
|
||||
code: eq.equipment_code,
|
||||
label: `${eq.equipment_code} · ${eq.equipment_name}`,
|
||||
code: eq.id,
|
||||
label: eq.equipment_name,
|
||||
}))}
|
||||
value={equipmentPick || ""}
|
||||
onValueChange={setEquipmentPick}
|
||||
@@ -553,8 +565,11 @@ export function ProcessMasterTab() {
|
||||
{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>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{pe.equipment_name
|
||||
|| equipmentMaster.find((e) => e.id === pe.equipment_code || e.equipment_code === pe.equipment_code)?.equipment_name
|
||||
|| "설비명 없음"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => void handleRemoveEquipment(pe)}>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
Search,
|
||||
Inbox,
|
||||
Settings2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
@@ -41,6 +42,8 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartExcelUploadModal } from "@/components/common/SmartExcelUpload";
|
||||
import type { SmartExcelUploadConfig, ParsedSheetData } from "@/components/common/SmartExcelUpload";
|
||||
|
||||
/* ───── 테이블명 ───── */
|
||||
const INSPECTION_TABLE = "inspection_standard";
|
||||
@@ -118,6 +121,11 @@ export default function InspectionManagementPage() {
|
||||
const [catOptions, setCatOptions] = useState<Record<string, CatOption[]>>({});
|
||||
const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
/* ───── 엑셀업로드 모달 오픈 상태 ───── */
|
||||
const [inspExcelOpen, setInspExcelOpen] = useState(false);
|
||||
const [defExcelOpen, setDefExcelOpen] = useState(false);
|
||||
const [eqExcelOpen, setEqExcelOpen] = useState(false);
|
||||
|
||||
/* ═══════════════════ 카테고리 로드 ═══════════════════ */
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
@@ -128,6 +136,7 @@ export default function InspectionManagementPage() {
|
||||
{ table: INSPECTION_TABLE, col: "inspection_method" },
|
||||
{ table: INSPECTION_TABLE, col: "judgment_criteria" },
|
||||
{ table: INSPECTION_TABLE, col: "unit" },
|
||||
{ table: INSPECTION_TABLE, col: "is_active" },
|
||||
{ table: DEFECT_TABLE, col: "defect_type" },
|
||||
{ table: DEFECT_TABLE, col: "severity" },
|
||||
{ table: DEFECT_TABLE, col: "inspection_type" },
|
||||
@@ -395,7 +404,7 @@ export default function InspectionManagementPage() {
|
||||
|
||||
/* ═══════════════════ 불량관리 CRUD ═══════════════════ */
|
||||
const openDefCreate = async () => {
|
||||
setDefForm({ is_active: "사용" });
|
||||
setDefForm({ is_active: "CAT_DA_01" });
|
||||
setDefEditMode(false);
|
||||
setNumberingRuleId(null);
|
||||
setPreviewCode(null);
|
||||
@@ -606,6 +615,617 @@ export default function InspectionManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══════════════════ 엑셀 업로드 공통 헬퍼 ═══════════════════ */
|
||||
// 라벨 배열 추출 (비어있는 카테고리는 빈 배열 반환)
|
||||
const catLabels = useCallback(
|
||||
(table: string, col: string): string[] => {
|
||||
return (catOptions[`${table}.${col}`] || []).map((o) => o.label);
|
||||
},
|
||||
[catOptions],
|
||||
);
|
||||
// 라벨→코드 단일 맵 생성
|
||||
const catLabelToCode = useCallback(
|
||||
(table: string, col: string): Record<string, string> => {
|
||||
const map: Record<string, string> = {};
|
||||
(catOptions[`${table}.${col}`] || []).forEach((o) => {
|
||||
map[o.label] = o.code;
|
||||
});
|
||||
return map;
|
||||
},
|
||||
[catOptions],
|
||||
);
|
||||
|
||||
/* ═══════════════════ 탭 1: 검사기준 엑셀업로드 ═══════════════════ */
|
||||
const inspExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "검사기준",
|
||||
sheets: [
|
||||
{
|
||||
name: "검사기준",
|
||||
typeKey: "inspection_standard",
|
||||
columns: [
|
||||
{ key: "inspection_code", label: "검사코드", type: "text", width: 14 },
|
||||
{ key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 },
|
||||
{ key: "inspection_criteria", label: "검사기준", required: true, type: "text", width: 20 },
|
||||
{ key: "criteria_detail", label: "기준상세", type: "text", width: 20 },
|
||||
{ key: "inspection_item", label: "검사항목", required: true, type: "text", width: 18 },
|
||||
{
|
||||
key: "inspection_method",
|
||||
label: "검사방법",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "inspection_method") },
|
||||
width: 14,
|
||||
},
|
||||
{
|
||||
key: "judgment_criteria",
|
||||
label: "판단기준",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "judgment_criteria") },
|
||||
width: 14,
|
||||
},
|
||||
{
|
||||
key: "unit",
|
||||
label: "단위",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "unit") },
|
||||
width: 10,
|
||||
},
|
||||
{
|
||||
key: "apply_type",
|
||||
label: "적용구분",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "apply_type") },
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(INSPECTION_TABLE, "is_active") },
|
||||
width: 10,
|
||||
},
|
||||
{ key: "selection_options", label: "선택옵션", type: "text", width: 22 },
|
||||
{
|
||||
key: "manager",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remark", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
conditionalRules: [
|
||||
{ when: { column: "judgment_criteria", equals: "선택형" }, require: ["selection_options"], ignore: [] },
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const inspDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
inspection_method: catLabels(INSPECTION_TABLE, "inspection_method"),
|
||||
judgment_criteria: catLabels(INSPECTION_TABLE, "judgment_criteria"),
|
||||
unit: catLabels(INSPECTION_TABLE, "unit"),
|
||||
apply_type: catLabels(INSPECTION_TABLE, "apply_type"),
|
||||
is_active: catLabels(INSPECTION_TABLE, "is_active"),
|
||||
manager: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const inspLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
inspection_method: catLabelToCode(INSPECTION_TABLE, "inspection_method"),
|
||||
judgment_criteria: catLabelToCode(INSPECTION_TABLE, "judgment_criteria"),
|
||||
unit: catLabelToCode(INSPECTION_TABLE, "unit"),
|
||||
apply_type: catLabelToCode(INSPECTION_TABLE, "apply_type"),
|
||||
is_active: catLabelToCode(INSPECTION_TABLE, "is_active"),
|
||||
manager: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleInspExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
// 채번 규칙 사전 조회 (코드 미입력 건에만 사용)
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${INSPECTION_TABLE}/inspection_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* 채번 규칙 없으면 기존 코드로만 처리 */
|
||||
}
|
||||
|
||||
// templateParser가 dropdown 컬럼은 labelToCodeMap으로 이미 label→code 자동변환 완료.
|
||||
// row.{key}는 코드, row.{key}_label은 원본 라벨.
|
||||
// inspection_type은 text 타입이라 자동변환 제외 → 수동 처리 + 카테고리 라벨 검증.
|
||||
const inspTypeMap = catLabelToCode(INSPECTION_TABLE, "inspection_type");
|
||||
const inspTypeLabels = catLabels(INSPECTION_TABLE, "inspection_type");
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// inspection_type 다중값: ,로 split → 각 라벨이 카테고리에 있는지 검증 → 코드로 변환
|
||||
const rawTypes = String(row.inspection_type || "").trim();
|
||||
const typeLabelsArr = rawTypes
|
||||
? rawTypes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const invalidTypes = typeLabelsArr.filter((label) => !inspTypeLabels.includes(label));
|
||||
if (invalidTypes.length > 0) {
|
||||
failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
const typeCodes = typeLabelsArr.map((label) => inspTypeMap[label] || label).join(",");
|
||||
|
||||
// selection_options 조건부 검증 (판단기준 라벨이 "선택형"일 때 필수)
|
||||
const judgmentOrigLabel = String(row.judgment_criteria_label || row.judgment_criteria || "").trim();
|
||||
if (judgmentOrigLabel === "선택형" && !String(row.selection_options || "").trim()) {
|
||||
failList.push(`${i + 2}행: 선택형은 옵션을 입력해주세요`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// row.{key}는 이미 코드 (dropdown 컬럼, templateParser가 labelToCodeMap 자동변환).
|
||||
const payload: Record<string, any> = {
|
||||
inspection_type: typeCodes,
|
||||
inspection_criteria: row.inspection_criteria || "",
|
||||
criteria_detail: row.criteria_detail || "",
|
||||
inspection_item: row.inspection_item || "",
|
||||
inspection_method: row.inspection_method || "",
|
||||
judgment_criteria: row.judgment_criteria || "",
|
||||
unit: row.unit || "",
|
||||
apply_type: row.apply_type || "",
|
||||
is_active: row.is_active || "CAT_IS_01",
|
||||
selection_options: row.selection_options || "",
|
||||
manager: row.manager || "",
|
||||
remark: row.remark || "",
|
||||
};
|
||||
|
||||
// 코드 있으면 update, 없으면 insert (채번 할당)
|
||||
const inspectionCode = String(row.inspection_code || "").trim();
|
||||
if (inspectionCode) {
|
||||
// 기존 행 조회 후 upsert
|
||||
const existRes = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "inspection_code", operator: "equals", value: inspectionCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${INSPECTION_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, inspection_code: inspectionCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_code: inspectionCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 코드 자동 채번
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
inspection_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch (e) {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchInspections();
|
||||
};
|
||||
|
||||
/* ═══════════════════ 탭 2: 불량관리 엑셀업로드 ═══════════════════ */
|
||||
const defExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "불량관리",
|
||||
sheets: [
|
||||
{
|
||||
name: "불량관리",
|
||||
typeKey: "defect_standard_mng",
|
||||
columns: [
|
||||
{ key: "defect_code", label: "불량코드", type: "text", width: 14 },
|
||||
{
|
||||
key: "defect_type",
|
||||
label: "불량유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "defect_type") },
|
||||
width: 14,
|
||||
},
|
||||
{ key: "defect_name", label: "불량명", required: true, type: "text", width: 20 },
|
||||
{ key: "defect_content", label: "불량내용", required: true, type: "text", width: 24 },
|
||||
{
|
||||
key: "severity",
|
||||
label: "심각도",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "severity") },
|
||||
width: 12,
|
||||
},
|
||||
{ key: "inspection_type", label: "검사유형", required: true, type: "text", width: 22 },
|
||||
{ key: "apply_target", label: "적용대상", type: "text", width: 18 },
|
||||
{
|
||||
key: "is_active",
|
||||
label: "사용여부",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(DEFECT_TABLE, "is_active") },
|
||||
width: 10,
|
||||
},
|
||||
{
|
||||
key: "manager_id",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remarks", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const defDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
defect_type: catLabels(DEFECT_TABLE, "defect_type"),
|
||||
severity: catLabels(DEFECT_TABLE, "severity"),
|
||||
is_active: catLabels(DEFECT_TABLE, "is_active"),
|
||||
manager_id: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const defLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
defect_type: catLabelToCode(DEFECT_TABLE, "defect_type"),
|
||||
severity: catLabelToCode(DEFECT_TABLE, "severity"),
|
||||
is_active: catLabelToCode(DEFECT_TABLE, "is_active"),
|
||||
manager_id: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleDefExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${DEFECT_TABLE}/defect_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
// templateParser가 dropdown 컬럼은 이미 label→code 변환 완료. inspection_type/apply_target만 text라 수동 처리 + 계층 검증.
|
||||
const inspTypeMap = catLabelToCode(DEFECT_TABLE, "inspection_type");
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
// depth=1 부모(검사유형), depth=2 자식(적용대상)
|
||||
const parentLabels = defInspOpts.filter((o) => o.depth === 1).map((o) => o.label);
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// inspection_type 다중값: ,로 split → depth=1 라벨 검증 → 코드로 변환
|
||||
const rawTypes = String(row.inspection_type || "").trim();
|
||||
const typeLabelsArr = rawTypes
|
||||
? rawTypes.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const invalidTypes = typeLabelsArr.filter((label) => !parentLabels.includes(label));
|
||||
if (invalidTypes.length > 0) {
|
||||
failList.push(`${i + 2}행: 검사유형 미등록 값(${invalidTypes.join(", ")})`);
|
||||
continue;
|
||||
}
|
||||
const typeCodeList = typeLabelsArr.map((label) => inspTypeMap[label] || label);
|
||||
const typeCodes = typeCodeList.join(",");
|
||||
|
||||
// apply_target 다중값: 선택한 검사유형의 자식(depth=2)만 허용 → 자식 코드로 변환
|
||||
const rawTargets = String(row.apply_target || "").trim();
|
||||
const targetLabelsArr = rawTargets
|
||||
? rawTargets.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const targetCodeList: string[] = [];
|
||||
const invalidTargets: string[] = [];
|
||||
for (const label of targetLabelsArr) {
|
||||
const child = defInspOpts.find(
|
||||
(o) =>
|
||||
o.depth === 2 &&
|
||||
o.label === label &&
|
||||
o.parentCode &&
|
||||
typeCodeList.includes(o.parentCode),
|
||||
);
|
||||
if (child) targetCodeList.push(child.code);
|
||||
else invalidTargets.push(label);
|
||||
}
|
||||
if (invalidTargets.length > 0) {
|
||||
failList.push(
|
||||
`${i + 2}행: 적용대상이 선택한 검사유형의 하위가 아니거나 미등록(${invalidTargets.join(", ")})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const targetCodes = targetCodeList.join(",");
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
defect_type: row.defect_type || "",
|
||||
defect_name: row.defect_name || "",
|
||||
defect_content: row.defect_content || "",
|
||||
severity: row.severity || "",
|
||||
inspection_type: typeCodes,
|
||||
apply_target: targetCodes,
|
||||
is_active: row.is_active || "",
|
||||
manager_id: row.manager_id || "",
|
||||
remarks: row.remarks || "",
|
||||
};
|
||||
|
||||
const defectCode = String(row.defect_code || "").trim();
|
||||
if (defectCode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "defect_code", operator: "equals", value: defectCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${DEFECT_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, defect_code: defectCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
defect_code: defectCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${DEFECT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
defect_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchDefects();
|
||||
};
|
||||
|
||||
/* ═══════════════════ 탭 3: 검사장비 엑셀업로드 ═══════════════════ */
|
||||
const eqExcelConfig = useMemo<SmartExcelUploadConfig>(() => {
|
||||
return {
|
||||
templateName: "검사장비",
|
||||
sheets: [
|
||||
{
|
||||
name: "검사장비",
|
||||
typeKey: "inspection_equipment_mng",
|
||||
columns: [
|
||||
{ key: "equipment_code", label: "장비코드", type: "text", width: 14 },
|
||||
{ key: "equipment_name", label: "장비명", required: true, type: "text", width: 20 },
|
||||
{
|
||||
key: "equipment_type",
|
||||
label: "장비유형",
|
||||
required: true,
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_type") },
|
||||
width: 14,
|
||||
},
|
||||
{ key: "model_name", label: "모델명", type: "text", width: 16 },
|
||||
{ key: "manufacturer", label: "제조사", type: "text", width: 16 },
|
||||
{ key: "serial_number", label: "시리얼번호", type: "text", width: 16 },
|
||||
{ key: "installation_location", label: "설치위치", type: "text", width: 18 },
|
||||
{ key: "last_calibration_date", label: "최종교정일", type: "date", width: 14 },
|
||||
{ key: "calibration_period", label: "교정주기(개월)", type: "number", width: 14 },
|
||||
{
|
||||
key: "equipment_status",
|
||||
label: "장비상태",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: catLabels(EQUIPMENT_TABLE, "equipment_status") },
|
||||
width: 12,
|
||||
},
|
||||
{
|
||||
key: "manager_id",
|
||||
label: "관리자",
|
||||
type: "dropdown",
|
||||
dropdown: { source: "custom", values: userOptions.map((u) => u.label) },
|
||||
width: 18,
|
||||
},
|
||||
{ key: "remarks", label: "비고", type: "text", width: 20 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const eqDropdownOptions = useMemo<Record<string, string[]>>(() => {
|
||||
return {
|
||||
equipment_type: catLabels(EQUIPMENT_TABLE, "equipment_type"),
|
||||
equipment_status: catLabels(EQUIPMENT_TABLE, "equipment_status"),
|
||||
manager_id: userOptions.map((u) => u.label),
|
||||
};
|
||||
}, [catLabels, userOptions]);
|
||||
|
||||
const eqLabelToCodeMap = useMemo<Record<string, Record<string, string>>>(() => {
|
||||
const userMap: Record<string, string> = {};
|
||||
userOptions.forEach((u) => {
|
||||
userMap[u.label] = u.code;
|
||||
});
|
||||
return {
|
||||
equipment_type: catLabelToCode(EQUIPMENT_TABLE, "equipment_type"),
|
||||
equipment_status: catLabelToCode(EQUIPMENT_TABLE, "equipment_status"),
|
||||
manager_id: userMap,
|
||||
};
|
||||
}, [catLabelToCode, userOptions]);
|
||||
|
||||
const handleEqExcelUpload = async (data: ParsedSheetData[]) => {
|
||||
const rows = data[0]?.rows ?? [];
|
||||
if (rows.length === 0) {
|
||||
toast.error("업로드할 데이터가 없어요");
|
||||
return;
|
||||
}
|
||||
|
||||
let ruleId: string | null = null;
|
||||
try {
|
||||
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${EQUIPMENT_TABLE}/equipment_code`);
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
ruleId = ruleRes.data.data.ruleId;
|
||||
}
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
|
||||
let okCount = 0;
|
||||
const failList: string[] = [];
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
try {
|
||||
// calibration_period 숫자 검증
|
||||
const periodRaw = String(row.calibration_period ?? "").trim();
|
||||
if (periodRaw && isNaN(Number(periodRaw))) {
|
||||
failList.push(`${i + 2}행: 교정주기는 숫자만 입력해주세요`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// last_calibration_date 포맷 검증 (YYYY-MM-DD 기대)
|
||||
let calibDate = String(row.last_calibration_date ?? "").trim();
|
||||
if (calibDate) {
|
||||
// Date 객체로 변환되어 왔을 수도 있어 ISO 추출
|
||||
const d = new Date(calibDate);
|
||||
if (isNaN(d.getTime())) {
|
||||
failList.push(`${i + 2}행: 최종교정일 포맷 오류(${calibDate})`);
|
||||
continue;
|
||||
}
|
||||
calibDate = d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// row.{key}는 이미 코드 (templateParser가 labelToCodeMap으로 자동변환 완료).
|
||||
const payload: Record<string, any> = {
|
||||
equipment_name: row.equipment_name || "",
|
||||
equipment_type: row.equipment_type || "",
|
||||
model_name: row.model_name || "",
|
||||
manufacturer: row.manufacturer || "",
|
||||
serial_number: row.serial_number || "",
|
||||
installation_location: row.installation_location || "",
|
||||
last_calibration_date: calibDate,
|
||||
calibration_period: periodRaw,
|
||||
equipment_status: row.equipment_status || "",
|
||||
manager_id: row.manager_id || "",
|
||||
remarks: row.remarks || "",
|
||||
};
|
||||
|
||||
const equipmentCode = String(row.equipment_code || "").trim();
|
||||
if (equipmentCode) {
|
||||
const existRes = await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [{ columnName: "equipment_code", operator: "equals", value: equipmentCode }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const existing = existRes.data?.data?.data || existRes.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.put(`/table-management/tables/${EQUIPMENT_TABLE}/edit`, {
|
||||
originalData: { id: existing[0].id },
|
||||
updatedData: { ...payload, equipment_code: equipmentCode },
|
||||
});
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
equipment_code: equipmentCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let finalCode = "";
|
||||
if (ruleId) {
|
||||
const allocRes = await allocateNumberingCode(ruleId);
|
||||
if (allocRes.success && allocRes.data?.generatedCode) {
|
||||
finalCode = allocRes.data.generatedCode;
|
||||
}
|
||||
}
|
||||
await apiClient.post(`/table-management/tables/${EQUIPMENT_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
equipment_code: finalCode,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
okCount++;
|
||||
} catch {
|
||||
failList.push(`${i + 2}행: 저장 실패`);
|
||||
}
|
||||
}
|
||||
|
||||
if (okCount > 0) toast.success(`${okCount}건을 업로드했어요`);
|
||||
if (failList.length > 0) {
|
||||
toast.error(`실패 ${failList.length}건: ${failList.slice(0, 3).join(" / ")}${failList.length > 3 ? " …" : ""}`);
|
||||
}
|
||||
fetchEquipments();
|
||||
};
|
||||
|
||||
/* ═══════════════════ JSX ═══════════════════ */
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3 h-[calc(100vh-4rem)] overflow-auto">
|
||||
@@ -663,6 +1283,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setInspExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -725,6 +1349,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setDefExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -876,12 +1504,15 @@ export default function InspectionManagementPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={row.is_active === "사용" || row.is_active === "true" ? "default" : "secondary"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{row.is_active === "사용" || row.is_active === "true" ? "사용" : row.is_active || "미사용"}
|
||||
</Badge>
|
||||
{(() => {
|
||||
const label = getCatLabel(DEFECT_TABLE, "is_active", row.is_active) || row.is_active || "-";
|
||||
const isOn = row.is_active === "CAT_DA_01" || label === "사용";
|
||||
return (
|
||||
<Badge variant={isOn ? "default" : "secondary"} className="text-[10px]">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{row.reg_date || (row.created_date ? row.created_date.slice(0, 10) : "-")}
|
||||
@@ -919,6 +1550,10 @@ export default function InspectionManagementPage() {
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
등록
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEqExcelOpen(true)}>
|
||||
<Upload className="mr-1 h-4 w-4" />
|
||||
엑셀업로드
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1508,15 +2143,18 @@ export default function InspectionManagementPage() {
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">사용여부</Label>
|
||||
<Select
|
||||
value={defForm.is_active || "사용"}
|
||||
value={defForm.is_active || "CAT_DA_01"}
|
||||
onValueChange={(v) => setDefForm((p) => ({ ...p, is_active: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="사용">사용</SelectItem>
|
||||
<SelectItem value="미사용">미사용</SelectItem>
|
||||
{(catOptions[`${DEFECT_TABLE}.is_active`] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1771,6 +2409,101 @@ export default function InspectionManagementPage() {
|
||||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||||
onSave={ts.applySettings}
|
||||
/>
|
||||
|
||||
{/* ═══════════════════ 엑셀업로드 모달 ═══════════════════ */}
|
||||
<SmartExcelUploadModal
|
||||
open={inspExcelOpen}
|
||||
onOpenChange={setInspExcelOpen}
|
||||
config={inspExcelConfig}
|
||||
dropdownOptions={inspDropdownOptions}
|
||||
labelToCodeMap={inspLabelToCodeMap}
|
||||
onUpload={handleInspExcelUpload}
|
||||
customValidator={(data) => {
|
||||
// inspection_type 다중값(text) 카테고리 라벨 검증
|
||||
const errors: any[] = [];
|
||||
const validLabels = catLabels(INSPECTION_TABLE, "inspection_type");
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
const raw = String(row.inspection_type || "").trim();
|
||||
if (!raw) return;
|
||||
const labels = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const invalid = labels.filter((l) => !validLabels.includes(l));
|
||||
if (invalid.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "검사유형",
|
||||
message: `등록되지 않은 값: ${invalid.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
<SmartExcelUploadModal
|
||||
open={defExcelOpen}
|
||||
onOpenChange={setDefExcelOpen}
|
||||
config={defExcelConfig}
|
||||
dropdownOptions={defDropdownOptions}
|
||||
labelToCodeMap={defLabelToCodeMap}
|
||||
onUpload={handleDefExcelUpload}
|
||||
customValidator={(data) => {
|
||||
const errors: any[] = [];
|
||||
const defInspOpts = catOptions[`${DEFECT_TABLE}.inspection_type`] || [];
|
||||
const parentByLabel = new Map(defInspOpts.filter((o) => o.depth === 1).map((o) => [o.label, o.code]));
|
||||
for (const sheet of data) {
|
||||
sheet.rows.forEach((row, idx) => {
|
||||
// 검사유형(depth=1) 검증
|
||||
const rawType = String(row.inspection_type || "").trim();
|
||||
const typeLabels = rawType ? rawType.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const invalidTypes = typeLabels.filter((l) => !parentByLabel.has(l));
|
||||
if (invalidTypes.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "검사유형",
|
||||
message: `등록되지 않은 값: ${invalidTypes.join(", ")}`,
|
||||
});
|
||||
}
|
||||
// 적용대상(depth=2) 계층 검증 — 선택한 검사유형의 자식만 허용
|
||||
const rawTarget = String(row.apply_target || "").trim();
|
||||
if (!rawTarget) return;
|
||||
const targetLabels = rawTarget.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const selectedParentCodes = typeLabels
|
||||
.map((l) => parentByLabel.get(l))
|
||||
.filter(Boolean) as string[];
|
||||
const invalidTargets = targetLabels.filter((label) => {
|
||||
const child = defInspOpts.find(
|
||||
(o) =>
|
||||
o.depth === 2 &&
|
||||
o.label === label &&
|
||||
o.parentCode &&
|
||||
selectedParentCodes.includes(o.parentCode),
|
||||
);
|
||||
return !child;
|
||||
});
|
||||
if (invalidTargets.length > 0) {
|
||||
errors.push({
|
||||
sheet: sheet.sheetName,
|
||||
row: idx + 2,
|
||||
column: "적용대상",
|
||||
message: `선택한 검사유형의 하위가 아니거나 미등록: ${invalidTargets.join(", ")}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return errors;
|
||||
}}
|
||||
/>
|
||||
<SmartExcelUploadModal
|
||||
open={eqExcelOpen}
|
||||
onOpenChange={setEqExcelOpen}
|
||||
config={eqExcelConfig}
|
||||
dropdownOptions={eqDropdownOptions}
|
||||
labelToCodeMap={eqLabelToCodeMap}
|
||||
onUpload={handleEqExcelUpload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,102 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
const config: ReportConfig = {
|
||||
key: "sales_report_v2",
|
||||
title: "영업 리포트",
|
||||
description: "다중 조건 비교 분석",
|
||||
apiEndpoint: "/sales-report/data",
|
||||
metrics: [
|
||||
const GLASS_COMPANIES = new Set(["COMPANY_9", "COMPANY_30"]);
|
||||
|
||||
function buildConfig(isGlass: boolean): ReportConfig {
|
||||
const baseMetrics = [
|
||||
{ id: "orderAmt", name: "수주금액", unit: "원", color: "#3b82f6" },
|
||||
{ id: "orderQty", name: "수주수량", unit: "EA", color: "#10b981" },
|
||||
{ id: "shipQty", name: "출하수량", unit: "EA", color: "#ef4444" },
|
||||
{ id: "unitPrice", name: "단가", unit: "원", color: "#8b5cf6" },
|
||||
{ id: "orderCount", name: "수주건수", unit: "건", color: "#f59e0b" },
|
||||
];
|
||||
const glassMetrics = [
|
||||
{ id: "totalArea", name: "총 면적", unit: "㎡", color: "#0891b2" },
|
||||
{ id: "width", name: "가로", unit: "mm", color: "#14b8a6" },
|
||||
{ id: "height", name: "세로", unit: "mm", color: "#d946ef" },
|
||||
{ id: "thickness", name: "두께", unit: "mm", color: "#f97316" },
|
||||
{ id: "areaSingle", name: "면적(건당)", unit: "㎡", color: "#06b6d4" },
|
||||
],
|
||||
groupByOptions: [
|
||||
];
|
||||
|
||||
const baseGroupByOptions = [
|
||||
{ id: "customer", name: "거래처별" },
|
||||
{ id: "item", name: "품목별" },
|
||||
{ id: "status", name: "상태별" },
|
||||
{ id: "thickness", name: "두께별" },
|
||||
{ id: "size", name: "사이즈별 (가로×세로)" },
|
||||
{ id: "sizeRange", name: "사이즈 구간별" },
|
||||
{ id: "monthly", name: "월별" },
|
||||
{ id: "quarterly", name: "분기별" },
|
||||
{ id: "weekly", name: "주별" },
|
||||
{ id: "daily", name: "일별" },
|
||||
],
|
||||
// 자유 조합 그룹핑: 기본 그룹 + 아래 필드 중 여러 개 추가해서 조합
|
||||
groupableFields: [
|
||||
];
|
||||
const glassGroupByOptions = [
|
||||
{ id: "thickness", name: "두께별" },
|
||||
{ id: "size", name: "사이즈별 (가로×세로)" },
|
||||
{ id: "sizeRange", name: "사이즈 구간별" },
|
||||
];
|
||||
|
||||
const baseGroupableFields = [
|
||||
{ id: "customer", name: "거래처" },
|
||||
{ id: "item", name: "품목" },
|
||||
{ id: "status", name: "상태" },
|
||||
{ id: "thickness", name: "두께" },
|
||||
{ id: "size", name: "사이즈" },
|
||||
{ id: "sizeRange", name: "사이즈 구간" },
|
||||
{ id: "monthly", name: "월" },
|
||||
{ id: "quarterly", name: "분기" },
|
||||
{ id: "weekly", name: "주" },
|
||||
{ id: "daily", name: "일" },
|
||||
],
|
||||
defaultGroupBy: "customer",
|
||||
defaultMetrics: ["orderAmt"],
|
||||
thresholds: [
|
||||
{ id: "low", label: "목표 미달 ≤", defaultValue: 80, unit: "%" },
|
||||
{ id: "high", label: "목표 초과 ≥", defaultValue: 120, unit: "%" },
|
||||
],
|
||||
filterFieldDefs: [
|
||||
{ id: "customer", name: "거래처", type: "select", optionKey: "customers" },
|
||||
{ id: "item", name: "품목", type: "select", optionKey: "items" },
|
||||
{ id: "status", name: "상태", type: "select", optionKey: "statuses" },
|
||||
{ id: "thickness", name: "두께", type: "number" },
|
||||
{ id: "width", name: "가로", type: "number" },
|
||||
{ id: "height", name: "세로", type: "number" },
|
||||
{ id: "totalArea", name: "총 면적", type: "number" },
|
||||
{ id: "orderAmt", name: "수주금액", type: "number" },
|
||||
{ id: "orderQty", name: "수주수량", type: "number" },
|
||||
],
|
||||
drilldownColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
];
|
||||
const glassGroupableFields = [
|
||||
{ id: "thickness", name: "두께" },
|
||||
{ id: "size", name: "사이즈" },
|
||||
{ id: "sizeRange", name: "사이즈 구간" },
|
||||
];
|
||||
|
||||
const baseFilterFieldDefs = [
|
||||
{ id: "customer", name: "거래처", type: "select" as const, optionKey: "customers" },
|
||||
{ id: "item", name: "품목", type: "select" as const, optionKey: "items" },
|
||||
{ id: "status", name: "상태", type: "select" as const, optionKey: "statuses" },
|
||||
{ id: "orderAmt", name: "수주금액", type: "number" as const },
|
||||
{ id: "orderQty", name: "수주수량", type: "number" as const },
|
||||
];
|
||||
const glassFilterFieldDefs = [
|
||||
{ id: "thickness", name: "두께", type: "number" as const },
|
||||
{ id: "width", name: "가로", type: "number" as const },
|
||||
{ id: "height", name: "세로", type: "number" as const },
|
||||
{ id: "totalArea", name: "총 면적", type: "number" as const },
|
||||
];
|
||||
|
||||
const baseDrilldownColumns = [
|
||||
{ id: "date", name: "날짜", format: "date" as const },
|
||||
{ id: "order_no", name: "수주번호" },
|
||||
{ id: "customer", name: "거래처" },
|
||||
{ id: "item", name: "품목" },
|
||||
{ id: "width", name: "가로", align: "right" },
|
||||
{ id: "height", name: "세로", align: "right" },
|
||||
{ id: "thickness", name: "두께", align: "right" },
|
||||
{ id: "areaSingle", name: "면적(㎡)", align: "right", format: "number" },
|
||||
{ id: "status", name: "상태", format: "badge" },
|
||||
{ id: "orderQty", name: "수주수량", align: "right", format: "number" },
|
||||
{ id: "unitPrice", name: "단가", align: "right", format: "number" },
|
||||
{ id: "orderAmt", name: "수주금액", align: "right", format: "number" },
|
||||
{ id: "totalArea", name: "총면적(㎡)", align: "right", format: "number" },
|
||||
{ id: "shipQty", name: "출하수량", align: "right", format: "number" },
|
||||
],
|
||||
rawDataColumns: [
|
||||
{ id: "date", name: "날짜", format: "date" },
|
||||
{ id: "status", name: "상태", format: "badge" as const },
|
||||
{ id: "orderQty", name: "수주수량", align: "right" as const, format: "number" as const },
|
||||
{ id: "unitPrice", name: "단가", align: "right" as const, format: "number" as const },
|
||||
{ id: "orderAmt", name: "수주금액", align: "right" as const, format: "number" as const },
|
||||
{ id: "shipQty", name: "출하수량", align: "right" as const, format: "number" as const },
|
||||
];
|
||||
const glassDrilldownColumns = [
|
||||
{ id: "width", name: "가로", align: "right" as const },
|
||||
{ id: "height", name: "세로", align: "right" as const },
|
||||
{ id: "thickness", name: "두께", align: "right" as const },
|
||||
{ id: "areaSingle", name: "면적(㎡)", align: "right" as const, format: "number" as const },
|
||||
{ id: "totalArea", name: "총면적(㎡)", align: "right" as const, format: "number" as const },
|
||||
];
|
||||
|
||||
const baseRawDataColumns = [
|
||||
{ id: "date", name: "날짜", format: "date" as const },
|
||||
{ id: "order_no", name: "수주번호" },
|
||||
{ id: "customer", name: "거래처" },
|
||||
{ id: "part_code", name: "품목코드" },
|
||||
{ id: "item", name: "품목명" },
|
||||
{ id: "width", name: "가로", align: "right" },
|
||||
{ id: "height", name: "세로", align: "right" },
|
||||
{ id: "thickness", name: "두께", align: "right" },
|
||||
{ id: "areaSingle", name: "면적(㎡)", align: "right", format: "number" },
|
||||
{ id: "status", name: "상태", format: "badge" },
|
||||
{ id: "orderQty", name: "수주수량", align: "right", format: "number" },
|
||||
{ id: "unitPrice", name: "단가", align: "right", format: "number" },
|
||||
{ id: "orderAmt", name: "수주금액", align: "right", format: "number" },
|
||||
{ id: "totalArea", name: "총면적(㎡)", align: "right", format: "number" },
|
||||
{ id: "shipQty", name: "출하수량", align: "right", format: "number" },
|
||||
],
|
||||
emptyMessage: "수주 데이터가 없습니다",
|
||||
};
|
||||
{ id: "status", name: "상태", format: "badge" as const },
|
||||
{ id: "orderQty", name: "수주수량", align: "right" as const, format: "number" as const },
|
||||
{ id: "unitPrice", name: "단가", align: "right" as const, format: "number" as const },
|
||||
{ id: "orderAmt", name: "수주금액", align: "right" as const, format: "number" as const },
|
||||
{ id: "shipQty", name: "출하수량", align: "right" as const, format: "number" as const },
|
||||
];
|
||||
|
||||
return {
|
||||
key: "sales_report_v2",
|
||||
title: "영업 리포트",
|
||||
description: "다중 조건 비교 분석",
|
||||
apiEndpoint: "/sales-report/data",
|
||||
metrics: isGlass ? [...baseMetrics, ...glassMetrics] : baseMetrics,
|
||||
groupByOptions: isGlass ? [...baseGroupByOptions, ...glassGroupByOptions] : baseGroupByOptions,
|
||||
groupableFields: isGlass ? [...baseGroupableFields, ...glassGroupableFields] : baseGroupableFields,
|
||||
defaultGroupBy: "customer",
|
||||
defaultMetrics: ["orderAmt"],
|
||||
thresholds: [
|
||||
{ id: "low", label: "목표 미달 ≤", defaultValue: 80, unit: "%" },
|
||||
{ id: "high", label: "목표 초과 ≥", defaultValue: 120, unit: "%" },
|
||||
],
|
||||
filterFieldDefs: isGlass ? [...baseFilterFieldDefs, ...glassFilterFieldDefs] : baseFilterFieldDefs,
|
||||
drilldownColumns: isGlass
|
||||
? [...baseDrilldownColumns.slice(0, 4), ...glassDrilldownColumns.slice(0, 4), ...baseDrilldownColumns.slice(4), glassDrilldownColumns[4]]
|
||||
: baseDrilldownColumns,
|
||||
rawDataColumns: isGlass
|
||||
? [...baseRawDataColumns.slice(0, 5), ...glassDrilldownColumns.slice(0, 4), ...baseRawDataColumns.slice(5), glassDrilldownColumns[4]]
|
||||
: baseRawDataColumns,
|
||||
emptyMessage: "수주 데이터가 없습니다",
|
||||
};
|
||||
}
|
||||
|
||||
export default function SalesReportPage() {
|
||||
const { user } = useAuth();
|
||||
const companyCode = user?.companyCode || "";
|
||||
const config = useMemo(() => buildConfig(GLASS_COMPANIES.has(companyCode)), [companyCode]);
|
||||
return <ReportEngine config={config} />;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ import type {
|
||||
ValidationError,
|
||||
ItemProcessMapping,
|
||||
} from "./types";
|
||||
// ValidationError is used in customValidator prop signature
|
||||
export type { ValidationError };
|
||||
import { generateTemplate } from "./templateGenerator";
|
||||
import type { GenerateTemplateOptions } from "./templateGenerator";
|
||||
import { parseTemplate } from "./templateParser";
|
||||
@@ -60,6 +62,8 @@ export interface SmartExcelUploadModalProps {
|
||||
extraMeta?: Record<string, string>;
|
||||
/** 업로드 완료 콜백 */
|
||||
onUpload: (data: ParsedSheetData[]) => Promise<void>;
|
||||
/** 파싱 후 추가 검증 (text 타입 카테고리 검증 등 커스텀 로직) — ValidationError[] 반환, 빈 배열이면 통과 */
|
||||
customValidator?: (data: ParsedSheetData[]) => ValidationError[];
|
||||
/** 품목명 등 표시용 제목 */
|
||||
subtitle?: string;
|
||||
/** 데이터 로딩 중 여부 (외부에서 품목 등 로딩 시) */
|
||||
@@ -80,6 +84,7 @@ export function SmartExcelUploadModal({
|
||||
labelToCodeMap = {},
|
||||
extraMeta = {},
|
||||
onUpload,
|
||||
customValidator,
|
||||
subtitle,
|
||||
dataLoading = false,
|
||||
loadProgress,
|
||||
@@ -166,6 +171,16 @@ export function SmartExcelUploadModal({
|
||||
labelToCodeMap,
|
||||
};
|
||||
const result = await parseTemplate(parseOptions);
|
||||
|
||||
// 커스텀 검증 (text 타입 카테고리 등 parseTemplate가 못 잡는 항목)
|
||||
if (customValidator && result.data.length > 0) {
|
||||
const customErrors = customValidator(result.data);
|
||||
if (customErrors.length > 0) {
|
||||
result.errors = [...result.errors, ...customErrors];
|
||||
result.success = false;
|
||||
}
|
||||
}
|
||||
|
||||
setParseResult(result);
|
||||
|
||||
if (result.success) {
|
||||
|
||||
Reference in New Issue
Block a user