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:
@@ -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: "수주 데이터가 없습니다",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,6 +32,9 @@ export interface MaterialData {
|
||||
current: number;
|
||||
unit: string;
|
||||
locations: MaterialLocation[];
|
||||
width?: string;
|
||||
height?: string;
|
||||
thickness?: string;
|
||||
}
|
||||
|
||||
export interface WarehouseData {
|
||||
|
||||
Reference in New Issue
Block a user