From 51c4fddde098ce2355332892fa2053bdba4042f1 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 20 Apr 2026 17:59:28 +0900 Subject: [PATCH] feat: Add report cell value management functionality - Introduced a new controller for managing custom input values in report cells, allowing users to retrieve and upsert values associated with specific reports and targets. - Implemented API routes for fetching and saving report cell values, ensuring proper authentication and data handling. - Enhanced the frontend components to support the new report cell input functionality, including the ability to edit and save input values in a modal. - Updated inventory and equipment management pages to include new features for handling missing items and managing warehouse locations effectively. --- backend-node/src/app.ts | 2 + .../controllers/reportCellValueController.ts | 93 ++++++++ .../src/routes/reportCellValueRoutes.ts | 12 + .../(main)/COMPANY_10/equipment/info/page.tsx | 94 +++++++- .../COMPANY_10/logistics/inventory/page.tsx | 212 +++++++++++++++-- .../WorkStandardEditModal.tsx | 4 +- .../COMPANY_16/logistics/inventory/page.tsx | 215 ++++++++++++++++-- .../WorkStandardEditModal.tsx | 4 +- .../(main)/COMPANY_16/sales/quote/page.tsx | 153 ++++++++++++- .../(main)/COMPANY_29/equipment/info/page.tsx | 94 +++++++- .../COMPANY_29/logistics/inventory/page.tsx | 212 +++++++++++++++-- .../WorkStandardEditModal.tsx | 4 +- .../(main)/COMPANY_30/equipment/info/page.tsx | 94 +++++++- .../COMPANY_30/logistics/inventory/page.tsx | 215 ++++++++++++++++-- .../(main)/COMPANY_30/production/bom/page.tsx | 99 +++++--- .../WorkStandardEditModal.tsx | 4 +- .../(main)/COMPANY_30/sales/order/page.tsx | 127 +++++++---- .../(main)/COMPANY_7/equipment/info/page.tsx | 94 +++++++- .../COMPANY_7/logistics/inventory/page.tsx | 215 ++++++++++++++++-- .../WorkStandardEditModal.tsx | 4 +- .../(main)/COMPANY_8/equipment/info/page.tsx | 94 +++++++- .../COMPANY_8/logistics/inventory/page.tsx | 214 +++++++++++++++-- .../WorkStandardEditModal.tsx | 4 +- .../(main)/COMPANY_9/equipment/info/page.tsx | 94 +++++++- .../COMPANY_9/logistics/inventory/page.tsx | 212 +++++++++++++++-- .../(main)/COMPANY_9/production/bom/page.tsx | 95 +++++--- .../WorkStandardEditModal.tsx | 4 +- .../app/(main)/COMPANY_9/sales/order/page.tsx | 111 ++++++--- frontend/components/common/SmartSelect.tsx | 121 +++++++--- .../components/report/ReportInlineViewer.tsx | 20 +- .../report/designer/modals/GridEditor.tsx | 16 ++ .../designer/renderers/TableRenderer.tsx | 25 +- .../report/designer/renderers/types.ts | 9 +- frontend/hooks/useCurrent2ndLevelMenuObjid.ts | 17 +- frontend/types/report.ts | 4 +- 35 files changed, 2696 insertions(+), 295 deletions(-) create mode 100644 backend-node/src/controllers/reportCellValueController.ts create mode 100644 backend-node/src/routes/reportCellValueRoutes.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index d9b2ece3..9b7434a2 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -157,6 +157,7 @@ import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리 import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트 import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별) +import reportCellValueRoutes from "./routes/reportCellValueRoutes"; // 리포트 셀 커스텀 입력값 (input 셀) import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형) import systemNoticeRoutes from "./routes/systemNoticeRoutes"; // 시스템 공지 import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN) @@ -381,6 +382,7 @@ app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리 app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리 app.use("/api/sales-report", salesReportRoutes); // 영업 리포트 app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장) +app.use("/api/report-cell-values", reportCellValueRoutes); // 리포트 셀 커스텀 입력값 app.use("/api/system-notice", systemNoticeRoutes); // 시스템 공지 app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형) app.use("/api/design", designRoutes); // 설계 모듈 diff --git a/backend-node/src/controllers/reportCellValueController.ts b/backend-node/src/controllers/reportCellValueController.ts new file mode 100644 index 00000000..8fb023d2 --- /dev/null +++ b/backend-node/src/controllers/reportCellValueController.ts @@ -0,0 +1,93 @@ +/** + * 리포트 셀 커스텀 입력값 컨트롤러 + * + * 리포트 디자이너에서 cellType="input"으로 지정한 셀에 대해 + * 각 대상 레코드(quote 등)별로 사용자가 입력한 값을 관리 + */ + +import type { Response } from "express"; +import { getPool } from "../database/db"; +import type { AuthenticatedRequest } from "../types/auth"; +import { logger } from "../utils/logger"; + +// 목록 조회: 특정 리포트 + 타겟에 대한 모든 셀 값 +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { report_id, target_type, target_id } = req.query; + + if (!report_id || !target_type || !target_id) { + return res.status(400).json({ + success: false, + message: "report_id, target_type, target_id는 필수입니다.", + }); + } + + const pool = getPool(); + const result = await pool.query( + `SELECT id, report_id, target_type, target_id, component_id, cell_id, value + FROM report_cell_values + WHERE company_code = $1 AND report_id = $2 AND target_type = $3 AND target_id = $4`, + [companyCode, report_id, target_type, target_id], + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("리포트 셀 값 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// UPSERT 단건: 같은 (report_id, target_type, target_id, component_id, cell_id)면 UPDATE, 아니면 INSERT +export async function upsert(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { report_id, target_type, target_id, component_id, cell_id, value } = + req.body; + + if (!report_id || !target_type || !target_id || !component_id || !cell_id) { + return res.status(400).json({ + success: false, + message: "필수 필드 누락", + }); + } + + const pool = getPool(); + + // value가 빈 문자열이면 DELETE (오버라이드 해제) + if (value === "" || value === null || value === undefined) { + await pool.query( + `DELETE FROM report_cell_values + WHERE company_code = $1 AND report_id = $2 AND target_type = $3 + AND target_id = $4 AND component_id = $5 AND cell_id = $6`, + [companyCode, report_id, target_type, target_id, component_id, cell_id], + ); + return res.json({ success: true, data: null }); + } + + const result = await pool.query( + `INSERT INTO report_cell_values + (id, company_code, report_id, target_type, target_id, component_id, cell_id, value, created_by, updated_by) + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $8) + ON CONFLICT (company_code, report_id, target_type, target_id, component_id, cell_id) + DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP, updated_by = EXCLUDED.updated_by + RETURNING *`, + [ + companyCode, + report_id, + target_type, + target_id, + component_id, + cell_id, + value, + userId, + ], + ); + + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("리포트 셀 값 저장 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/reportCellValueRoutes.ts b/backend-node/src/routes/reportCellValueRoutes.ts new file mode 100644 index 00000000..84d1dc4e --- /dev/null +++ b/backend-node/src/routes/reportCellValueRoutes.ts @@ -0,0 +1,12 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as controller from "../controllers/reportCellValueController"; + +const router = Router(); + +router.use(authenticateToken); + +router.get("/", controller.getList); +router.post("/", controller.upsert); + +export default router; diff --git a/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx b/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx index 17d81598..64a8a2a1 100644 --- a/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx +++ b/frontend/app/(main)/COMPANY_10/equipment/info/page.tsx @@ -86,6 +86,7 @@ export default function EquipmentInfoPage() { const [inspectionForm, setInspectionForm] = useState>({}); const [inspectionContinuous, setInspectionContinuous] = useState(false); const [inspectionEditMode, setInspectionEditMode] = useState(false); + const [checkedInspectionIds, setCheckedInspectionIds] = useState>(new Set()); // 소모품 추가/수정 모달 const [consumableModalOpen, setConsumableModalOpen] = useState(false); @@ -93,6 +94,7 @@ export default function EquipmentInfoPage() { const [consumableContinuous, setConsumableContinuous] = useState(false); const [consumableEditMode, setConsumableEditMode] = useState(false); const [consumableItemOptions, setConsumableItemOptions] = useState([]); + const [checkedConsumableIds, setCheckedConsumableIds] = useState>(new Set()); // 점검항목 복사 const [copyModalOpen, setCopyModalOpen] = useState(false); @@ -200,6 +202,7 @@ export default function EquipmentInfoPage() { // 우측: 점검항목 조회 useEffect(() => { + setCheckedInspectionIds(new Set()); if (!selectedEquip?.equipment_code) { setInspections([]); return; } const fetchData = async () => { setInspectionLoading(true); @@ -217,6 +220,7 @@ export default function EquipmentInfoPage() { // 우측: 소모품 조회 useEffect(() => { + setCheckedConsumableIds(new Set()); if (!selectedEquip?.equipment_code) { setConsumables([]); return; } const fetchData = async () => { setConsumableLoading(true); @@ -292,6 +296,34 @@ export default function EquipmentInfoPage() { } catch { toast.error("삭제 실패"); } }; + // 점검항목 삭제 + const handleInspectionDelete = async () => { + const ids = Array.from(checkedInspectionIds); + if (ids.length === 0) { toast.error("삭제할 점검항목을 선택해주세요."); return; } + const ok = await confirm(`선택한 ${ids.length}건의 점검항목을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await apiClient.delete(`/table-management/tables/${INSPECTION_TABLE}/delete`, { data: ids.map((id) => ({ id })) }); + toast.success("삭제되었습니다."); + setCheckedInspectionIds(new Set()); + refreshRight(); + } catch { toast.error("삭제 실패"); } + }; + + // 소모품 삭제 + const handleConsumableDelete = async () => { + const ids = Array.from(checkedConsumableIds); + if (ids.length === 0) { toast.error("삭제할 소모품을 선택해주세요."); return; } + const ok = await confirm(`선택한 ${ids.length}건의 소모품을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await apiClient.delete(`/table-management/tables/${CONSUMABLE_TABLE}/delete`, { data: ids.map((id) => ({ id })) }); + toast.success("삭제되었습니다."); + setCheckedConsumableIds(new Set()); + refreshRight(); + } catch { toast.error("삭제 실패"); } + }; + // 점검항목 추가 const handleInspectionSave = async () => { if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; } @@ -546,15 +578,23 @@ export default function EquipmentInfoPage() { + )} {rightTab === "consumable" && ( - + <> + + + )} @@ -633,6 +673,16 @@ export default function EquipmentInfoPage() { + { + const allChecked = inspections.length > 0 && checkedInspectionIds.size === inspections.length; + if (allChecked) setCheckedInspectionIds(new Set()); + else setCheckedInspectionIds(new Set(inspections.map((i) => i.id))); + }} + > + 0 && checkedInspectionIds.size === inspections.length} /> + 점검항목 점검주기 점검방법 @@ -660,6 +710,20 @@ export default function EquipmentInfoPage() { setInspectionEditMode(true); setInspectionModalOpen(true); }}> + { + e.stopPropagation(); + setCheckedInspectionIds((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); else next.add(item.id); + return next; + }); + }} + onDoubleClick={(e) => e.stopPropagation()} + > + + {item.inspection_item || "-"} {resolve("inspection_cycle", item.inspection_cycle)} {resolve("inspection_method", item.inspection_method)} @@ -688,6 +752,16 @@ export default function EquipmentInfoPage() {
+ { + const allChecked = consumables.length > 0 && checkedConsumableIds.size === consumables.length; + if (allChecked) setCheckedConsumableIds(new Set()); + else setCheckedConsumableIds(new Set(consumables.map((i) => i.id))); + }} + > + 0 && checkedConsumableIds.size === consumables.length} /> + 소모품명 교체주기 단위 @@ -703,6 +777,20 @@ export default function EquipmentInfoPage() { loadConsumableItems(); setConsumableModalOpen(true); }}> + { + e.stopPropagation(); + setCheckedConsumableIds((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); else next.add(item.id); + return next; + }); + }} + onDoubleClick={(e) => e.stopPropagation()} + > + + {item.consumable_name || "-"} {item.replacement_cycle || "-"} {item.unit || "-"} diff --git a/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx index 0f9d675f..c6936a6c 100644 --- a/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_10/logistics/inventory/page.tsx @@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { @@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock"; const STOCK_COLUMNS = [ { key: "item_code", label: "품목코드" }, { key: "item_name", label: "품명" }, + { key: "spec", label: "규격" }, { key: "warehouse_code", label: "창고" }, { key: "location_code", label: "위치" }, { key: "current_qty", label: "현재수량", align: "right" as const }, @@ -87,6 +89,8 @@ const getStatusVariant = ( return "destructive"; case "과잉": return "secondary"; + case "미등록": + return "outline"; default: return "outline"; } @@ -119,6 +123,15 @@ export default function InventoryStatusPage() { const [stockLoading, setStockLoading] = useState(false); const [selectedStockId, setSelectedStockId] = useState(null); + // 재고 없는 품목 표시 여부 + const [showMissingItems, setShowMissingItems] = useState(false); + + // 창고 목록 (조정 모달에서 사용) + const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]); + + // 선택된 창고의 위치 목록 (조정 모달에서 사용) + const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]); + // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); @@ -132,7 +145,9 @@ export default function InventoryStatusPage() { adjust_type: string; adjust_qty: string; reason: string; - }>({ adjust_type: "증가", adjust_qty: "", reason: "" }); + warehouse_code: string; + location_code: string; + }>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" }); const [adjustSaving, setAdjustSaving] = useState(false); // 카테고리 옵션 @@ -201,8 +216,9 @@ export default function InventoryStatusPage() { const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || []; const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || []; - const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "" }])); + const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }])); const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code])); + setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code }))); const resolve = (col: string, code: string) => { if (!code) return ""; return categoryOptions[col]?.find((o) => o.code === code)?.label || code; @@ -213,19 +229,50 @@ export default function InventoryStatusPage() { return { ...r, item_name: itemInfo?.name || "", + spec: itemInfo?.spec || "", unit: resolve("item_inventory_unit", rawUnit) || rawUnit, warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "", status: resolve("status", r.status), _isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty), }; }); - setStockItems(data); + + if (showMissingItems) { + const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean)); + const missingRows = items + .filter((i: any) => { + const code = i.item_number || i.item_code; + return code && !existingCodes.has(code); + }) + .map((i: any) => { + const code = i.item_number || i.item_code; + const rawUnit = i.inventory_unit || ""; + return { + id: `missing-${code}`, + item_code: code, + item_name: i.item_name || "", + spec: i.size || "", + warehouse_code: "", + warehouse_name: "", + location_code: "", + current_qty: "0", + safety_qty: "", + unit: resolve("item_inventory_unit", rawUnit) || rawUnit, + status: "미등록", + _isLow: false, + _isMissing: true, + }; + }); + setStockItems([...data, ...missingRows]); + } else { + setStockItems(data); + } } catch { toast.error("재고 목록을 불러오지 못했어요"); } finally { setStockLoading(false); } - }, [categoryOptions, searchFilters]); + }, [categoryOptions, searchFilters, showMissingItems]); useEffect(() => { fetchStock(); @@ -279,6 +326,35 @@ export default function InventoryStatusPage() { fetchHistory(); }, [fetchHistory]); + useEffect(() => { + const whCode = adjustForm.warehouse_code; + if (!whCode) { + setLocationList([]); + return; + } + (async () => { + try { + const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, { + page: 1, + size: 0, + dataFilter: { + enabled: true, + filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }], + }, + autoFilter: true, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + setLocationList( + rows + .filter((r: any) => r.location_code) + .map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code })) + ); + } catch { + setLocationList([]); + } + })(); + }, [adjustForm.warehouse_code]); + // 재고 조정 저장 const handleAdjustSave = async () => { if (!selectedStock) return; @@ -291,6 +367,20 @@ export default function InventoryStatusPage() { toast.error("조정 사유를 입력해주세요"); return; } + + const isMissing = !!selectedStock._isMissing; + const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || ""); + const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || ""); + + if (isMissing && !targetWhCode) { + toast.error("창고를 선택해주세요"); + return; + } + if (isMissing && adjustForm.adjust_type === "감소") { + toast.error("미등록 품목은 감소 조정이 불가해요"); + return; + } + setAdjustSaving(true); try { const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty; @@ -301,8 +391,8 @@ export default function InventoryStatusPage() { { id: crypto.randomUUID(), item_code: selectedStock.item_code, - warehouse_code: selectedStock.warehouse_code || "", - location_code: selectedStock.location_code || "", + warehouse_code: targetWhCode, + location_code: targetLocCode, transaction_type: "조정", transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), quantity: String(changeQty), @@ -311,17 +401,33 @@ export default function InventoryStatusPage() { } ); - await apiClient.put( - `/table-management/tables/${STOCK_TABLE}/edit`, - { - originalData: { id: selectedStock.id }, - updatedData: { current_qty: afterQty }, - } - ); + if (isMissing) { + await apiClient.post( + `/table-management/tables/${STOCK_TABLE}/add`, + { + id: crypto.randomUUID(), + item_code: selectedStock.item_code, + warehouse_code: targetWhCode, + location_code: targetLocCode, + current_qty: String(afterQty), + safety_qty: "0", + last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + } + ); + } else { + await apiClient.put( + `/table-management/tables/${STOCK_TABLE}/edit`, + { + originalData: { id: selectedStock.id }, + updatedData: { current_qty: afterQty }, + } + ); + } toast.success("재고가 조정되었어요"); setAdjustModalOpen(false); - setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" }); + setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" }); + setSelectedStockId(null); fetchStock(); } catch { toast.error("재고 조정에 실패했어요"); @@ -385,6 +491,7 @@ export default function InventoryStatusPage() { stockItems.map((r) => ({ 품목코드: r.item_code, 품명: r.item_name, + 규격: r.spec || "", 창고: r.warehouse_name || r.warehouse_code, 위치: r.location_code, 현재수량: r.current_qty, @@ -438,6 +545,13 @@ export default function InventoryStatusPage() { {stockItems.length}건 +
+ {selectedStock?._isMissing && ( + <> +
+ + +
+
+ + +
+ + )} +
diff --git a/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx index d9167dcb..da7e8fd5 100644 --- a/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_10/production/work-instruction/WorkStandardEditModal.tsx @@ -586,7 +586,7 @@ export function WorkStandardEditModal({
- + @@ -597,7 +597,7 @@ export function WorkStandardEditModal({ diff --git a/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx index 0f9d675f..ac27563d 100644 --- a/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_16/logistics/inventory/page.tsx @@ -13,6 +13,7 @@ import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { @@ -68,6 +69,7 @@ const STOCK_TABLE = "inventory_stock"; const STOCK_COLUMNS = [ { key: "item_code", label: "품목코드" }, { key: "item_name", label: "품명" }, + { key: "spec", label: "규격" }, { key: "warehouse_code", label: "창고" }, { key: "location_code", label: "위치" }, { key: "current_qty", label: "현재수량", align: "right" as const }, @@ -87,6 +89,8 @@ const getStatusVariant = ( return "destructive"; case "과잉": return "secondary"; + case "미등록": + return "outline"; default: return "outline"; } @@ -119,6 +123,15 @@ export default function InventoryStatusPage() { const [stockLoading, setStockLoading] = useState(false); const [selectedStockId, setSelectedStockId] = useState(null); + // 재고 없는 품목 표시 여부 + const [showMissingItems, setShowMissingItems] = useState(false); + + // 창고 목록 (조정 모달에서 사용) + const [warehouseList, setWarehouseList] = useState<{ code: string; name: string }[]>([]); + + // 선택된 창고의 위치 목록 (조정 모달에서 사용) + const [locationList, setLocationList] = useState<{ code: string; name: string }[]>([]); + // 검색 필터 const [searchFilters, setSearchFilters] = useState([]); @@ -132,7 +145,9 @@ export default function InventoryStatusPage() { adjust_type: string; adjust_qty: string; reason: string; - }>({ adjust_type: "증가", adjust_qty: "", reason: "" }); + warehouse_code: string; + location_code: string; + }>({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" }); const [adjustSaving, setAdjustSaving] = useState(false); // 카테고리 옵션 @@ -201,8 +216,9 @@ export default function InventoryStatusPage() { const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || []; const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || []; - const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "" }])); + const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.inventory_unit || "", spec: i.size || "" }])); const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code])); + setWarehouseList(warehouses.map((w: any) => ({ code: w.warehouse_code, name: w.warehouse_name || w.warehouse_code }))); const resolve = (col: string, code: string) => { if (!code) return ""; return categoryOptions[col]?.find((o) => o.code === code)?.label || code; @@ -213,19 +229,51 @@ export default function InventoryStatusPage() { return { ...r, item_name: itemInfo?.name || "", + spec: itemInfo?.spec || "", unit: resolve("item_inventory_unit", rawUnit) || rawUnit, warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "", status: resolve("status", r.status), _isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty), }; }); - setStockItems(data); + + // 재고 없는 품목 표시: inventory_stock에 없는 item_info 품목을 미등록 가상 행으로 추가 + if (showMissingItems) { + const existingCodes = new Set(raw.map((r: any) => r.item_code).filter(Boolean)); + const missingRows = items + .filter((i: any) => { + const code = i.item_number || i.item_code; + return code && !existingCodes.has(code); + }) + .map((i: any) => { + const code = i.item_number || i.item_code; + const rawUnit = i.inventory_unit || ""; + return { + id: `missing-${code}`, + item_code: code, + item_name: i.item_name || "", + spec: i.size || "", + warehouse_code: "", + warehouse_name: "", + location_code: "", + current_qty: "0", + safety_qty: "", + unit: resolve("item_inventory_unit", rawUnit) || rawUnit, + status: "미등록", + _isLow: false, + _isMissing: true, + }; + }); + setStockItems([...data, ...missingRows]); + } else { + setStockItems(data); + } } catch { toast.error("재고 목록을 불러오지 못했어요"); } finally { setStockLoading(false); } - }, [categoryOptions, searchFilters]); + }, [categoryOptions, searchFilters, showMissingItems]); useEffect(() => { fetchStock(); @@ -279,6 +327,36 @@ export default function InventoryStatusPage() { fetchHistory(); }, [fetchHistory]); + // 창고 선택 시 해당 창고의 위치 목록 조회 (조정 모달용) + useEffect(() => { + const whCode = adjustForm.warehouse_code; + if (!whCode) { + setLocationList([]); + return; + } + (async () => { + try { + const res = await apiClient.post(`/table-management/tables/warehouse_location/data`, { + page: 1, + size: 0, + dataFilter: { + enabled: true, + filters: [{ columnName: "warehouse_code", operator: "equals", value: whCode }], + }, + autoFilter: true, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + setLocationList( + rows + .filter((r: any) => r.location_code) + .map((r: any) => ({ code: r.location_code, name: r.location_name || r.location_code })) + ); + } catch { + setLocationList([]); + } + })(); + }, [adjustForm.warehouse_code]); + // 재고 조정 저장 const handleAdjustSave = async () => { if (!selectedStock) return; @@ -291,6 +369,20 @@ export default function InventoryStatusPage() { toast.error("조정 사유를 입력해주세요"); return; } + + const isMissing = !!selectedStock._isMissing; + const targetWhCode = isMissing ? adjustForm.warehouse_code : (selectedStock.warehouse_code || ""); + const targetLocCode = isMissing ? adjustForm.location_code : (selectedStock.location_code || ""); + + if (isMissing && !targetWhCode) { + toast.error("창고를 선택해주세요"); + return; + } + if (isMissing && adjustForm.adjust_type === "감소") { + toast.error("미등록 품목은 감소 조정이 불가해요"); + return; + } + setAdjustSaving(true); try { const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty; @@ -301,8 +393,8 @@ export default function InventoryStatusPage() { { id: crypto.randomUUID(), item_code: selectedStock.item_code, - warehouse_code: selectedStock.warehouse_code || "", - location_code: selectedStock.location_code || "", + warehouse_code: targetWhCode, + location_code: targetLocCode, transaction_type: "조정", transaction_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), quantity: String(changeQty), @@ -311,17 +403,34 @@ export default function InventoryStatusPage() { } ); - await apiClient.put( - `/table-management/tables/${STOCK_TABLE}/edit`, - { - originalData: { id: selectedStock.id }, - updatedData: { current_qty: afterQty }, - } - ); + if (isMissing) { + // 새 재고 레코드 생성 + await apiClient.post( + `/table-management/tables/${STOCK_TABLE}/add`, + { + id: crypto.randomUUID(), + item_code: selectedStock.item_code, + warehouse_code: targetWhCode, + location_code: targetLocCode, + current_qty: String(afterQty), + safety_qty: "0", + last_in_date: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().replace("Z", "+09:00"), + } + ); + } else { + await apiClient.put( + `/table-management/tables/${STOCK_TABLE}/edit`, + { + originalData: { id: selectedStock.id }, + updatedData: { current_qty: afterQty }, + } + ); + } toast.success("재고가 조정되었어요"); setAdjustModalOpen(false); - setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" }); + setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "", warehouse_code: "", location_code: "" }); + setSelectedStockId(null); fetchStock(); } catch { toast.error("재고 조정에 실패했어요"); @@ -385,6 +494,7 @@ export default function InventoryStatusPage() { stockItems.map((r) => ({ 품목코드: r.item_code, 품명: r.item_name, + 규격: r.spec || "", 창고: r.warehouse_name || r.warehouse_code, 위치: r.location_code, 현재수량: r.current_qty, @@ -438,6 +548,13 @@ export default function InventoryStatusPage() { {stockItems.length}건 +
+ {selectedStock?._isMissing && ( + <> +
+ + +
+
+ + +
+ + )} +
diff --git a/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx index d9167dcb..da7e8fd5 100644 --- a/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_16/production/work-instruction/WorkStandardEditModal.tsx @@ -586,7 +586,7 @@ export function WorkStandardEditModal({
- + @@ -597,7 +597,7 @@ export function WorkStandardEditModal({ diff --git a/frontend/app/(main)/COMPANY_16/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_16/sales/quote/page.tsx index 73c6fc40..ac1da733 100644 --- a/frontend/app/(main)/COMPANY_16/sales/quote/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/quote/page.tsx @@ -29,7 +29,7 @@ import { ResizablePanelGroup, ResizablePanel, ResizableHandle, } from "@/components/ui/resizable"; import { ReportInlineViewer } from "@/components/report/ReportInlineViewer"; -import { ReportMaster, ComponentConfig } from "@/types/report"; +import { ReportMaster, ComponentConfig, GridCell } from "@/types/report"; const MASTER_TABLE = "quote_mng"; @@ -83,6 +83,12 @@ export default function QuoteManagementPage() { const [basicInfoOpen, setBasicInfoOpen] = useState(false); const [basicForm, setBasicForm] = useState({ quote_date: "", valid_until: "", status: "draft" }); + // 리포트 셀 input 오버라이드 + const [cellOverrides, setCellOverrides] = useState>>({}); + const [inputCellOpen, setInputCellOpen] = useState(false); + const [inputCellCtx, setInputCellCtx] = useState<{ comp: ComponentConfig; cells: GridCell[] } | null>(null); + const [inputCellValues, setInputCellValues] = useState>({}); + // 엑셀 / 리포트 const [excelOpen, setExcelOpen] = useState(false); const [reportList, setReportList] = useState([]); @@ -170,6 +176,104 @@ export default function QuoteManagementPage() { })(); }, [current2ndLevelMenuObjid]); + // ── 리포트 셀 오버라이드: 견적/리포트 변경 시 로드 ── + useEffect(() => { + if (!selectedRow?.objid || !selectedReportId) { + setCellOverrides({}); + return; + } + (async () => { + try { + const res = await apiClient.get("/report-cell-values", { + params: { report_id: selectedReportId, target_type: "quote", target_id: String(selectedRow.objid) }, + }); + const rows = res.data?.data || []; + const map: Record> = {}; + for (const r of rows) { + if (!map[r.component_id]) map[r.component_id] = {}; + map[r.component_id][r.cell_id] = r.value ?? ""; + } + setCellOverrides(map); + } catch { + setCellOverrides({}); + } + })(); + }, [selectedRow?.objid, selectedReportId]); + + // ── input 셀 클릭 → 해당 테이블의 모든 input 셀을 모아 한 모달에 표시 ── + const handleInputCellClick = (comp: ComponentConfig, _cell: GridCell) => { + const allCells = ((comp as any).gridCells || []) as GridCell[]; + const inputCells = allCells + .filter((c) => c.cellType === "input" && !c.merged) + .sort((a, b) => (a.row - b.row) || (a.col - b.col)); + const vals: Record = {}; + for (const c of inputCells) { + vals[c.id] = cellOverrides[comp.id]?.[c.id] ?? ""; + } + setInputCellCtx({ comp, cells: inputCells }); + setInputCellValues(vals); + setInputCellOpen(true); + }; + + // ── input 셀 라벨 찾기: 같은 행의 static 라벨 셀 값 → 없으면 placeholder ── + const getInputCellLabel = (comp: ComponentConfig, cell: GridCell): string => { + const allCells = ((comp as any).gridCells || []) as GridCell[]; + const labelCell = allCells + .filter((c) => c.row === cell.row && c.col < cell.col && c.cellType === "static" && c.value && !c.merged) + .sort((a, b) => b.col - a.col)[0]; + if (labelCell?.value) return String(labelCell.value).trim(); + return cell.inputPlaceholder || "값"; + }; + + // ── input 셀 저장: 변경된 셀들만 일괄 저장 ── + const handleInputCellSave = async () => { + if (!inputCellCtx || !selectedRow?.objid || !selectedReportId) return; + const { comp, cells } = inputCellCtx; + const existing = cellOverrides[comp.id] || {}; + const toSave: { cellId: string; value: string }[] = []; + for (const c of cells) { + const newVal = inputCellValues[c.id] ?? ""; + const oldVal = existing[c.id] ?? ""; + if (newVal !== oldVal) toSave.push({ cellId: c.id, value: newVal }); + } + if (toSave.length === 0) { + setInputCellOpen(false); + setInputCellCtx(null); + return; + } + try { + await Promise.all( + toSave.map((t) => + apiClient.post("/report-cell-values", { + report_id: selectedReportId, + target_type: "quote", + target_id: String(selectedRow.objid), + component_id: comp.id, + cell_id: t.cellId, + value: t.value, + }) + ) + ); + setCellOverrides((prev) => { + const next = { ...prev }; + const curr = { ...(next[comp.id] || {}) }; + for (const c of cells) { + const v = inputCellValues[c.id] ?? ""; + if (v === "") delete curr[c.id]; + else curr[c.id] = v; + } + if (Object.keys(curr).length === 0) delete next[comp.id]; + else next[comp.id] = curr; + return next; + }); + toast.success(`${toSave.length}개 항목이 저장됐어요`); + setInputCellOpen(false); + setInputCellCtx(null); + } catch { + toast.error("저장 실패"); + } + }; + // ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ── const handleCreate = async () => { @@ -218,6 +322,15 @@ export default function QuoteManagementPage() { setEditComp(comp); if (comp.type === "table") { + // 품목 테이블 판별: tableColumns 중 품목 관련 필드가 포함되어야 편집 대상 + const cols = (comp as any).tableColumns || []; + const ITEM_FIELDS = new Set(["item_code", "item_name", "qty", "unit_price", "spec", "total_amount", "supply_amount", "vat_amount"]); + const isItemTable = cols.some((c: any) => ITEM_FIELDS.has((c.field || "").toLowerCase())); + if (!isItemTable) { + toast.info("이 테이블의 각 셀에서 직접 입력하세요 (input 셀로 지정된 곳만 편집 가능)"); + setEditComp(null); + return; + } // 테이블 → 품목 편집 try { const res = await apiClient.get(`/quotes/${selectedRow.objid}`); @@ -696,6 +809,8 @@ export default function QuoteManagementPage() { reportId={selectedReportId} contextParams={contextParams} onComponentClick={handleComponentClick} + cellOverrides={cellOverrides} + onInputCellClick={handleInputCellClick} /> )} @@ -852,6 +967,42 @@ export default function QuoteManagementPage() { + {/* ═══ 리포트 input 셀 입력 모달 (테이블 내 모든 input 셀을 한 번에 편집) ═══ */} + { if (!o) { setInputCellOpen(false); setInputCellCtx(null); } }}> + + + 값 입력 + + 이 테이블의 입력 항목을 한 번에 편집할 수 있어요. 빈 값으로 저장하면 해당 항목은 리포트에서 숨겨져요. + + +
+ {inputCellCtx?.cells.map((c) => ( +
+ +
순서유형유형 내용 필수 관리
{idx + 1} - + {getDetailTypeLabel(detail.detail_type || "checklist")}
순서유형유형 내용 필수 관리
{idx + 1} - + {getDetailTypeLabel(detail.detail_type || "checklist")}