From 623cbc0b617618d87a778e0131dc6b1c22708d79 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 16 Apr 2026 12:08:28 +0900 Subject: [PATCH] feat: add report preset management API - Implemented CRUD operations for report presets in reportPresetController. - Added routes for listing, creating, updating, and deleting report presets. - Ensured authentication is required for all preset operations. - Enhanced MaterialData interface to include optional width, height, and thickness properties. --- backend-node/src/app.ts | 2 + .../controllers/materialStatusController.ts | 16 +- .../src/controllers/receivingController.ts | 11 +- .../src/controllers/reportPresetController.ts | 141 ++++ .../src/controllers/salesReportController.ts | 35 +- backend-node/src/routes/reportPresetRoutes.ts | 17 + .../src/services/numberingRuleService.ts | 6 +- .../(main)/COMPANY_30/equipment/info/page.tsx | 30 +- .../COMPANY_30/logistics/inventory/page.tsx | 10 +- .../logistics/material-status/page.tsx | 20 + .../COMPANY_30/logistics/outbound/page.tsx | 42 +- .../COMPANY_30/logistics/receiving/page.tsx | 39 +- .../COMPANY_30/master-data/item-info/page.tsx | 33 +- .../(main)/COMPANY_30/purchase/order/page.tsx | 60 +- .../purchase/purchase-item/page.tsx | 24 +- .../COMPANY_30/quality/inspection/page.tsx | 2 +- .../(main)/COMPANY_30/sales/order/page.tsx | 471 ++++++----- .../(main)/COMPANY_30/sales/quote/page.tsx | 2 +- .../COMPANY_30/sales/sales-item/page.tsx | 24 +- .../app/(main)/admin/report/sales/page.tsx | 35 + .../components/admin/report/ReportEngine.tsx | 779 +++++++++++++++--- frontend/lib/api/materialStatus.ts | 3 + 22 files changed, 1411 insertions(+), 391 deletions(-) create mode 100644 backend-node/src/controllers/reportPresetController.ts create mode 100644 backend-node/src/routes/reportPresetRoutes.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index ff14ba19..d9b2ece3 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 salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트 +import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별) import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형) import systemNoticeRoutes from "./routes/systemNoticeRoutes"; // 시스템 공지 import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN) @@ -379,6 +380,7 @@ app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 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/system-notice", systemNoticeRoutes); // 시스템 공지 app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형) app.use("/api/design", designRoutes); // 설계 모듈 diff --git a/backend-node/src/controllers/materialStatusController.ts b/backend-node/src/controllers/materialStatusController.ts index b5427659..9103a7d1 100644 --- a/backend-node/src/controllers/materialStatusController.ts +++ b/backend-node/src/controllers/materialStatusController.ts @@ -164,7 +164,7 @@ export async function getMaterialStatus( } const bomQuery = ` - SELECT + SELECT b.item_code AS parent_item_code, b.base_qty AS bom_base_qty, bd.child_item_id, @@ -173,7 +173,10 @@ export async function getMaterialStatus( bd.loss_rate, ii.item_name AS material_name, ii.item_number AS material_code, - ii.unit AS material_unit + ii.unit AS material_unit, + COALESCE(ii.width::text, '') AS material_width, + COALESCE(ii.height::text, '') AS material_height, + COALESCE(ii.thickness::text, '') AS material_thickness FROM bom b JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND b.company_code = ii.company_code @@ -191,6 +194,9 @@ export async function getMaterialStatus( materialName: string; unit: string; requiredQty: number; + width: string; + height: string; + thickness: string; } const materialMap: Record = {}; @@ -216,6 +222,9 @@ export async function getMaterialStatus( materialName: bomRow.material_name || "알 수 없음", unit: bomRow.bom_unit || bomRow.material_unit || "EA", requiredQty, + width: bomRow.material_width || "", + height: bomRow.material_height || "", + thickness: bomRow.material_thickness || "", }; } } @@ -303,6 +312,9 @@ export async function getMaterialStatus( current: totalCurrentQty, unit: material.unit, locations, + width: material.width, + height: material.height, + thickness: material.thickness, }; }); diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index b2d920b5..7bdaf415 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -90,7 +90,10 @@ export async function getList(req: AuthenticatedRequest, res: Response) { id.id AS detail_id, id.seq_no, id.inbound_type AS detail_inbound_type, - wh.warehouse_name + wh.warehouse_name, + COALESCE(ii.width::text, '') AS width, + COALESCE(ii.height::text, '') AS height, + COALESCE(ii.thickness::text, '') AS thickness FROM ( SELECT DISTINCT ON (h.company_code, h.inbound_number) h.* FROM inbound_mng h @@ -101,6 +104,12 @@ export async function getList(req: AuthenticatedRequest, res: Response) { LEFT JOIN warehouse_info wh ON im.warehouse_code = wh.warehouse_code AND im.company_code = wh.company_code + LEFT JOIN LATERAL ( + SELECT width, height, thickness FROM item_info + WHERE item_number = COALESCE(id.item_number, im.item_number) + AND company_code = im.company_code + LIMIT 1 + ) ii ON true ${whereClause} ORDER BY im.created_date DESC, id.seq_no ASC `; diff --git a/backend-node/src/controllers/reportPresetController.ts b/backend-node/src/controllers/reportPresetController.ts new file mode 100644 index 00000000..d4157719 --- /dev/null +++ b/backend-node/src/controllers/reportPresetController.ts @@ -0,0 +1,141 @@ +import { Response } from "express"; +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * 리포트 프리셋 컨트롤러 + * - 회사별 + 리포트별로 사용자 조건 구성 저장/조회 + * - 브라우저 localStorage가 아닌 서버 DB에 저장하여 기기/브라우저 간 공유 가능 + */ + +/** + * GET /report-presets?reportKey=xxx + * 현재 회사 + report_key 에 해당하는 프리셋 목록 반환 + */ +export async function listPresets(req: any, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); + return; + } + const reportKey = String(req.query.reportKey || ""); + if (!reportKey) { + res.status(400).json({ success: false, message: "reportKey가 필요합니다" }); + return; + } + const rows = await query( + `SELECT id, preset_name AS name, description, config_json AS config, created_by, created_at, updated_at + FROM report_presets + WHERE (company_code = $1 OR $1 = '*') AND report_key = $2 + ORDER BY updated_at DESC NULLS LAST, id DESC`, + [companyCode, reportKey] + ); + res.status(200).json({ success: true, data: rows }); + } catch (error: any) { + logger.error("리포트 프리셋 목록 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * POST /report-presets + * body: { reportKey, name, description, config } + */ +export async function createPreset(req: any, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + const userId = req.user?.userId || null; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); + return; + } + const { reportKey, name, description, config } = req.body; + if (!reportKey || !name || !config) { + res.status(400).json({ success: false, message: "reportKey, name, config는 필수입니다" }); + return; + } + const rows = await query( + `INSERT INTO report_presets (company_code, report_key, preset_name, description, config_json, created_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6, NOW(), NOW()) + RETURNING id, preset_name AS name, description, config_json AS config, created_by, created_at, updated_at`, + [companyCode, reportKey, name, description || null, JSON.stringify(config), userId] + ); + res.status(200).json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("리포트 프리셋 생성 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * PUT /report-presets/:id + * body: { name?, description?, config? } + */ +export async function updatePreset(req: any, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); + return; + } + const { id } = req.params; + const { name, description, config } = req.body; + + const sets: string[] = []; + const params: any[] = []; + let idx = 1; + if (name !== undefined) { sets.push(`preset_name = $${idx++}`); params.push(name); } + if (description !== undefined) { sets.push(`description = $${idx++}`); params.push(description); } + if (config !== undefined) { sets.push(`config_json = $${idx++}::jsonb`); params.push(JSON.stringify(config)); } + sets.push("updated_at = NOW()"); + + params.push(id); + const idParam = `$${idx++}`; + params.push(companyCode); + const companyParam = `$${idx++}`; + + const rows = await query( + `UPDATE report_presets SET ${sets.join(", ")} + WHERE id = ${idParam} AND (company_code = ${companyParam} OR ${companyParam} = '*') + RETURNING id, preset_name AS name, description, config_json AS config, created_by, created_at, updated_at`, + params + ); + if (rows.length === 0) { + res.status(404).json({ success: false, message: "프리셋을 찾을 수 없거나 권한이 없습니다" }); + return; + } + res.status(200).json({ success: true, data: rows[0] }); + } catch (error: any) { + logger.error("리포트 프리셋 수정 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +/** + * DELETE /report-presets/:id + */ +export async function deletePreset(req: any, res: Response): Promise { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); + return; + } + const { id } = req.params; + const rows = await query( + `DELETE FROM report_presets + WHERE id = $1 AND (company_code = $2 OR $2 = '*') + RETURNING id`, + [id, companyCode] + ); + if (rows.length === 0) { + res.status(404).json({ success: false, message: "프리셋을 찾을 수 없거나 권한이 없습니다" }); + return; + } + res.status(200).json({ success: true }); + } catch (error: any) { + logger.error("리포트 프리셋 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/salesReportController.ts b/backend-node/src/controllers/salesReportController.ts index 9d4e4fff..8e621b05 100644 --- a/backend-node/src/controllers/salesReportController.ts +++ b/backend-node/src/controllers/salesReportController.ts @@ -51,7 +51,7 @@ export async function getSalesReportData( conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; const dataQuery = ` - SELECT + SELECT som.order_no, COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) as date, som.order_date, @@ -64,22 +64,37 @@ export async function getSalesReportData( CAST(COALESCE(NULLIF(sod.unit_price, ''), '0') AS numeric) as "unitPrice", CAST(COALESCE(NULLIF(sod.amount, ''), '0') AS numeric) as "orderAmt", 1 as "orderCount", + COALESCE(NULLIF(sod.width::text, ''), '') as width, + COALESCE(NULLIF(sod.height::text, ''), '') as height, + COALESCE(NULLIF(sod.thickness::text, ''), ii.thickness::text, '') as thickness, + -- 면적(㎡) = 가로×세로 / 1,000,000 (가로/세로 mm 단위 가정) + CASE + WHEN sod.width IS NOT NULL AND sod.height IS NOT NULL + THEN ROUND((CAST(sod.width AS numeric) * CAST(sod.height AS numeric) / 1000000.0)::numeric, 4) + ELSE 0 + END as "areaSingle", + -- 주문량 반영 총면적 + CASE + WHEN sod.width IS NOT NULL AND sod.height IS NOT NULL + THEN ROUND((CAST(sod.width AS numeric) * CAST(sod.height AS numeric) / 1000000.0 * COALESCE(CAST(NULLIF(sod.qty, '') AS numeric), 0))::numeric, 4) + ELSE 0 + END as "totalArea", som.status, som.company_code FROM sales_order_mng som - JOIN sales_order_detail sod - ON som.order_no = sod.order_no + JOIN sales_order_detail sod + ON som.order_no = sod.order_no AND som.company_code = sod.company_code - LEFT JOIN customer_mng cm - ON som.partner_id = cm.customer_code + LEFT JOIN customer_mng cm + ON som.partner_id = cm.customer_code AND som.company_code = cm.company_code LEFT JOIN ( - SELECT DISTINCT ON (item_number, company_code) - item_number, item_name, company_code - FROM item_info + SELECT DISTINCT ON (item_number, company_code) + item_number, item_name, thickness, company_code + FROM item_info ORDER BY item_number, company_code, created_date DESC - ) ii - ON sod.part_code = ii.item_number + ) ii + ON sod.part_code = ii.item_number AND sod.company_code = ii.company_code ${whereClause} ORDER BY date DESC NULLS LAST diff --git a/backend-node/src/routes/reportPresetRoutes.ts b/backend-node/src/routes/reportPresetRoutes.ts new file mode 100644 index 00000000..12babee9 --- /dev/null +++ b/backend-node/src/routes/reportPresetRoutes.ts @@ -0,0 +1,17 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + listPresets, + createPreset, + updatePreset, + deletePreset, +} from "../controllers/reportPresetController"; + +const router = Router(); + +router.get("/", authenticateToken, listPresets); +router.post("/", authenticateToken, createPreset); +router.put("/:id", authenticateToken, updatePreset); +router.delete("/:id", authenticateToken, deletePreset); + +export default router; diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index c1573d77..2933c46e 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -1464,7 +1464,8 @@ class NumberingRuleService { case "sequence": { const length = autoConfig.sequenceLength || 3; const startFrom = autoConfig.startFrom || 1; - const nextSequence = baseSeq + startFrom; + // 순수 max+1: 테이블에 max가 있으면 max+1, 없으면 startFrom + const nextSequence = baseSeq > 0 ? baseSeq + 1 : startFrom; return String(nextSequence).padStart(length, "0"); } @@ -1606,7 +1607,8 @@ class NumberingRuleService { case "sequence": { const length = autoConfig.sequenceLength || 3; const startFrom = autoConfig.startFrom || 1; - const actualSequence = allocatedSequence + startFrom - 1; + // allocatedSequence는 이미 max+1 형태. 테이블이 비어 있을 때만 startFrom 적용 + const actualSequence = allocatedSequence > 1 ? allocatedSequence : startFrom; return String(actualSequence).padStart(length, "0"); } diff --git a/frontend/app/(main)/COMPANY_30/equipment/info/page.tsx b/frontend/app/(main)/COMPANY_30/equipment/info/page.tsx index cad20e4d..f415be9c 100644 --- a/frontend/app/(main)/COMPANY_30/equipment/info/page.tsx +++ b/frontend/app/(main)/COMPANY_30/equipment/info/page.tsx @@ -35,7 +35,6 @@ import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable"; -import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; const EQUIP_TABLE = "equipment_mng"; const INSPECTION_TABLE = "equipment_inspection_item"; @@ -74,7 +73,6 @@ export default function EquipmentInfoPage() { const [equipModalOpen, setEquipModalOpen] = useState(false); const [equipEditMode, setEquipEditMode] = useState(false); const [equipForm, setEquipForm] = useState>({}); - const [equipCodeRuleId, setEquipCodeRuleId] = useState(null); const [saving, setSaving] = useState(false); // 기본정보 탭 편집 폼 @@ -244,19 +242,8 @@ export default function EquipmentInfoPage() { }; // 설비 등록/수정 - const openEquipRegister = async () => { - setEquipForm({}); setEquipEditMode(false); setEquipCodeRuleId(null); setEquipModalOpen(true); - try { - const ruleRes = await apiClient.get("/numbering-rules/by-column/equipment_mng/equipment_code"); - if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) { - const ruleId = ruleRes.data.data.ruleId; - setEquipCodeRuleId(ruleId); - const previewRes = await previewNumberingCode(ruleId); - if (previewRes.success && previewRes.data?.generatedCode) { - setEquipForm((p) => ({ ...p, equipment_code: previewRes.data.generatedCode })); - } - } - } catch { /* 채번 규칙 없으면 수동 입력 */ } + const openEquipRegister = () => { + setEquipForm({}); setEquipEditMode(false); setEquipModalOpen(true); }; const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); }; @@ -269,15 +256,6 @@ export default function EquipmentInfoPage() { await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields }); toast.success("수정되었습니다."); } else { - // 채번 규칙이 있으면 allocate - if (equipCodeRuleId) { - try { - const allocRes = await allocateNumberingCode(equipCodeRuleId); - if (allocRes.success && allocRes.data?.generatedCode) { - fields.equipment_code = allocRes.data.generatedCode; - } else { toast.error("설비코드 채번 실패"); setSaving(false); return; } - } catch { toast.error("설비코드 채번 실패"); setSaving(false); return; } - } if (!fields.equipment_code) { toast.error("설비코드는 필수입니다."); setSaving(false); return; } await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/add`, { id: crypto.randomUUID(), ...fields }); toast.success("등록되었습니다."); @@ -733,8 +711,8 @@ export default function EquipmentInfoPage() {
-
- !equipCodeRuleId && setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} readOnly={!!equipCodeRuleId || equipEditMode} placeholder={equipCodeRuleId ? "자동 채번" : "설비코드"} className={cn("h-9", (equipCodeRuleId || equipEditMode) && "bg-muted cursor-not-allowed")} />
+
+ setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} readOnly={equipEditMode} placeholder="설비코드" className={cn("h-9", equipEditMode && "bg-muted cursor-not-allowed")} />
setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" />
diff --git a/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx index 5c0ddcc1..5af56151 100644 --- a/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx +++ b/frontend/app/(main)/COMPANY_30/logistics/inventory/page.tsx @@ -72,6 +72,9 @@ const STOCK_COLUMNS = [ { key: "location_code", label: "위치" }, { key: "current_qty", label: "현재수량", align: "right" as const }, { key: "safety_qty", label: "안전재고", align: "right" as const }, + { key: "width", label: "가로", align: "right" as const }, + { key: "height", label: "세로", align: "right" as const }, + { key: "thickness", label: "두께", align: "right" as const }, { key: "unit", label: "단위" }, { key: "status", label: "상태" }, ]; @@ -164,7 +167,7 @@ export default function InventoryStatusPage() { } // item_info 단위 카테고리 try { - const res = await apiClient.get("/table-categories/item_info/inventory_unit/values?filterCompanyCode=COMPANY_16"); + const res = await apiClient.get("/table-categories/item_info/inventory_unit/values?filterCompanyCode=COMPANY_30"); if (res.data?.success) optMap["item_inventory_unit"] = flatten(res.data.data || []); } catch { /* skip */ } setCategoryOptions(optMap); @@ -201,7 +204,7 @@ 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 || "", width: i.width || "", height: i.height || "", thickness: i.thickness || "" }])); const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code])); const resolve = (col: string, code: string) => { if (!code) return ""; @@ -213,6 +216,9 @@ export default function InventoryStatusPage() { return { ...r, item_name: itemInfo?.name || "", + width: itemInfo?.width || "", + height: itemInfo?.height || "", + thickness: itemInfo?.thickness || "", unit: resolve("item_inventory_unit", rawUnit) || rawUnit, warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "", status: resolve("status", r.status), diff --git a/frontend/app/(main)/COMPANY_30/logistics/material-status/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/material-status/page.tsx index 58354385..87fc9766 100644 --- a/frontend/app/(main)/COMPANY_30/logistics/material-status/page.tsx +++ b/frontend/app/(main)/COMPANY_30/logistics/material-status/page.tsx @@ -557,6 +557,26 @@ export default function MaterialStatusPage() { ({material.code}) + {(material.width || material.height || material.thickness) && ( + <> + | + + {material.width && W {material.width}} + {material.height && ( + <> + {material.width && ×} + H {material.height} + + )} + {material.thickness && ( + <> + {(material.width || material.height) && ×} + T {material.thickness} + + )} + + + )} | diff --git a/frontend/app/(main)/COMPANY_30/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/outbound/page.tsx index ea244869..2fd39734 100644 --- a/frontend/app/(main)/COMPANY_30/logistics/outbound/page.tsx +++ b/frontend/app/(main)/COMPANY_30/logistics/outbound/page.tsx @@ -73,6 +73,36 @@ import { type WarehouseOption, } from "@/lib/api/outbound"; +// item_info 테이블에서 가로/세로/두께를 join하여 보강 (소스 raw 데이터에 없을 수 있음) +async function enrichWithItemDimensions>(rows: T[]): Promise { + const codes = [...new Set(rows.map((r) => r.item_code || r.item_number).filter(Boolean) as string[])]; + if (codes.length === 0) return rows; + try { + const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: codes.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codes }] }, + autoFilter: true, + }); + const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; + const dimMap: Record = {}; + for (const i of items) { + dimMap[i.item_number] = { width: i.width || "", height: i.height || "", thickness: i.thickness || "" }; + } + return rows.map((r) => { + const code = r.item_code || r.item_number; + const dim = code ? dimMap[code] : undefined; + return { + ...r, + width: r.width || dim?.width || "", + height: r.height || dim?.height || "", + thickness: r.thickness || dim?.thickness || "", + }; + }); + } catch { + return rows; + } +} + // 출고유형 옵션 const OUTBOUND_TYPES = [ { value: "판매출고", label: "판매출고", color: "bg-primary/10 text-primary" }, @@ -275,7 +305,7 @@ export default function OutboundPage() { Promise.all([ ...["material", "inventory_unit"].map(async (col) => { try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_30`); const items = flatten(res.data?.data || []); map[col] = {}; for (const item of items) map[col][item.code] = item.label; @@ -482,10 +512,16 @@ export default function OutboundPage() { try { if (type === "판매출고") { const res = await getShipmentInstructionSources(keyword || undefined); - if (res.success) setShipmentInstructions(res.data); + if (res.success) { + const enriched = await enrichWithItemDimensions(res.data); + setShipmentInstructions(enriched as ShipmentInstructionSource[]); + } } else if (type === "반품출고") { const res = await getPurchaseOrderSources(keyword || undefined); - if (res.success) setPurchaseOrders(res.data); + if (res.success) { + const enriched = await enrichWithItemDimensions(res.data); + setPurchaseOrders(enriched as PurchaseOrderSource[]); + } } else { const res = await getItemSources(keyword || undefined); if (res.success) setItems(res.data); diff --git a/frontend/app/(main)/COMPANY_30/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_30/logistics/receiving/page.tsx index 8cf7d653..56965078 100644 --- a/frontend/app/(main)/COMPANY_30/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_30/logistics/receiving/page.tsx @@ -55,6 +55,7 @@ import { import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; +import { toast } from "sonner"; import { useTableSettings } from "@/hooks/useTableSettings"; import { TableSettingsModal } from "@/components/common/TableSettingsModal"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; @@ -77,6 +78,36 @@ import { type WarehouseOption, } from "@/lib/api/receiving"; +// item_info 테이블에서 가로/세로/두께를 join하여 보강 (소스 raw 데이터에 없을 수 있음) +async function enrichWithItemDimensions>(rows: T[]): Promise { + const codes = [...new Set(rows.map((r) => r.item_code || r.item_number).filter(Boolean) as string[])]; + if (codes.length === 0) return rows; + try { + const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: codes.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: codes }] }, + autoFilter: true, + }); + const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || []; + const dimMap: Record = {}; + for (const i of items) { + dimMap[i.item_number] = { width: i.width || "", height: i.height || "", thickness: i.thickness || "" }; + } + return rows.map((r) => { + const code = r.item_code || r.item_number; + const dim = code ? dimMap[code] : undefined; + return { + ...r, + width: r.width || dim?.width || "", + height: r.height || dim?.height || "", + thickness: r.thickness || dim?.thickness || "", + }; + }); + } catch { + return rows; + } +} + const GRID_COLUMNS = [ { key: "inbound_number", label: "입고번호" }, { key: "inbound_type", label: "입고유형" }, @@ -330,7 +361,7 @@ export default function ReceivingPage() { Promise.all( ["material", "inventory_unit"].map(async (col) => { try { - const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`); + const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_30`); const items = flatten(res.data?.data || []); map[col] = {}; for (const item of items) map[col][item.code] = item.label; @@ -522,13 +553,15 @@ export default function ReceivingPage() { if (type === "구매입고") { const res = await getPurchaseOrderSources(params); if (res.success) { - setPurchaseOrders(res.data); + const enriched = await enrichWithItemDimensions(res.data); + setPurchaseOrders(enriched as PurchaseOrderSource[]); setSourceTotalCount(res.totalCount || 0); } } else if (type === "반품입고") { const res = await getShipmentSources(params); if (res.success) { - setShipments(res.data); + const enriched = await enrichWithItemDimensions(res.data); + setShipments(enriched as ShipmentSource[]); setSourceTotalCount(res.totalCount || 0); } } else { diff --git a/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx index 55d4f24b..2bf8de1b 100644 --- a/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx +++ b/frontend/app/(main)/COMPANY_30/master-data/item-info/page.tsx @@ -214,6 +214,7 @@ export default function ItemInfoPage() { // 선택된 행 const [selectedId, setSelectedId] = useState(null); + const [checkedIds, setCheckedIds] = useState([]); // 채번 관련 상태 const [numberingRule, setNumberingRule] = useState(null); @@ -604,20 +605,22 @@ export default function ItemInfoPage() { } }; - // 삭제 + // 삭제 (체크박스 다중 우선, 없으면 단일 선택) const handleDelete = async () => { - if (!selectedId) { + const targetIds = checkedIds.length > 0 ? checkedIds : (selectedId ? [selectedId] : []); + if (targetIds.length === 0) { toast.error("삭제할 품목을 선택해 주세요."); return; } - if (!confirm("선택한 품목을 삭제할까요?")) return; + if (!confirm(`선택한 ${targetIds.length}건의 품목을 삭제할까요?`)) return; try { await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { - data: [{ id: selectedId }], + data: targetIds.map((id) => ({ id })), }); toast.success("삭제되었어요."); - setSelectedId(null); + setCheckedIds([]); + if (selectedId && targetIds.includes(selectedId)) setSelectedId(null); fetchItems(); } catch (err) { console.error("삭제 실패:", err); @@ -672,9 +675,11 @@ export default function ItemInfoPage() { -
- - - - - - No - 구분 - 품명 - 규격 - 가로 - 세로 - 두께 - 면적(㎡) - 단위 - 수량 - 단가 - 금액 - 납기일 - - - - {modalDetailRows.length === 0 ? ( - 품목을 추가해주세요 - ) : modalDetailRows.map((row, idx) => ( - { e.dataTransfer.setData("text/plain", String(idx)); e.currentTarget.classList.add("opacity-50"); }} - onDragEnd={(e) => { e.currentTarget.classList.remove("opacity-50"); }} - onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add("border-t-2", "border-primary"); }} - onDragLeave={(e) => { e.currentTarget.classList.remove("border-t-2", "border-primary"); }} - onDrop={(e) => { - e.preventDefault(); - e.currentTarget.classList.remove("border-t-2", "border-primary"); - const fromIdx = parseInt(e.dataTransfer.getData("text/plain")); - if (!isNaN(fromIdx) && fromIdx !== idx) { - setModalDetailRows((prev) => { - const next = [...prev]; - const [moved] = next.splice(fromIdx, 1); - next.splice(idx, 0, moved); - return next; - }); - } - }} - > - - - - - - - {idx + 1} - {/* 구분: 품목검색 → 읽기전용, 행추가 → Select */} - - {row._fromItemInfo ? ( - {row._divisionLabel || "-"} - ) : ( - - )} - - {/* 품명: 품목검색 → 읽기전용, 행추가 → 입력 */} - - {row._fromItemInfo ? ( - {row.part_name || "-"} - ) : ( - updateDetailRow(idx, "part_name", e.target.value)} - className="h-8 text-sm" placeholder="품명" /> - )} - - {/* 규격: 품목검색 → 읽기전용, 행추가 → 입력 */} - - {row._fromItemInfo ? ( - {row.spec || "-"} - ) : ( - updateDetailRow(idx, "spec", e.target.value)} - className="h-8 text-sm" placeholder="규격" /> - )} - - - updateDetailRow(idx, "width", parseNumber(e.target.value))} - className="h-8 text-sm text-right" placeholder="mm" /> - - - updateDetailRow(idx, "height", parseNumber(e.target.value))} - className="h-8 text-sm text-right" placeholder="mm" /> - - - updateDetailRow(idx, "thickness", e.target.value)} - className="h-8 text-sm text-right" placeholder="mm" /> - - - {row.area || "-"} - - {/* 단위: 품목검색 → 읽기전용, 행추가 → 입력 */} - - {row._fromItemInfo ? ( - {row.unit || "-"} - ) : ( - updateDetailRow(idx, "unit", e.target.value)} - className="h-8 text-sm" placeholder="㎡" /> - )} - - - updateDetailRow(idx, "qty", parseNumber(e.target.value))} - className="h-8 text-sm text-right" /> - - - updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} - className="h-8 text-sm text-right" /> - - - {row.amount ? Number(row.amount).toLocaleString() : ""} - - - updateDetailRow(idx, "due_date", v)} placeholder="납기일" /> - + +
+ + + + + No + c.key)} strategy={horizontalListSortingStrategy}> + {modalColumns.map((col) => ( + + ))} + - ))} - {/* 합계 행 */} - {modalDetailRows.length > 0 && ( - - 합계 - - {modalDetailRows.reduce((s, r) => s + (parseFloat(r.qty) || 0), 0).toLocaleString()} - - - - {modalDetailRows.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0).toLocaleString()}원 - - - - )} - -
+ + + {modalDetailRows.length === 0 ? ( + 품목을 추가해주세요 + ) : modalDetailRows.map((row, idx) => ( + { e.dataTransfer.setData("text/plain", String(idx)); e.currentTarget.classList.add("opacity-50"); }} + onDragEnd={(e) => { e.currentTarget.classList.remove("opacity-50"); }} + onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add("border-t-2", "border-primary"); }} + onDragLeave={(e) => { e.currentTarget.classList.remove("border-t-2", "border-primary"); }} + onDrop={(e) => { + e.preventDefault(); + e.currentTarget.classList.remove("border-t-2", "border-primary"); + const fromIdx = parseInt(e.dataTransfer.getData("text/plain")); + if (!isNaN(fromIdx) && fromIdx !== idx) { + setModalDetailRows((prev) => { + const next = [...prev]; + const [moved] = next.splice(fromIdx, 1); + next.splice(idx, 0, moved); + return next; + }); + } + }} + > + + + + + + + {idx + 1} + {modalColumns.map((col) => ( + + {renderModalCell(col.key, row, idx)} + + ))} + + ))} + {modalDetailRows.length > 0 && ( + + + 합계 수량: {modalDetailRows.reduce((s, r) => s + (parseFloat(r.qty) || 0), 0).toLocaleString()} + 합계 금액: {modalDetailRows.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0).toLocaleString()}원 + + + )} + + +
@@ -1147,7 +1242,7 @@ export default function JeilGlassOrderPage() { open={tableSettingsOpen} onOpenChange={setTableSettingsOpen} tableName={MASTER_TABLE} - settingsId="jeilglass-order" + settingsId="c30-sales-order" onSave={applyTableSettings} /> diff --git a/frontend/app/(main)/COMPANY_30/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_30/sales/quote/page.tsx index f5d3f4f7..7734d120 100644 --- a/frontend/app/(main)/COMPANY_30/sales/quote/page.tsx +++ b/frontend/app/(main)/COMPANY_30/sales/quote/page.tsx @@ -587,7 +587,7 @@ export default function QuoteManagementPage() { const handleRowClick = (row: any) => setSelectedRow(row); const contextParams = selectedRow - ? { "$1": selectedRow.objid, "$2": user?.companyCode || "COMPANY_7", objid: selectedRow.objid, quote_no: selectedRow.quote_no, customer_name: selectedRow.customer_name, quote_date: selectedRow.quote_date } + ? { "$1": selectedRow.objid, "$2": user?.companyCode || "COMPANY_30", objid: selectedRow.objid, quote_no: selectedRow.quote_no, customer_name: selectedRow.customer_name, quote_date: selectedRow.quote_date } : undefined; // ── 편집 모달 제목/타입 판별 ── diff --git a/frontend/app/(main)/COMPANY_30/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_30/sales/sales-item/page.tsx index 70ebf5c2..bf51997b 100644 --- a/frontend/app/(main)/COMPANY_30/sales/sales-item/page.tsx +++ b/frontend/app/(main)/COMPANY_30/sales/sales-item/page.tsx @@ -286,7 +286,7 @@ export default function SalesItemPage() { }; for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) { try { - const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); + const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values?filterCompanyCode=COMPANY_30`); if (res.data?.success) optMap[col] = flatten(res.data.data || []); } catch { /* skip */ } } @@ -336,11 +336,27 @@ export default function SalesItemPage() { page: 1, size: 5000, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, autoFilter: true, + sortBy: "item_number", + sortOrder: "desc", }); const raw = res.data?.data?.data || res.data?.data?.rows || []; - setRawItems(raw); + + // 안전장치: division 카테고리 매칭이 안 잡히는 경우 클라이언트에서 영업관리 코드 포함된 것만 + const filteredRaw = salesCode + ? raw.filter((r: any) => { + const div = String(r.division || ""); + return div.split(",").map((s: string) => s.trim()).includes(salesCode); + }) + : raw; + + // item_number 내림차순 자연 정렬 + const sortedRaw = [...filteredRaw].sort((a: any, b: any) => + String(b.item_number || "").localeCompare(String(a.item_number || ""), undefined, { numeric: true, sensitivity: "base" }) + ); + + setRawItems(sortedRaw); const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]; - const data = raw.map((r: any) => { + const data = sortedRaw.map((r: any) => { const converted = { ...r }; for (const col of CATS) { if (converted[col]) converted[col] = resolve(col, converted[col]); @@ -348,7 +364,7 @@ export default function SalesItemPage() { return converted; }); setItems(data); - setItemCount(res.data?.data?.total || raw.length); + setItemCount(sortedRaw.length); } catch (err) { console.error("품목 조회 실패:", err); toast.error("품목 목록을 불러오는데 실패했습니다."); diff --git a/frontend/app/(main)/admin/report/sales/page.tsx b/frontend/app/(main)/admin/report/sales/page.tsx index 1136028e..9d3bc1d4 100644 --- a/frontend/app/(main)/admin/report/sales/page.tsx +++ b/frontend/app/(main)/admin/report/sales/page.tsx @@ -13,16 +13,37 @@ const config: ReportConfig = { { id: "shipQty", name: "출하수량", unit: "EA", color: "#ef4444" }, { id: "unitPrice", name: "단가", unit: "원", color: "#8b5cf6" }, { id: "orderCount", name: "수주건수", unit: "건", color: "#f59e0b" }, + { id: "totalArea", name: "총 면적", unit: "㎡", color: "#0891b2" }, + { id: "width", name: "가로", unit: "mm", color: "#14b8a6" }, + { id: "height", name: "세로", unit: "mm", color: "#d946ef" }, + { id: "thickness", name: "두께", unit: "mm", color: "#f97316" }, + { id: "areaSingle", name: "면적(건당)", unit: "㎡", color: "#06b6d4" }, ], groupByOptions: [ { id: "customer", name: "거래처별" }, { id: "item", name: "품목별" }, { id: "status", name: "상태별" }, + { id: "thickness", name: "두께별" }, + { id: "size", name: "사이즈별 (가로×세로)" }, + { id: "sizeRange", name: "사이즈 구간별" }, { id: "monthly", name: "월별" }, { id: "quarterly", name: "분기별" }, { id: "weekly", name: "주별" }, { id: "daily", name: "일별" }, ], + // 자유 조합 그룹핑: 기본 그룹 + 아래 필드 중 여러 개 추가해서 조합 + groupableFields: [ + { id: "customer", name: "거래처" }, + { id: "item", name: "품목" }, + { id: "status", name: "상태" }, + { id: "thickness", name: "두께" }, + { id: "size", name: "사이즈" }, + { id: "sizeRange", name: "사이즈 구간" }, + { id: "monthly", name: "월" }, + { id: "quarterly", name: "분기" }, + { id: "weekly", name: "주" }, + { id: "daily", name: "일" }, + ], defaultGroupBy: "customer", defaultMetrics: ["orderAmt"], thresholds: [ @@ -33,6 +54,10 @@ const config: ReportConfig = { { id: "customer", name: "거래처", type: "select", optionKey: "customers" }, { id: "item", name: "품목", type: "select", optionKey: "items" }, { id: "status", name: "상태", type: "select", optionKey: "statuses" }, + { id: "thickness", name: "두께", type: "number" }, + { id: "width", name: "가로", type: "number" }, + { id: "height", name: "세로", type: "number" }, + { id: "totalArea", name: "총 면적", type: "number" }, { id: "orderAmt", name: "수주금액", type: "number" }, { id: "orderQty", name: "수주수량", type: "number" }, ], @@ -41,10 +66,15 @@ const config: ReportConfig = { { id: "order_no", name: "수주번호" }, { id: "customer", name: "거래처" }, { id: "item", name: "품목" }, + { id: "width", name: "가로", align: "right" }, + { id: "height", name: "세로", align: "right" }, + { id: "thickness", name: "두께", align: "right" }, + { id: "areaSingle", name: "면적(㎡)", align: "right", format: "number" }, { id: "status", name: "상태", format: "badge" }, { id: "orderQty", name: "수주수량", align: "right", format: "number" }, { id: "unitPrice", name: "단가", align: "right", format: "number" }, { id: "orderAmt", name: "수주금액", align: "right", format: "number" }, + { id: "totalArea", name: "총면적(㎡)", align: "right", format: "number" }, { id: "shipQty", name: "출하수량", align: "right", format: "number" }, ], rawDataColumns: [ @@ -53,10 +83,15 @@ const config: ReportConfig = { { id: "customer", name: "거래처" }, { id: "part_code", name: "품목코드" }, { id: "item", name: "품목명" }, + { id: "width", name: "가로", align: "right" }, + { id: "height", name: "세로", align: "right" }, + { id: "thickness", name: "두께", align: "right" }, + { id: "areaSingle", name: "면적(㎡)", align: "right", format: "number" }, { id: "status", name: "상태", format: "badge" }, { id: "orderQty", name: "수주수량", align: "right", format: "number" }, { id: "unitPrice", name: "단가", align: "right", format: "number" }, { id: "orderAmt", name: "수주금액", align: "right", format: "number" }, + { id: "totalArea", name: "총면적(㎡)", align: "right", format: "number" }, { id: "shipQty", name: "출하수량", align: "right", format: "number" }, ], emptyMessage: "수주 데이터가 없습니다", diff --git a/frontend/components/admin/report/ReportEngine.tsx b/frontend/components/admin/report/ReportEngine.tsx index 92f14ba7..92537f23 100644 --- a/frontend/components/admin/report/ReportEngine.tsx +++ b/frontend/components/admin/report/ReportEngine.tsx @@ -97,6 +97,17 @@ export interface ReportMetric { export interface ReportGroupByOption { id: string; name: string; + /** 복합 그룹인 경우 파트 이름들 (예: ["거래처", "사이즈"]). 1차 그룹 선택 드롭다운에 사용 */ + parts?: string[]; +} + +/** + * 자유 조합 그룹핑에서 사용할 단위 필드 + * 사용자가 이 필드들을 자유롭게 선택/조합해서 그룹 조건을 만들 수 있음 + */ +export interface ReportGroupableField { + id: string; // getGroupKey에 넘겨질 id (예: "customer", "size", "thickness", "monthly") + name: string; // 화면 표시명 (예: "거래처") } export interface ReportThreshold { @@ -127,6 +138,8 @@ export interface ReportConfig { apiEndpoint: string; metrics: ReportMetric[]; groupByOptions: ReportGroupByOption[]; + /** 자유 조합 그룹핑에 쓸 단위 필드 목록 (선택 사항) */ + groupableFields?: ReportGroupableField[]; defaultGroupBy: string; defaultMetrics: string[]; thresholds: ReportThreshold[]; @@ -164,15 +177,18 @@ interface FilterField { } interface Preset { + id: number; name: string; - desc: string; + description: string | null; config: { groupBy: string; startDate: string; endDate: string; conditions: ConditionGroup[]; }; - savedAt: string; + created_by?: string | null; + created_at?: string; + updated_at?: string; } // ============================================ @@ -243,6 +259,48 @@ function getGroupKey(row: Record, groupBy: string): string { const m = parseInt(dateStr.substring(5, 7)); return `${dateStr.substring(0, 4)}-Q${Math.ceil(m / 3)}`; } + case "thickness": { + const t = row.thickness; + if (!t || t === "" || t === "0") return "미지정"; + return `${t}T`; + } + case "sizeRange": { + const area = parseFloat(row.areaSingle) || 0; + if (area <= 0) return "미지정"; + if (area < 1) return "소형 (1㎡ 미만)"; + if (area < 3) return "중형 (1-3㎡)"; + if (area < 6) return "대형 (3-6㎡)"; + return "특대형 (6㎡ 이상)"; + } + case "size": { + const w = row.width, h = row.height; + if (!w || !h) return "미지정"; + return `${w}×${h}`; + } + case "itemSize": { + const item = row.item || "미지정"; + const w = row.width, h = row.height; + const sizeStr = w && h ? `${w}×${h}` : "미지정"; + return `${item} / ${sizeStr}`; + } + case "customerSize": { + const customer = row.customer || "미지정"; + const w = row.width, h = row.height; + const sizeStr = w && h ? `${w}×${h}` : "미지정"; + return `${customer} / ${sizeStr}`; + } + case "customerItem": { + const customer = row.customer || "미지정"; + const item = row.item || "미지정"; + return `${customer} / ${item}`; + } + case "customerItemSize": { + const customer = row.customer || "미지정"; + const item = row.item || "미지정"; + const w = row.width, h = row.height; + const sizeStr = w && h ? `${w}×${h}` : "미지정"; + return `${customer} / ${item} / ${sizeStr}`; + } default: return row[groupBy] || "미지정"; } @@ -343,9 +401,11 @@ export default function ReportEngine({ config }: ReportEngineProps) { const [isLoading, setIsLoading] = useState(false); const [groupBy, setGroupBy] = useState(config.defaultGroupBy); + // 자유 조합 그룹핑: 기본 groupBy 뒤에 이어 붙일 추가 그룹 필드들 (순서대로) + const [extraGroupBys, setExtraGroupBys] = useState([]); const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); - const [activePreset, setActivePreset] = useState("last6m"); + const [activePreset, setActivePreset] = useState("last1m"); const [filterOpen, setFilterOpen] = useState(true); const [conditions, setConditions] = useState([]); @@ -355,12 +415,21 @@ export default function ReportEngine({ config }: ReportEngineProps) { const [viewMode, setViewMode] = useState<"table" | "card">("table"); const [drilldownLabel, setDrilldownLabel] = useState(null); const [rawDataOpen, setRawDataOpen] = useState(false); + // 집계 테이블 검색/정렬 + const [tableSearchQuery, setTableSearchQuery] = useState(""); + const [tableSortColumn, setTableSortColumn] = useState(null); + const [tableSortDirection, setTableSortDirection] = useState<"asc" | "desc">("desc"); + // 집계 테이블 그룹핑 (1차 그룹으로 묶기) + const [tableGrouped, setTableGrouped] = useState(false); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const [primaryGroupIndex, setPrimaryGroupIndex] = useState(0); // 현재 그룹화의 어떤 파트를 1차 기준으로 쓸지 const [presets, setPresets] = useState([]); const [presetModalOpen, setPresetModalOpen] = useState(false); + const [presetMode, setPresetMode] = useState<"new" | "update">("new"); const [presetName, setPresetName] = useState(""); const [presetDesc, setPresetDesc] = useState(""); - const [selectedPresetIdx, setSelectedPresetIdx] = useState(""); + const [selectedPresetId, setSelectedPresetId] = useState(""); const [thresholdValues, setThresholdValues] = useState>(() => { const defaults: Record = {}; @@ -371,8 +440,6 @@ export default function ReportEngine({ config }: ReportEngineProps) { const [refreshInterval, setRefreshInterval] = useState(0); const refreshTimerRef = useRef | null>(null); - const PRESET_KEY = `${config.key}_presets`; - const filterFields: FilterField[] = useMemo( () => config.filterFieldDefs.map((def) => ({ @@ -393,7 +460,7 @@ export default function ReportEngine({ config }: ReportEngineProps) { // 초기화 // ============================================ useEffect(() => { - const range = getDatePresetRange("last6m"); + const range = getDatePresetRange("last1m"); setStartDate(range.start); setEndDate(range.end); @@ -523,6 +590,22 @@ export default function ReportEngine({ config }: ReportEngineProps) { ); }; + // 메트릭 순서 변경 (왼쪽/오른쪽) + const moveMetric = (condId: number, metricId: string, dir: -1 | 1) => { + setConditions((prev) => + prev.map((c) => { + if (c.id !== condId) return c; + const idx = c.metrics.indexOf(metricId); + if (idx === -1) return c; + const newIdx = idx + dir; + if (newIdx < 0 || newIdx >= c.metrics.length) return c; + const next = [...c.metrics]; + [next[idx], next[newIdx]] = [next[newIdx], next[idx]]; + return { ...c, metrics: next }; + }) + ); + }; + const addFilter = (condId: number) => { filterIdRef.current++; const firstField = config.filterFieldDefs[0]?.id || ""; @@ -587,37 +670,81 @@ export default function ReportEngine({ config }: ReportEngineProps) { }; // ============================================ - // 프리셋 저장/불러오기 + // 프리셋 저장/불러오기 (DB 기반 - company_code + report_key) // ============================================ - const loadPresets = () => { + const loadPresets = async () => { try { - const stored = localStorage.getItem(PRESET_KEY); - if (stored) setPresets(JSON.parse(stored)); - } catch {} + const res = await apiClient.get( + `/report-presets?reportKey=${encodeURIComponent(config.key)}` + ); + if (res.data?.success) { + setPresets(res.data.data || []); + } + } catch (err) { + console.error("프리셋 목록 조회 실패", err); + } }; - const savePreset = () => { - if (!presetName.trim()) return; - const newPresets = [ - ...presets, - { - name: presetName.trim(), - desc: presetDesc.trim(), - config: { groupBy, startDate, endDate, conditions }, - savedAt: new Date().toISOString(), - }, - ]; - setPresets(newPresets); - localStorage.setItem(PRESET_KEY, JSON.stringify(newPresets)); - setPresetModalOpen(false); + // 저장 모달 열기 - 신규 + const openNewPresetModal = () => { + setPresetMode("new"); setPresetName(""); setPresetDesc(""); + setPresetModalOpen(true); }; - const loadSelectedPreset = (idx: string) => { - setSelectedPresetIdx(idx); - if (idx === "") return; - const p = presets[parseInt(idx)]; + // 저장 모달 열기 - 선택된 프리셋 수정 + const openUpdatePresetModal = () => { + if (!selectedPresetId) return; + const p = presets.find((x) => String(x.id) === selectedPresetId); + if (!p) return; + setPresetMode("update"); + setPresetName(p.name); + setPresetDesc(p.description || ""); + setPresetModalOpen(true); + }; + + // 모달 저장 - mode 에 따라 POST(신규) / PUT(수정) 분기 + const savePreset = async () => { + if (!presetName.trim()) return; + const body = { + name: presetName.trim(), + description: presetDesc.trim() || null, + config: { groupBy, startDate, endDate, conditions }, + }; + try { + if (presetMode === "update" && selectedPresetId) { + const res = await apiClient.put(`/report-presets/${selectedPresetId}`, body); + if (res.data?.success) { + setPresets((prev) => + prev.map((x) => (String(x.id) === selectedPresetId ? res.data.data : x)) + ); + setPresetModalOpen(false); + setPresetName(""); + setPresetDesc(""); + } + } else { + const res = await apiClient.post(`/report-presets`, { + reportKey: config.key, + ...body, + }); + if (res.data?.success) { + setPresets((prev) => [res.data.data, ...prev]); + setSelectedPresetId(String(res.data.data.id)); + setPresetModalOpen(false); + setPresetName(""); + setPresetDesc(""); + } + } + } catch (err) { + console.error("프리셋 저장 실패", err); + } + }; + + const loadSelectedPreset = (id: string) => { + setSelectedPresetId(id); + if (id === "") return; + const p = presets.find((x) => String(x.id) === id); if (!p?.config) return; setGroupBy(p.config.groupBy); setStartDate(p.config.startDate); @@ -625,12 +752,17 @@ export default function ReportEngine({ config }: ReportEngineProps) { setConditions(p.config.conditions); }; - const deletePreset = () => { - if (selectedPresetIdx === "") return; - const newPresets = presets.filter((_, i) => i !== parseInt(selectedPresetIdx)); - setPresets(newPresets); - localStorage.setItem(PRESET_KEY, JSON.stringify(newPresets)); - setSelectedPresetIdx(""); + const deletePreset = async () => { + if (selectedPresetId === "") return; + try { + const res = await apiClient.delete(`/report-presets/${selectedPresetId}`); + if (res.data?.success) { + setPresets((prev) => prev.filter((p) => String(p.id) !== selectedPresetId)); + setSelectedPresetId(""); + } + } catch (err) { + console.error("프리셋 삭제 실패", err); + } }; // ============================================ @@ -649,24 +781,47 @@ export default function ReportEngine({ config }: ReportEngineProps) { aggMethod: string; chartType: string; groups: Record[]>; + values: Record; // 미리 계산된 집계값 (O(1) 조회) + totalValue: number; // 전체 합계(또는 선택된 집계) }[] = []; const allLabelsSet = new Set(); - conditions.forEach((cond, ci) => { - const condData = applyConditionFilters(rawData, cond.filters, filterFields); + // 각 condition의 filter는 cache (같은 filter면 재사용) + const condFilterCache = new Map(); + conditions.forEach((cond, ci) => { + // filter 결과 캐싱 (같은 필터면 재사용) + const filterKey = JSON.stringify(cond.filters); + let condData = condFilterCache.get(filterKey); + if (!condData) { + condData = applyConditionFilters(rawData, cond.filters, filterFields); + condFilterCache.set(filterKey, condData); + } + + // 그룹핑 (1회) — 기본 groupBy + extraGroupBys 조합 + const allGroupBys = [groupBy, ...extraGroupBys]; const groups: Record[]> = {}; - condData.forEach((d) => { - const key = getGroupKey(d, groupBy); + for (let i = 0; i < condData.length; i++) { + const d = condData[i]; + const keyParts = allGroupBys.map((g) => getGroupKey(d, g)); + const key = keyParts.join(" / "); if (!groups[key]) groups[key] = []; groups[key].push(d); - }); - Object.keys(groups).forEach((k) => allLabelsSet.add(k)); + } + // Set 에 라벨 추가 + for (const k in groups) allLabelsSet.add(k); cond.metrics.forEach((metricId) => { const m = config.metrics.find((x) => x.id === metricId); if (!m) return; + // 각 라벨별 집계값을 한 번에 계산해서 저장 (렌더링 시 lookup 만) + const values: Record = {}; + for (const lb in groups) { + values[lb] = aggregateValues(groups[lb], metricId, cond.aggMethod); + } + // 전체 합계 + const totalValue = aggregateValues(condData, metricId, cond.aggMethod); seriesList.push({ condId: cond.id, condName: cond.name, @@ -677,6 +832,8 @@ export default function ReportEngine({ config }: ReportEngineProps) { aggMethod: cond.aggMethod, chartType: cond.chartType, groups, + values, + totalValue, }); }); }); @@ -687,25 +844,126 @@ export default function ReportEngine({ config }: ReportEngineProps) { if (isTimeBased) { labels.sort((a, b) => a.localeCompare(b)); } else if (seriesList.length > 0) { - const first = seriesList[0]; + // 다단계 정렬: 미리 계산된 values 사용 (O(1) 조회) labels.sort((a, b) => { - const va = aggregateValues(first.groups[a] || [], first.metricId, first.aggMethod); - const vb = aggregateValues(first.groups[b] || [], first.metricId, first.aggMethod); - return vb - va; + for (const s of seriesList) { + const va = s.values[a] || 0; + const vb = s.values[b] || 0; + // 0은 하단으로: 한쪽만 0이면 0 아닌 쪽 우선 + if (va === 0 && vb !== 0) return 1; + if (va !== 0 && vb === 0) return -1; + if (vb !== va) return vb - va; // 큰 값 먼저 + } + // 모든 메트릭 동일 시 라벨 자연 정렬 + return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); }); } - const chartData = labels.map((label) => { + // 차트 렌더링 성능 보호: 그룹이 너무 많으면 상위 N개만 차트에 표시 + // (테이블/드릴다운은 전체 labels 유지 — 집계 계산은 그대로) + const CHART_MAX_LABELS = 30; + const chartLabels = !isTimeBased && labels.length > CHART_MAX_LABELS + ? labels.slice(0, CHART_MAX_LABELS) + : labels; + + const chartData = chartLabels.map((label) => { const point: Record = { name: label }; seriesList.forEach((s) => { const key = `${s.condName}_${s.metricName}`; - point[key] = aggregateValues(s.groups[label] || [], s.metricId, s.aggMethod); + point[key] = s.values[label] || 0; }); return point; }); return { series: seriesList, labels, chartData }; - }, [rawData, conditions, groupBy, filterFields, config.metrics]); + }, [rawData, conditions, groupBy, extraGroupBys, filterFields, config.metrics]); + + // 집계 테이블 표시용 라벨 (검색 + 헤더 정렬 적용) — 미리 계산된 values 사용 + const displayLabels = useMemo(() => { + let list = analysisResult.labels; + if (tableSearchQuery) { + const q = tableSearchQuery.toLowerCase(); + list = list.filter((l) => l.toLowerCase().includes(q)); + } + if (tableSortColumn !== null) { + list = [...list].sort((a, b) => { + if (tableSortColumn === "__label__") { + const cmp = a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); + return tableSortDirection === "asc" ? cmp : -cmp; + } + const si = Number(tableSortColumn); + const s = analysisResult.series[si]; + if (!s) return 0; + const va = s.values[a] || 0; + const vb = s.values[b] || 0; + return tableSortDirection === "asc" ? va - vb : vb - va; + }); + } + return list; + }, [analysisResult, tableSearchQuery, tableSortColumn, tableSortDirection]); + + const toggleTableSort = (col: string) => { + if (tableSortColumn === col) { + setTableSortDirection((d) => (d === "asc" ? "desc" : "asc")); + } else { + setTableSortColumn(col); + setTableSortDirection("desc"); + } + }; + + // 현재 그룹화 옵션의 parts 메타 (기본 groupBy + extraGroupBys 조합) + const currentGroupParts = useMemo(() => { + const getPartName = (id: string): string => { + const gf = config.groupableFields?.find((f) => f.id === id); + if (gf) return gf.name; + const go = config.groupByOptions.find((o) => o.id === id); + if (go) return go.name.replace(/별$/, ""); + return id; + }; + if (extraGroupBys.length === 0) { + // 기본 그룹의 parts 메타 (예전 복합 옵션) 호환 + const opt = config.groupByOptions.find((o) => o.id === groupBy); + if (opt?.parts) return opt.parts; + return []; + } + return [groupBy, ...extraGroupBys].map(getPartName); + }, [config.groupByOptions, config.groupableFields, groupBy, extraGroupBys]); + + // 그룹핑 가능 여부 (복합 그룹일 때만): parts가 2개 이상 + const canGroupTable = currentGroupParts.length >= 2; + + // groupBy 변경 시 primaryGroupIndex 초기화 + useEffect(() => { + if (primaryGroupIndex >= currentGroupParts.length) { + setPrimaryGroupIndex(0); + } + }, [currentGroupParts.length, primaryGroupIndex]); + + // 1차 그룹으로 묶은 결과 (tableGrouped가 true이고 canGroupTable일 때만) + const groupedLabels = useMemo(() => { + if (!tableGrouped || !canGroupTable) return null; + const groups: Record = {}; + const order: string[] = []; + for (const label of displayLabels) { + const parts = label.split(" / "); + const primary = parts[primaryGroupIndex] ?? parts[0] ?? label; + if (!groups[primary]) { + groups[primary] = []; + order.push(primary); + } + groups[primary].push(label); + } + return { groups, order }; + }, [displayLabels, tableGrouped, canGroupTable, primaryGroupIndex]); + + const toggleGroupCollapse = (primary: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(primary)) next.delete(primary); + else next.add(primary); + return next; + }); + }; // ============================================ // 렌더링 @@ -747,19 +1005,35 @@ export default function ReportEngine({ config }: ReportEngineProps) {
저장된 조건 - - +
@@ -782,15 +1056,71 @@ export default function ReportEngine({ config }: ReportEngineProps) {
- +
+ + {/* 자유 조합 그룹핑: 추가 그룹 필드 칩 */} + {config.groupableFields && config.groupableFields.length > 0 && ( + <> + {extraGroupBys.map((id, idx) => { + const f = config.groupableFields!.find((gf) => gf.id === id); + return ( + + + + {f?.name || id} + + + ); + })} + + {extraGroupBys.length > 0 && ( + + )} + + )} +
@@ -887,8 +1217,71 @@ export default function ReportEngine({ config }: ReportEngineProps) { {!cond.collapsed && (
-
- 데이터 +
+ 데이터 (선택 순서 = 표시/정렬 기준) + {/* 선택된 메트릭: 순서대로 + 이동 버튼 */} + {cond.metrics.length > 0 && ( +
+ {cond.metrics.map((mid, idx) => { + const m = config.metrics.find((x) => x.id === mid); + if (!m) return null; + const isFirst = idx === 0; + const isLast = idx === cond.metrics.length - 1; + return ( +
+ + {idx + 1} + + + {m.name} + + + +
+ ); + })} +
+ )} + {/* 전체 메트릭 목록 */}
{config.metrics.map((m) => { const active = cond.metrics.includes(m.id); @@ -1115,7 +1508,9 @@ export default function ReportEngine({ config }: ReportEngineProps) { {analysisResult.series.length > 0 && (
- {config.groupByOptions.find((o) => o.id === groupBy)?.name} + {extraGroupBys.length > 0 && currentGroupParts.length > 0 + ? currentGroupParts.map((p) => `${p}별`).join(" × ") + : config.groupByOptions.find((o) => o.id === groupBy)?.name} {(startDate || endDate) && ( @@ -1253,27 +1648,87 @@ export default function ReportEngine({ config }: ReportEngineProps) { {/* 집계 데이터 */} {analysisResult.series.length > 0 && (
-
-

집계 데이터

-
- - +
+

집계 데이터

+
+ setTableSearchQuery(e.target.value)} + placeholder="검색 (거래처 / 품목 / 사이즈 등)" + className="h-8 w-full max-w-[280px] rounded-md border px-2 text-xs" + /> + {tableSearchQuery && ( + + )} + {canGroupTable && ( + + )} + {tableGrouped && canGroupTable && ( + <> + + + + + )} +
+ + +
@@ -1283,26 +1738,102 @@ export default function ReportEngine({ config }: ReportEngineProps) { - - {analysisResult.series.map((s, si) => ( - - ))} + {analysisResult.series.map((s, si) => { + const colKey = String(si); + return ( + + ); + })} - {analysisResult.labels.map((label) => ( + {displayLabels.length === 0 ? ( + + + + ) : tableGrouped && groupedLabels ? ( + groupedLabels.order.map((primary) => { + const subLabels = groupedLabels.groups[primary]; + const isCollapsed = collapsedGroups.has(primary); + return ( + + toggleGroupCollapse(primary)} + > + + {analysisResult.series.map((s, si) => { + const allRows = subLabels.flatMap((lb) => s.groups[lb] || []); + return ( + + ); + })} + + {!isCollapsed && subLabels.map((label) => { + const parts = label.split(" / "); + const subPart = parts.filter((_, i) => i !== primaryGroupIndex).join(" / ") || label; + return ( + setDrilldownLabel(label)} + > + + {analysisResult.series.map((s, si) => ( + + ))} + + ); + })} + + ); + }) + ) : displayLabels.map((label) => ( {label} {analysisResult.series.map((s, si) => ( ))} ))} - + {analysisResult.series.map((s, si) => { - const allRows = analysisResult.labels.flatMap( - (lb) => s.groups[lb] || [] - ); + // 검색 시에만 동적 계산, 아니면 미리 계산된 totalValue 사용 + let total: number; + if (tableSearchQuery) { + const allRows = displayLabels.flatMap((lb) => s.groups[lb] || []); + total = aggregateValues(allRows, s.metricId, s.aggMethod); + } else { + total = s.totalValue; + } return ( ); })} @@ -1336,11 +1872,14 @@ export default function ReportEngine({ config }: ReportEngineProps) { ) : (
- {analysisResult.labels.map((label) => { + {displayLabels.length === 0 && ( +
+ {tableSearchQuery ? "검색 결과가 없습니다" : "데이터가 없습니다"} +
+ )} + {displayLabels.map((label) => { const firstS = analysisResult.series[0]; - const val = firstS - ? aggregateValues(firstS.groups[label] || [], firstS.metricId, firstS.aggMethod) - : 0; + const val = firstS ? (firstS.values[label] || 0) : 0; return (
1 && (

{analysisResult.series.slice(1).map((s) => { - const v = aggregateValues(s.groups[label] || [], s.metricId, s.aggMethod); + const v = s.values[label] || 0; return `${s.condName}-${s.metricName}: ${formatNumber(v)}`; }).join(" | ")}

@@ -1496,7 +2035,9 @@ export default function ReportEngine({ config }: ReportEngineProps) { - 조건 저장 + + {presetMode === "update" ? "조건 수정" : "신규 조건 저장"} +
@@ -1532,7 +2073,7 @@ export default function ReportEngine({ config }: ReportEngineProps) { onClick={savePreset} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" > - 저장 + {presetMode === "update" ? "수정 저장" : "저장"} diff --git a/frontend/lib/api/materialStatus.ts b/frontend/lib/api/materialStatus.ts index 698b333c..acbc88e9 100644 --- a/frontend/lib/api/materialStatus.ts +++ b/frontend/lib/api/materialStatus.ts @@ -32,6 +32,9 @@ export interface MaterialData { current: number; unit: string; locations: MaterialLocation[]; + width?: string; + height?: string; + thickness?: string; } export interface WarehouseData {
- {config.groupByOptions.find((o) => o.id === groupBy)?.name} + toggleTableSort("__label__")} + > + + {tableGrouped && canGroupTable && currentGroupParts[primaryGroupIndex] + ? `${currentGroupParts[primaryGroupIndex]}별` + : (currentGroupParts.length > 0 + ? currentGroupParts.map((p) => `${p}별`).join(" × ") + : config.groupByOptions.find((o) => o.id === groupBy)?.name)} + {tableSortColumn === "__label__" && ( + {tableSortDirection === "asc" ? "▲" : "▼"} + )} + - {s.condName} -
- - {s.metricName}({aggLabel(s.aggMethod)}) - -
toggleTableSort(colKey)} + > + + + {s.condName} +
+ + {s.metricName}({aggLabel(s.aggMethod)}) + +
+ {tableSortColumn === colKey && ( + {tableSortDirection === "asc" ? "▲" : "▼"} + )} +
+
+ {tableSearchQuery ? "검색 결과가 없습니다" : "데이터가 없습니다"} +
+ + {isCollapsed ? "▶" : "▼"} + {primary} + ({subLabels.length}건) + + + {formatNumber(aggregateValues(allRows, s.metricId, s.aggMethod))} +
{subPart} + {formatNumber(s.values[label] || 0)} +
- {formatNumber( - aggregateValues(s.groups[label] || [], s.metricId, s.aggMethod) - )} + {formatNumber(s.values[label] || 0)}
전체 + {tableSearchQuery ? `필터 (${displayLabels.length}건)` : "전체"} + - {formatNumber(aggregateValues(allRows, s.metricId, s.aggMethod))} + {formatNumber(total)}