diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 52e8190b..389f8b11 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -156,6 +156,7 @@ import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리 import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리 import cuttingPlanRoutes from "./routes/cuttingPlanRoutes"; // 절단계획 관리 +import salesOrderBulkRoutes from "./routes/salesOrderBulkRoutes"; // 수주 엑셀 일괄업로드 (제일그라스) import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트 import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별) import reportCellValueRoutes from "./routes/reportCellValueRoutes"; // 리포트 셀 커스텀 입력값 (input 셀) @@ -383,6 +384,7 @@ app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리 app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리 app.use("/api/cutting-plan", cuttingPlanRoutes); // 절단계획 관리 +app.use("/api/sales-order", salesOrderBulkRoutes); // 수주 엑셀 일괄업로드 app.use("/api/sales-report", salesReportRoutes); // 영업 리포트 app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장) app.use("/api/report-cell-values", reportCellValueRoutes); // 리포트 셀 커스텀 입력값 diff --git a/backend-node/src/controllers/moldController.ts b/backend-node/src/controllers/moldController.ts index 19ef1eb2..dff3f765 100644 --- a/backend-node/src/controllers/moldController.ts +++ b/backend-node/src/controllers/moldController.ts @@ -403,6 +403,56 @@ export async function createMoldInspection(req: AuthenticatedRequest, res: Respo } } +export async function updateMoldInspection(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const { + inspection_item, inspection_cycle, inspection_method, + inspection_content, lower_limit, upper_limit, unit, + is_active, checklist, remarks, + } = req.body; + + if (!inspection_item) { + res.status(400).json({ success: false, message: "점검항목명은 필수입니다." }); + return; + } + + const sql = ` + UPDATE mold_inspection_item SET + inspection_item = $1, + inspection_cycle = $2, + inspection_method = $3, + inspection_content = $4, + lower_limit = $5, + upper_limit = $6, + unit = $7, + is_active = COALESCE($8, is_active), + checklist = $9, + remarks = $10 + WHERE id = $11 AND company_code = $12 + RETURNING * + `; + const params = [ + inspection_item, inspection_cycle || null, inspection_method || null, + inspection_content || null, lower_limit || null, upper_limit || null, + unit || null, is_active, checklist || null, remarks || null, + id, companyCode, + ]; + const result = await query(sql, params); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "점검항목을 찾을 수 없습니다." }); + return; + } + + 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 deleteMoldInspection(req: AuthenticatedRequest, res: Response): Promise { try { const companyCode = req.user!.companyCode; @@ -481,6 +531,52 @@ export async function createMoldPart(req: AuthenticatedRequest, res: Response): } } +export async function updateMoldPart(req: AuthenticatedRequest, res: Response): Promise { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const { + part_name, replacement_cycle, unit, specification, + manufacturer, manufacturer_code, image_path, remarks, + } = req.body; + + if (!part_name) { + res.status(400).json({ success: false, message: "부품명은 필수입니다." }); + return; + } + + const sql = ` + UPDATE mold_part SET + part_name = $1, + replacement_cycle = $2, + unit = $3, + specification = $4, + manufacturer = $5, + manufacturer_code = $6, + image_path = $7, + remarks = $8 + WHERE id = $9 AND company_code = $10 + RETURNING * + `; + const params = [ + part_name, replacement_cycle || null, unit || null, + specification || null, manufacturer || null, manufacturer_code || null, + image_path || null, remarks || null, id, companyCode, + ]; + const result = await query(sql, params); + + if (result.length === 0) { + res.status(404).json({ success: false, message: "부품을 찾을 수 없습니다." }); + return; + } + + 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 deleteMoldPart(req: AuthenticatedRequest, res: Response): Promise { try { const companyCode = req.user!.companyCode; diff --git a/backend-node/src/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts index bb83fd2c..22dfe51d 100644 --- a/backend-node/src/controllers/processInfoController.ts +++ b/backend-node/src/controllers/processInfoController.ts @@ -477,8 +477,9 @@ export async function saveRoutingDetails(req: AuthenticatedRequest, res: Respons for (let i = 0; i < supplierIds.length; i++) { await client.query( + // 본서버 id 컬럼이 uuid 타입, 개발서버는 varchar — ::text 캐스팅하면 본서버에서 타입 불일치 오류 발생 `INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_id, seq_order) - VALUES (gen_random_uuid()::text, $1, $2, $3, $4)`, + VALUES (gen_random_uuid(), $1, $2, $3, $4)`, [companyCode, newDetailId, supplierIds[i], i] ); } diff --git a/backend-node/src/controllers/salesOrderBulkController.ts b/backend-node/src/controllers/salesOrderBulkController.ts new file mode 100644 index 00000000..84deae33 --- /dev/null +++ b/backend-node/src/controllers/salesOrderBulkController.ts @@ -0,0 +1,45 @@ +/** + * 제일그라스 수주 엑셀 일괄 업로드 컨트롤러 + */ +import { Request, Response } from "express"; +import logger from "../utils/logger"; +import { excelBulkUpload } from "../services/salesOrderBulkService"; + +export async function bulkUploadHandler(req: Request, res: Response): Promise { + try { + const user = (req as any).user || {}; + const companyCode = user.companyCode; + const userId = user.userId || user.username || "system"; + + if (!companyCode) { + res.status(401).json({ success: false, message: "로그인 정보 없음" }); + return; + } + + const { rows, autoCreateItems, defaultItemDivision } = req.body || {}; + if (!Array.isArray(rows) || rows.length === 0) { + res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다." }); + return; + } + + const result = await excelBulkUpload({ + companyCode, + userId, + rows, + autoCreateItems, + defaultItemDivision, + }); + + res.status(200).json({ + success: true, + message: "업로드 완료", + data: result, + }); + } catch (err: any) { + logger.error("수주 일괄 업로드 실패:", err); + res.status(500).json({ + success: false, + message: err?.message || "업로드 실패", + }); + } +} diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index ae795dc1..12cb7de7 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1185,12 +1185,15 @@ export async function editTableData( if (companyCode !== "*") { const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code"); if (hasCompanyCodeColumn) { - // 1. 원본 데이터의 company_code 확인 - if (originalData?.id) { + // 1. 원본 데이터의 company_code 확인 (id exact match 필수) + if (originalData?.id !== undefined && originalData?.id !== null && originalData?.id !== "") { try { + // ⚠️ 기존 search: { id: String(id) } 는 내부에서 ILIKE '%id%' 부분일치로 변환되어 + // 다른 회사의 id (예: 116 검색 시 1116, 1160 등)가 매칭되어 cross-company 오탐 발생. + // 반드시 operator:equals 를 명시하여 정확 일치 조회할 것. const existing = await tableManagementService.getTableData(tableName, { page: 1, size: 1, - search: { id: String(originalData.id) }, + search: { id: { value: String(originalData.id), operator: "equals" } }, }); const existingRow = existing.data?.[0]; if (existingRow && existingRow.company_code && existingRow.company_code !== companyCode && existingRow.company_code !== "*") { diff --git a/backend-node/src/routes/moldRoutes.ts b/backend-node/src/routes/moldRoutes.ts index 5067ef79..3c5a72d6 100644 --- a/backend-node/src/routes/moldRoutes.ts +++ b/backend-node/src/routes/moldRoutes.ts @@ -12,9 +12,11 @@ import { deleteMoldSerial, getMoldInspections, createMoldInspection, + updateMoldInspection, deleteMoldInspection, getMoldParts, createMoldPart, + updateMoldPart, deleteMoldPart, getMoldSerialSummary, } from "../controllers/moldController"; @@ -41,11 +43,13 @@ router.get("/:moldCode/serial-summary", getMoldSerialSummary); // 점검항목 router.get("/:moldCode/inspections", getMoldInspections); router.post("/:moldCode/inspections", createMoldInspection); +router.put("/inspections/:id", updateMoldInspection); router.delete("/inspections/:id", deleteMoldInspection); // 부품 router.get("/:moldCode/parts", getMoldParts); router.post("/:moldCode/parts", createMoldPart); +router.put("/parts/:id", updateMoldPart); router.delete("/parts/:id", deleteMoldPart); export default router; diff --git a/backend-node/src/routes/salesOrderBulkRoutes.ts b/backend-node/src/routes/salesOrderBulkRoutes.ts new file mode 100644 index 00000000..4df281c4 --- /dev/null +++ b/backend-node/src/routes/salesOrderBulkRoutes.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { bulkUploadHandler } from "../controllers/salesOrderBulkController"; + +const router = Router(); + +// POST /api/sales-order/excel-bulk-upload +router.post("/excel-bulk-upload", authenticateToken, bulkUploadHandler); + +export default router; diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts index b9c349ce..691c24d4 100644 --- a/backend-node/src/services/bomService.ts +++ b/backend-node/src/services/bomService.ts @@ -57,9 +57,11 @@ export async function addBomHistory( export async function getBomHeader(bomId: string, tableName?: string) { const table = safeTableName(tableName || "", "bom"); + // b.* 에 포함된 item_type(BOM 유형)을 i.division으로 덮어쓰지 않도록 alias 제거. + // 품목 분류가 필요하면 프론트에서 i.division 컬럼을 직접 사용. const sql = ` SELECT b.*, - i.item_name, i.item_number, i.division as item_type, + i.item_name, i.item_number, COALESCE(NULLIF(b.unit, ''), NULLIF(i.unit, ''), NULLIF(i.inventory_unit, '')) as unit, i.unit as item_unit, i.inventory_unit as item_inventory_unit, diff --git a/backend-node/src/services/salesOrderBulkService.ts b/backend-node/src/services/salesOrderBulkService.ts new file mode 100644 index 00000000..f83828e0 --- /dev/null +++ b/backend-node/src/services/salesOrderBulkService.ts @@ -0,0 +1,377 @@ +/** + * 제일그라스(COMPANY_9) 수주 엑셀 일괄 업로드 서비스 + * - 품목 자동 등록 (item_info에 없는 품명/규격 조합은 신규 생성) + * - 마스터 UPSERT (sales_order_mng) + * - 디테일 INSERT (sales_order_detail) + * - 트랜잭션 보장 + */ + +import { getPool } from "../database/db"; +import { numberingRuleService } from "./numberingRuleService"; +import logger from "../utils/logger"; + +// 업로드 요청 페이로드 — 프론트 매핑 결과 +export interface BulkRow { + // 마스터 후보 필드 + order_no?: string; + partner_code?: string; + partner_name?: string; + order_date?: string; + due_date?: string; + status?: string; + + // 디테일 필드 + part_code?: string; + part_name?: string; + spec?: string; + width?: number | string; + height?: number | string; + thickness?: number | string; + area?: number | string; + unit?: string; + qty?: number | string; + unit_price?: number | string; + amount?: number | string; + memo?: string; + + // 품목 자동등록 옵션 필드 + division?: string; +} + +export interface BulkUploadPayload { + companyCode: string; + userId: string; + rows: BulkRow[]; + autoCreateItems?: boolean; + defaultItemDivision?: string; // e.g. 'CAT_DIV_RAW_MAT' +} + +export interface BulkUploadResult { + itemsCreated: number; + mastersCreated: number; + detailsCreated: number; + warnings: string[]; + errors: string[]; +} + +const ITEM_NUMBER_PREFIX = "R"; // 자동 생성 품번 접두어 + +function toNum(v: any): number { + if (v === null || v === undefined || v === "") return 0; + const n = Number(String(v).replace(/,/g, "")); + return isNaN(n) ? 0 : n; +} + +function normStr(v: any): string { + if (v === null || v === undefined) return ""; + return String(v).trim(); +} + +/** + * 엑셀 한 줄 → item_info에서 기존 품목 조회, 없으면 INSERT + * 반환: { itemNumber: string, created: boolean } + */ +async function resolveOrCreateItem( + client: any, + companyCode: string, + row: BulkRow, + defaultDivision: string, + userId: string +): Promise<{ itemNumber: string; created: boolean } | null> { + const partName = normStr(row.part_name); + const partCode = normStr(row.part_code); + if (!partName && !partCode) return null; + + // 1) part_code 직접 매칭 우선 + if (partCode) { + const r = await client.query( + `SELECT item_number FROM item_info + WHERE company_code = $1 AND item_number = $2 + LIMIT 1`, + [companyCode, partCode] + ); + if (r.rows.length > 0) return { itemNumber: r.rows[0].item_number, created: false }; + } + + // 2) part_name + 규격 매칭 + if (partName) { + const w = toNum(row.width); + const h = toNum(row.height); + const t = toNum(row.thickness); + const r2 = await client.query( + `SELECT item_number FROM item_info + WHERE company_code = $1 + AND item_name = $2 + AND COALESCE(width::numeric,0) = $3 + AND COALESCE(height::numeric,0) = $4 + AND COALESCE(thickness::numeric,0) = $5 + LIMIT 1`, + [companyCode, partName, w, h, t] + ); + if (r2.rows.length > 0) return { itemNumber: r2.rows[0].item_number, created: false }; + } + + // 3) 자동 생성 + if (!partName) return null; // 품명 없으면 생성 불가 + + // 간단 채번: R_YYYYMMDD_NNNN (company 내 해당 prefix 최대값+1) + const today = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + const prefix = `${ITEM_NUMBER_PREFIX}_${today}_`; + const seqR = await client.query( + `SELECT COUNT(*)::int AS c FROM item_info + WHERE company_code = $1 AND item_number LIKE $2`, + [companyCode, `${prefix}%`] + ); + const seq = (Number(seqR.rows[0]?.c) || 0) + 1; + const itemNumber = `${prefix}${String(seq).padStart(4, "0")}`; + + const division = normStr(row.division) || defaultDivision; + const unit = normStr(row.unit) || "EA"; + + await client.query( + `INSERT INTO item_info ( + id, company_code, item_number, item_name, + size, width, height, thickness, + unit, division, status, writer, created_date + ) VALUES ( + gen_random_uuid()::text, $1, $2, $3, + $4, $5, $6, $7, + $8, $9, 'active', $10, NOW() + )`, + [ + companyCode, + itemNumber, + partName, + normStr(row.spec), + toNum(row.width) || null, + toNum(row.height) || null, + toNum(row.thickness) || null, + unit, + division, + userId, + ] + ); + + return { itemNumber, created: true }; +} + +/** + * 수주번호 채번 — numbering_rules에 설정 있으면 사용, 없으면 ORD-YYYYMMDD-NNNN 폴백 + */ +async function allocateOrderNo(companyCode: string, client: any): Promise { + // 기존 규칙 조회 + const ruleRes = await client.query( + `SELECT rule_id FROM numbering_rules + WHERE company_code = $1 AND table_name = 'sales_order_mng' AND column_name = 'order_no' + LIMIT 1`, + [companyCode] + ); + if (ruleRes.rows.length > 0) { + try { + const code = await numberingRuleService.allocateCode( + ruleRes.rows[0].rule_id, + companyCode + ); + if (code) return code; + } catch (e: any) { + logger.warn(`allocateCode 실패 → 폴백 사용: ${e?.message}`); + } + } + + // 폴백 + const today = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + const prefix = `ORD-${today}-`; + const r = await client.query( + `SELECT COUNT(*)::int AS c FROM sales_order_mng + WHERE company_code = $1 AND order_no LIKE $2`, + [companyCode, `${prefix}%`] + ); + const seq = (Number(r.rows[0]?.c) || 0) + 1; + return `${prefix}${String(seq).padStart(4, "0")}`; +} + +export async function excelBulkUpload( + payload: BulkUploadPayload +): Promise { + const pool = getPool(); + const client = await pool.connect(); + const result: BulkUploadResult = { + itemsCreated: 0, + mastersCreated: 0, + detailsCreated: 0, + warnings: [], + errors: [], + }; + + const autoCreate = payload.autoCreateItems !== false; // 기본 true + const defaultDivision = payload.defaultItemDivision || "CAT_DIV_RAW_MAT"; + + try { + await client.query("BEGIN"); + + // 1) 각 행의 품목 확정 (part_code를 확정값으로 채움) + const resolved: Array<{ row: BulkRow; partCode: string }> = []; + for (let i = 0; i < payload.rows.length; i++) { + const row = payload.rows[i]; + if (!autoCreate && !normStr(row.part_code)) { + // 자동생성 비활성 + part_code 비어있음: 매칭만 시도 + const pr = await resolveOrCreateItem( + client, payload.companyCode, row, defaultDivision, payload.userId + ); + if (!pr) { + result.warnings.push(`행 ${i + 1}: 품목 매칭 실패 (품명=${row.part_name})`); + continue; + } + resolved.push({ row, partCode: pr.itemNumber }); + } else { + const pr = await resolveOrCreateItem( + client, payload.companyCode, row, defaultDivision, payload.userId + ); + if (!pr) { + result.warnings.push(`행 ${i + 1}: 품목 정보 부족 (품명/품번 모두 없음)`); + continue; + } + if (pr.created) result.itemsCreated++; + resolved.push({ row, partCode: pr.itemNumber }); + } + } + + if (resolved.length === 0) { + await client.query("ROLLBACK"); + result.errors.push("유효한 행이 없습니다."); + return result; + } + + // 2) order_no 기준 그룹핑 + // - 엑셀에 order_no 있는 행은 동일 번호끼리 묶음 + // - 비어있는 행들은 하나의 그룹 "(auto)" 으로 묶어 자동 채번 1건 + const groups = new Map>(); + for (const item of resolved) { + const key = normStr(item.row.order_no) || "__AUTO__"; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(item); + } + + // 3) 그룹별 마스터 UPSERT + 디테일 INSERT + let autoOrderNo: string | null = null; + + for (const [key, items] of groups.entries()) { + let orderNo = key; + if (key === "__AUTO__") { + if (!autoOrderNo) autoOrderNo = await allocateOrderNo(payload.companyCode, client); + orderNo = autoOrderNo; + } + + // 마스터 존재 확인 + const mRes = await client.query( + `SELECT id FROM sales_order_mng + WHERE company_code = $1 AND order_no = $2 LIMIT 1`, + [payload.companyCode, orderNo] + ); + + // 대표값 (첫 행 기준) + const first = items[0].row; + + // partner_id 조회 (name으로) — 없어도 무방 + let partnerId = normStr(first.partner_code); + if (!partnerId && normStr(first.partner_name)) { + const pRes = await client.query( + `SELECT id FROM customer_mng + WHERE company_code = $1 AND customer_name = $2 LIMIT 1`, + [payload.companyCode, normStr(first.partner_name)] + ); + if (pRes.rows.length > 0) partnerId = pRes.rows[0].id; + } + + if (mRes.rows.length === 0) { + // INSERT + await client.query( + `INSERT INTO sales_order_mng ( + company_code, order_no, order_date, due_date, partner_id, + status, manager_id, created_date, created_by + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, NOW(), $8 + )`, + [ + payload.companyCode, + orderNo, + normStr(first.order_date) || new Date().toISOString().slice(0, 10), + normStr(first.due_date) || null, + partnerId || null, + normStr(first.status) || "수주", + payload.userId, + payload.userId, + ] + ); + result.mastersCreated++; + } + + // 디테일 INSERT + // 기존 디테일의 최대 seq_no 조회 + const sRes = await client.query( + `SELECT COALESCE(MAX(NULLIF(seq_no,'')::int), 0)::int AS max_seq + FROM sales_order_detail + WHERE company_code = $1 AND order_no = $2`, + [payload.companyCode, orderNo] + ); + let seqStart = (Number(sRes.rows[0]?.max_seq) || 0) + 1; + + for (const it of items) { + const r = it.row; + const w = toNum(r.width); + const h = toNum(r.height); + const qty = toNum(r.qty); + const price = toNum(r.unit_price); + const area = normStr(r.area) || (w > 0 && h > 0 ? (w * h / 91808).toFixed(4) : ""); + const amount = normStr(r.amount) || (qty && price ? String(qty * price) : ""); + + await client.query( + `INSERT INTO sales_order_detail ( + id, company_code, order_no, seq_no, + part_code, part_name, spec, + width, height, thickness, area, + unit, qty, unit_price, amount, + delivery_partner_code, due_date, 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, + $18, NOW() + )`, + [ + payload.companyCode, + orderNo, + String(seqStart++), + it.partCode, + normStr(r.part_name), + normStr(r.spec), + w || null, h || null, toNum(r.thickness) || null, area || null, + normStr(r.unit) || null, + qty || null, + price || null, + amount || null, + normStr(r.partner_code) || null, + normStr(r.due_date) || null, + normStr(r.memo) || null, + payload.userId, + ] + ); + result.detailsCreated++; + } + } + + await client.query("COMMIT"); + return result; + } catch (err: any) { + await client.query("ROLLBACK"); + logger.error(`excelBulkUpload 실패: ${err?.message}`, err); + result.errors.push(err?.message || "알 수 없는 오류"); + return result; + } finally { + client.release(); + } +} diff --git a/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx b/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx index 64a8a2a1..9b6d2fd9 100644 --- a/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx +++ b/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx @@ -59,6 +59,7 @@ export default function EquipmentInfoPage() { const [equipCount, setEquipCount] = useState(0); const [searchFilters, setSearchFilters] = useState([]); const [selectedEquipId, setSelectedEquipId] = useState(null); + const [checkedEquipIds, setCheckedEquipIds] = useState([]); // 우측 탭 const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info"); @@ -258,7 +259,15 @@ export default function EquipmentInfoPage() { } } catch { /* 채번 규칙 없으면 수동 입력 */ } }; - const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); }; + const openEquipEdit = () => { + if (checkedEquipIds.length > 1) { toast.error("수정은 한 건만 선택해주세요."); return; } + const targetId = checkedEquipIds[0] || selectedEquipId; + const target = equipments.find((e) => e.id === targetId); + if (!target) { toast.error("수정할 설비를 선택해주세요."); return; } + setEquipForm({ ...target }); + setEquipEditMode(true); + setEquipModalOpen(true); + }; const handleEquipSave = async () => { if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; } @@ -287,12 +296,16 @@ export default function EquipmentInfoPage() { }; const handleEquipDelete = async () => { - if (!selectedEquipId) return; - const ok = await confirm("설비를 삭제하시겠습니까?", { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" }); + const targetIds = checkedEquipIds.length > 0 ? checkedEquipIds : (selectedEquipId ? [selectedEquipId] : []); + if (targetIds.length === 0) { toast.error("삭제할 설비를 선택해주세요."); return; } + const ok = await confirm(`선택한 ${targetIds.length}건의 설비를 삭제하시겠습니까?`, { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" }); if (!ok) return; try { - await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: [{ id: selectedEquipId }] }); - toast.success("삭제되었습니다."); setSelectedEquipId(null); fetchEquipments(); + await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: targetIds.map((id) => ({ id })) }); + toast.success("삭제되었습니다."); + setCheckedEquipIds([]); + if (selectedEquipId && targetIds.includes(selectedEquipId)) setSelectedEquipId(null); + fetchEquipments(); } catch { toast.error("삭제 실패"); } }; @@ -398,7 +411,12 @@ export default function EquipmentInfoPage() { const allItems = new Map(); for (const res of results) { const rows = res.data?.data?.data || res.data?.data?.rows || []; - for (const row of rows) allItems.set(row.id, row); + for (const row of rows) { + // item_info 이미지 컬럼 이중화 대응: `image`(품목정보 UI가 저장하는 기본 컬럼) 우선, + // `image_path`는 과거 데이터 레거시 fallback. 옵션 객체는 image_path 필드로 정규화해 하류 호환 유지. + const normalized = { ...row, image_path: row.image || row.image_path || "" }; + allItems.set(row.id, normalized); + } } setConsumableItemOptions(Array.from(allItems.values())); } catch { setConsumableItemOptions([]); } @@ -530,9 +548,9 @@ export default function EquipmentInfoPage() {
- +
- +
-
- setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" />
-
- setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" />
+
+ + setConsumableForm((p) => ({ ...p, unit: e.target.value }))} + placeholder="단위" + className={cn("h-9", consumableItemOptions.length > 0 && "bg-muted cursor-not-allowed")} + readOnly={consumableItemOptions.length > 0} + /> +
+
+ + setConsumableForm((p) => ({ ...p, specification: e.target.value }))} + placeholder="규격" + className={cn("h-9", consumableItemOptions.length > 0 && "bg-muted cursor-not-allowed")} + readOnly={consumableItemOptions.length > 0} + /> +
setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" />
-
- setConsumableForm((p) => ({ ...p, image_path: v }))} - tableName={CONSUMABLE_TABLE} columnName="image_path" />
+
+ + {/* value 변경 시 확실한 리마운트를 위해 key에 image_path 바인딩 (이전 미리보기 잔류 방지) */} + setConsumableForm((p) => ({ ...p, image_path: v }))} + tableName={CONSUMABLE_TABLE} + columnName="image_path" + disabled={consumableItemOptions.length > 0} + /> +
- {/* 데이터 테이블 — 플랫 리스트 */} -
- {loading ? ( -
- ) : orders.length === 0 ? ( -
- -

등록된 발주가 없어요

-
- ) : ( - (() => { - const numCols = new Set(["order_qty", "received_qty", "remain_qty", "unit_price", "amount"]); - const renderCell = (row: any, key: string) => { - const val = row[key]; - if (key === "status") return val ? {val} : "-"; - if (key === "order_date" || key === "due_date") return val ? new Date(val).toLocaleDateString("ko-KR") : "-"; - if (numCols.has(key)) return {val ? Number(val).toLocaleString() : ""}; - return val || ""; - }; - return ( - - - - { - const allIds = orders.map((r) => r.id); - const allChecked = allIds.length > 0 && allIds.every((id) => checkedIds.includes(id)); - setCheckedIds(allChecked ? [] : allIds); - }} - > - 0 && orders.every((r) => checkedIds.includes(r.id))} - onCheckedChange={() => {}} - /> - - {ts.visibleColumns.map((col) => ( - - {col.label} - - ))} - - - - {orders.map((row) => { - 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.purchase_no)} - > - { - e.stopPropagation(); - setCheckedIds((prev) => - prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id] - ); - }} - > - {}} /> - - {ts.visibleColumns.map((col) => ( - - {renderCell(row, col.key)} - - ))} - - ); - })} - -
- ); - })() - )} -
- 전체 {orders.length}건 -
+ {/* 데이터 테이블 — 플랫 리스트 (EDataTable: 컬럼별 Popover 체크박스 필터 + 정렬 내장) */} +
+ openEditModal(row.purchase_no)} + showPagination + draggableColumns={false} + columnOrderKey="c10-purchase-order-main" + />
{/* 발주 등록/수정 모달 */} diff --git a/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx b/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx index 0b297655..9b6d2fd9 100644 --- a/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx +++ b/frontend/app/(main)/COMPANY_16/equipment/info/page.tsx @@ -411,7 +411,12 @@ export default function EquipmentInfoPage() { const allItems = new Map(); for (const res of results) { const rows = res.data?.data?.data || res.data?.data?.rows || []; - for (const row of rows) allItems.set(row.id, row); + for (const row of rows) { + // item_info 이미지 컬럼 이중화 대응: `image`(품목정보 UI가 저장하는 기본 컬럼) 우선, + // `image_path`는 과거 데이터 레거시 fallback. 옵션 객체는 image_path 필드로 정규화해 하류 호환 유지. + const normalized = { ...row, image_path: row.image || row.image_path || "" }; + allItems.set(row.id, normalized); + } } setConsumableItemOptions(Array.from(allItems.values())); } catch { setConsumableItemOptions([]); } @@ -946,11 +951,16 @@ export default function EquipmentInfoPage() { {consumableItemOptions.length > 0 ? ( setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" />
-
- setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" />
-
- setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" />
+
+ + setConsumableForm((p) => ({ ...p, unit: e.target.value }))} + placeholder="단위" + className={cn("h-9", consumableItemOptions.length > 0 && "bg-muted cursor-not-allowed")} + readOnly={consumableItemOptions.length > 0} + /> +
+
+ + setConsumableForm((p) => ({ ...p, specification: e.target.value }))} + placeholder="규격" + className={cn("h-9", consumableItemOptions.length > 0 && "bg-muted cursor-not-allowed")} + readOnly={consumableItemOptions.length > 0} + /> +
setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" />
-
- setConsumableForm((p) => ({ ...p, image_path: v }))} - tableName={CONSUMABLE_TABLE} columnName="image_path" />
+
+ + {/* value 변경 시 확실한 리마운트를 위해 key에 image_path 바인딩 (이전 미리보기 잔류 방지) */} + setConsumableForm((p) => ({ ...p, image_path: v }))} + tableName={CONSUMABLE_TABLE} + columnName="image_path" + disabled={consumableItemOptions.length > 0} + /> +