From adcc16da360215ab1b25eef97f1e66c3cc57a244 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 3 Apr 2026 14:17:26 +0900 Subject: [PATCH] feat: Enhance mold management functionality - Added new `updateMoldSerial` API endpoint for updating mold serial details. - Modified existing mold-related SQL queries to include `id` and `created_date` fields. - Updated frontend to handle mold serial updates and image uploads. - Improved subcontractor management table with additional fields and rendering logic. This update improves the overall functionality and user experience in managing molds and subcontractors. --- .gitignore | 3 + .../src/controllers/moldController.ts | 59 +++- backend-node/src/routes/moldRoutes.ts | 2 + .../app/(main)/COMPANY_16/mold/info/page.tsx | 294 +++++++++++++----- .../outsourcing/subcontractor/page.tsx | 264 ++++++++++------ .../components/common/TableSettingsModal.tsx | 5 +- frontend/components/ui/select.tsx | 2 +- frontend/hooks/useTableSettings.ts | 17 +- 8 files changed, 458 insertions(+), 188 deletions(-) diff --git a/.gitignore b/.gitignore index e2062811..501eb3da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Claude Code .claude/ +# Test checklists +docs/test-checklists/ + # Dependencies node_modules/ jspm_packages/ diff --git a/backend-node/src/controllers/moldController.ts b/backend-node/src/controllers/moldController.ts index ee500f5e..cf01f362 100644 --- a/backend-node/src/controllers/moldController.ts +++ b/backend-node/src/controllers/moldController.ts @@ -104,11 +104,11 @@ export async function createMold(req: AuthenticatedRequest, res: Response): Prom const sql = ` INSERT INTO mold_mng ( - company_code, mold_code, mold_name, mold_type, category, + id, company_code, mold_code, mold_name, mold_type, category, manufacturer, manufacturing_number, manufacturing_date, cavity_count, shot_count, mold_quantity, base_input_qty, - operation_status, remarks, image_path, memo, writer - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) + operation_status, remarks, image_path, memo, writer, created_date + ) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW()) RETURNING * `; const params = [ @@ -231,7 +231,7 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response) const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { moldCode } = req.params; - const { serial_number, status, progress, work_description, manager, completion_date, remarks } = req.body; + const { serial_number, status, progress, work_description, manager, completion_date, remarks, current_shot_count, storage_location } = req.body; let finalSerialNumber = serial_number; @@ -266,14 +266,15 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response) } const sql = ` - INSERT INTO mold_serial (company_code, mold_code, serial_number, status, progress, work_description, manager, completion_date, remarks, writer) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + INSERT INTO mold_serial (id, company_code, mold_code, serial_number, status, progress, work_description, manager, completion_date, remarks, current_shot_count, storage_location, writer, created_date) + VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW()) RETURNING * `; const params = [ companyCode, moldCode, finalSerialNumber, status || "STORED", progress || 0, work_description || null, manager || null, - completion_date || null, remarks || null, userId, + completion_date || null, remarks || null, current_shot_count || 0, + storage_location || null, userId, ]; const result = await query(sql, params); @@ -288,6 +289,38 @@ export async function createMoldSerial(req: AuthenticatedRequest, res: Response) } } +export async function updateMoldSerial(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const { status, current_shot_count, storage_location, remarks } = req.body; + + const sql = ` + UPDATE mold_serial SET + status = COALESCE($1, status), + current_shot_count = COALESCE($2, current_shot_count), + storage_location = $3, + remarks = $4, + updated_date = NOW() + WHERE id = $5 AND company_code = $6 + RETURNING * + `; + const params = [status, current_shot_count, storage_location || null, remarks || null, id, companyCode]; + const result = await query(sql, params); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "일련번호를 찾을 수 없습니다." }); + return; + } + + logger.info("일련번호 수정", { companyCode, id }); + res.json({ success: true, data: result[0], message: "일련번호가 수정되었습니다." }); + } catch (error: any) { + logger.error("일련번호 수정 오류", error); + res.status(500).json({ success: false, message: error.message }); + } +} + export async function deleteMoldSerial(req: AuthenticatedRequest, res: Response): Promise { try { const companyCode = req.user!.companyCode; @@ -347,10 +380,10 @@ export async function createMoldInspection(req: AuthenticatedRequest, res: Respo const sql = ` INSERT INTO mold_inspection_item ( - company_code, mold_code, inspection_item, inspection_cycle, + id, company_code, mold_code, inspection_item, inspection_cycle, inspection_method, inspection_content, lower_limit, upper_limit, - unit, is_active, checklist, remarks, writer - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + unit, is_active, checklist, remarks, writer, created_date + ) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,NOW()) RETURNING * `; const params = [ @@ -426,10 +459,10 @@ export async function createMoldPart(req: AuthenticatedRequest, res: Response): const sql = ` INSERT INTO mold_part ( - company_code, mold_code, part_name, replacement_cycle, + id, company_code, mold_code, part_name, replacement_cycle, unit, specification, manufacturer, manufacturer_code, - image_path, remarks, writer - ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + image_path, remarks, writer, created_date + ) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW()) RETURNING * `; const params = [ diff --git a/backend-node/src/routes/moldRoutes.ts b/backend-node/src/routes/moldRoutes.ts index 76eaa67d..5067ef79 100644 --- a/backend-node/src/routes/moldRoutes.ts +++ b/backend-node/src/routes/moldRoutes.ts @@ -8,6 +8,7 @@ import { deleteMold, getMoldSerials, createMoldSerial, + updateMoldSerial, deleteMoldSerial, getMoldInspections, createMoldInspection, @@ -31,6 +32,7 @@ router.delete("/:moldCode", deleteMold); // 일련번호 router.get("/:moldCode/serials", getMoldSerials); router.post("/:moldCode/serials", createMoldSerial); +router.put("/serials/:id", updateMoldSerial); router.delete("/serials/:id", deleteMoldSerial); // 일련번호 현황 집계 diff --git a/frontend/app/(main)/COMPANY_16/mold/info/page.tsx b/frontend/app/(main)/COMPANY_16/mold/info/page.tsx index 5db95d13..332b3fbc 100644 --- a/frontend/app/(main)/COMPANY_16/mold/info/page.tsx +++ b/frontend/app/(main)/COMPANY_16/mold/info/page.tsx @@ -5,7 +5,7 @@ * * 좌측: 금형 카드 리스트 (이미지 + 코드 + 이름 + 유형뱃지 + 수명진행률) * 우측: 금형 상세 + 탭 3개 (일련번호 / 점검항목 / 부품) - * 전용 API: /api/molds/* + * 전용 API: /molds/* */ import React, { useState, useEffect, useCallback } from "react"; @@ -21,7 +21,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen import { Plus, Trash2, Loader2, Pencil, Box, Inbox, Search, Wrench, ClipboardCheck, Package, LayoutGrid, List, - Image as ImageIcon, + Image as ImageIcon, Upload, X as XIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -30,7 +30,7 @@ import { toast } from "sonner"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; // ─── API base ─── -const API = "/api/molds"; +const API = "/mold"; // ─── 상태/유형 매핑 ─── const STATUS_MAP: Record = { @@ -99,6 +99,8 @@ export default function MoldInfoPage() { const [moldEditMode, setMoldEditMode] = useState(false); const [moldForm, setMoldForm] = useState>({}); const [saving, setSaving] = useState(false); + const [moldImagePreview, setMoldImagePreview] = useState(null); + const moldImageRef = React.useRef(null); const [serialModalOpen, setSerialModalOpen] = useState(false); const [serialForm, setSerialForm] = useState>({}); @@ -205,6 +207,7 @@ export default function MoldInfoPage() { const handleOpenRegister = () => { setMoldEditMode(false); setMoldForm({}); + setMoldImagePreview(null); setMoldModalOpen(true); }; @@ -212,9 +215,22 @@ export default function MoldInfoPage() { if (!detail) return; setMoldEditMode(true); setMoldForm({ ...detail }); + setMoldImagePreview(detail.image_path || null); setMoldModalOpen(true); }; + const handleMoldImageUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onloadend = () => { + const result = reader.result as string; + setMoldImagePreview(result); + setMoldForm((prev) => ({ ...prev, image_path: result })); + }; + reader.readAsDataURL(file); + }; + const handleSaveMold = async () => { if (!moldForm.mold_code || !moldForm.mold_name) { toast.error("금형코드와 금형명은 필수예요."); @@ -260,18 +276,23 @@ export default function MoldInfoPage() { }; // ─── 일련번호 CRUD ─── - const handleAddSerial = async () => { + const handleSaveSerial = async () => { if (!selectedMoldCode) return; setSaving(true); try { - await apiClient.post(`${API}/${selectedMoldCode}/serials`, serialForm); - toast.success("일련번호가 등록되었어요."); + if (serialForm.id) { + await apiClient.put(`${API}/serials/${serialForm.id}`, serialForm); + toast.success("일련번호가 수정되었어요."); + } else { + await apiClient.post(`${API}/${selectedMoldCode}/serials`, serialForm); + toast.success("일련번호가 등록되었어요."); + } setSerialModalOpen(false); setSerialForm({}); fetchSerials(selectedMoldCode); fetchDetail(selectedMoldCode); } catch (err: any) { - toast.error(err?.response?.data?.message || "등록에 실패했어요."); + toast.error(err?.response?.data?.message || "저장에 실패했어요."); } finally { setSaving(false); } @@ -332,6 +353,10 @@ export default function MoldInfoPage() { toast.error("부품명은 필수예요."); return; } + if (!partForm.replacement_cycle) { + toast.error("교체주기는 필수예요."); + return; + } setSaving(true); try { await apiClient.post(`${API}/${selectedMoldCode}/parts`, partForm); @@ -467,7 +492,7 @@ export default function MoldInfoPage() {
- setFilterType(v === "__all__" ? "" : v)}> @@ -482,7 +507,7 @@ export default function MoldInfoPage() {
- setFilterStatus(v === "__all__" ? "" : v)}> @@ -495,11 +520,7 @@ export default function MoldInfoPage() {
- @@ -748,31 +769,70 @@ export default function MoldInfoPage() { 일련번호 상태 + 샷수 현황 보관위치 비고 - + 작업 {serials.map((s: any) => { const ss = SERIAL_STATUS_MAP[s.status] || { label: s.status || "-", variant: "secondary" as const }; + const maxShot = detail?.shot_count || 0; + const curShot = s.current_shot_count || 0; + const pct = maxShot > 0 ? Math.min(Math.round((curShot / maxShot) * 100), 100) : 0; return ( - {s.serial_number} + {s.serial_number} {ss.label} + + {maxShot > 0 ? ( +
+
+
+
+
+ + {pct}% + +
+

+ {curShot.toLocaleString()} / {maxShot.toLocaleString()} +

+
+ ) : ( + - + )} + {s.storage_location || "-"} {s.remarks || "-"} - +
+ + +
); @@ -1037,12 +1097,40 @@ export default function MoldInfoPage() { />
- - setMoldForm({ ...moldForm, image_path: e.target.value })} - placeholder="이미지 URL" + +
+ {moldImagePreview ? ( + <> + 금형 이미지 +
+ + +
+ + ) : ( + + )} +
+
@@ -1057,12 +1145,12 @@ export default function MoldInfoPage() { - {/* ========== 일련번호 등록 모달 ========== */} + {/* ========== 일련번호 등록/수정 모달 ========== */} - 일련번호 등록 - 일련번호는 자동으로 채번돼요. + {serialForm.id ? "일련번호 수정" : "일련번호 등록"} + {serialForm.id ? `${serialForm.serial_number} 정보를 수정해요.` : "일련번호는 자동으로 채번돼요."}
@@ -1078,9 +1166,21 @@ export default function MoldInfoPage() { 사용중 보관중 수리중 + 폐기
+
+ + setSerialForm({ ...serialForm, current_shot_count: e.target.value ? Number(e.target.value) : null })} + placeholder="0" + min={0} + /> +
-
+
- @@ -1137,46 +1237,71 @@ export default function MoldInfoPage() { />
- - 점검방법 * +
-
- - setInspectionForm({ ...inspectionForm, lower_limit: e.target.value })} - /> -
-
- - setInspectionForm({ ...inspectionForm, upper_limit: e.target.value })} - /> -
-
- - setInspectionForm({ ...inspectionForm, unit: e.target.value })} - placeholder="mm, ℃ 등" - /> -
-
+ {inspectionForm.inspection_method === "숫자" && ( + <> +
+ + setInspectionForm({ ...inspectionForm, lower_limit: e.target.value })} + placeholder="기준값" + /> +
+
+ + setInspectionForm({ ...inspectionForm, upper_limit: e.target.value })} + placeholder="허용 오차" + /> +
+
+ + setInspectionForm({ ...inspectionForm, unit: e.target.value })} + placeholder="mm, ℃ 등" + /> +
+ + )} +
- setInspectionForm({ ...inspectionForm, inspection_content: e.target.value })} placeholder="상세 내용" + rows={3} />
@@ -1208,13 +1333,25 @@ export default function MoldInfoPage() { />
- - 교체주기 * +
@@ -1245,11 +1382,12 @@ export default function MoldInfoPage() {
- setPartForm({ ...partForm, remarks: e.target.value })} placeholder="비고" + rows={3} />
diff --git a/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor/page.tsx b/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor/page.tsx index 5b806f86..82b9c7d6 100644 --- a/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor/page.tsx +++ b/frontend/app/(main)/COMPANY_16/outsourcing/subcontractor/page.tsx @@ -37,12 +37,26 @@ const MAPPING_TABLE = "subcontractor_item_mapping"; const PRICE_TABLE = "subcontractor_item_prices"; const GRID_COLUMNS_CONFIG = [ - { key: "subcontractor_code", label: "외주업체코드" }, - { key: "subcontractor_name", label: "외주업체명" }, + { key: "subcontractor_code", label: "외주사코드" }, + { key: "subcontractor_name", label: "외주사명" }, + { key: "division", label: "업체 유형" }, + { key: "status", label: "상태" }, { key: "contact_person", label: "담당자" }, - { key: "contact_phone", label: "연락처" }, - { key: "division_label", label: "유형" }, - { key: "status_label", label: "상태" }, + { key: "contact_phone", label: "담당자 전화번호" }, + { key: "contact_email", label: "담당자 이메일" }, + { key: "email", label: "이메일" }, + { key: "business_number", label: "사업자번호" }, + { key: "address", label: "주소" }, + { key: "phone", label: "전화번호" }, + { key: "fax", label: "팩스" }, + { key: "representative", label: "대표자명" }, + { key: "grade", label: "등급" }, + { key: "process_type", label: "공정 유형" }, + { key: "payment_terms", label: "결제 조건" }, + { key: "remarks", label: "비고" }, + { key: "writer", label: "작성자" }, + { key: "created_date", label: "생성일시" }, + { key: "updated_date", label: "수정일시" }, ]; export default function SubcontractorManagementPage() { const { confirm, ConfirmDialogComponent } = useConfirmDialog(); @@ -97,7 +111,8 @@ export default function SubcontractorManagementPage() { const [excelDetecting, setExcelDetecting] = useState(false); // 테이블 설정 - const ts = useTableSettings("c16-subcontractor", SUBCONTRACTOR_TABLE, GRID_COLUMNS_CONFIG); + const DEFAULT_VISIBLE_KEYS = ["subcontractor_code", "subcontractor_name", "division", "status", "contact_person", "contact_phone"]; + const ts = useTableSettings("c16-subcontractor-v2", SUBCONTRACTOR_TABLE, GRID_COLUMNS_CONFIG, DEFAULT_VISIBLE_KEYS); // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); @@ -139,6 +154,19 @@ export default function SubcontractorManagementPage() { return categoryOptions[col]?.find((o) => o.code === code)?.label || code; }; + // 셀 렌더링 헬퍼 + const renderCellValue = (row: any, key: string) => { + const val = key === "division" ? (row.division_label || row.division) + : key === "status" ? (row.status_label || row.status) + : row[key]; + if (!val) return "-"; + if (key === "subcontractor_code") return {val}; + if (key === "division") return {val}; + if (key === "status") return {val}; + if (key === "subcontractor_name") return {val}; + return val; + }; + // 외주업체 목록 조회 const fetchSubcontractors = useCallback(async () => { setSubcontractorLoading(true); @@ -365,6 +393,7 @@ export default function SubcontractorManagementPage() { } setItemMappings(mappings); setItemPrices(prices); + setEditItemData(null); setItemSelectOpen(false); setItemDetailOpen(true); }; @@ -430,7 +459,7 @@ export default function SubcontractorManagementPage() { })); }; - // 우측 품목 편집 열기 + // 우측 품목 편집 열기 — 해당 item_number의 모든 매핑+단가를 로드 const openEditItem = async (row: any) => { const itemKey = row.item_number || row.item_id; let itemInfo: any = { item_number: itemKey, item_name: row.item_name || "", size: "", unit: "" }; @@ -444,25 +473,49 @@ export default function SubcontractorManagementPage() { if (found) itemInfo = found; } catch { /* skip */ } - const mappingRows = [{ - _id: `m_existing_${row.id}`, - subcontractor_item_code: row.subcontractor_item_code || "", - subcontractor_item_name: row.subcontractor_item_name || "", - }].filter((m) => m.subcontractor_item_code || m.subcontractor_item_name); + // 같은 item_number를 가진 모든 priceItems 행에서 매핑 정보 추출 + const allRowsForItem = priceItems.filter((p: any) => (p.item_number || p.item_id) === itemKey); + const allMappingIds = allRowsForItem.map((r: any) => r.id).filter(Boolean); - const priceRows = [{ - _id: `p_existing_${row.id}`, - start_date: row.start_date || "", - end_date: row.end_date || "", - currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI", - base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW", - base_price: row.base_price ? String(row.base_price) : "", - discount_type: row.discount_type || "", - discount_value: row.discount_value ? String(row.discount_value) : "", - rounding_type: row.rounding_type || "", - rounding_unit_value: row.rounding_unit_value || "", - calculated_price: row.calculated_price ? String(row.calculated_price) : "", - }].filter((p) => p.base_price || p.start_date); + const mappingRows = allRowsForItem + .filter((r: any) => r.subcontractor_item_code || r.subcontractor_item_name) + .map((r: any) => ({ + _id: `m_existing_${r.id}`, + subcontractor_item_code: r.subcontractor_item_code || "", + subcontractor_item_name: r.subcontractor_item_name || "", + })); + + // 서버에서 이 item+subcontractor의 모든 단가를 raw 코드로 가져오기 + let priceRows: Array<{ + _id: string; start_date: string; end_date: string; currency_code: string; + base_price_type: string; base_price: string; discount_type: string; + discount_value: string; rounding_type: string; rounding_unit_value: string; + calculated_price: string; + }> = []; + try { + const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [ + { columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor!.subcontractor_code }, + { columnName: "item_id", operator: "equals", value: itemKey }, + ]}, + autoFilter: true, + }); + const rawPrices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + priceRows = rawPrices.map((p: any) => ({ + _id: `p_existing_${p.id}`, + start_date: p.start_date || "", + end_date: p.end_date || "", + currency_code: p.currency_code || "CAT_MLAMDKVN_PZJI", + base_price_type: p.base_price_type || "CAT_MLAMFGFT_4RZW", + base_price: p.base_price ? String(p.base_price) : "", + discount_type: p.discount_type || "", + discount_value: p.discount_value ? String(p.discount_value) : "", + rounding_type: p.rounding_type || "", + rounding_unit_value: p.rounding_unit_value || "", + calculated_price: p.calculated_price ? String(p.calculated_price) : "", + })); + } catch { /* skip */ } if (priceRows.length === 0) { priceRows.push({ @@ -475,7 +528,8 @@ export default function SubcontractorManagementPage() { setSelectedItemsForDetail([itemInfo]); setItemMappings({ [itemKey]: mappingRows }); setItemPrices({ [itemKey]: priceRows }); - setEditItemData(row); + // editItemData에 원본 매핑 ID 목록 저장 (삭제 시 사용) + setEditItemData({ ...row, _allMappingIds: allMappingIds }); setItemDetailOpen(true); }; @@ -489,23 +543,21 @@ export default function SubcontractorManagementPage() { const mappingRows = itemMappings[itemKey] || []; if (isEditingExisting && editItemData?.id) { - await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, { - originalData: { id: editItemData.id }, - updatedData: { - subcontractor_item_code: mappingRows[0]?.subcontractor_item_code || "", - subcontractor_item_name: mappingRows[0]?.subcontractor_item_name || "", - base_price: null, - discount_type: null, - discount_value: null, - calculated_price: null, - }, - }); + // 1) 기존 매핑 모두 삭제 + const allMappingIds: string[] = editItemData._allMappingIds || [editItemData.id]; + if (allMappingIds.length > 0) { + await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, { + data: allMappingIds.map((mid: string) => ({ id: mid })), + }); + } + // 2) 기존 단가 모두 삭제 (subcontractor_id + item_id 기준) try { const existingPrices = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { - page: 1, size: 100, + page: 1, size: 500, dataFilter: { enabled: true, filters: [ - { columnName: "mapping_id", operator: "equals", value: editItemData.id }, + { columnName: "subcontractor_id", operator: "equals", value: selectedSubcontractor.subcontractor_code }, + { columnName: "item_id", operator: "equals", value: itemKey }, ]}, autoFilter: true, }); const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || []; @@ -516,13 +568,39 @@ export default function SubcontractorManagementPage() { } } catch { /* skip */ } - const priceRows = (itemPrices[itemKey] || []).filter((p) => + // 3) 모든 매핑 재삽입 + let firstMappingId: string | null = null; + for (let mi = 0; mi < mappingRows.length; mi++) { + const newId = crypto.randomUUID(); + if (mi === 0) firstMappingId = newId; + await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { + id: newId, + subcontractor_id: selectedSubcontractor.subcontractor_code, + item_id: itemKey, + subcontractor_item_code: mappingRows[mi].subcontractor_item_code || "", + subcontractor_item_name: mappingRows[mi].subcontractor_item_name || "", + }); + } + // 매핑이 비어있으면 빈 매핑 1개 생성 (item_id 연결 유지) + if (mappingRows.length === 0) { + firstMappingId = crypto.randomUUID(); + await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, { + id: firstMappingId, + subcontractor_id: selectedSubcontractor.subcontractor_code, + item_id: itemKey, + subcontractor_item_code: "", + subcontractor_item_name: "", + }); + } + + // 4) 모든 단가 재삽입 + const filteredPriceRows = (itemPrices[itemKey] || []).filter((p) => (p.base_price && Number(p.base_price) > 0) || p.start_date ); - for (const price of priceRows) { + for (const price of filteredPriceRows) { await apiClient.post(`/table-management/tables/${PRICE_TABLE}/add`, { id: crypto.randomUUID(), - mapping_id: editItemData.id, + mapping_id: firstMappingId || "", subcontractor_id: selectedSubcontractor.subcontractor_code, item_id: itemKey, start_date: price.start_date || null, end_date: price.end_date || null, @@ -767,12 +845,9 @@ export default function SubcontractorManagementPage() { - {ts.isVisible("subcontractor_code") && 외주업체코드} - {ts.isVisible("subcontractor_name") && 외주업체명} - {ts.isVisible("contact_person") && 담당자} - {ts.isVisible("contact_phone") && 연락처} - {ts.isVisible("division_label") && 유형} - {ts.isVisible("status_label") && 상태} + {ts.visibleColumns.map((col) => ( + {col.label} + ))} @@ -788,24 +863,11 @@ export default function SubcontractorManagementPage() { onClick={() => setSelectedSubcontractorId(sub.id)} onDoubleClick={openSubcontractorEdit} > - {ts.isVisible("subcontractor_code") && {sub.subcontractor_code}} - {ts.isVisible("subcontractor_name") && {sub.subcontractor_name}} - {ts.isVisible("contact_person") && {sub.contact_person || "-"}} - {ts.isVisible("contact_phone") && {sub.contact_phone || "-"}} - {ts.isVisible("division_label") && ( - - {sub.division_label - ? {sub.division_label} - : "-"} + {ts.visibleColumns.map((col) => ( + + {renderCellValue(sub, col.key)} - )} - {ts.isVisible("status_label") && ( - - {sub.status_label - ? {sub.status_label} - : "-"} - - )} + ))} ))} @@ -877,24 +939,47 @@ export default function SubcontractorManagementPage() { - {priceItems.map((item) => ( - openEditItem(item)} - > - {item.item_number} - {item.item_name} - {item.subcontractor_item_code || "-"} - {item.subcontractor_item_name || "-"} - {item.base_price_type || "-"} - {item.base_price ? Number(item.base_price).toLocaleString() : "-"} - {item.discount_type || "-"} - {item.discount_value || "-"} - {item.calculated_price ? Number(item.calculated_price).toLocaleString() : "-"} - {item.currency_code || "-"} - - ))} + {(() => { + // item_number 기준 그룹화 (순서 유지) + const grouped: { itemNumber: string; rows: any[] }[] = []; + const groupMap = new Map(); + for (const item of priceItems) { + const key = item.item_number || item.item_id || item.id; + if (!groupMap.has(key)) { + const rows: any[] = []; + groupMap.set(key, rows); + grouped.push({ itemNumber: key, rows }); + } + groupMap.get(key)!.push(item); + } + return grouped.map((group, gIdx) => + group.rows.map((item, rowIdx) => ( + 0 && "border-t-2 border-t-border/60" + )} + onDoubleClick={() => openEditItem(item)} + > + + {rowIdx === 0 ? item.item_number : ""} + + + {rowIdx === 0 ? item.item_name : ""} + + {item.subcontractor_item_code || "-"} + {item.subcontractor_item_name || "-"} + {item.base_price_type || "-"} + {item.base_price ? Number(item.base_price).toLocaleString() : "-"} + {item.discount_type || "-"} + {item.discount_value || "-"} + {item.calculated_price ? Number(item.calculated_price).toLocaleString() : "-"} + {item.currency_code || "-"} + + )) + ); + })()}
)} @@ -1186,7 +1271,7 @@ export default function SubcontractorManagementPage() { className="h-8 text-xs flex-1" />
- updatePriceRow(itemKey, price._id, "currency_code", v)}> {(priceCategoryOptions["currency_code"] || []).map((o) => {o.label})} @@ -1197,8 +1282,8 @@ export default function SubcontractorManagementPage() { {/* 기준가/할인/반올림 */}
- updatePriceRow(itemKey, price._id, "base_price_type", v)}> + {(priceCategoryOptions["base_price_type"] || []).map((o) => {o.label})} @@ -1211,8 +1296,8 @@ export default function SubcontractorManagementPage() { placeholder="기준가" />
- updatePriceRow(itemKey, price._id, "discount_type", v)}> + 할인없음 {(priceCategoryOptions["discount_type"] || []).map((o) => {o.label})} @@ -1226,7 +1311,7 @@ export default function SubcontractorManagementPage() { placeholder="0" />
- updatePriceRow(itemKey, price._id, "rounding_unit_value", v)}> {(priceCategoryOptions["rounding_unit_value"] || []).map((o) => {o.label})} @@ -1292,6 +1377,7 @@ export default function SubcontractorManagementPage() { tableName={ts.tableName} settingsId={ts.settingsId} defaultVisibleKeys={ts.defaultVisibleKeys} + includeAutoColumns={["created_date", "updated_date", "writer"]} onSave={ts.applySettings} /> diff --git a/frontend/components/common/TableSettingsModal.tsx b/frontend/components/common/TableSettingsModal.tsx index 1c5fa16c..52259195 100644 --- a/frontend/components/common/TableSettingsModal.tsx +++ b/frontend/components/common/TableSettingsModal.tsx @@ -78,6 +78,8 @@ export interface TableSettingsModalProps { initialTab?: "columns" | "filters" | "groups"; /** 기본 표시 컬럼 키 목록 (GRID_COLUMNS 기준). 미지정 시 전체 표시 */ defaultVisibleKeys?: string[]; + /** AUTO_COLS에서 제외하지 않을 컬럼 키 목록 (예: ["created_date", "updated_date", "writer"]) */ + includeAutoColumns?: string[]; } // ===== 상수 ===== @@ -207,6 +209,7 @@ export function TableSettingsModal({ onSave, initialTab = "columns", defaultVisibleKeys, + includeAutoColumns, }: TableSettingsModalProps) { const [activeTab, setActiveTab] = useState(initialTab); const [loading, setLoading] = useState(false); @@ -240,7 +243,7 @@ export function TableSettingsModal({ // 기본 컬럼 설정 생성 const unsortedColumns: ColumnSetting[] = types - .filter((t) => !AUTO_COLS.includes(t.columnName)) + .filter((t) => !AUTO_COLS.includes(t.columnName) || includeAutoColumns?.includes(t.columnName)) .map((t) => ({ columnName: t.columnName, displayName: t.displayName || t.columnLabel || t.columnName, diff --git a/frontend/components/ui/select.tsx b/frontend/components/ui/select.tsx index 1ebfdfed..0b34a6b2 100644 --- a/frontend/components/ui/select.tsx +++ b/frontend/components/ui/select.tsx @@ -62,7 +62,7 @@ function SelectContent({ ( settingsId: string, tableName: string, defaultColumns: T[], + /** 초기 표시 컬럼 키 (미지정 시 defaultColumns 전체) */ + initialVisibleKeys?: string[], ) { const [open, setOpen] = useState(false); const [visibleKeys, setVisibleKeys] = useState>( - () => new Set(defaultColumns.map((c) => c.key)), + () => new Set(initialVisibleKeys || defaultColumns.map((c) => c.key)), ); const [columnWidths, setColumnWidths] = useState>({}); const [orderedKeys, setOrderedKeys] = useState( - () => defaultColumns.map((c) => c.key), + () => initialVisibleKeys || defaultColumns.map((c) => c.key), ); // 초기 filterConfig: GRID_COLUMNS에 있는 컬럼만 필터 가능 (전부 비활성) const [filterConfig, setFilterConfig] = useState( @@ -70,9 +72,12 @@ export function useTableSettings( } } - // settings에 없는 새 컬럼은 보이도록 추가 + // settings에 없는 새 컬럼은 초기 표시 목록에 있을 때만 보이도록 추가 + const initKeys = initialVisibleKeys + ? new Set(initialVisibleKeys) + : new Set(defaultColumns.map((c) => c.key)); for (const col of defaultColumns) { - if (!settings.columns.find((c) => c.columnName === col.key)) { + if (!settings.columns.find((c) => c.columnName === col.key) && initKeys.has(col.key)) { visible.add(col.key); order.push(col.key); } @@ -87,7 +92,7 @@ export function useTableSettings( settings.filters?.filter((f) => visible.has(f.columnName)), ); }, - [defaultColumns], + [defaultColumns, initialVisibleKeys], ); // 마운트 시 저장된 설정 복원 @@ -148,6 +153,6 @@ export function useTableSettings( /** 필터 설정 */ filterConfig, /** GRID_COLUMNS 기본 컬럼 키 목록 (TableSettingsModal defaultVisibleKeys용) */ - defaultVisibleKeys: defaultColumns.map((c) => c.key), + defaultVisibleKeys: initialVisibleKeys || defaultColumns.map((c) => c.key), }; }