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:
kjs
2026-04-21 13:54:14 +09:00
parent e0b89036d0
commit a863427c4f
18 changed files with 5695 additions and 185 deletions
@@ -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>
);
}
+92 -63
View File
@@ -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) {