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.
This commit is contained in:
DDD1542
2026-04-16 12:08:28 +09:00
parent 2e8350c0f6
commit 623cbc0b61
22 changed files with 1411 additions and 391 deletions
+2
View File
@@ -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); // 설계 모듈
@@ -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<string, MaterialNeed> = {};
@@ -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,
};
});
@@ -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
`;
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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 });
}
}
@@ -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
@@ -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;
@@ -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");
}
@@ -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<Record<string, any>>({});
const [equipCodeRuleId, setEquipCodeRuleId] = useState<string | null>(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() {
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.equipment_code || ""} onChange={(e) => !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")} /></div>
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} readOnly={equipEditMode} placeholder="설비코드" className={cn("h-9", equipEditMode && "bg-muted cursor-not-allowed")} /></div>
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
@@ -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),
@@ -557,6 +557,26 @@ export default function MaterialStatusPage() {
<span className="text-xs text-muted-foreground">
({material.code})
</span>
{(material.width || material.height || material.thickness) && (
<>
<span className="text-xs text-muted-foreground">|</span>
<span className="inline-flex items-center gap-1 rounded-md border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[11px] font-mono text-primary">
{material.width && <span>W {material.width}</span>}
{material.height && (
<>
{material.width && <span className="opacity-50">×</span>}
<span>H {material.height}</span>
</>
)}
{material.thickness && (
<>
{(material.width || material.height) && <span className="opacity-50">×</span>}
<span>T {material.thickness}</span>
</>
)}
</span>
</>
)}
<span className="text-xs text-muted-foreground">
|
</span>
@@ -73,6 +73,36 @@ import {
type WarehouseOption,
} from "@/lib/api/outbound";
// item_info 테이블에서 가로/세로/두께를 join하여 보강 (소스 raw 데이터에 없을 수 있음)
async function enrichWithItemDimensions<T extends Record<string, any>>(rows: T[]): Promise<T[]> {
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<string, { width: string; height: string; thickness: string }> = {};
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);
@@ -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<T extends Record<string, any>>(rows: T[]): Promise<T[]> {
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<string, { width: string; height: string; thickness: string }> = {};
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 {
@@ -214,6 +214,7 @@ export default function ItemInfoPage() {
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(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() {
<Button
variant="outline"
size="sm"
disabled={!selectedId}
disabled={checkedIds.length === 0 && !selectedId}
onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (checkedIds.length > 1) { toast.error("복사는 한 건만 선택해주세요."); return; }
const id = checkedIds[0] || selectedId;
const item = items.find((i) => i.id === id);
if (item) openCopyModal(item);
}}
>
@@ -683,16 +688,18 @@ export default function ItemInfoPage() {
<Button
variant="outline"
size="sm"
disabled={!selectedId}
disabled={checkedIds.length === 0 && !selectedId}
onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (checkedIds.length > 1) { toast.error("수정은 한 건만 선택해주세요."); return; }
const id = checkedIds[0] || selectedId;
const item = items.find((i) => i.id === id);
if (item) openEditModal(item);
}}
>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" />
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0 && !selectedId} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && `(${checkedIds.length})`}
</Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
@@ -719,6 +726,10 @@ export default function ItemInfoPage() {
emptyMessage="등록된 품목이 없어요"
selectedId={selectedId}
onSelect={(id) => setSelectedId(id)}
showCheckbox
checkboxClickOnly
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEditModal(row)}
showRowNumber
draggableColumns={false}
@@ -31,6 +31,7 @@ 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 MASTER_TABLE = "purchase_order_mng";
const DETAIL_TABLE = "purchase_detail";
@@ -169,6 +170,22 @@ export default function PurchaseOrderPage() {
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 채번
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
useEffect(() => {
const loadRule = async () => {
try {
const res = await apiClient.get(`/numbering-rules/by-column/${MASTER_TABLE}/purchase_no`);
const rule = res.data?.data;
if (rule?.ruleId || rule?.rule_id) {
setNumberingRuleId(rule.ruleId || rule.rule_id);
}
} catch { /* 채번규칙 없음 — fallback 사용 */ }
};
loadRule();
}, []);
// 테이블 설정
const ts = useTableSettings("c16-purchase-order", DETAIL_TABLE, GRID_COLUMNS_CONFIG);
@@ -388,10 +405,23 @@ export default function PurchaseOrderPage() {
};
// 등록 모달 열기
const openRegisterModal = () => {
const openRegisterModal = async () => {
const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || "";
const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || "";
let previewPurchaseNo = "";
if (numberingRuleId) {
try {
const res = await previewNumberingCode(numberingRuleId);
if (res.success && res.data?.generatedCode) {
previewPurchaseNo = res.data.generatedCode;
}
} catch { /* skip */ }
}
if (!previewPurchaseNo) {
previewPurchaseNo = `PO-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${String(Date.now()).slice(-4)}`;
}
setMasterForm({
purchase_no: previewPurchaseNo,
input_mode: defaultInputMode,
price_mode: defaultPriceMode,
manager: user?.userId || "",
@@ -481,6 +511,19 @@ export default function PurchaseOrderPage() {
}
setSaving(true);
try {
// 신규 등록 시 채번 할당 (시퀀스 확정)
if (!isEditMode) {
if (numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId, masterForm.purchase_no);
if (allocRes.success && allocRes.data?.generatedCode) {
masterForm.purchase_no = allocRes.data.generatedCode;
}
}
if (!masterForm.purchase_no) {
masterForm.purchase_no = `PO-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${String(Date.now()).slice(-4)}`;
}
}
const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm;
if (isEditMode && id) {
@@ -509,23 +552,12 @@ export default function PurchaseOrderPage() {
});
}
} else {
const { purchase_no: _pn, ...fieldsWithoutPurchaseNo } = masterFields;
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, { id: crypto.randomUUID(), ...fieldsWithoutPurchaseNo });
const createdData = masterRes.data?.data;
let purchaseNo = createdData?.purchase_no;
if (!purchaseNo) {
const queryRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
sort: { columnName: "created_date", order: "desc" },
autoFilter: true,
});
const records = queryRes.data?.data?.data || queryRes.data?.data?.rows || [];
purchaseNo = records[0]?.purchase_no;
}
const purchaseNo = masterFields.purchase_no;
if (!purchaseNo) {
toast.error("발주번호를 가져올 수 없어요. 다시 시도해주세요.");
return;
}
await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, { id: crypto.randomUUID(), ...masterFields });
for (const [idx, row] of detailRows.entries()) {
const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row;
await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, {
@@ -286,7 +286,7 @@ export default function PurchaseItemPage() {
await Promise.all(
CATEGORY_COLUMNS.map(async (col) => {
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 && res.data.data?.length > 0) optMap[col] = flatten(res.data.data);
} catch { /* skip */ }
})
@@ -337,10 +337,26 @@ export default function PurchaseItemPage() {
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);
const data = raw.map((r: any) => {
// 안전장치: 카테고리 코드가 안 잡혔거나 백엔드 contains 누락 시에도 division에 구매관리 코드 포함된 것만 표시
const filteredRaw = purchaseCode
? raw.filter((r: any) => {
const div = String(r.division || "");
return div.split(",").map((s: string) => s.trim()).includes(purchaseCode);
})
: 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 data = sortedRaw.map((r: any) => {
const converted = { ...r };
for (const col of CATEGORY_COLUMNS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
@@ -348,7 +364,7 @@ export default function PurchaseItemPage() {
return converted;
});
setItems(data);
setItemCount(res.data?.data?.total || raw.length);
setItemCount(sortedRaw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
@@ -136,7 +136,7 @@ export default function InspectionManagementPage() {
await Promise.all(
catList.map(async ({ table, col }) => {
try {
const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_7`);
const res = await apiClient.get(`/table-categories/${table}/${col}/values?filterCompanyCode=COMPANY_30`);
if (res.data?.data?.length > 0) {
optMap[`${table}.${col}`] = flattenCategories(res.data.data);
}
@@ -1,7 +1,7 @@
"use client";
/**
* (COMPANY_9)
* (COMPANY_30)
*
* 좌측: 수주 (order_no )
* 우측: 선택한 (sales_order_detail)
@@ -11,6 +11,9 @@
*/
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { DndContext, PointerSensor, closestCenter, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, arrayMove, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -84,7 +87,71 @@ const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "memo", label: "비고", width: "w-[80px]" },
];
export default function JeilGlassOrderPage() {
// 모달 품목 테이블 컬럼 (드래그 재정렬 + resize)
type ModalCol = { key: string; label: string; width: number };
const MODAL_DETAIL_COLUMNS: ModalCol[] = [
{ key: "division", label: "구분", width: 100 },
{ key: "part_name", label: "품명", width: 170 },
{ key: "spec", label: "규격", width: 110 },
{ key: "width", label: "가로", width: 90 },
{ key: "height", label: "세로", width: 90 },
{ key: "thickness", label: "두께", width: 90 },
{ key: "area", label: "면적(㎡)", width: 90 },
{ key: "unit", label: "단위", width: 100 },
{ key: "qty", label: "수량", width: 90 },
{ key: "unit_price", label: "단가", width: 110 },
{ key: "amount", label: "금액", width: 110 },
{ key: "due_date", label: "납기일", width: 160 },
];
const MODAL_COL_KEY = "c30_sales_order_modal_col";
function SortableModalHead({ col, onResize }: { col: ModalCol; onResize: (key: string, w: number) => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
const [resizing, setResizing] = useState(false);
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
width: col.width,
minWidth: col.width,
opacity: isDragging ? 0.4 : 1,
};
const startResize = (e: React.PointerEvent) => {
e.stopPropagation();
e.preventDefault();
setResizing(true);
const startX = e.clientX;
const startW = col.width;
const onMove = (ev: PointerEvent) => onResize(col.key, Math.max(50, startW + ev.clientX - startX));
const onUp = () => {
setResizing(false);
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
return (
<TableHead
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="group relative select-none cursor-grab active:cursor-grabbing hover:bg-muted/30 transition-colors"
>
<span className="truncate pointer-events-none">{col.label}</span>
<div
onPointerDown={startResize}
onClick={(e) => e.stopPropagation()}
className={cn(
"absolute right-0 top-0 bottom-0 w-[3px] cursor-col-resize transition-all",
resizing ? "bg-primary" : "bg-transparent group-hover:bg-border hover:!bg-primary/70",
)}
/>
</TableHead>
);
}
export default function ChunganSalesOrderPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
@@ -124,12 +191,57 @@ export default function JeilGlassOrderPage() {
// 채번
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
// 모달 품목 컬럼 (드래그 재정렬 + resize)
const [modalColumns, setModalColumns] = useState<ModalCol[]>(MODAL_DETAIL_COLUMNS);
const modalSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
useEffect(() => {
try {
const saved = localStorage.getItem(MODAL_COL_KEY);
if (!saved) return;
const parsed = JSON.parse(saved) as { key: string; width: number }[];
const byKey = new Map(parsed.map((c) => [c.key, c.width]));
const ordered = parsed
.map((p) => MODAL_DETAIL_COLUMNS.find((c) => c.key === p.key))
.filter(Boolean) as ModalCol[];
const missing = MODAL_DETAIL_COLUMNS.filter((c) => !byKey.has(c.key));
const merged = [...ordered, ...missing].map((c) => ({ ...c, width: byKey.get(c.key) ?? c.width }));
setModalColumns(merged);
} catch { /* skip */ }
}, []);
const persistModalColumns = (cols: ModalCol[]) => {
try {
localStorage.setItem(MODAL_COL_KEY, JSON.stringify(cols.map((c) => ({ key: c.key, width: c.width }))));
} catch { /* skip */ }
};
const handleModalDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setModalColumns((prev) => {
const from = prev.findIndex((c) => c.key === active.id);
const to = prev.findIndex((c) => c.key === over.id);
const next = arrayMove(prev, from, to);
persistModalColumns(next);
return next;
});
};
const handleColumnResize = (key: string, w: number) => {
setModalColumns((prev) => {
const next = prev.map((c) => (c.key === key ? { ...c, width: w } : c));
persistModalColumns(next);
return next;
});
};
// 테이블 설정
const applyTableSettings = (settings: TableSettings) => {
if (settings.filters) setFilterConfig(settings.filters);
};
useEffect(() => {
const saved = loadTableSettings(MASTER_TABLE, "jeilglass-order");
const saved = loadTableSettings(MASTER_TABLE, "c30-sales-order");
if (saved?.filters) setFilterConfig(saved.filters);
}, []);
@@ -198,7 +310,7 @@ export default function JeilGlassOrderPage() {
loadCategories();
}, []);
// 수주 목록 조회 (디테일 전체 → order_no 그룹핑)
// 수주 목록 조회 (마스터 기준 페치 → 디테일을 order_no로 합산)
const fetchMasterOrders = useCallback(async () => {
setLoading(true);
try {
@@ -207,29 +319,38 @@ export default function JeilGlassOrderPage() {
operator: f.operator,
value: f.value,
}));
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
// 1. 마스터 테이블 기준으로 500건 페치
const mRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "order_no", order: "desc" },
sortBy: "order_no",
sortOrder: "desc",
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setAllDetails(rows);
const masters = mRes.data?.data?.data || mRes.data?.data?.rows || [];
// 마스터 조회 (거래처 정보 확보)
const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))];
let masterMap: Record<string, any> = {};
// 2. 마스터의 order_no들로 디테일 전부 조회
const orderNos = masters.map((m: any) => m.order_no).filter(Boolean);
let details: any[] = [];
if (orderNos.length > 0) {
try {
const mRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: orderNos.length + 10,
const dRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: orderNos.length * 20 + 100,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] },
autoFilter: true,
});
const masters = mRes.data?.data?.data || mRes.data?.data?.rows || [];
for (const m of masters) masterMap[m.order_no] = m;
details = dRes.data?.data?.data || dRes.data?.data?.rows || [];
} catch { /* skip */ }
}
setAllDetails(details);
// 3. order_no 기준 디테일 집계 맵
const detailMap: Record<string, any[]> = {};
for (const d of details) {
if (!d.order_no) continue;
if (!detailMap[d.order_no]) detailMap[d.order_no] = [];
detailMap[d.order_no].push(d);
}
// 거래처 코드 → 이름 변환
const resolvePartner = (code: string) => {
@@ -237,35 +358,31 @@ export default function JeilGlassOrderPage() {
return categoryOptions["partner_id"]?.find((o) => o.code === code)?.label?.split(" (")[0] || code;
};
// order_no 기준 집계
const grouped: Record<string, any> = {};
for (const row of rows) {
const no = row.order_no;
if (!no) continue;
if (!grouped[no]) {
const master = masterMap[no] || {};
grouped[no] = {
id: `master_${no}`,
order_no: no,
partner_name: resolvePartner(master.partner_id),
item_count: 0,
total_qty: 0,
total_ship_qty: 0,
total_balance: 0,
total_amount: 0,
due_date: row.due_date || "",
status: master.status || "",
};
// 4. 마스터 1건 = 1행 (디테일 집계 값 포함)
const list = masters.map((master: any) => {
const ds = detailMap[master.order_no] || [];
let total_qty = 0, total_ship_qty = 0, total_balance = 0, total_amount = 0;
let due_date = "";
for (const d of ds) {
total_qty += parseFloat(d.qty) || 0;
total_ship_qty += parseFloat(d.ship_qty) || 0;
total_balance += parseFloat(d.balance_qty) || 0;
total_amount += parseFloat(d.amount) || 0;
if (d.due_date && (!due_date || d.due_date > due_date)) due_date = d.due_date;
}
const g = grouped[no];
g.item_count += 1;
g.total_qty += parseFloat(row.qty) || 0;
g.total_ship_qty += parseFloat(row.ship_qty) || 0;
g.total_balance += parseFloat(row.balance_qty) || 0;
g.total_amount += parseFloat(row.amount) || 0;
if (row.due_date && (!g.due_date || row.due_date > g.due_date)) g.due_date = row.due_date;
}
const list = Object.values(grouped);
return {
id: master.id || `master_${master.order_no}`,
order_no: master.order_no,
partner_name: resolvePartner(master.partner_id),
item_count: ds.length,
total_qty,
total_ship_qty,
total_balance,
total_amount,
due_date,
status: master.status || "",
};
});
setMasterOrders(list);
setTotalCount(list.length);
} catch (err) {
@@ -491,6 +608,7 @@ export default function JeilGlassOrderPage() {
const row = modalDetailRows[i];
const { _id, _fromItemInfo, _divisionLabel, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row;
await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, {
id: crypto.randomUUID(),
...detailFields,
order_no: masterForm.order_no,
seq_no: String(i + 1),
@@ -615,6 +733,58 @@ export default function JeilGlassOrderPage() {
setModalDetailRows((prev) => prev.filter((_, i) => i !== idx));
};
const renderModalCell = (colKey: string, row: any, idx: number) => {
switch (colKey) {
case "division":
return row._fromItemInfo ? (
<span className="text-sm px-2">{row._divisionLabel || "-"}</span>
) : (
<Select value={row.division || ""} onValueChange={(v) => updateDetailRow(idx, "division", v)}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="구분" /></SelectTrigger>
<SelectContent position="popper" sideOffset={4}>
{(categoryOptions["item_division"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
case "part_name":
return row._fromItemInfo ? (
<span className="text-sm px-2">{row.part_name || "-"}</span>
) : (
<Input value={row.part_name || ""} onChange={(e) => updateDetailRow(idx, "part_name", e.target.value)} className="h-8 text-sm" placeholder="품명" />
);
case "spec":
return row._fromItemInfo ? (
<span className="text-sm px-2">{row.spec || "-"}</span>
) : (
<Input value={row.spec || ""} onChange={(e) => updateDetailRow(idx, "spec", e.target.value)} className="h-8 text-sm" placeholder="규격" />
);
case "width":
return <Input value={formatNumber(row.width || "")} onChange={(e) => updateDetailRow(idx, "width", parseNumber(e.target.value))} className="h-8 text-sm text-right" placeholder="mm" />;
case "height":
return <Input value={formatNumber(row.height || "")} onChange={(e) => updateDetailRow(idx, "height", parseNumber(e.target.value))} className="h-8 text-sm text-right" placeholder="mm" />;
case "thickness":
return <Input value={row.thickness || ""} onChange={(e) => updateDetailRow(idx, "thickness", e.target.value)} className="h-8 text-sm text-right" placeholder="mm" />;
case "area":
return <span className="text-sm text-right text-muted-foreground block">{row.area || "-"}</span>;
case "unit":
return row._fromItemInfo ? (
<span className="text-sm px-2">{row.unit || "-"}</span>
) : (
<Input value={row.unit || ""} onChange={(e) => updateDetailRow(idx, "unit", e.target.value)} className="h-8 text-sm" placeholder="㎡" />
);
case "qty":
return <Input value={formatNumber(row.qty || "")} onChange={(e) => updateDetailRow(idx, "qty", parseNumber(e.target.value))} className="h-8 text-sm text-right" />;
case "unit_price":
return <Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} className="h-8 text-sm text-right" />;
case "amount":
return <span className="text-sm text-right font-medium block">{row.amount ? Number(row.amount).toLocaleString() : ""}</span>;
case "due_date":
return <FormDatePicker value={row.due_date || ""} onChange={(v) => updateDetailRow(idx, "due_date", v)} placeholder="납기일" />;
default:
return null;
}
};
// 엑셀 다운로드 (마스터+디테일 통합)
const handleExcelDownload = async () => {
if (allDetails.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
@@ -648,7 +818,7 @@ export default function JeilGlassOrderPage() {
"비고": o.memo || "",
};
});
await exportToExcel(data, "제일그라스_수주관리.xlsx", "수주목록");
await exportToExcel(data, "중앙안전유리_수주관리.xlsx", "수주목록");
toast.success("다운로드 완료");
};
@@ -716,7 +886,7 @@ export default function JeilGlassOrderPage() {
{/* 검색 필터 */}
<DynamicSearchFilter
tableName={MASTER_TABLE}
filterId="jeilglass-sales-order"
filterId="c30-sales-order"
onFilterChange={setSearchFilters}
dataCount={totalCount}
externalFilterConfig={filterConfig}
@@ -761,7 +931,7 @@ export default function JeilGlassOrderPage() {
</div>
</div>
<DataGrid
gridId="jeilglass-order-master"
gridId="c30-sales-order-master"
columns={LEFT_COLUMNS}
data={masterOrders}
loading={loading}
@@ -809,7 +979,7 @@ export default function JeilGlassOrderPage() {
</div>
) : (
<DataGrid
gridId="jeilglass-order-detail"
gridId="c30-sales-order-detail"
columns={RIGHT_COLUMNS}
data={detailItems}
loading={detailLoading}
@@ -913,147 +1083,72 @@ export default function JeilGlassOrderPage() {
</div>
</div>
<div className="overflow-x-auto overflow-y-visible">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[30px]"></TableHead>
<TableHead className="w-[36px]"></TableHead>
<TableHead className="w-[40px]">No</TableHead>
<TableHead className="min-w-[90px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[80px]">()</TableHead>
<TableHead className="min-w-[60px]"></TableHead>
<TableHead className="min-w-[80px]"></TableHead>
<TableHead className="min-w-[90px]"></TableHead>
<TableHead className="min-w-[90px]"></TableHead>
<TableHead className="min-w-[140px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{modalDetailRows.length === 0 ? (
<TableRow><TableCell colSpan={15} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : modalDetailRows.map((row, idx) => (
<TableRow
key={row._id || idx}
draggable
onDragStart={(e) => { 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;
});
}
}}
>
<TableCell className="cursor-grab active:cursor-grabbing">
<GripVertical className="w-4 h-4 text-muted-foreground" />
</TableCell>
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
{/* 구분: 품목검색 → 읽기전용, 행추가 → Select */}
<TableCell>
{row._fromItemInfo ? (
<span className="text-sm px-2">{row._divisionLabel || "-"}</span>
) : (
<Select value={row.division || ""} onValueChange={(v) => updateDetailRow(idx, "division", v)}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="구분" /></SelectTrigger>
<SelectContent position="popper" sideOffset={4} className="z-[9999]">
{(categoryOptions["item_division"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
)}
</TableCell>
{/* 품명: 품목검색 → 읽기전용, 행추가 → 입력 */}
<TableCell>
{row._fromItemInfo ? (
<span className="text-sm px-2">{row.part_name || "-"}</span>
) : (
<Input value={row.part_name || ""} onChange={(e) => updateDetailRow(idx, "part_name", e.target.value)}
className="h-8 text-sm" placeholder="품명" />
)}
</TableCell>
{/* 규격: 품목검색 → 읽기전용, 행추가 → 입력 */}
<TableCell>
{row._fromItemInfo ? (
<span className="text-sm px-2">{row.spec || "-"}</span>
) : (
<Input value={row.spec || ""} onChange={(e) => updateDetailRow(idx, "spec", e.target.value)}
className="h-8 text-sm" placeholder="규격" />
)}
</TableCell>
<TableCell>
<Input value={formatNumber(row.width || "")} onChange={(e) => updateDetailRow(idx, "width", parseNumber(e.target.value))}
className="h-8 text-sm text-right" placeholder="mm" />
</TableCell>
<TableCell>
<Input value={formatNumber(row.height || "")} onChange={(e) => updateDetailRow(idx, "height", parseNumber(e.target.value))}
className="h-8 text-sm text-right" placeholder="mm" />
</TableCell>
<TableCell>
<Input value={row.thickness || ""} onChange={(e) => updateDetailRow(idx, "thickness", e.target.value)}
className="h-8 text-sm text-right" placeholder="mm" />
</TableCell>
<TableCell className="text-sm text-right text-muted-foreground">
{row.area || "-"}
</TableCell>
{/* 단위: 품목검색 → 읽기전용, 행추가 → 입력 */}
<TableCell>
{row._fromItemInfo ? (
<span className="text-sm px-2">{row.unit || "-"}</span>
) : (
<Input value={row.unit || ""} onChange={(e) => updateDetailRow(idx, "unit", e.target.value)}
className="h-8 text-sm" placeholder="㎡" />
)}
</TableCell>
<TableCell>
<Input value={formatNumber(row.qty || "")} onChange={(e) => updateDetailRow(idx, "qty", parseNumber(e.target.value))}
className="h-8 text-sm text-right" />
</TableCell>
<TableCell>
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
className="h-8 text-sm text-right" />
</TableCell>
<TableCell className="text-sm text-right font-medium">
{row.amount ? Number(row.amount).toLocaleString() : ""}
</TableCell>
<TableCell>
<FormDatePicker value={row.due_date || ""} onChange={(v) => updateDetailRow(idx, "due_date", v)} placeholder="납기일" />
</TableCell>
<DndContext sensors={modalSensors} collisionDetection={closestCenter} onDragEnd={handleModalDragEnd}>
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[30px]"></TableHead>
<TableHead className="w-[36px]"></TableHead>
<TableHead className="w-[40px]">No</TableHead>
<SortableContext items={modalColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
{modalColumns.map((col) => (
<SortableModalHead key={col.key} col={col} onResize={handleColumnResize} />
))}
</SortableContext>
</TableRow>
))}
{/* 합계 행 */}
{modalDetailRows.length > 0 && (
<TableRow className="bg-muted/30 font-semibold">
<TableCell colSpan={11} className="text-right text-sm"></TableCell>
<TableCell className="text-sm text-right">
{modalDetailRows.reduce((s, r) => s + (parseFloat(r.qty) || 0), 0).toLocaleString()}
</TableCell>
<TableCell></TableCell>
<TableCell className="text-sm text-right text-blue-600">
{modalDetailRows.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0).toLocaleString()}
</TableCell>
<TableCell></TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{modalDetailRows.length === 0 ? (
<TableRow><TableCell colSpan={3 + modalColumns.length} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : modalDetailRows.map((row, idx) => (
<TableRow
key={row._id || idx}
draggable
onDragStart={(e) => { 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;
});
}
}}
>
<TableCell className="cursor-grab active:cursor-grabbing">
<GripVertical className="w-4 h-4 text-muted-foreground" />
</TableCell>
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
{modalColumns.map((col) => (
<TableCell key={col.key} style={{ width: col.width, minWidth: col.width }}>
{renderModalCell(col.key, row, idx)}
</TableCell>
))}
</TableRow>
))}
{modalDetailRows.length > 0 && (
<TableRow className="bg-muted/30 font-semibold">
<TableCell colSpan={3 + modalColumns.length} className="text-right text-sm py-2">
<span className="mr-8"> : {modalDetailRows.reduce((s, r) => s + (parseFloat(r.qty) || 0), 0).toLocaleString()}</span>
<span className="text-blue-600"> : {modalDetailRows.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0).toLocaleString()}</span>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</DndContext>
</div>
</div>
</div>
@@ -1147,7 +1242,7 @@ export default function JeilGlassOrderPage() {
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={MASTER_TABLE}
settingsId="jeilglass-order"
settingsId="c30-sales-order"
onSave={applyTableSettings}
/>
@@ -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;
// ── 편집 모달 제목/타입 판별 ──
@@ -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("품목 목록을 불러오는데 실패했습니다.");
@@ -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: "수주 데이터가 없습니다",
+660 -119
View File
@@ -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<string, any>, 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<string[]>([]);
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<ConditionGroup[]>([]);
@@ -355,12 +415,21 @@ export default function ReportEngine({ config }: ReportEngineProps) {
const [viewMode, setViewMode] = useState<"table" | "card">("table");
const [drilldownLabel, setDrilldownLabel] = useState<string | null>(null);
const [rawDataOpen, setRawDataOpen] = useState(false);
// 집계 테이블 검색/정렬
const [tableSearchQuery, setTableSearchQuery] = useState("");
const [tableSortColumn, setTableSortColumn] = useState<string | null>(null);
const [tableSortDirection, setTableSortDirection] = useState<"asc" | "desc">("desc");
// 집계 테이블 그룹핑 (1차 그룹으로 묶기)
const [tableGrouped, setTableGrouped] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const [primaryGroupIndex, setPrimaryGroupIndex] = useState(0); // 현재 그룹화의 어떤 파트를 1차 기준으로 쓸지
const [presets, setPresets] = useState<Preset[]>([]);
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<string>("");
const [thresholdValues, setThresholdValues] = useState<Record<string, number>>(() => {
const defaults: Record<string, number> = {};
@@ -371,8 +440,6 @@ export default function ReportEngine({ config }: ReportEngineProps) {
const [refreshInterval, setRefreshInterval] = useState(0);
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | 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<string, Record<string, any>[]>;
values: Record<string, number>; // 미리 계산된 집계값 (O(1) 조회)
totalValue: number; // 전체 합계(또는 선택된 집계)
}[] = [];
const allLabelsSet = new Set<string>();
conditions.forEach((cond, ci) => {
const condData = applyConditionFilters(rawData, cond.filters, filterFields);
// 각 condition의 filter는 cache (같은 filter면 재사용)
const condFilterCache = new Map<string, any[]>();
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<string, Record<string, any>[]> = {};
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<string, number> = {};
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<string, any> = { 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<string, string[]> = {};
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) {
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-3">
<span className="text-xs font-medium text-muted-foreground"> </span>
<select
value={selectedPresetIdx}
value={selectedPresetId}
onChange={(e) => loadSelectedPreset(e.target.value)}
className="h-8 min-w-[180px] rounded-md border px-2 text-xs"
>
<option value=""> </option>
{presets.map((p, i) => (
<option key={i} value={i}>{p.name}</option>
{presets.map((p) => (
<option key={p.id} value={String(p.id)}>{p.name}</option>
))}
</select>
<Button size="sm" variant="default" className="h-8 text-xs" onClick={() => setPresetModalOpen(true)}>
<Button size="sm" variant="default" className="h-8 text-xs" onClick={openNewPresetModal}>
</Button>
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={deletePreset}>
<Button
size="sm"
variant="secondary"
className="h-8 text-xs"
onClick={openUpdatePresetModal}
disabled={!selectedPresetId}
title={selectedPresetId ? "현재 조건을 선택된 프리셋에 덮어쓰기" : "먼저 프리셋을 선택하세요"}
>
</Button>
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={deletePreset}
disabled={!selectedPresetId}
>
</Button>
</div>
@@ -782,15 +1056,71 @@ export default function ReportEngine({ config }: ReportEngineProps) {
<div className="flex flex-wrap gap-4">
<div className="space-y-1">
<Label className="text-xs"> (X축)</Label>
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
className="h-9 w-[140px] rounded-md border px-2 text-sm"
>
{config.groupByOptions.map((o) => (
<option key={o.id} value={o.id}>{o.name}</option>
))}
</select>
<div className="flex flex-wrap items-center gap-1">
<select
value={groupBy}
onChange={(e) => setGroupBy(e.target.value)}
className="h-9 w-[160px] rounded-md border px-2 text-sm"
>
{config.groupByOptions.map((o) => (
<option key={o.id} value={o.id}>{o.name}</option>
))}
</select>
{/* 자유 조합 그룹핑: 추가 그룹 필드 칩 */}
{config.groupableFields && config.groupableFields.length > 0 && (
<>
{extraGroupBys.map((id, idx) => {
const f = config.groupableFields!.find((gf) => gf.id === id);
return (
<span
key={`${id}-${idx}`}
className="inline-flex items-center gap-1 rounded-md border border-primary/30 bg-primary/10 px-2 py-1 text-xs text-primary"
>
<span className="text-muted-foreground">+</span>
{f?.name || id}
<button
onClick={() =>
setExtraGroupBys((prev) => prev.filter((_, i) => i !== idx))
}
className="ml-0.5 text-primary/60 hover:text-destructive"
title="제거"
>
×
</button>
</span>
);
})}
<select
value=""
onChange={(e) => {
if (e.target.value) {
setExtraGroupBys((prev) => [...prev, e.target.value]);
}
}}
className="h-9 rounded-md border px-2 text-xs text-muted-foreground"
title="그룹 조합 추가"
>
<option value="">+ </option>
{config.groupableFields
.filter((f) => f.id !== groupBy && !extraGroupBys.includes(f.id))
.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
{extraGroupBys.length > 0 && (
<button
onClick={() => setExtraGroupBys([])}
className="h-9 px-2 rounded-md border text-xs text-muted-foreground hover:text-destructive"
title="조합 모두 제거"
>
</button>
)}
</>
)}
</div>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
@@ -887,8 +1217,71 @@ export default function ReportEngine({ config }: ReportEngineProps) {
{!cond.collapsed && (
<div className="space-y-3 border-t p-3">
<div className="space-y-1">
<span className="text-xs text-muted-foreground"></span>
<div className="space-y-2">
<span className="text-xs text-muted-foreground"> ( = / )</span>
{/* 선택된 메트릭: 순서대로 + 이동 버튼 */}
{cond.metrics.length > 0 && (
<div className="flex flex-wrap gap-1.5 rounded-md border border-primary/20 bg-primary/5 p-2">
{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 (
<div
key={mid}
className="inline-flex items-center gap-1 rounded-full border border-primary bg-background px-2 py-1 text-xs font-medium"
>
<span className="inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] text-primary-foreground">
{idx + 1}
</span>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ background: m.color }}
/>
<span>{m.name}</span>
<button
type="button"
onClick={() => moveMetric(cond.id, mid, -1)}
disabled={isFirst}
className={cn(
"text-muted-foreground hover:text-primary ml-0.5",
isFirst && "opacity-30 cursor-not-allowed"
)}
title="왼쪽으로"
>
</button>
<button
type="button"
onClick={() => moveMetric(cond.id, mid, 1)}
disabled={isLast}
className={cn(
"text-muted-foreground hover:text-primary",
isLast && "opacity-30 cursor-not-allowed"
)}
title="오른쪽으로"
>
</button>
<button
type="button"
onClick={() => toggleMetric(cond.id, mid)}
disabled={cond.metrics.length <= 1}
className={cn(
"text-muted-foreground hover:text-destructive ml-0.5",
cond.metrics.length <= 1 && "opacity-30 cursor-not-allowed"
)}
title="제거"
>
×
</button>
</div>
);
})}
</div>
)}
{/* 전체 메트릭 목록 */}
<div className="flex flex-wrap gap-1.5">
{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 && (
<div className="flex flex-wrap gap-1.5">
<Badge variant="secondary">
{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}
</Badge>
{(startDate || endDate) && (
<Badge variant="secondary">
@@ -1253,27 +1648,87 @@ export default function ReportEngine({ config }: ReportEngineProps) {
{/* 집계 데이터 */}
{analysisResult.series.length > 0 && (
<div className="rounded-md border">
<div className="flex items-center justify-between border-b p-3">
<h3 className="text-sm font-semibold"> </h3>
<div className="flex rounded-md border">
<button
onClick={() => setViewMode("table")}
className={cn(
"px-3 py-1 text-xs",
viewMode === "table" && "bg-primary text-primary-foreground"
)}
>
</button>
<button
onClick={() => setViewMode("card")}
className={cn(
"px-3 py-1 text-xs",
viewMode === "card" && "bg-primary text-primary-foreground"
)}
>
</button>
<div className="flex items-center justify-between gap-2 border-b p-3">
<h3 className="text-sm font-semibold shrink-0"> </h3>
<div className="flex items-center gap-2 flex-1 justify-end">
<input
type="text"
value={tableSearchQuery}
onChange={(e) => setTableSearchQuery(e.target.value)}
placeholder="검색 (거래처 / 품목 / 사이즈 등)"
className="h-8 w-full max-w-[280px] rounded-md border px-2 text-xs"
/>
{tableSearchQuery && (
<button
onClick={() => setTableSearchQuery("")}
className="text-muted-foreground hover:text-foreground text-xs"
>
</button>
)}
{canGroupTable && (
<button
onClick={() => setTableGrouped((v) => !v)}
className={cn(
"h-8 px-3 rounded-md border text-xs shrink-0",
tableGrouped && "border-primary bg-primary/10 text-primary"
)}
title="1차 그룹 기준으로 묶어서 접기/펼치기"
>
{tableGrouped ? "ON" : "OFF"}
</button>
)}
{tableGrouped && canGroupTable && (
<>
<select
value={primaryGroupIndex}
onChange={(e) => {
setPrimaryGroupIndex(Number(e.target.value));
setCollapsedGroups(new Set());
}}
className="h-8 rounded-md border px-2 text-xs shrink-0 bg-background"
title="1차 그룹 기준"
>
{currentGroupParts.map((part, idx) => (
<option key={idx} value={idx}>{part}</option>
))}
</select>
<button
onClick={() => setCollapsedGroups(new Set(groupedLabels?.order || []))}
className="h-8 px-2 rounded-md border text-xs shrink-0"
title="모두 접기"
>
</button>
<button
onClick={() => setCollapsedGroups(new Set())}
className="h-8 px-2 rounded-md border text-xs shrink-0"
title="모두 펼치기"
>
</button>
</>
)}
<div className="flex rounded-md border shrink-0">
<button
onClick={() => setViewMode("table")}
className={cn(
"px-3 py-1 text-xs",
viewMode === "table" && "bg-primary text-primary-foreground"
)}
>
</button>
<button
onClick={() => setViewMode("card")}
className={cn(
"px-3 py-1 text-xs",
viewMode === "card" && "bg-primary text-primary-foreground"
)}
>
</button>
</div>
</div>
</div>
@@ -1283,26 +1738,102 @@ export default function ReportEngine({ config }: ReportEngineProps) {
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="p-2 text-left text-xs font-medium text-muted-foreground">
{config.groupByOptions.find((o) => o.id === groupBy)?.name}
<th
className="p-2 text-left text-xs font-medium text-muted-foreground cursor-pointer hover:bg-muted/50 select-none"
onClick={() => toggleTableSort("__label__")}
>
<span className="inline-flex items-center gap-1">
{tableGrouped && canGroupTable && currentGroupParts[primaryGroupIndex]
? `${currentGroupParts[primaryGroupIndex]}`
: (currentGroupParts.length > 0
? currentGroupParts.map((p) => `${p}`).join(" × ")
: config.groupByOptions.find((o) => o.id === groupBy)?.name)}
{tableSortColumn === "__label__" && (
<span className="text-primary">{tableSortDirection === "asc" ? "▲" : "▼"}</span>
)}
</span>
</th>
{analysisResult.series.map((s, si) => (
<th
key={si}
className="p-2 text-right text-xs font-medium"
style={{ borderBottomColor: COLORS[si % COLORS.length], borderBottomWidth: 2 }}
>
{s.condName}
<br />
<span className="font-normal text-muted-foreground">
{s.metricName}({aggLabel(s.aggMethod)})
</span>
</th>
))}
{analysisResult.series.map((s, si) => {
const colKey = String(si);
return (
<th
key={si}
className="p-2 text-right text-xs font-medium cursor-pointer hover:bg-muted/50 select-none"
style={{ borderBottomColor: COLORS[si % COLORS.length], borderBottomWidth: 2 }}
onClick={() => toggleTableSort(colKey)}
>
<span className="inline-flex items-center gap-1 justify-end w-full">
<span>
{s.condName}
<br />
<span className="font-normal text-muted-foreground">
{s.metricName}({aggLabel(s.aggMethod)})
</span>
</span>
{tableSortColumn === colKey && (
<span className="text-primary">{tableSortDirection === "asc" ? "▲" : "▼"}</span>
)}
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{analysisResult.labels.map((label) => (
{displayLabels.length === 0 ? (
<tr>
<td colSpan={analysisResult.series.length + 1} className="p-6 text-center text-xs text-muted-foreground">
{tableSearchQuery ? "검색 결과가 없습니다" : "데이터가 없습니다"}
</td>
</tr>
) : tableGrouped && groupedLabels ? (
groupedLabels.order.map((primary) => {
const subLabels = groupedLabels.groups[primary];
const isCollapsed = collapsedGroups.has(primary);
return (
<React.Fragment key={`grp-${primary}`}>
<tr
className="bg-muted/40 border-b border-primary/20 cursor-pointer font-semibold hover:bg-muted"
onClick={() => toggleGroupCollapse(primary)}
>
<td className="p-2">
<span className="inline-flex items-center gap-1">
<span className="text-primary">{isCollapsed ? "▶" : "▼"}</span>
{primary}
<span className="text-muted-foreground text-xs font-normal">({subLabels.length})</span>
</span>
</td>
{analysisResult.series.map((s, si) => {
const allRows = subLabels.flatMap((lb) => s.groups[lb] || []);
return (
<td key={si} className="p-2 text-right tabular-nums">
{formatNumber(aggregateValues(allRows, s.metricId, s.aggMethod))}
</td>
);
})}
</tr>
{!isCollapsed && subLabels.map((label) => {
const parts = label.split(" / ");
const subPart = parts.filter((_, i) => i !== primaryGroupIndex).join(" / ") || label;
return (
<tr
key={label}
className="cursor-pointer border-b transition-colors hover:bg-muted/50"
onClick={() => setDrilldownLabel(label)}
>
<td className="p-2 pl-6 text-muted-foreground">{subPart}</td>
{analysisResult.series.map((s, si) => (
<td key={si} className="p-2 text-right tabular-nums">
{formatNumber(s.values[label] || 0)}
</td>
))}
</tr>
);
})}
</React.Fragment>
);
})
) : displayLabels.map((label) => (
<tr
key={label}
className="cursor-pointer border-b transition-colors hover:bg-muted/50"
@@ -1311,22 +1842,27 @@ export default function ReportEngine({ config }: ReportEngineProps) {
<td className="p-2 font-medium">{label}</td>
{analysisResult.series.map((s, si) => (
<td key={si} className="p-2 text-right tabular-nums">
{formatNumber(
aggregateValues(s.groups[label] || [], s.metricId, s.aggMethod)
)}
{formatNumber(s.values[label] || 0)}
</td>
))}
</tr>
))}
<tr className="border-t-2 bg-muted/30 font-bold">
<td className="p-2"></td>
<td className="p-2">
{tableSearchQuery ? `필터 (${displayLabels.length}건)` : "전체"}
</td>
{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 (
<td key={si} className="p-2 text-right tabular-nums">
{formatNumber(aggregateValues(allRows, s.metricId, s.aggMethod))}
{formatNumber(total)}
</td>
);
})}
@@ -1336,11 +1872,14 @@ export default function ReportEngine({ config }: ReportEngineProps) {
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{analysisResult.labels.map((label) => {
{displayLabels.length === 0 && (
<div className="col-span-full p-6 text-center text-xs text-muted-foreground">
{tableSearchQuery ? "검색 결과가 없습니다" : "데이터가 없습니다"}
</div>
)}
{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 (
<div
key={label}
@@ -1357,7 +1896,7 @@ export default function ReportEngine({ config }: ReportEngineProps) {
{analysisResult.series.length > 1 && (
<p className="mt-1 truncate text-xs text-muted-foreground">
{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(" | ")}
</p>
@@ -1496,7 +2035,9 @@ export default function ReportEngine({ config }: ReportEngineProps) {
<Dialog open={presetModalOpen} onOpenChange={setPresetModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogTitle className="text-base sm:text-lg">
{presetMode === "update" ? "조건 수정" : "신규 조건 저장"}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
@@ -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" ? "수정 저장" : "저장"}
</Button>
</DialogFooter>
</DialogContent>
+3
View File
@@ -32,6 +32,9 @@ export interface MaterialData {
current: number;
unit: string;
locations: MaterialLocation[];
width?: string;
height?: string;
thickness?: string;
}
export interface WarehouseData {