From ecb6321cb6b485e29227f10790f4cb273f22a5bc Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 14 Apr 2026 16:58:53 +0900 Subject: [PATCH 1/2] feat: Enhance inspection management and item inspection pages with selection options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated the inspection management page to include handling for selection options when the judgment criteria is of type "선택형". - Implemented logic to validate that at least one option is provided when the selection criteria is selected, improving user feedback with appropriate error messages. - Enhanced the item inspection page to support judgment criteria and selection options, allowing for more detailed inspection configurations. - Added functionality to dynamically load and display category options for judgment criteria and units, streamlining the user experience in setting up inspections. - These changes aim to improve the usability and functionality of the inspection management process across multiple company implementations. --- .../COMPANY_10/quality/inspection/page.tsx | 51 +- .../quality/item-inspection/page.tsx | 174 +- .../COMPANY_16/quality/inspection/page.tsx | 51 +- .../quality/item-inspection/page.tsx | 174 +- .../COMPANY_29/quality/inspection/page.tsx | 51 +- .../quality/item-inspection/page.tsx | 174 +- .../COMPANY_30/quality/inspection/page.tsx | 51 +- .../quality/item-inspection/page.tsx | 174 +- .../(main)/COMPANY_30/sales/order/page.tsx | 2398 ++++++----------- .../COMPANY_7/quality/inspection/page.tsx | 49 + .../quality/item-inspection/page.tsx | 174 +- .../COMPANY_8/quality/inspection/page.tsx | 51 +- .../quality/item-inspection/page.tsx | 174 +- .../COMPANY_9/quality/inspection/page.tsx | 51 +- .../quality/item-inspection/page.tsx | 174 +- .../app/(main)/COMPANY_9/sales/order/page.tsx | 2398 ++++++----------- .../ProcessWorkStandardComponent.tsx | 1 + .../components/DetailFormModal.tsx | 169 +- .../components/ItemProcessSelector.tsx | 6 +- .../components/WorkItemAddModal.tsx | 5 +- .../components/WorkItemDetailList.tsx | 5 +- .../components/WorkPhaseSection.tsx | 3 + .../hooks/useProcessWorkStandard.ts | 5 +- .../v2-process-work-standard/types.ts | 1 + 24 files changed, 3381 insertions(+), 3183 deletions(-) diff --git a/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx index 424993b0..86f01a94 100644 --- a/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/inspection/page.tsx @@ -136,7 +136,7 @@ export default function InspectionManagementPage() { await Promise.all( catList.map(async ({ table, col }) => { try { - const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_10`); + const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_7`); if (res.data?.data?.length > 0) { optMap[`${table}.${col}`] = flattenCategories(res.data.data); } @@ -330,6 +330,11 @@ export default function InspectionManagementPage() { toast.error("판단기준은 필수예요"); return; } + const judgmentLabel = (catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label; + if (judgmentLabel === "선택형" && !inspForm.selection_options?.trim()) { + toast.error("선택형은 옵션을 1개 이상 추가해주세요"); + return; + } setInspSaving(true); try { let finalCode = inspForm.inspection_code || ""; @@ -1178,6 +1183,50 @@ export default function InspectionManagementPage() { + {/* 선택형 옵션 입력 (판단기준이 선택형일 때만) */} + {(catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label === "선택형" && ( +
+ +
+
+ {(inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []).map((opt: string, idx: number) => ( + + {opt} + + + ))} +
+
+ { + if (e.key === "Enter" && !e.nativeEvent.isComposing) { + e.preventDefault(); + const v = (e.target as HTMLInputElement).value.trim(); + if (!v) return; + const existing = inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []; + if (existing.includes(v)) { toast.error("이미 존재하는 옵션입니다."); return; } + setInspForm((p: any) => ({ ...p, selection_options: [...existing, v].join(",") })); + (e.target as HTMLInputElement).value = ""; + } + }} + /> +
+

옵션명을 입력하고 Enter를 눌러 추가하세요. POP에서 이 옵션 중 선택하여 검사 결과를 입력합니다.

+
+
+ )} {/* 단위 */}
diff --git a/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx index d6aaaabe..d3b008d7 100644 --- a/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_10/quality/item-inspection/page.tsx @@ -49,6 +49,9 @@ type InspectionRow = { apply_process: string; acceptance_criteria: string; is_required: boolean; + judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형) + selection_options?: string; // 선택형일 때 옵션 (콤마 구분) + unit?: string; // 검사 단위 }; export default function ItemInspectionInfoPage() { @@ -74,15 +77,25 @@ export default function ItemInspectionInfoPage() { // FK 옵션 const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]); - const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]); + const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]); + const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]); + const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]); const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]); + // 품목 카테고리 코드→라벨 (type, inventory_unit) + const [itemCatMap, setItemCatMap] = useState>>({}); + const itemCatMapRef = React.useRef(itemCatMap); + itemCatMapRef.current = itemCatMap; + // 검사유형별 검사항목 rows (모달용) const [inspectionRows, setInspectionRows] = useState>({}); const [collapsedTypes, setCollapsedTypes] = useState>({}); + // 기본 라우팅 공정 목록 (적용공정 Select용) + const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]); + // 품목 선택 모달 const [itemModalOpen, setItemModalOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); @@ -116,9 +129,26 @@ export default function ItemInspectionInfoPage() { label: r.inspection_criteria || r.inspection_standard || r.id, detail: r.inspection_item || r.inspection_criteria || "", method: r.inspection_method || "", + judgment_criteria: r.judgment_criteria || "", + selection_options: r.selection_options || "", + unit: r.unit || "", types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [], }))); + // 품목 카테고리 (type, unit) + const catMap: Record> = {}; + for (const col of ["type", "unit", "inventory_unit"]) { + try { + const catRes = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (catRes.data?.success && catRes.data.data?.length) { + catMap[col] = {}; + const fl = (arr: any[]) => { for (const v of arr) { catMap[col][v.valueCode] = v.valueLabel; if (v.children?.length) fl(v.children); } }; + fl(catRes.data.data); + } + } catch { /* skip */ } + } + setItemCatMap(catMap); + // 검사유형 카테고리 try { const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`); @@ -137,6 +167,24 @@ export default function ItemInspectionInfoPage() { setInspMethodCatOptions(flatMethods); } catch { /* skip */ } + // 판단기준 카테고리 + try { + const jcRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/judgment_criteria/values`); + const flatJc: { code: string; label: string }[] = []; + const flattenJc = (arr: any[]) => { for (const v of arr) { flatJc.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenJc(v.children); } }; + if (jcRes.data?.data?.length) flattenJc(jcRes.data.data); + setJudgmentCatOptions(flatJc); + } catch { /* skip */ } + + // 검사 단위 카테고리 + try { + const unitRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/unit/values`); + const flatUnit: { code: string; label: string }[] = []; + const flattenU = (arr: any[]) => { for (const v of arr) { flatUnit.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenU(v.children); } }; + if (unitRes.data?.data?.length) flattenU(unitRes.data.data); + setInspUnitCatOptions(flatUnit); + } catch { /* skip */ } + const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; setUserOptions(users.map((u: any) => ({ code: u.user_id || u.id, @@ -147,6 +195,23 @@ export default function ItemInspectionInfoPage() { loadOptions(); }, []); + // 품목별 기본 라우팅 공정 로드 + const loadProcessOptions = async (itemCode: string) => { + try { + const res = await apiClient.get(`/work-instruction/__lookup__/routing-versions/${encodeURIComponent(itemCode)}`); + if (res.data?.success && res.data.data?.length > 0) { + const defaultVer = res.data.data.find((v: any) => v.is_default) || res.data.data[0]; + const procs = (defaultVer.processes || []).map((p: any) => ({ + code: p.process_code, + name: p.process_name || p.process_code, + })); + setProcessOptions(procs); + } else { + setProcessOptions([]); + } + } catch { setProcessOptions([]); } + }; + /* ═══════════════════ 품목 선택 모달 ═══════════════════ */ const searchItemServer = async (page?: number) => { const p = page ?? itemPage; @@ -163,15 +228,21 @@ export default function ItemInspectionInfoPage() { }); const resData = res.data?.data; const rows = resData?.data || resData?.rows || []; - setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: r.type || "", unit: r.inventory_unit || "" }))); + const cm = itemCatMapRef.current; + setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); setItemTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; const openItemModal = () => { setItemSearchKeyword(""); setItemPage(1); setItemModalOpen(true); searchItemServer(1); }; const handleItemSearch = () => { setItemPage(1); searchItemServer(1); }; const selectItem = (item: typeof itemOptions[0]) => { + if (groupedData.some(g => g.item_code === item.code)) { + toast.error(`"${item.name}" 은(는) 이미 등록된 품목입니다.`); + return; + } setForm(p => ({ ...p, item_code: item.code, item_name: item.name })); setItemModalOpen(false); + loadProcessOptions(item.code); }; /* ═══════════════════ 데이터 조회 ═══════════════════ */ @@ -242,6 +313,7 @@ export default function ItemInspectionInfoPage() { if (!code) { toast.error("수정할 항목을 선택해주세요"); return; } const group = groupedData.find(g => g.item_code === code); if (!group) return; + loadProcessOptions(code); const row = group.rows[0]; setForm({ ...row }); setEditMode(true); @@ -269,6 +341,12 @@ export default function ItemInspectionInfoPage() { if (!rowMap[typeKey]) rowMap[typeKey] = []; const mCode = r.inspection_method || ""; const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode; + // 판단기준/선택옵션/단위 resolve + const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id); + const jcCode = inspOpt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = inspOpt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; rowMap[typeKey].push({ id: r.id, inspection_standard_id: r.inspection_standard_id || "", @@ -277,6 +355,9 @@ export default function ItemInspectionInfoPage() { apply_process: "", acceptance_criteria: r.pass_criteria || "", is_required: r.is_required === "true" || r.is_required === true, + judgment_criteria: jcLabel, + selection_options: inspOpt?.selection_options || "", + unit: unitLabel, }); } setInspectionRows(rowMap); @@ -304,7 +385,22 @@ export default function ItemInspectionInfoPage() { const opt = inspOptions.find(o => o.code === value); const methodCode = opt?.method || ""; const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode; - return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel }; + // 판단기준 라벨 resolve + const jcCode = opt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + // 단위 라벨 resolve + const unitCode = opt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return { + ...r, + inspection_standard_id: value, + inspection_detail: opt?.detail || "", + inspection_method: methodLabel, + judgment_criteria: jcLabel, + selection_options: opt?.selection_options || "", + unit: unitLabel, + acceptance_criteria: "", // 판단기준 변경 시 초기화 + }; } return { ...r, [field]: value }; }), @@ -561,9 +657,12 @@ export default function ItemInspectionInfoPage() { {resolveMethodLabel(row.inspection_method)} {row.apply_process || "-"} - {row.judgment_criteria ? ( - {row.judgment_criteria} - ) : "-"} + {(() => { + const insp = inspOptions.find(o => o.code === row.inspection_standard_id); + const jcCode = insp?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + return jcLabel ? {jcLabel} : "-"; + })()} {row.pass_criteria || "-"} @@ -588,7 +687,7 @@ export default function ItemInspectionInfoPage() { {/* ═══════════════════ 등록/수정 모달 ═══════════════════ */} { if (!open) setItemModalOpen(false); setModalOpen(open); }}> - + {itemModalOpen ? ( <> @@ -729,17 +828,18 @@ export default function ItemInspectionInfoPage() { 검사기준 선택 - 검사항목 + 검사기준 상세 검사방법 - 적용공정 - 합격기준 + 적용공정 + 판단기준 + 합격기준 (판단기준별) 필수 {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -751,10 +851,58 @@ export default function ItemInspectionInfoPage() { - updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> + {processOptions.length > 0 ? ( + + ) : ( + updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> + )} + + + {row.judgment_criteria ? {row.judgment_criteria} : -} - updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} /> + {row.judgment_criteria === "선택형" && row.selection_options ? ( + + ) : row.judgment_criteria === "O/X" ? ( + + ) : row.judgment_criteria === "수치(범위)" ? ( +
+ { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[0] = e.target.value; + updateInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="기준값" disabled={!row.inspection_standard_id} /> + ± + { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[1] = e.target.value; + updateInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="오차" disabled={!row.inspection_standard_id} /> + {row.unit && {row.unit}} +
+ ) : ( + updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준 텍스트 입력" disabled={!row.inspection_standard_id} /> + )}
updateInspRow(key, row.id, "is_required", !!v)} /> diff --git a/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx index 61382072..86f01a94 100644 --- a/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_16/quality/inspection/page.tsx @@ -136,7 +136,7 @@ export default function InspectionManagementPage() { await Promise.all( catList.map(async ({ table, col }) => { try { - const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_16`); + const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_7`); if (res.data?.data?.length > 0) { optMap[`${table}.${col}`] = flattenCategories(res.data.data); } @@ -330,6 +330,11 @@ export default function InspectionManagementPage() { toast.error("판단기준은 필수예요"); return; } + const judgmentLabel = (catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label; + if (judgmentLabel === "선택형" && !inspForm.selection_options?.trim()) { + toast.error("선택형은 옵션을 1개 이상 추가해주세요"); + return; + } setInspSaving(true); try { let finalCode = inspForm.inspection_code || ""; @@ -1178,6 +1183,50 @@ export default function InspectionManagementPage() {
+ {/* 선택형 옵션 입력 (판단기준이 선택형일 때만) */} + {(catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label === "선택형" && ( +
+ +
+
+ {(inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []).map((opt: string, idx: number) => ( + + {opt} + + + ))} +
+
+ { + if (e.key === "Enter" && !e.nativeEvent.isComposing) { + e.preventDefault(); + const v = (e.target as HTMLInputElement).value.trim(); + if (!v) return; + const existing = inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []; + if (existing.includes(v)) { toast.error("이미 존재하는 옵션입니다."); return; } + setInspForm((p: any) => ({ ...p, selection_options: [...existing, v].join(",") })); + (e.target as HTMLInputElement).value = ""; + } + }} + /> +
+

옵션명을 입력하고 Enter를 눌러 추가하세요. POP에서 이 옵션 중 선택하여 검사 결과를 입력합니다.

+
+
+ )} {/* 단위 */}
diff --git a/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx index d6aaaabe..d3b008d7 100644 --- a/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_16/quality/item-inspection/page.tsx @@ -49,6 +49,9 @@ type InspectionRow = { apply_process: string; acceptance_criteria: string; is_required: boolean; + judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형) + selection_options?: string; // 선택형일 때 옵션 (콤마 구분) + unit?: string; // 검사 단위 }; export default function ItemInspectionInfoPage() { @@ -74,15 +77,25 @@ export default function ItemInspectionInfoPage() { // FK 옵션 const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]); - const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]); + const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]); + const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]); + const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]); const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]); + // 품목 카테고리 코드→라벨 (type, inventory_unit) + const [itemCatMap, setItemCatMap] = useState>>({}); + const itemCatMapRef = React.useRef(itemCatMap); + itemCatMapRef.current = itemCatMap; + // 검사유형별 검사항목 rows (모달용) const [inspectionRows, setInspectionRows] = useState>({}); const [collapsedTypes, setCollapsedTypes] = useState>({}); + // 기본 라우팅 공정 목록 (적용공정 Select용) + const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]); + // 품목 선택 모달 const [itemModalOpen, setItemModalOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); @@ -116,9 +129,26 @@ export default function ItemInspectionInfoPage() { label: r.inspection_criteria || r.inspection_standard || r.id, detail: r.inspection_item || r.inspection_criteria || "", method: r.inspection_method || "", + judgment_criteria: r.judgment_criteria || "", + selection_options: r.selection_options || "", + unit: r.unit || "", types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [], }))); + // 품목 카테고리 (type, unit) + const catMap: Record> = {}; + for (const col of ["type", "unit", "inventory_unit"]) { + try { + const catRes = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (catRes.data?.success && catRes.data.data?.length) { + catMap[col] = {}; + const fl = (arr: any[]) => { for (const v of arr) { catMap[col][v.valueCode] = v.valueLabel; if (v.children?.length) fl(v.children); } }; + fl(catRes.data.data); + } + } catch { /* skip */ } + } + setItemCatMap(catMap); + // 검사유형 카테고리 try { const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`); @@ -137,6 +167,24 @@ export default function ItemInspectionInfoPage() { setInspMethodCatOptions(flatMethods); } catch { /* skip */ } + // 판단기준 카테고리 + try { + const jcRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/judgment_criteria/values`); + const flatJc: { code: string; label: string }[] = []; + const flattenJc = (arr: any[]) => { for (const v of arr) { flatJc.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenJc(v.children); } }; + if (jcRes.data?.data?.length) flattenJc(jcRes.data.data); + setJudgmentCatOptions(flatJc); + } catch { /* skip */ } + + // 검사 단위 카테고리 + try { + const unitRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/unit/values`); + const flatUnit: { code: string; label: string }[] = []; + const flattenU = (arr: any[]) => { for (const v of arr) { flatUnit.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenU(v.children); } }; + if (unitRes.data?.data?.length) flattenU(unitRes.data.data); + setInspUnitCatOptions(flatUnit); + } catch { /* skip */ } + const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; setUserOptions(users.map((u: any) => ({ code: u.user_id || u.id, @@ -147,6 +195,23 @@ export default function ItemInspectionInfoPage() { loadOptions(); }, []); + // 품목별 기본 라우팅 공정 로드 + const loadProcessOptions = async (itemCode: string) => { + try { + const res = await apiClient.get(`/work-instruction/__lookup__/routing-versions/${encodeURIComponent(itemCode)}`); + if (res.data?.success && res.data.data?.length > 0) { + const defaultVer = res.data.data.find((v: any) => v.is_default) || res.data.data[0]; + const procs = (defaultVer.processes || []).map((p: any) => ({ + code: p.process_code, + name: p.process_name || p.process_code, + })); + setProcessOptions(procs); + } else { + setProcessOptions([]); + } + } catch { setProcessOptions([]); } + }; + /* ═══════════════════ 품목 선택 모달 ═══════════════════ */ const searchItemServer = async (page?: number) => { const p = page ?? itemPage; @@ -163,15 +228,21 @@ export default function ItemInspectionInfoPage() { }); const resData = res.data?.data; const rows = resData?.data || resData?.rows || []; - setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: r.type || "", unit: r.inventory_unit || "" }))); + const cm = itemCatMapRef.current; + setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); setItemTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; const openItemModal = () => { setItemSearchKeyword(""); setItemPage(1); setItemModalOpen(true); searchItemServer(1); }; const handleItemSearch = () => { setItemPage(1); searchItemServer(1); }; const selectItem = (item: typeof itemOptions[0]) => { + if (groupedData.some(g => g.item_code === item.code)) { + toast.error(`"${item.name}" 은(는) 이미 등록된 품목입니다.`); + return; + } setForm(p => ({ ...p, item_code: item.code, item_name: item.name })); setItemModalOpen(false); + loadProcessOptions(item.code); }; /* ═══════════════════ 데이터 조회 ═══════════════════ */ @@ -242,6 +313,7 @@ export default function ItemInspectionInfoPage() { if (!code) { toast.error("수정할 항목을 선택해주세요"); return; } const group = groupedData.find(g => g.item_code === code); if (!group) return; + loadProcessOptions(code); const row = group.rows[0]; setForm({ ...row }); setEditMode(true); @@ -269,6 +341,12 @@ export default function ItemInspectionInfoPage() { if (!rowMap[typeKey]) rowMap[typeKey] = []; const mCode = r.inspection_method || ""; const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode; + // 판단기준/선택옵션/단위 resolve + const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id); + const jcCode = inspOpt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = inspOpt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; rowMap[typeKey].push({ id: r.id, inspection_standard_id: r.inspection_standard_id || "", @@ -277,6 +355,9 @@ export default function ItemInspectionInfoPage() { apply_process: "", acceptance_criteria: r.pass_criteria || "", is_required: r.is_required === "true" || r.is_required === true, + judgment_criteria: jcLabel, + selection_options: inspOpt?.selection_options || "", + unit: unitLabel, }); } setInspectionRows(rowMap); @@ -304,7 +385,22 @@ export default function ItemInspectionInfoPage() { const opt = inspOptions.find(o => o.code === value); const methodCode = opt?.method || ""; const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode; - return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel }; + // 판단기준 라벨 resolve + const jcCode = opt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + // 단위 라벨 resolve + const unitCode = opt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return { + ...r, + inspection_standard_id: value, + inspection_detail: opt?.detail || "", + inspection_method: methodLabel, + judgment_criteria: jcLabel, + selection_options: opt?.selection_options || "", + unit: unitLabel, + acceptance_criteria: "", // 판단기준 변경 시 초기화 + }; } return { ...r, [field]: value }; }), @@ -561,9 +657,12 @@ export default function ItemInspectionInfoPage() { {resolveMethodLabel(row.inspection_method)} {row.apply_process || "-"} - {row.judgment_criteria ? ( - {row.judgment_criteria} - ) : "-"} + {(() => { + const insp = inspOptions.find(o => o.code === row.inspection_standard_id); + const jcCode = insp?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + return jcLabel ? {jcLabel} : "-"; + })()} {row.pass_criteria || "-"} @@ -588,7 +687,7 @@ export default function ItemInspectionInfoPage() { {/* ═══════════════════ 등록/수정 모달 ═══════════════════ */} { if (!open) setItemModalOpen(false); setModalOpen(open); }}> - + {itemModalOpen ? ( <> @@ -729,17 +828,18 @@ export default function ItemInspectionInfoPage() { 검사기준 선택 - 검사항목 + 검사기준 상세 검사방법 - 적용공정 - 합격기준 + 적용공정 + 판단기준 + 합격기준 (판단기준별) 필수 {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -751,10 +851,58 @@ export default function ItemInspectionInfoPage() { - updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> + {processOptions.length > 0 ? ( + + ) : ( + updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> + )} + + + {row.judgment_criteria ? {row.judgment_criteria} : -} - updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} /> + {row.judgment_criteria === "선택형" && row.selection_options ? ( + + ) : row.judgment_criteria === "O/X" ? ( + + ) : row.judgment_criteria === "수치(범위)" ? ( +
+ { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[0] = e.target.value; + updateInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="기준값" disabled={!row.inspection_standard_id} /> + ± + { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[1] = e.target.value; + updateInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="오차" disabled={!row.inspection_standard_id} /> + {row.unit && {row.unit}} +
+ ) : ( + updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준 텍스트 입력" disabled={!row.inspection_standard_id} /> + )}
updateInspRow(key, row.id, "is_required", !!v)} /> diff --git a/frontend/app/(main)/COMPANY_29/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_29/quality/inspection/page.tsx index 2fc9c242..86f01a94 100644 --- a/frontend/app/(main)/COMPANY_29/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_29/quality/inspection/page.tsx @@ -136,7 +136,7 @@ export default function InspectionManagementPage() { await Promise.all( catList.map(async ({ table, col }) => { try { - const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_29`); + const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_7`); if (res.data?.data?.length > 0) { optMap[`${table}.${col}`] = flattenCategories(res.data.data); } @@ -330,6 +330,11 @@ export default function InspectionManagementPage() { toast.error("판단기준은 필수예요"); return; } + const judgmentLabel = (catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label; + if (judgmentLabel === "선택형" && !inspForm.selection_options?.trim()) { + toast.error("선택형은 옵션을 1개 이상 추가해주세요"); + return; + } setInspSaving(true); try { let finalCode = inspForm.inspection_code || ""; @@ -1178,6 +1183,50 @@ export default function InspectionManagementPage() {
+ {/* 선택형 옵션 입력 (판단기준이 선택형일 때만) */} + {(catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label === "선택형" && ( +
+ +
+
+ {(inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []).map((opt: string, idx: number) => ( + + {opt} + + + ))} +
+
+ { + if (e.key === "Enter" && !e.nativeEvent.isComposing) { + e.preventDefault(); + const v = (e.target as HTMLInputElement).value.trim(); + if (!v) return; + const existing = inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []; + if (existing.includes(v)) { toast.error("이미 존재하는 옵션입니다."); return; } + setInspForm((p: any) => ({ ...p, selection_options: [...existing, v].join(",") })); + (e.target as HTMLInputElement).value = ""; + } + }} + /> +
+

옵션명을 입력하고 Enter를 눌러 추가하세요. POP에서 이 옵션 중 선택하여 검사 결과를 입력합니다.

+
+
+ )} {/* 단위 */}
diff --git a/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx index d6aaaabe..d3b008d7 100644 --- a/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_29/quality/item-inspection/page.tsx @@ -49,6 +49,9 @@ type InspectionRow = { apply_process: string; acceptance_criteria: string; is_required: boolean; + judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형) + selection_options?: string; // 선택형일 때 옵션 (콤마 구분) + unit?: string; // 검사 단위 }; export default function ItemInspectionInfoPage() { @@ -74,15 +77,25 @@ export default function ItemInspectionInfoPage() { // FK 옵션 const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]); - const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]); + const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]); + const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]); + const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]); const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]); + // 품목 카테고리 코드→라벨 (type, inventory_unit) + const [itemCatMap, setItemCatMap] = useState>>({}); + const itemCatMapRef = React.useRef(itemCatMap); + itemCatMapRef.current = itemCatMap; + // 검사유형별 검사항목 rows (모달용) const [inspectionRows, setInspectionRows] = useState>({}); const [collapsedTypes, setCollapsedTypes] = useState>({}); + // 기본 라우팅 공정 목록 (적용공정 Select용) + const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]); + // 품목 선택 모달 const [itemModalOpen, setItemModalOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); @@ -116,9 +129,26 @@ export default function ItemInspectionInfoPage() { label: r.inspection_criteria || r.inspection_standard || r.id, detail: r.inspection_item || r.inspection_criteria || "", method: r.inspection_method || "", + judgment_criteria: r.judgment_criteria || "", + selection_options: r.selection_options || "", + unit: r.unit || "", types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [], }))); + // 품목 카테고리 (type, unit) + const catMap: Record> = {}; + for (const col of ["type", "unit", "inventory_unit"]) { + try { + const catRes = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (catRes.data?.success && catRes.data.data?.length) { + catMap[col] = {}; + const fl = (arr: any[]) => { for (const v of arr) { catMap[col][v.valueCode] = v.valueLabel; if (v.children?.length) fl(v.children); } }; + fl(catRes.data.data); + } + } catch { /* skip */ } + } + setItemCatMap(catMap); + // 검사유형 카테고리 try { const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`); @@ -137,6 +167,24 @@ export default function ItemInspectionInfoPage() { setInspMethodCatOptions(flatMethods); } catch { /* skip */ } + // 판단기준 카테고리 + try { + const jcRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/judgment_criteria/values`); + const flatJc: { code: string; label: string }[] = []; + const flattenJc = (arr: any[]) => { for (const v of arr) { flatJc.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenJc(v.children); } }; + if (jcRes.data?.data?.length) flattenJc(jcRes.data.data); + setJudgmentCatOptions(flatJc); + } catch { /* skip */ } + + // 검사 단위 카테고리 + try { + const unitRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/unit/values`); + const flatUnit: { code: string; label: string }[] = []; + const flattenU = (arr: any[]) => { for (const v of arr) { flatUnit.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenU(v.children); } }; + if (unitRes.data?.data?.length) flattenU(unitRes.data.data); + setInspUnitCatOptions(flatUnit); + } catch { /* skip */ } + const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; setUserOptions(users.map((u: any) => ({ code: u.user_id || u.id, @@ -147,6 +195,23 @@ export default function ItemInspectionInfoPage() { loadOptions(); }, []); + // 품목별 기본 라우팅 공정 로드 + const loadProcessOptions = async (itemCode: string) => { + try { + const res = await apiClient.get(`/work-instruction/__lookup__/routing-versions/${encodeURIComponent(itemCode)}`); + if (res.data?.success && res.data.data?.length > 0) { + const defaultVer = res.data.data.find((v: any) => v.is_default) || res.data.data[0]; + const procs = (defaultVer.processes || []).map((p: any) => ({ + code: p.process_code, + name: p.process_name || p.process_code, + })); + setProcessOptions(procs); + } else { + setProcessOptions([]); + } + } catch { setProcessOptions([]); } + }; + /* ═══════════════════ 품목 선택 모달 ═══════════════════ */ const searchItemServer = async (page?: number) => { const p = page ?? itemPage; @@ -163,15 +228,21 @@ export default function ItemInspectionInfoPage() { }); const resData = res.data?.data; const rows = resData?.data || resData?.rows || []; - setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: r.type || "", unit: r.inventory_unit || "" }))); + const cm = itemCatMapRef.current; + setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); setItemTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; const openItemModal = () => { setItemSearchKeyword(""); setItemPage(1); setItemModalOpen(true); searchItemServer(1); }; const handleItemSearch = () => { setItemPage(1); searchItemServer(1); }; const selectItem = (item: typeof itemOptions[0]) => { + if (groupedData.some(g => g.item_code === item.code)) { + toast.error(`"${item.name}" 은(는) 이미 등록된 품목입니다.`); + return; + } setForm(p => ({ ...p, item_code: item.code, item_name: item.name })); setItemModalOpen(false); + loadProcessOptions(item.code); }; /* ═══════════════════ 데이터 조회 ═══════════════════ */ @@ -242,6 +313,7 @@ export default function ItemInspectionInfoPage() { if (!code) { toast.error("수정할 항목을 선택해주세요"); return; } const group = groupedData.find(g => g.item_code === code); if (!group) return; + loadProcessOptions(code); const row = group.rows[0]; setForm({ ...row }); setEditMode(true); @@ -269,6 +341,12 @@ export default function ItemInspectionInfoPage() { if (!rowMap[typeKey]) rowMap[typeKey] = []; const mCode = r.inspection_method || ""; const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode; + // 판단기준/선택옵션/단위 resolve + const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id); + const jcCode = inspOpt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = inspOpt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; rowMap[typeKey].push({ id: r.id, inspection_standard_id: r.inspection_standard_id || "", @@ -277,6 +355,9 @@ export default function ItemInspectionInfoPage() { apply_process: "", acceptance_criteria: r.pass_criteria || "", is_required: r.is_required === "true" || r.is_required === true, + judgment_criteria: jcLabel, + selection_options: inspOpt?.selection_options || "", + unit: unitLabel, }); } setInspectionRows(rowMap); @@ -304,7 +385,22 @@ export default function ItemInspectionInfoPage() { const opt = inspOptions.find(o => o.code === value); const methodCode = opt?.method || ""; const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode; - return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel }; + // 판단기준 라벨 resolve + const jcCode = opt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + // 단위 라벨 resolve + const unitCode = opt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return { + ...r, + inspection_standard_id: value, + inspection_detail: opt?.detail || "", + inspection_method: methodLabel, + judgment_criteria: jcLabel, + selection_options: opt?.selection_options || "", + unit: unitLabel, + acceptance_criteria: "", // 판단기준 변경 시 초기화 + }; } return { ...r, [field]: value }; }), @@ -561,9 +657,12 @@ export default function ItemInspectionInfoPage() { {resolveMethodLabel(row.inspection_method)} {row.apply_process || "-"} - {row.judgment_criteria ? ( - {row.judgment_criteria} - ) : "-"} + {(() => { + const insp = inspOptions.find(o => o.code === row.inspection_standard_id); + const jcCode = insp?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + return jcLabel ? {jcLabel} : "-"; + })()} {row.pass_criteria || "-"} @@ -588,7 +687,7 @@ export default function ItemInspectionInfoPage() { {/* ═══════════════════ 등록/수정 모달 ═══════════════════ */} { if (!open) setItemModalOpen(false); setModalOpen(open); }}> - + {itemModalOpen ? ( <> @@ -729,17 +828,18 @@ export default function ItemInspectionInfoPage() { 검사기준 선택 - 검사항목 + 검사기준 상세 검사방법 - 적용공정 - 합격기준 + 적용공정 + 판단기준 + 합격기준 (판단기준별) 필수 {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -751,10 +851,58 @@ export default function ItemInspectionInfoPage() { - updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> + {processOptions.length > 0 ? ( + + ) : ( + updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> + )} + + + {row.judgment_criteria ? {row.judgment_criteria} : -} - updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} /> + {row.judgment_criteria === "선택형" && row.selection_options ? ( + + ) : row.judgment_criteria === "O/X" ? ( + + ) : row.judgment_criteria === "수치(범위)" ? ( +
+ { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[0] = e.target.value; + updateInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="기준값" disabled={!row.inspection_standard_id} /> + ± + { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[1] = e.target.value; + updateInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="오차" disabled={!row.inspection_standard_id} /> + {row.unit && {row.unit}} +
+ ) : ( + updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준 텍스트 입력" disabled={!row.inspection_standard_id} /> + )}
updateInspRow(key, row.id, "is_required", !!v)} /> diff --git a/frontend/app/(main)/COMPANY_30/quality/inspection/page.tsx b/frontend/app/(main)/COMPANY_30/quality/inspection/page.tsx index d9464ff5..86f01a94 100644 --- a/frontend/app/(main)/COMPANY_30/quality/inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_30/quality/inspection/page.tsx @@ -136,7 +136,7 @@ export default function InspectionManagementPage() { await Promise.all( catList.map(async ({ table, col }) => { try { - const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_30`); + const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_7`); if (res.data?.data?.length > 0) { optMap[`${table}.${col}`] = flattenCategories(res.data.data); } @@ -330,6 +330,11 @@ export default function InspectionManagementPage() { toast.error("판단기준은 필수예요"); return; } + const judgmentLabel = (catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label; + if (judgmentLabel === "선택형" && !inspForm.selection_options?.trim()) { + toast.error("선택형은 옵션을 1개 이상 추가해주세요"); + return; + } setInspSaving(true); try { let finalCode = inspForm.inspection_code || ""; @@ -1178,6 +1183,50 @@ export default function InspectionManagementPage() {
+ {/* 선택형 옵션 입력 (판단기준이 선택형일 때만) */} + {(catOptions[`${INSPECTION_TABLE}.judgment_criteria`] || []).find((o) => o.code === inspForm.judgment_criteria)?.label === "선택형" && ( +
+ +
+
+ {(inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []).map((opt: string, idx: number) => ( + + {opt} + + + ))} +
+
+ { + if (e.key === "Enter" && !e.nativeEvent.isComposing) { + e.preventDefault(); + const v = (e.target as HTMLInputElement).value.trim(); + if (!v) return; + const existing = inspForm.selection_options ? inspForm.selection_options.split(",").filter(Boolean) : []; + if (existing.includes(v)) { toast.error("이미 존재하는 옵션입니다."); return; } + setInspForm((p: any) => ({ ...p, selection_options: [...existing, v].join(",") })); + (e.target as HTMLInputElement).value = ""; + } + }} + /> +
+

옵션명을 입력하고 Enter를 눌러 추가하세요. POP에서 이 옵션 중 선택하여 검사 결과를 입력합니다.

+
+
+ )} {/* 단위 */}
diff --git a/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx b/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx index d6aaaabe..d3b008d7 100644 --- a/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx +++ b/frontend/app/(main)/COMPANY_30/quality/item-inspection/page.tsx @@ -49,6 +49,9 @@ type InspectionRow = { apply_process: string; acceptance_criteria: string; is_required: boolean; + judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형) + selection_options?: string; // 선택형일 때 옵션 (콤마 구분) + unit?: string; // 검사 단위 }; export default function ItemInspectionInfoPage() { @@ -74,15 +77,25 @@ export default function ItemInspectionInfoPage() { // FK 옵션 const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]); - const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; types: string[] }[]>([]); + const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]); const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]); const [inspMethodCatOptions, setInspMethodCatOptions] = useState<{ code: string; label: string }[]>([]); + const [judgmentCatOptions, setJudgmentCatOptions] = useState<{ code: string; label: string }[]>([]); + const [inspUnitCatOptions, setInspUnitCatOptions] = useState<{ code: string; label: string }[]>([]); const [userOptions, setUserOptions] = useState<{ code: string; label: string }[]>([]); + // 품목 카테고리 코드→라벨 (type, inventory_unit) + const [itemCatMap, setItemCatMap] = useState>>({}); + const itemCatMapRef = React.useRef(itemCatMap); + itemCatMapRef.current = itemCatMap; + // 검사유형별 검사항목 rows (모달용) const [inspectionRows, setInspectionRows] = useState>({}); const [collapsedTypes, setCollapsedTypes] = useState>({}); + // 기본 라우팅 공정 목록 (적용공정 Select용) + const [processOptions, setProcessOptions] = useState<{ code: string; name: string }[]>([]); + // 품목 선택 모달 const [itemModalOpen, setItemModalOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); @@ -116,9 +129,26 @@ export default function ItemInspectionInfoPage() { label: r.inspection_criteria || r.inspection_standard || r.id, detail: r.inspection_item || r.inspection_criteria || "", method: r.inspection_method || "", + judgment_criteria: r.judgment_criteria || "", + selection_options: r.selection_options || "", + unit: r.unit || "", types: r.inspection_type ? r.inspection_type.split(",").filter(Boolean) : [], }))); + // 품목 카테고리 (type, unit) + const catMap: Record> = {}; + for (const col of ["type", "unit", "inventory_unit"]) { + try { + const catRes = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (catRes.data?.success && catRes.data.data?.length) { + catMap[col] = {}; + const fl = (arr: any[]) => { for (const v of arr) { catMap[col][v.valueCode] = v.valueLabel; if (v.children?.length) fl(v.children); } }; + fl(catRes.data.data); + } + } catch { /* skip */ } + } + setItemCatMap(catMap); + // 검사유형 카테고리 try { const catRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/inspection_type/values`); @@ -137,6 +167,24 @@ export default function ItemInspectionInfoPage() { setInspMethodCatOptions(flatMethods); } catch { /* skip */ } + // 판단기준 카테고리 + try { + const jcRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/judgment_criteria/values`); + const flatJc: { code: string; label: string }[] = []; + const flattenJc = (arr: any[]) => { for (const v of arr) { flatJc.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenJc(v.children); } }; + if (jcRes.data?.data?.length) flattenJc(jcRes.data.data); + setJudgmentCatOptions(flatJc); + } catch { /* skip */ } + + // 검사 단위 카테고리 + try { + const unitRes = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/unit/values`); + const flatUnit: { code: string; label: string }[] = []; + const flattenU = (arr: any[]) => { for (const v of arr) { flatUnit.push({ code: v.valueCode, label: v.valueLabel }); if (v.children?.length) flattenU(v.children); } }; + if (unitRes.data?.data?.length) flattenU(unitRes.data.data); + setInspUnitCatOptions(flatUnit); + } catch { /* skip */ } + const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; setUserOptions(users.map((u: any) => ({ code: u.user_id || u.id, @@ -147,6 +195,23 @@ export default function ItemInspectionInfoPage() { loadOptions(); }, []); + // 품목별 기본 라우팅 공정 로드 + const loadProcessOptions = async (itemCode: string) => { + try { + const res = await apiClient.get(`/work-instruction/__lookup__/routing-versions/${encodeURIComponent(itemCode)}`); + if (res.data?.success && res.data.data?.length > 0) { + const defaultVer = res.data.data.find((v: any) => v.is_default) || res.data.data[0]; + const procs = (defaultVer.processes || []).map((p: any) => ({ + code: p.process_code, + name: p.process_name || p.process_code, + })); + setProcessOptions(procs); + } else { + setProcessOptions([]); + } + } catch { setProcessOptions([]); } + }; + /* ═══════════════════ 품목 선택 모달 ═══════════════════ */ const searchItemServer = async (page?: number) => { const p = page ?? itemPage; @@ -163,15 +228,21 @@ export default function ItemInspectionInfoPage() { }); const resData = res.data?.data; const rows = resData?.data || resData?.rows || []; - setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: r.type || "", unit: r.inventory_unit || "" }))); + const cm = itemCatMapRef.current; + setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" }))); setItemTotal(resData?.total || resData?.totalCount || rows.length); } catch { /* skip */ } finally { setItemSearchLoading(false); } }; const openItemModal = () => { setItemSearchKeyword(""); setItemPage(1); setItemModalOpen(true); searchItemServer(1); }; const handleItemSearch = () => { setItemPage(1); searchItemServer(1); }; const selectItem = (item: typeof itemOptions[0]) => { + if (groupedData.some(g => g.item_code === item.code)) { + toast.error(`"${item.name}" 은(는) 이미 등록된 품목입니다.`); + return; + } setForm(p => ({ ...p, item_code: item.code, item_name: item.name })); setItemModalOpen(false); + loadProcessOptions(item.code); }; /* ═══════════════════ 데이터 조회 ═══════════════════ */ @@ -242,6 +313,7 @@ export default function ItemInspectionInfoPage() { if (!code) { toast.error("수정할 항목을 선택해주세요"); return; } const group = groupedData.find(g => g.item_code === code); if (!group) return; + loadProcessOptions(code); const row = group.rows[0]; setForm({ ...row }); setEditMode(true); @@ -269,6 +341,12 @@ export default function ItemInspectionInfoPage() { if (!rowMap[typeKey]) rowMap[typeKey] = []; const mCode = r.inspection_method || ""; const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode; + // 판단기준/선택옵션/단위 resolve + const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id); + const jcCode = inspOpt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + const unitCode = inspOpt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; rowMap[typeKey].push({ id: r.id, inspection_standard_id: r.inspection_standard_id || "", @@ -277,6 +355,9 @@ export default function ItemInspectionInfoPage() { apply_process: "", acceptance_criteria: r.pass_criteria || "", is_required: r.is_required === "true" || r.is_required === true, + judgment_criteria: jcLabel, + selection_options: inspOpt?.selection_options || "", + unit: unitLabel, }); } setInspectionRows(rowMap); @@ -304,7 +385,22 @@ export default function ItemInspectionInfoPage() { const opt = inspOptions.find(o => o.code === value); const methodCode = opt?.method || ""; const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode; - return { ...r, inspection_standard_id: value, inspection_detail: opt?.detail || "", inspection_method: methodLabel }; + // 판단기준 라벨 resolve + const jcCode = opt?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + // 단위 라벨 resolve + const unitCode = opt?.unit || ""; + const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode; + return { + ...r, + inspection_standard_id: value, + inspection_detail: opt?.detail || "", + inspection_method: methodLabel, + judgment_criteria: jcLabel, + selection_options: opt?.selection_options || "", + unit: unitLabel, + acceptance_criteria: "", // 판단기준 변경 시 초기화 + }; } return { ...r, [field]: value }; }), @@ -561,9 +657,12 @@ export default function ItemInspectionInfoPage() { {resolveMethodLabel(row.inspection_method)} {row.apply_process || "-"} - {row.judgment_criteria ? ( - {row.judgment_criteria} - ) : "-"} + {(() => { + const insp = inspOptions.find(o => o.code === row.inspection_standard_id); + const jcCode = insp?.judgment_criteria || ""; + const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode; + return jcLabel ? {jcLabel} : "-"; + })()} {row.pass_criteria || "-"} @@ -588,7 +687,7 @@ export default function ItemInspectionInfoPage() { {/* ═══════════════════ 등록/수정 모달 ═══════════════════ */} { if (!open) setItemModalOpen(false); setModalOpen(open); }}> - + {itemModalOpen ? ( <> @@ -729,17 +828,18 @@ export default function ItemInspectionInfoPage() { 검사기준 선택 - 검사항목 + 검사기준 상세 검사방법 - 적용공정 - 합격기준 + 적용공정 + 판단기준 + 합격기준 (판단기준별) 필수 {(!inspectionRows[key] || inspectionRows[key].length === 0) ? ( - 항목추가 버튼으로 검사항목을 추가하세요 + 항목추가 버튼으로 검사항목을 추가하세요 ) : inspectionRows[key].map((row) => ( @@ -751,10 +851,58 @@ export default function ItemInspectionInfoPage() { - updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> + {processOptions.length > 0 ? ( + + ) : ( + updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" /> + )} + + + {row.judgment_criteria ? {row.judgment_criteria} : -} - updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} /> + {row.judgment_criteria === "선택형" && row.selection_options ? ( + + ) : row.judgment_criteria === "O/X" ? ( + + ) : row.judgment_criteria === "수치(범위)" ? ( +
+ { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[0] = e.target.value; + updateInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="기준값" disabled={!row.inspection_standard_id} /> + ± + { + const parts = (row.acceptance_criteria || "||").split("|"); + parts[1] = e.target.value; + updateInspRow(key, row.id, "acceptance_criteria", parts.join("|")); + }} placeholder="오차" disabled={!row.inspection_standard_id} /> + {row.unit && {row.unit}} +
+ ) : ( + updateInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준 텍스트 입력" disabled={!row.inspection_standard_id} /> + )}
updateInspRow(key, row.id, "is_required", !!v)} /> diff --git a/frontend/app/(main)/COMPANY_30/sales/order/page.tsx b/frontend/app/(main)/COMPANY_30/sales/order/page.tsx index 07f00fce..6f4502bd 100644 --- a/frontend/app/(main)/COMPANY_30/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_30/sales/order/page.tsx @@ -1,39 +1,48 @@ "use client"; +/** + * 제일그라스(COMPANY_9) 수주관리 — 하드코딩 페이지 + * + * 좌측: 수주 마스터 목록 (order_no 그룹핑 집계) + * 우측: 선택한 수주의 품목 상세 (sales_order_detail) + * + * 특화: 가로/세로/두께/면적 컬럼 (유리 업종) + * 특화: 품목 자동 등록 (item_info에 없으면 저장 시 자동 INSERT) + */ + import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; -import { Checkbox } from "@/components/ui/checkbox"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { - Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, - ClipboardList, Pencil, Search, X, Truck, Package, - ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, - Settings2, RotateCcw, Filter, Check, ArrowUp, ArrowDown, + Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, + ClipboardList, Package, Search, X, Settings2, GripVertical, } from "lucide-react"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; -import { useConfirmDialog } from "@/components/common/ConfirmDialog"; -import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; -import { exportToExcel } from "@/lib/utils/excelExport"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; -import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal"; -import { useTableSettings } from "@/hooks/useTableSettings"; -import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; -const DETAIL_TABLE = "sales_order_detail"; const MASTER_TABLE = "sales_order_mng"; +const DETAIL_TABLE = "sales_order_detail"; +const ITEM_TABLE = "item_info"; -// 천단위 구분자 표시용 const formatNumber = (val: string) => { const num = val.replace(/[^\d.-]/g, ""); if (!num) return ""; @@ -43,273 +52,161 @@ const formatNumber = (val: string) => { }; const parseNumber = (val: string) => val.replace(/,/g, ""); -// 플랫 테이블 컬럼 정의 (마스터+디테일 통합) -const FLAT_COLUMNS = [ - { key: "order_no", label: "수주번호", source: "master" }, - { key: "partner_id", label: "거래처", source: "master" }, - { key: "order_date", label: "수주일", source: "master" }, - { key: "part_code", label: "품번", source: "detail" }, - { key: "part_name", label: "품명", source: "detail" }, - { key: "spec", label: "규격", source: "detail" }, - { key: "unit", label: "단위", source: "detail" }, - { key: "qty", label: "수량", source: "detail" }, - { key: "ship_qty", label: "출하수량", source: "detail" }, - { key: "balance_qty", label: "잔량", source: "detail" }, - { key: "unit_price", label: "단가", source: "detail" }, - { key: "amount", label: "금액", source: "detail" }, - { key: "due_date", label: "납기일", source: "detail" }, - { key: "memo", label: "메모", source: "master" }, +// 좌측: 수주 마스터 집계 목록 +const LEFT_COLUMNS: DataGridColumn[] = [ + { key: "order_no", label: "수주번호", width: "w-[120px]" }, + { key: "partner_name", label: "거래처", minWidth: "min-w-[100px]" }, + { key: "item_count", label: "품목수", width: "w-[60px]", align: "right" }, + { key: "total_qty", label: "총수량", width: "w-[80px]", formatNumber: true, align: "right" }, + { key: "total_ship_qty", label: "출하량", width: "w-[70px]", formatNumber: true, align: "right" }, + { key: "total_balance", label: "잔량", width: "w-[70px]", formatNumber: true, align: "right" }, + { key: "total_amount", label: "총금액", width: "w-[100px]", formatNumber: true, align: "right" }, + { key: "due_date", label: "납기일", width: "w-[100px]" }, + { key: "status", label: "상태", width: "w-[70px]" }, ]; -const DETAIL_HEADER_COLS = FLAT_COLUMNS.filter((c) => c.source === "detail"); +// 우측: 품목 상세 (가로/세로/두께/면적 포함) +const RIGHT_COLUMNS: DataGridColumn[] = [ + { key: "division", label: "구분", width: "w-[70px]" }, + { key: "part_name", label: "품명", minWidth: "min-w-[120px]" }, + { key: "spec", label: "규격", width: "w-[100px]" }, + { key: "width", label: "가로", width: "w-[65px]", formatNumber: true, align: "right" }, + { key: "height", label: "세로", width: "w-[65px]", formatNumber: true, align: "right" }, + { key: "thickness", label: "두께", width: "w-[60px]", align: "right" }, + { key: "area", label: "면적", width: "w-[70px]", align: "right" }, + { key: "unit", label: "단위", width: "w-[50px]" }, + { key: "qty", label: "수량", width: "w-[70px]", formatNumber: true, align: "right" }, + { key: "ship_qty", label: "출하", width: "w-[60px]", formatNumber: true, align: "right" }, + { key: "balance_qty", label: "잔량", width: "w-[60px]", formatNumber: true, align: "right" }, + { key: "unit_price", label: "단가", width: "w-[85px]", formatNumber: true, align: "right" }, + { key: "amount", label: "금액", width: "w-[95px]", formatNumber: true, align: "right" }, + { key: "due_date", label: "납기일", width: "w-[100px]" }, + { key: "memo", label: "비고", width: "w-[80px]" }, +]; -// 필터용 전체 키 -const GRID_COLUMNS_CONFIG = FLAT_COLUMNS.map(({ key, label }) => ({ key, label })); - -// 총 컬럼 수: 체크박스(1) + 플랫 컬럼(14) = 15 -const TOTAL_COLS = 15; - -// 헤더 필터 Popover -function HeaderFilterPopover({ - colKey, colLabel, uniqueValues, filterValues, onToggle, onClear, -}: { - colKey: string; - colLabel: string; - uniqueValues: string[]; - filterValues: Set; - onToggle: (colKey: string, value: string) => void; - onClear: (colKey: string) => void; -}) { - const [filterSearch, setFilterSearch] = useState(""); - const hasFilter = filterValues.size > 0; - const filteredValues = uniqueValues.filter( - (v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase()) - ); - - return ( - - - - - e.stopPropagation()}> -
-
- 필터: {colLabel} - {hasFilter && ( - - )} -
-
- - setFilterSearch(e.target.value)} - placeholder="검색..." - className="h-7 text-xs pl-7" - /> -
-
- {filteredValues.slice(0, 100).map((val) => { - const isSelected = filterValues.has(val); - return ( -
onToggle(colKey, val)} - > -
- {isSelected && } -
- {val || "(빈 값)"} -
- ); - })} - {filteredValues.length > 100 && ( -
- ...외 {filteredValues.length - 100}개 -
- )} -
-
-
-
- ); -} - -export default function SalesOrderPage() { +export default function JeilGlassOrderPage() { const { user } = useAuth(); const { confirm, ConfirmDialogComponent } = useConfirmDialog(); - const [orders, setOrders] = useState([]); + + // 좌측: 수주 목록 + const [masterOrders, setMasterOrders] = useState([]); + const [allDetails, setAllDetails] = useState([]); const [loading, setLoading] = useState(false); const [totalCount, setTotalCount] = useState(0); - - // 페이지네이션 - const [currentPage, setCurrentPage] = useState(1); - const [pageSize, setPageSize] = useState(20); - const [pageSizeInput, setPageSizeInput] = useState("20"); - - // 검색 필터 (DynamicSearchFilter에서 관리) const [searchFilters, setSearchFilters] = useState([]); + const [selectedOrderNo, setSelectedOrderNo] = useState(null); + const [checkedIds, setCheckedIds] = useState([]); + + // 우측: 디테일 + const [detailItems, setDetailItems] = useState([]); + const [detailLoading, setDetailLoading] = useState(false); // 모달 const [isModalOpen, setIsModalOpen] = useState(false); const [isEditMode, setIsEditMode] = useState(false); const [saving, setSaving] = useState(false); const [masterForm, setMasterForm] = useState>({}); - const [detailRows, setDetailRows] = useState([]); - const [allowPriceEdit, setAllowPriceEdit] = useState(true); - - // 수주번호 자동 채번 - const [orderNoRuleId, setOrderNoRuleId] = useState(null); - const [orderNoPreview, setOrderNoPreview] = useState(null); + const [modalDetailRows, setModalDetailRows] = useState([]); // 품목 선택 모달 const [itemSelectOpen, setItemSelectOpen] = useState(false); const [itemSearchKeyword, setItemSearchKeyword] = useState(""); const [itemSearchResults, setItemSearchResults] = useState([]); const [itemSearchLoading, setItemSearchLoading] = useState(false); - const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); - const [itemSearchDivision, setItemSearchDivision] = useState("all"); - const [itemPage, setItemPage] = useState(1); - const [itemPageSize, setItemPageSize] = useState(20); - const [itemTotalPages, setItemTotalPages] = useState(0); - const [itemTotal, setItemTotal] = useState(0); - const [itemPageInput, setItemPageInput] = useState("1"); + const [itemCheckedIds, setItemCheckedIds] = useState>(new Set()); - // 엑셀 업로드 + // 기타 const [excelUploadOpen, setExcelUploadOpen] = useState(false); - - // 출하계획 모달 - const [shippingPlanOpen, setShippingPlanOpen] = useState(false); - - // 카테고리 옵션 + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); const [categoryOptions, setCategoryOptions] = useState>({}); - const [isCategoriesLoaded, setIsCategoriesLoaded] = useState(false); - // 체크된 행 (다중선택) - const [checkedIds, setCheckedIds] = useState([]); - - // 납품처 목록 - const [deliveryOptions, setDeliveryOptions] = useState<{ code: string; label: string }[]>([]); + // 채번 + const [numberingRuleId, setNumberingRuleId] = useState(null); // 테이블 설정 - const ts = useTableSettings("c16-sales-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG); + const applyTableSettings = (settings: TableSettings) => { + if (settings.filters) setFilterConfig(settings.filters); + }; + useEffect(() => { + const saved = loadTableSettings(MASTER_TABLE, "jeilglass-order"); + if (saved?.filters) setFilterConfig(saved.filters); + }, []); - // 헤더 필터 & 정렬 - const [headerFilters, setHeaderFilters] = useState>>({}); - const [sortState, setSortState] = useState<{ key: string; direction: "asc" | "desc" } | null>(null); + // 채번규칙 로드 + useEffect(() => { + const loadRule = async () => { + try { + const res = await apiClient.get(`/numbering-rules/by-column/${MASTER_TABLE}/order_no`); + const rule = res.data?.data; + if (rule?.ruleId || rule?.rule_id) { + setNumberingRuleId(rule.ruleId || rule.rule_id); + } + } catch { /* 채번규칙 없음 — fallback 사용 */ } + }; + loadRule(); + }, []); // 카테고리 로드 useEffect(() => { const loadCategories = async () => { - try { - const catColumns = ["sell_mode", "input_mode", "price_mode", "incoterms", "payment_term"]; - const optMap: Record = {}; - const flatten = (vals: any[]): { code: string; label: string }[] => { - const result: { code: string; label: string }[] = []; - for (const v of vals) { - result.push({ code: v.valueCode, label: v.valueLabel }); - if (v.children?.length) result.push(...flatten(v.children)); - } - return result; - }; - const LABEL_REPLACE: Record = { - "공급업체 우선": "거래처 우선", - "공급업체우선": "거래처 우선", - }; - const dedup = (items: { code: string; label: string }[]) => { - const seen = new Set(); - return items - .map((item) => ({ ...item, label: LABEL_REPLACE[item.label] || item.label })) - .filter((item) => { - const key = item.label.replace(/\s/g, ""); - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - }; - await Promise.all( - catColumns.map(async (col) => { - try { - const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`); - if (res.data?.success && res.data.data?.length > 0) { - optMap[col] = dedup(flatten(res.data.data)); - } - } catch { /* skip */ } - }) - ); - // 거래처 목록 - try { - const custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, { - page: 1, size: 500, autoFilter: true, - }); - const custs = custRes.data?.data?.data || custRes.data?.data?.rows || []; - optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: c.customer_name })); - } catch { /* skip */ } - // 사용자 목록 - try { - const userRes = await apiClient.post(`/table-management/tables/user_info/data`, { - page: 1, size: 500, autoFilter: true, - }); - const users = userRes.data?.data?.data || userRes.data?.data?.rows || []; - optMap["manager_id"] = users.map((u: any) => ({ - code: u.user_id || u.id, - label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`, - })); - } catch { /* skip */ } - // item_info 카테고리 - for (const col of ["inventory_unit", "material", "division", "type"]) { - try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values`); - if (res.data?.success && res.data.data?.length > 0) { - optMap[`item_${col}`] = flatten(res.data.data); - } - } catch { /* skip */ } + const optMap: Record = {}; + const flatten = (vals: any[]): { code: string; label: string }[] => { + const result: { code: string; label: string }[] = []; + for (const v of vals) { + result.push({ code: v.valueCode || v.code, label: v.valueLabel || v.label || v.valueCode }); + if (v.children?.length) result.push(...flatten(v.children)); } - setCategoryOptions(optMap); - // division 기본값 - const divs = optMap["item_division"] || []; - const salesDiv = divs.find((o: any) => o.label === "영업관리") - || divs.find((o: any) => o.label === "제품") - || divs.find((o: any) => o.label === "판매품"); - if (salesDiv) setItemSearchDivision(salesDiv.code); - } catch (err) { - console.error("카테고리 로드 실패:", err); - } finally { - setIsCategoriesLoaded(true); + return result; + }; + // 마스터 카테고리 + for (const col of ["sell_mode", "input_mode", "price_mode"]) { + try { + const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`); + optMap[col] = flatten(res.data?.data || []); + } catch { /* skip */ } } + // 거래처 + try { + const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 500, autoFilter: true }); + const custs = res.data?.data?.data || res.data?.data?.rows || []; + optMap["partner_id"] = custs.map((c: any) => ({ + code: c.customer_code, + label: `${c.customer_name} (${c.customer_code})`, + })); + } catch { /* skip */ } + // 담당자 + try { + const res = await apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 200, autoFilter: true }); + const users = res.data?.data?.data || res.data?.data?.rows || []; + optMap["manager_id"] = users.map((u: any) => ({ + code: u.user_id || u.id, + label: `${u.user_name || ""}${u.position_name ? ` (${u.position_name})` : ""}`, + })); + } catch { /* skip */ } + // 품목 카테고리 (단위, 구분, 재질, 유형) + for (const col of ["unit", "division", "material", "type"]) { + try { + const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); + if (res.data?.success && res.data.data?.length > 0) { + optMap[`item_${col}`] = flatten(res.data.data); + } + } catch { /* skip */ } + } + setCategoryOptions(optMap); }; loadCategories(); }, []); - // 데이터 조회 - const fetchOrders = useCallback(async () => { - if (!isCategoriesLoaded) return; + // 수주 목록 조회 (디테일 전체 → order_no 그룹핑) + const fetchMasterOrders = useCallback(async () => { setLoading(true); try { - const filters = searchFilters.map(f => ({ + const filters: any[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value, })); - const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { page: 1, size: 500, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, @@ -317,246 +214,116 @@ export default function SalesOrderPage() { sort: { columnName: "order_no", order: "desc" }, }); const rows = res.data?.data?.data || res.data?.data?.rows || []; + setAllDetails(rows); - // order_no → sales_order_mng 조인 + // 마스터 조회 (거래처 정보 확보) const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))]; let masterMap: Record = {}; if (orderNos.length > 0) { try { - const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + const mRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { page: 1, size: orderNos.length + 10, dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] }, autoFilter: true, }); - const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; + const masters = mRes.data?.data?.data || mRes.data?.data?.rows || []; for (const m of masters) masterMap[m.order_no] = m; } catch { /* skip */ } } - // part_code → item_info 조인 - const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))]; - let itemMap: Record = {}; - if (partCodes.length > 0) { - try { - const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: partCodes.length + 10, - dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: partCodes }] }, - autoFilter: true, - }); - const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; - for (const item of items) itemMap[item.item_number] = item; - } catch { /* skip */ } - } - - // 조인 적용 + 카테고리 코드→라벨 변환 - const resolveLabel = (key: string, code: string) => { + // 거래처 코드 → 이름 변환 + const resolvePartner = (code: string) => { if (!code) return ""; - const opts = categoryOptions[key]; - if (!opts) return code; - return opts.find((o) => o.code === code)?.label || code; + return categoryOptions["partner_id"]?.find((o) => o.code === code)?.label?.split(" (")[0] || code; }; - const data = rows.map((row: any) => { - const item = itemMap[row.part_code]; - const master = masterMap[row.order_no]; - const rawUnit = row.unit || item?.inventory_unit || ""; - return { - ...row, - part_name: row.part_name || item?.item_name || "", - spec: row.spec || item?.size || "", - material: row.material || (item ? (resolveLabel("item_material", item.material) || item.material || "") : ""), - unit: resolveLabel("item_inventory_unit", rawUnit) || rawUnit, - memo: row.memo || master?.memo || "", - _master: master || {}, - }; - }); - setOrders(data); - setTotalCount(res.data?.data?.total || data.length); + // order_no 기준 집계 + const grouped: Record = {}; + for (const row of rows) { + const no = row.order_no; + if (!no) continue; + if (!grouped[no]) { + const master = masterMap[no] || {}; + grouped[no] = { + id: `master_${no}`, + order_no: no, + partner_name: resolvePartner(master.partner_id), + item_count: 0, + total_qty: 0, + total_ship_qty: 0, + total_balance: 0, + total_amount: 0, + due_date: row.due_date || "", + status: master.status || "", + }; + } + const g = grouped[no]; + g.item_count += 1; + g.total_qty += parseFloat(row.qty) || 0; + g.total_ship_qty += parseFloat(row.ship_qty) || 0; + g.total_balance += parseFloat(row.balance_qty) || 0; + g.total_amount += parseFloat(row.amount) || 0; + if (row.due_date && (!g.due_date || row.due_date > g.due_date)) g.due_date = row.due_date; + } + const list = Object.values(grouped); + setMasterOrders(list); + setTotalCount(list.length); } catch (err) { + console.error("수주 조회 실패:", err); toast.error("수주 목록을 불러오는데 실패했습니다."); } finally { setLoading(false); } - }, [searchFilters, categoryOptions, isCategoriesLoaded]); + }, [searchFilters, categoryOptions]); - useEffect(() => { fetchOrders(); }, [fetchOrders]); + useEffect(() => { fetchMasterOrders(); }, [fetchMasterOrders]); - // 카테고리 코드→라벨 변환 - const resolveLabel = useCallback((key: string, code: string) => { - if (!code) return ""; - if (key === "partner_id" || key === "manager_id" || key === "price_mode") { - return categoryOptions[key]?.find((o) => o.code === code)?.label || code; + // 통계 + const stats = useMemo(() => { + let totalAmount = 0, totalQty = 0; + for (const m of masterOrders) { + totalAmount += m.total_amount || 0; + totalQty += m.total_qty || 0; } - return code; - }, [categoryOptions]); + return { totalAmount, totalQty }; + }, [masterOrders]); - // 플랫 행 생성 (마스터 필드를 각 디테일 행에 병합) - const flatRows = useMemo(() => { - return orders.map((row) => { - const master = row._master || {}; - return { - ...row, - partner_id: resolveLabel("partner_id", master.partner_id || row.partner_id || ""), - order_date: master.order_date || row.order_date || "", - memo: row.memo || master.memo || "", - }; - }); - }, [orders, resolveLabel]); + // 우측: 선택된 수주 디테일 조회 (division 코드→라벨 변환) + useEffect(() => { + if (!selectedOrderNo) { setDetailItems([]); return; } + const items = allDetails + .filter((d) => d.order_no === selectedOrderNo) + .map((d) => ({ + ...d, + division: categoryOptions["item_division"]?.find((o) => o.code === d.division)?.label || d.division || "", + })); + setDetailItems(items); + }, [selectedOrderNo, allDetails, categoryOptions]); - // 컬럼별 고유값 (헤더 필터용) - const columnUniqueValues = useMemo(() => { - const result: Record = {}; - for (const col of FLAT_COLUMNS) { - const values = new Set(); - flatRows.forEach((row) => { - const val = row[col.key]; - if (val !== null && val !== undefined && val !== "") values.add(String(val)); - }); - result[col.key] = Array.from(values).sort(); - } - return result; - }, [flatRows]); - - // 필터 + 정렬 적용된 플랫 데이터 - const filteredFlatRows = useMemo(() => { - let rows = [...flatRows]; - - // 1차: 헤더 필터 적용 - for (const [colKey, values] of Object.entries(headerFilters)) { - if (values.size === 0) continue; - rows = rows.filter((row) => { - const cellVal = row[colKey] != null ? String(row[colKey]) : ""; - return values.has(cellVal); - }); - } - - // 2차: 정렬 - if (sortState) { - const { key, direction } = sortState; - rows.sort((a, b) => { - const av = a[key] ?? ""; - const bv = b[key] ?? ""; - const na = Number(av); const nb = Number(bv); - if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na; - return direction === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av)); - }); - } - - return rows; - }, [flatRows, headerFilters, sortState]); - - // 페이지네이션 계산 - const totalPages = Math.max(1, Math.ceil(filteredFlatRows.length / pageSize)); - const safePage = Math.min(Math.max(1, currentPage), totalPages); - const paginatedRows = useMemo(() => { - const start = (safePage - 1) * pageSize; - return filteredFlatRows.slice(start, start + pageSize); - }, [filteredFlatRows, safePage, pageSize]); - - const applyPageSize = () => { - const n = parseInt(pageSizeInput, 10); - if (!isNaN(n) && n >= 1) { setPageSize(n); setCurrentPage(1); } - else setPageSizeInput(String(pageSize)); + // 좌측 행 클릭 + const handleMasterRowClick = (row: any) => { + setSelectedOrderNo(row.order_no); }; - const getPageNumbers = (): (number | "...")[] => { - const pages: (number | "...")[] = []; - if (totalPages <= 7) { - for (let i = 1; i <= totalPages; i++) pages.push(i); - } else { - pages.push(1); - if (safePage > 3) pages.push("..."); - for (let i = Math.max(2, safePage - 1); i <= Math.min(totalPages - 1, safePage + 1); i++) pages.push(i); - if (safePage < totalPages - 2) pages.push("..."); - pages.push(totalPages); - } - return pages; - }; - - // 필터 변경 시 첫 페이지로 이동 - useEffect(() => { setCurrentPage(1); }, [headerFilters, sortState, filteredFlatRows.length]); - - // 헤더 필터 토글/초기화 - const toggleHeaderFilter = (colKey: string, value: string) => { - setHeaderFilters((prev) => { - const next = { ...prev }; - const set = new Set(next[colKey] || []); - if (set.has(value)) set.delete(value); else set.add(value); - if (set.size === 0) delete next[colKey]; else next[colKey] = set; - return next; - }); - }; - - const clearHeaderFilter = (colKey: string) => { - setHeaderFilters((prev) => { - const next = { ...prev }; - delete next[colKey]; - return next; - }); - }; - - const handleSort = (key: string) => { - setSortState((prev) => - prev?.key === key - ? prev.direction === "asc" ? { key, direction: "desc" } : null - : { key, direction: "asc" } - ); - }; - - const getCategoryLabel = (col: string, code: string) => { - if (!code) return ""; - const found = categoryOptions[col]?.find((o) => o.code === code); - return found?.label || code; - }; - - const loadDeliveryOptions = async (customerCode: string) => { - if (!customerCode) { setDeliveryOptions([]); return; } - try { - const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, { - page: 1, size: 100, - dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] }, - autoFilter: true, - }); - const rows = res.data?.data?.data || res.data?.data?.rows || []; - setDeliveryOptions(rows.map((r: any) => ({ - code: r.destination_code || r.id, - label: `${r.destination_name}${r.address ? ` (${r.address})` : ""}`, - }))); - } catch { setDeliveryOptions([]); } - }; - - // 등록 모달 열기 + // 등록 모달 const openRegisterModal = async () => { - const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || ""; - const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || ""; - const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || ""; - setMasterForm({ - input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, - manager_id: user?.userId || "", order_date: new Date().toISOString().split("T")[0], - }); - setDetailRows([]); - setDeliveryOptions([]); - setIsEditMode(false); - setOrderNoRuleId(null); - setOrderNoPreview(null); - setIsModalOpen(true); - - // 수주번호 자동 채번 조회 - try { - const ruleRes = await apiClient.get("/numbering-rules/by-column/sales_order_mng/order_no"); - if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) { - const ruleId = ruleRes.data.data.ruleId; - setOrderNoRuleId(ruleId); - const previewRes = await previewNumberingCode(ruleId); - if (previewRes.success && previewRes.data?.generatedCode) { - setOrderNoPreview(previewRes.data.generatedCode); - setMasterForm((prev) => ({ ...prev, order_no: previewRes.data.generatedCode })); - } + let previewOrderNo = ""; + if (numberingRuleId) { + const res = await previewNumberingCode(numberingRuleId); + if (res.success && res.data?.generatedCode) { + previewOrderNo = res.data.generatedCode; } - } catch { /* 채번 규칙 없으면 수동 입력 */ } + } + if (!previewOrderNo) { + previewOrderNo = `ORD-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${String(Date.now()).slice(-4)}`; + } + setMasterForm({ order_no: previewOrderNo, manager_id: user?.userId || "" }); + setModalDetailRows([]); + setIsEditMode(false); + setIsModalOpen(true); }; - // 수정 모달 열기 + // 수정 모달 const openEditModal = async (orderNo: string) => { try { const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { @@ -574,91 +341,132 @@ export default function SalesOrderPage() { const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; setMasterForm(masterData || {}); - setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` }))); + setModalDetailRows(detailData.map((d: any, i: number) => ({ + ...d, + _id: d.id || `row_${i}`, + _fromItemInfo: !!d.part_code, + _divisionLabel: categoryOptions["item_division"]?.find((o: any) => o.code === d.division)?.label || d.division || "", + }))); setIsEditMode(true); setIsModalOpen(true); } catch (err) { + console.error("수주 상세 조회 실패:", err); toast.error("수주 정보를 불러오는데 실패했습니다."); } }; - // 삭제 (선택한 디테일 삭제 → 디테일 0건인 마스터 자동 삭제) + // 삭제 const handleDelete = async () => { - if (checkedIds.length === 0) { toast.error("삭제할 항목을 선택해주세요."); return; } - const ok = await confirm(`${checkedIds.length}건의 수주 항목을 삭제하시겠습니까?`, { + if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; } + const selectedItems = masterOrders.filter((o) => checkedIds.includes(o.id)); + const orderNos = selectedItems.map((o) => o.order_no); + const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, { description: "삭제된 데이터는 복구할 수 없습니다.", variant: "destructive", confirmText: "삭제", }); if (!ok) return; try { - // 1. 선택한 디테일 삭제 - await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, { - data: checkedIds.map((id) => ({ id })), - }); - - // 2. 영향받는 수주번호의 잔여 디테일 확인 → 0건이면 마스터도 삭제 - const selectedItems = orders.filter((o) => checkedIds.includes(o.id)); - const orderNos = [...new Set(selectedItems.map((o) => o.order_no))]; for (const orderNo of orderNos) { - const remainRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { + // 디테일 삭제 + const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, + autoFilter: true, + }); + const details = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; + if (details.length > 0) { + await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, { + data: details.map((d: any) => ({ id: d.id })), + }); + } + // 마스터 삭제 + const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { page: 1, size: 1, dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, autoFilter: true, }); - const remaining = remainRes.data?.data?.data || remainRes.data?.data?.rows || []; - if (remaining.length === 0) { - // 디테일 0건 → 마스터 삭제 - const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { - page: 1, size: 1, - dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, - autoFilter: true, + const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; + if (masters.length > 0) { + await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, { + data: masters.map((m: any) => ({ id: m.id })), }); - const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; - if (masters.length > 0) { - await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, { - data: masters.map((m: any) => ({ id: m.id })), - }); - } } } toast.success("삭제되었습니다."); setCheckedIds([]); - fetchOrders(); + setSelectedOrderNo(null); + fetchMasterOrders(); } catch (err) { + console.error("삭제 실패:", err); toast.error("삭제에 실패했습니다."); } }; - // 저장 (마스터 + 디테일) - const handleSave = async () => { - // 채번 규칙이 있으면 allocate, 없으면 수동 입력 필수 - if (!isEditMode && orderNoRuleId) { + // 품목 자동 등록 (item_info에 없으면 등록) + const autoRegisterItems = async (rows: any[]) => { + for (const row of rows) { + if (row.part_code || !row.part_name) continue; try { - const allocRes = await allocateNumberingCode(orderNoRuleId); - if (allocRes.success && allocRes.data?.generatedCode) { - setMasterForm((prev) => ({ ...prev, order_no: allocRes.data.generatedCode })); - masterForm.order_no = allocRes.data.generatedCode; - } else { - toast.error("수주번호 채번에 실패했습니다."); - return; + // item_info에서 품명으로 검색 + const searchRes = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [{ columnName: "item_name", operator: "equals", value: row.part_name }] }, + autoFilter: true, + }); + const found = (searchRes.data?.data?.data || searchRes.data?.data?.rows || [])[0]; + if (found) { + row.part_code = found.item_number; + continue; } - } catch { - toast.error("수주번호 채번에 실패했습니다."); - return; + // 없으면 자동 등록 + await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { + item_name: row.part_name, + size: row.spec || "", + unit: row.unit || "", + }); + // 등록 후 재조회하여 item_number 획득 + const reSearch = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [{ columnName: "item_name", operator: "equals", value: row.part_name }] }, + autoFilter: true, + sort: { columnName: "created_date", order: "desc" }, + }); + const newItem = (reSearch.data?.data?.data || reSearch.data?.data?.rows || [])[0]; + if (newItem) row.part_code = newItem.item_number; + } catch (err) { + console.warn("품목 자동 등록 실패:", row.part_name, err); } } - if (!masterForm.order_no && !isEditMode) { - toast.error("수주번호는 필수입니다."); - return; - } - if (detailRows.length === 0) { + }; + + // 저장 + const handleSave = async () => { + if (modalDetailRows.length === 0) { toast.error("품목을 1개 이상 추가해주세요."); return; } + setSaving(true); try { + // 품목 자동 등록 + await autoRegisterItems(modalDetailRows); + + // 신규 등록 시 채번 할당 + if (!isEditMode) { + if (numberingRuleId) { + const allocRes = await allocateNumberingCode(numberingRuleId, masterForm.order_no); + if (allocRes.success && allocRes.data?.generatedCode) { + masterForm.order_no = allocRes.data.generatedCode; + } + } + if (!masterForm.order_no) { + masterForm.order_no = `ORD-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${String(Date.now()).slice(-4)}`; + } + } + const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm; + if (isEditMode && id) { await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, { originalData: { id }, @@ -678,18 +486,22 @@ export default function SalesOrderPage() { } else { await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields); } - for (const row of detailRows) { - const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; + + for (let i = 0; i < modalDetailRows.length; i++) { + const row = modalDetailRows[i]; + const { _id, _fromItemInfo, _divisionLabel, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { ...detailFields, - id: crypto.randomUUID(), order_no: masterForm.order_no, + seq_no: String(i + 1), }); } + toast.success(isEditMode ? "수정되었습니다." : "등록되었습니다."); setIsModalOpen(false); - fetchOrders(); + fetchMasterOrders(); } catch (err: any) { + console.error("저장 실패:", err); toast.error(err.response?.data?.message || "저장에 실패했습니다."); } finally { setSaving(false); @@ -697,221 +509,91 @@ export default function SalesOrderPage() { }; // 품목 검색 - const searchItems = async (page?: number, size?: number) => { - const p = page ?? itemPage; - const s = size ?? itemPageSize; + const searchItems = async () => { setItemSearchLoading(true); try { const filters: any[] = []; - if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); - - // 관리품목 필터를 서버 쿼리에 포함 (코드 + 라벨 양쪽 대응) - if (itemSearchDivision !== "all") { - const divLabel = categoryOptions["item_division"]?.find((o) => o.code === itemSearchDivision)?.label || ""; - // 코드 또는 라벨이 저장된 경우 모두 조회하기 위해 in 연산자 사용 - const divValues = [itemSearchDivision]; - if (divLabel) divValues.push(divLabel); - filters.push({ columnName: "division", operator: "in", value: divValues }); + if (itemSearchKeyword) { + filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } - - // 거래처우선 단가방식일 때 거래처에 연결된 품목만 필터링 - const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI"; - const partnerId = masterForm.partner_id; - let customerItemIds: Set | null = null; - - if (isCustomerPrice && partnerId) { - try { - const mappingRes = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, { - page: 1, size: 5000, - dataFilter: { enabled: true, filters: [{ columnName: "customer_id", operator: "equals", value: partnerId }] }, - autoFilter: true, - }); - const mappings = mappingRes.data?.data?.data || mappingRes.data?.data?.rows || []; - customerItemIds = new Set(mappings.map((m: any) => m.item_id).filter(Boolean)); - } catch { /* skip */ } - } - - const res = await apiClient.post(`/table-management/tables/item_info/data`, { - page: p, size: s, + const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { + page: 1, size: 50, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, }); - const resData = res.data?.data; - let rows = resData?.data || resData?.rows || []; - const serverTotal = resData?.total || resData?.totalCount || rows.length; - - // 거래처우선일 때 연결된 품목만 표시 (클라이언트 필터) - if (customerItemIds) { - rows = rows.filter((item: any) => customerItemIds!.has(item.item_number) || customerItemIds!.has(item.id)); - } - - setItemSearchResults(rows); - setItemTotal(serverTotal); - setItemTotalPages(Math.max(1, Math.ceil(serverTotal / s))); - } catch { /* skip */ } finally { - setItemSearchLoading(false); - } + setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []); + } catch { setItemSearchResults([]); } + finally { setItemSearchLoading(false); } }; - const handleItemPageChange = (newPage: number) => { - if (newPage < 1 || newPage > itemTotalPages) return; - setItemPage(newPage); - setItemPageInput(String(newPage)); - searchItems(newPage); - }; - - const commitItemPageInput = () => { - const parsed = parseInt(itemPageInput, 10); - if (isNaN(parsed) || itemPageInput.trim() === "") { setItemPageInput(String(itemPage)); return; } - const clamped = Math.max(1, Math.min(parsed, itemTotalPages || 1)); - if (clamped !== itemPage) handleItemPageChange(clamped); - setItemPageInput(String(clamped)); - }; - - const triggerNewSearch = () => { - setItemPage(1); - setItemPageInput("1"); - searchItems(1); - }; - - const addSelectedItemsToDetail = async () => { - const selected = Array.from(itemSelectedMap.values()); - if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; } - - const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ"; - const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI"; - const partnerId = masterForm.partner_id; - - let customerPriceMap: Record = {}; - if (isCustomerPrice && partnerId) { - try { - const itemIds = selected.map((item) => item.item_number || item.id); - const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, { - page: 1, size: 500, - dataFilter: { - enabled: true, - filters: [ - { columnName: "customer_id", operator: "equals", value: partnerId }, - { columnName: "item_id", operator: "in", value: itemIds }, - ], - }, - autoFilter: true, - }); - const prices = res.data?.data?.data || res.data?.data?.rows || []; - const _n = new Date(); - const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; - for (const p of prices) { - const start = p.start_date || ""; - const end = p.end_date || ""; - if (start && start > today) continue; - if (end && end < today) continue; - const price = p.calculated_price || p.base_price || p.unit_price || ""; - if (price && Number(price) > 0) { - const existing = customerPriceMap[p.item_id]; - if (!existing || Number(price) > 0) customerPriceMap[p.item_id] = String(price); - } - } - } catch { /* skip */ } - } - - const newRows = selected.map((item) => { - const itemCode = item.item_number || item.id; - let unitPrice = ""; - if (isStandardPrice) { - unitPrice = item.standard_price || item.selling_price || ""; - } else if (isCustomerPrice && partnerId) { - unitPrice = customerPriceMap[itemCode] || ""; - } - return { - _id: `new_${Date.now()}_${Math.random()}`, - part_code: itemCode, - part_name: item.item_name, - spec: item.size || "", - material: getCategoryLabel("item_material", item.material) || item.material || "", - packing_material: "", - unit: getCategoryLabel("item_inventory_unit", item.inventory_unit) || item.inventory_unit || "", - qty: "1", - pack_qty: "0", - unit_price: unitPrice, - amount: unitPrice ? String(1 * parseFloat(unitPrice)) : "", - due_date: "", - }; - }); - - setDetailRows((prev) => [...prev, ...newRows]); - toast.success(`${selected.length}개 품목이 추가되었습니다.`); - setItemSelectedMap(new Map()); + // 품목 선택 → 리피터에 추가 + const addSelectedItemsToDetail = () => { + const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id)); + const resolveUnit = (code: string) => { + if (!code) return ""; + return categoryOptions["item_unit"]?.find((o) => o.code === code)?.label || code; + }; + const resolveDivision = (code: string) => { + if (!code) return ""; + return categoryOptions["item_division"]?.find((o) => o.code === code)?.label || code; + }; + const newRows = selected.map((item) => ({ + _id: `new_${Date.now()}_${Math.random()}`, + _fromItemInfo: true, + part_code: item.item_number || "", + part_name: item.item_name || "", + spec: item.size || "", + division: item.division || "", + _divisionLabel: resolveDivision(item.division), + unit: resolveUnit(item.unit) || "", + width: "", height: "", thickness: "", area: "", + qty: "", unit_price: item.selling_price || item.standard_price || "", amount: "", + due_date: "", memo: "", + })); + setModalDetailRows((prev) => [...prev, ...newRows]); setItemSelectOpen(false); + setItemCheckedIds(new Set()); }; - // 단가 재계산: 단가방식/거래처 변경 시 기존 품목 단가 갱신 - const recalcPrices = useCallback(async (priceMode: string, partnerId: string) => { - if (detailRows.length === 0) return; - const STANDARD_CODES = ["CAT_MM0BUZKL_HJ7U", "CAT_MLKG792S_54WJ"]; - const CUSTOMER_CODES = ["CAT_MM0BV3OS_41DX", "CAT_MLKG7D8K_N8SI"]; - const isStandard = STANDARD_CODES.includes(priceMode); - const isCustomer = CUSTOMER_CODES.includes(priceMode); + // 빈 행 추가 (품명 직접 입력용) + const addEmptyRow = () => { + setModalDetailRows((prev) => [...prev, { + _id: `new_${Date.now()}_${Math.random()}`, + _fromItemInfo: false, + part_code: "", part_name: "", spec: "", division: "", _divisionLabel: "", unit: "㎡", + width: "", height: "", thickness: "", area: "", + qty: "", unit_price: "", amount: "", + due_date: "", memo: "", + }]); + }; - if (isStandard) { - // 품목 기준단가 조회 - const itemCodes = detailRows.map((r) => r.part_code).filter(Boolean); - if (itemCodes.length === 0) return; - try { - const res = await apiClient.post(`/table-management/tables/item_info/data`, { - page: 1, size: 500, - dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] }, - autoFilter: true, - }); - const items = res.data?.data?.data || res.data?.data?.rows || []; - const priceMap: Record = {}; - for (const item of items) { - const price = item.standard_price || item.selling_price || ""; - if (price) priceMap[item.item_number] = String(price); - } - setDetailRows((prev) => prev.map((row) => { - const up = priceMap[row.part_code] || ""; - const qty = parseFloat(row.qty) || 0; - const price = parseFloat(up) || 0; - return { ...row, unit_price: up, amount: (qty * price).toString() }; - })); - } catch { /* skip */ } - } else if (isCustomer && partnerId) { - // 거래처별 단가 조회 - const itemCodes = detailRows.map((r) => r.part_code).filter(Boolean); - if (itemCodes.length === 0) return; - try { - const res = await apiClient.post(`/table-management/tables/customer_item_prices/data`, { - page: 1, size: 500, - dataFilter: { enabled: true, filters: [ - { columnName: "customer_id", operator: "equals", value: partnerId }, - { columnName: "item_id", operator: "in", value: itemCodes }, - ]}, - autoFilter: true, - }); - const prices = res.data?.data?.data || res.data?.data?.rows || []; - const _n = new Date(); - const today = `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "0")}`; - const priceMap: Record = {}; - for (const p of prices) { - if (p.start_date && p.start_date > today) continue; - if (p.end_date && p.end_date < today) continue; - const price = p.calculated_price || p.base_price || p.unit_price || ""; - if (price && Number(price) > 0) priceMap[p.item_id] = String(price); - } - setDetailRows((prev) => prev.map((row) => { - const up = priceMap[row.part_code] || ""; - const qty = parseFloat(row.qty) || 0; - const price = parseFloat(up) || 0; - return { ...row, unit_price: up, amount: (qty * price).toString() }; - })); - } catch { /* skip */ } - } - }, [detailRows]); + // 구분(division) 라벨로 면적 계산 제수 결정 + const getAreaDivisor = (divisionCode: string) => { + const label = categoryOptions["item_division"]?.find((o) => o.code === divisionCode)?.label || ""; + // 원판, 원자재 → 92,094 / 그 외(제품 등) → 91,808 + if (label.includes("원판") || label.includes("원자재")) return 92094; + return 91808; + }; + // 면적 계산 (구분에 따른 제수 적용) + const calcArea = (row: any) => { + const w = parseFloat(row.width) || 0; + const h = parseFloat(row.height) || 0; + if (w <= 0 || h <= 0) return ""; + const divisor = getAreaDivisor(row.division); + return (w * h / divisor).toFixed(4); + }; + + // 리피터 행 값 변경 + 면적/금액 자동 계산 const updateDetailRow = (idx: number, field: string, value: string) => { - setDetailRows((prev) => { + setModalDetailRows((prev) => { const next = [...prev]; next[idx] = { ...next[idx], [field]: value }; + // 면적 자동 계산 (구분/가로/세로 변경 시) + if (field === "width" || field === "height" || field === "division") { + next[idx].area = calcArea(next[idx]); + } + // 금액 자동 계산 if (field === "qty" || field === "unit_price") { const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0; const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0; @@ -922,887 +604,539 @@ export default function SalesOrderPage() { }; const removeDetailRow = (idx: number) => { - setDetailRows((prev) => prev.filter((_, i) => i !== idx)); + setModalDetailRows((prev) => prev.filter((_, i) => i !== idx)); }; - // 조건부 레이어 판단 - const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W"; - const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO"; - + // 엑셀 다운로드 (마스터+디테일 통합) const handleExcelDownload = async () => { - if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; } - const cols = ["order_no","part_code","part_name","spec","unit","qty","ship_qty","balance_qty","unit_price","amount","currency_code","due_date","memo"]; - const labels: Record = { - order_no:"수주번호",part_code:"품번",part_name:"품명",spec:"규격",unit:"단위", - qty:"수량",ship_qty:"출하수량",balance_qty:"잔량",unit_price:"단가", - amount:"금액",currency_code:"통화",due_date:"납기일",memo:"메모", - }; - const data = orders.map((o) => { - const row: Record = {}; - for (const col of cols) row[labels[col]] = o[col] || ""; - return row; + if (allDetails.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; } + // 마스터 정보 매핑 + const masterMap: Record = {}; + for (const m of masterOrders) masterMap[m.order_no] = m; + + const resolveDiv = (code: string) => + categoryOptions["item_division"]?.find((o) => o.code === code)?.label || code || ""; + + const data = allDetails.map((o) => { + const master = masterMap[o.order_no] || {}; + return { + "수주번호": o.order_no || "", + "거래처": master.partner_name || "", + "상태": master.status || "", + "구분": resolveDiv(o.division), + "품명": o.part_name || "", + "규격": o.spec || "", + "가로": o.width || "", + "세로": o.height || "", + "두께": o.thickness || "", + "면적": o.area || "", + "단위": o.unit || "", + "수량": o.qty || "", + "출하": o.ship_qty || "", + "잔량": o.balance_qty || "", + "단가": o.unit_price || "", + "금액": o.amount || "", + "납기일": o.due_date || "", + "비고": o.memo || "", + }; }); - await exportToExcel(data, "수주관리.xlsx", "수주목록"); + await exportToExcel(data, "제일그라스_수주관리.xlsx", "수주목록"); toast.success("다운로드 완료"); }; + // 엑셀 업로드 후처리: order_no가 비어있는 디테일에 마스터 자동 생성 + const handleExcelUploadSuccess = async () => { + try { + // 마스터 없는 디테일(order_no 비어있는) 조회 + const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { + page: 1, size: 500, autoFilter: true, + sort: { columnName: "created_date", order: "desc" }, + }); + const allRows = res.data?.data?.data || res.data?.data?.rows || []; + + // order_no가 비어있는 행들 수집 + const noOrderRows = allRows.filter((r: any) => !r.order_no); + if (noOrderRows.length > 0) { + // 채번 후 마스터 생성 + 디테일에 order_no 설정 + let orderNo = ""; + if (numberingRuleId) { + const allocRes = await allocateNumberingCode(numberingRuleId); + if (allocRes.success && allocRes.data?.generatedCode) orderNo = allocRes.data.generatedCode; + } + if (!orderNo) { + orderNo = `ORD-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${String(Date.now()).slice(-4)}`; + } + + // 마스터 생성 + await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, { + order_no: orderNo, + status: "수주", + manager_id: user?.userId || "", + order_date: new Date().toISOString().slice(0, 10), + }); + + // 디테일에 order_no + 면적/금액 계산하여 업데이트 + for (let i = 0; i < noOrderRows.length; i++) { + const row = noOrderRows[i]; + const w = parseFloat(row.width) || 0; + const h = parseFloat(row.height) || 0; + const qty = parseFloat(row.qty) || 0; + const price = parseFloat(row.unit_price) || 0; + const area = w > 0 && h > 0 ? (w * h / 91808).toFixed(4) : ""; + const amount = (qty * price).toString(); + + await apiClient.put(`/table-management/tables/${DETAIL_TABLE}/edit`, { + originalData: { id: row.id }, + updatedData: { + order_no: orderNo, + seq_no: String(i + 1), + area: area || row.area || "", + amount: amount || row.amount || "", + }, + }); + } + toast.success(`${noOrderRows.length}건의 품목에 수주번호 ${orderNo} 할당 완료`); + } + } catch (err) { + console.error("엑셀 업로드 후처리 실패:", err); + } + fetchMasterOrders(); + }; + return (
- {/* 브레드크럼 */} - - - {/* 검색 필터 (DynamicSearchFilter) */} + {/* 검색 필터 */} - {/* 액션 바 */} -
-
-

수주 목록

- - {totalCount}건 - + {/* 통계 바 */} +
+
+ 총 금액 + {stats.totalAmount.toLocaleString()}원
-
- - - -
- - - -
- +
+ 총 수량 + {stats.totalQty.toLocaleString()}개
- {/* 데이터 테이블 (플랫 리스트) */} -
-
- - - - - - - - - - - - - - - - - - - - - { - const allFilteredIds = filteredFlatRows.map((r) => r.id); - const allChecked = allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); - setCheckedIds(allChecked ? [] : allFilteredIds); - }} - > - { - const allFilteredIds = filteredFlatRows.map((r) => r.id); - return allFilteredIds.length > 0 && allFilteredIds.every((id) => checkedIds.includes(id)); - })()} - onCheckedChange={() => {}} - /> - - {FLAT_COLUMNS.map((col) => { - const isRight = ["qty", "ship_qty", "balance_qty", "unit_price", "amount"].includes(col.key); - return ( - -
-
handleSort(col.key)}> - {col.label} - {sortState?.key === col.key && ( - sortState.direction === "asc" - ? - : - )} -
- {(columnUniqueValues[col.key] || []).length > 0 && ( - ()} - onToggle={toggleHeaderFilter} onClear={clearHeaderFilter} - /> - )} -
-
- ); - })} -
-
- - {loading ? ( - - - - - - ) : filteredFlatRows.length === 0 ? ( - - -
- - 등록된 수주가 없어요 -
-
-
- ) : ( - ts.groupData(paginatedRows).map((row: any) => { - // 그룹 요약 행 렌더링 - if (row._isGroupSummary) { - return ( - - - {row._groupLabel || "합계"}: {row._count ? `${row._count}건` : ""} - {row.qty ? ` · 수량 ${Number(row.qty).toLocaleString()}` : ""} - {row.amount ? ` · 금액 ${Number(row.amount).toLocaleString()}` : ""} - - - ); - } - const isChecked = checkedIds.includes(row.id); - return ( - { - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - onDoubleClick={() => openEditModal(row.order_no)} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - {row.order_no} - {row.partner_id || ""} - {row.order_date || ""} - {row.part_code} - {row.part_name} - {row.spec} - {row.unit} - {row.qty ? Number(row.qty).toLocaleString() : ""} - {row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""} - {row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""} - {row.unit_price ? Number(row.unit_price).toLocaleString() : ""} - {row.amount ? Number(row.amount).toLocaleString() : ""} - {row.due_date || ""} - {row.memo || ""} - - ); - }) - )} -
-
-
- - {/* 페이지네이션 */} -
-
-
- 전체 - {filteredFlatRows.length.toLocaleString()} - -
-
- setPageSizeInput(e.target.value)} - onBlur={applyPageSize} - onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }} - className="h-7 w-16 text-center text-xs" - /> - 건씩 보기 -
-
-
- - - {getPageNumbers().map((page, idx) => - page === "..." ? ( - ... - ) : ( - - ) - )} - - -
-
- { - if (e.key === "Enter") { - const val = parseInt((e.target as HTMLInputElement).value, 10); - if (!isNaN(val) && val >= 1 && val <= totalPages) { - setCurrentPage(val); - (e.target as HTMLInputElement).value = ""; - (e.target as HTMLInputElement).blur(); - } - } - }} - onBlur={(e) => { - const val = parseInt(e.target.value, 10); - if (!isNaN(val) && val >= 1 && val <= totalPages) { - setCurrentPage(val); - } - e.target.value = ""; - }} - /> - / {totalPages} 페이지 -
-
-
- - {/* 수주 등록/수정 모달 */} - - - - - {isEditMode ? "수주 수정" : "수주 등록"} - - - {isEditMode ? "수주 정보를 수정합니다." : "새로운 수주를 등록합니다."} - - - -
- {/* 기본 정보 섹션 */} -
-
- 기본 정보 -
-
-
-
- - !orderNoRuleId && setMasterForm((p) => ({ ...p, order_no: e.target.value }))} - readOnly={!!orderNoRuleId || isEditMode} - placeholder={orderNoRuleId ? "자동 채번" : "수주번호"} - className={cn("h-9", (orderNoRuleId || isEditMode) && "bg-muted cursor-not-allowed")} - /> + {/* 좌우 분할 */} +
+ + {/* 좌측: 수주 목록 */} + +
+
+
+ 수주 목록 + {totalCount}건
-
- - setMasterForm((p) => ({ ...p, order_date: e.target.value }))} - className="h-9" - /> -
-
- - -
-
- - -
-
- - -
-
- + 수정 + +
-
- - {/* 거래처 우선 조건부 레이어 */} - {isSupplierFirst && ( -
-
- 거래처 정보 -
-
-
- - -
-
- - -
-
- - {deliveryOptions.length > 0 ? ( - - ) : ( - setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))} - placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택해주세요"} - className="h-9" disabled={!masterForm.partner_id} - /> - )} -
-
- - setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))} - placeholder="납품장소" className="h-9" - /> -
-
-
- )} - - {/* 해외판매 조건부 레이어 */} - {isOverseas && ( -
-
- 해외판매 추가 정보 -
-
-
- - -
-
- - -
-
- - -
-
- - setMasterForm((p) => ({ ...p, port_of_loading: e.target.value }))} className="h-9" /> -
-
- - setMasterForm((p) => ({ ...p, port_of_discharge: e.target.value }))} className="h-9" /> -
-
- - setMasterForm((p) => ({ ...p, hs_code: e.target.value }))} className="h-9 font-mono text-xs" /> -
-
-
- )} - - {/* 품목 내역 리피터 */} -
-
-
- 품목 내역 - - {detailRows.length} - -
- -
- {detailRows.length === 0 ? ( -
- - 아직 추가된 품목이 없어요. 위 버튼으로 품목을 추가해주세요. -
- ) : ( -
- - - - No - 품번 - 품명 - 규격 - 재질 - 포장재 - 단위 - 수량 - 포장수량 - 단가 - 금액 - 납기일 - 분할/삭제 - - - - {detailRows.map((row, idx) => ( - - {idx + 1} - - {row.part_code} - - - {row.part_name} - - {row.spec} - {row.material} - - updateDetailRow(idx, "packing_material", e.target.value)} - placeholder="포장재" - className="h-8 text-xs w-full" - /> - - - - - - e.target.select()} - onChange={(e) => updateDetailRow(idx, "qty", e.target.value)} - className="h-8 text-xs text-right font-mono w-full" - /> - - - updateDetailRow(idx, "pack_qty", e.target.value)} - className="h-8 text-xs text-right font-mono w-full" - /> - - - updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} - readOnly={!allowPriceEdit} - className={cn("h-8 text-xs text-right font-mono w-full", !allowPriceEdit && "bg-muted cursor-not-allowed")} - /> - - - {row.amount ? Number(row.amount).toLocaleString() : "0"} - - - updateDetailRow(idx, "due_date", e.target.value)} - className="h-8 text-xs w-full" - /> - - -
- - -
-
-
- ))} -
-
-
- )} -
- - {/* 비고 */} -
-
- 비고 -
-
-