From 2efe30e282b85d6d4814260846ddc00ed94cf89f Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 23 Apr 2026 14:32:52 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/workInstructionController.ts | 26 +- .../WorkInstructionApplyModal.tsx | 282 ++++++++++----- .../production/cutting-plan/page.tsx | 3 +- .../production/work-instruction/page.tsx | 17 +- .../components/admin/report/ReportEngine.tsx | 327 ++++++++++++++++-- frontend/package-lock.json | 7 + frontend/package.json | 1 + 7 files changed, 531 insertions(+), 132 deletions(-) diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index c6b9e667..955700f4 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -96,13 +96,13 @@ export async function getList(req: AuthenticatedRequest, res: Response) { const countRes = await pool.query(countSql, params); const totalCount = countRes.rows[0]?.cnt ?? 0; - // 2) 현재 페이지 WI id 목록 + // 2) 현재 페이지 WI id 목록 (최신 생성순, 동일 created_date일 때 번호 내림차순) const offset = (pageNum! - 1) * sizeNum!; const pageSql = ` SELECT wi.id FROM work_instruction wi ${whereClause} - ORDER BY wi.created_date DESC, wi.id DESC + ORDER BY wi.created_date DESC NULLS LAST, wi.work_instruction_no DESC LIMIT ${sizeNum} OFFSET ${offset} `; const pageRes = await pool.query(pageSql, params); @@ -128,6 +128,8 @@ export async function getList(req: AuthenticatedRequest, res: Response) { wi.worker, wi.remark AS wi_remark, wi.created_date, + wi.batch_no, + wi.cutting_plan_id, d.id AS detail_id, d.item_number, d.qty AS detail_qty, @@ -148,7 +150,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { COALESCE(e.equipment_code, '') AS equipment_code, wi.routing AS routing_version_id, COALESCE(rv.version_name, '') AS routing_name, - ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq, + ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date, d.id) AS detail_seq, COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count FROM work_instruction wi INNER JOIN work_instruction_detail d @@ -160,7 +162,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code WHERE wi.id = ANY($1::varchar[]) - ORDER BY wi.created_date DESC, wi.id DESC, d.created_date ASC + ORDER BY wi.created_date DESC NULLS LAST, wi.work_instruction_no DESC, d.created_date ASC, d.id ASC `; const dataRes = await pool.query(dataSql, [wiIds]); @@ -189,6 +191,8 @@ export async function getList(req: AuthenticatedRequest, res: Response) { wi.worker, wi.remark AS wi_remark, wi.created_date, + wi.batch_no, + wi.cutting_plan_id, d.id AS detail_id, d.item_number, d.qty AS detail_qty, @@ -209,7 +213,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { COALESCE(e.equipment_code, '') AS equipment_code, wi.routing AS routing_version_id, COALESCE(rv.version_name, '') AS routing_name, - ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq, + ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date, d.id) AS detail_seq, COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count FROM work_instruction wi INNER JOIN work_instruction_detail d @@ -219,7 +223,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code ${whereClause} - ORDER BY wi.created_date DESC, d.created_date ASC + ORDER BY wi.created_date DESC NULLS LAST, wi.work_instruction_no DESC, d.created_date ASC, d.id ASC `; const result = await pool.query(query, params); @@ -262,7 +266,7 @@ export async function save(req: AuthenticatedRequest, res: Response) { await ensureDetailRoutingColumn(); const companyCode = req.user!.companyCode; const userId = req.user!.userId; - const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body; + const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId, batchNo, cuttingPlanId } = req.body; if (!items || items.length === 0) { return res.status(400).json({ success: false, message: "품목을 선택해주세요" }); @@ -281,8 +285,8 @@ export async function save(req: AuthenticatedRequest, res: Response) { wiId = editId; wiNo = check.rows[0].work_instruction_no; await client.query( - `UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, routing=$10, updated_date=NOW(), writer=$11 WHERE id=$12 AND company_code=$13`, - [wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId, editId, companyCode] + `UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, routing=$10, batch_no=COALESCE($11, batch_no), cutting_plan_id=COALESCE($12, cutting_plan_id), updated_date=NOW(), writer=$13 WHERE id=$14 AND company_code=$15`, + [wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, batchNo||null, cuttingPlanId||null, userId, editId, companyCode] ); await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_id=$1`, [wiId]); } else { @@ -296,8 +300,8 @@ export async function save(req: AuthenticatedRequest, res: Response) { wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`; } const insertRes = await client.query( - `INSERT INTO work_instruction (id,company_code,work_instruction_no,status,progress_status,reason,start_date,end_date,equipment_id,work_team,worker,remark,routing,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),$13) RETURNING id`, - [companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId] + `INSERT INTO work_instruction (id,company_code,work_instruction_no,status,progress_status,reason,start_date,end_date,equipment_id,work_team,worker,remark,routing,batch_no,cutting_plan_id,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW(),$15) RETURNING id`, + [companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, batchNo||null, cuttingPlanId||null, userId] ); wiId = insertRes.rows[0].id; } diff --git a/frontend/app/(main)/COMPANY_30/production/cutting-plan/WorkInstructionApplyModal.tsx b/frontend/app/(main)/COMPANY_30/production/cutting-plan/WorkInstructionApplyModal.tsx index 74765b15..b4a70af2 100644 --- a/frontend/app/(main)/COMPANY_30/production/cutting-plan/WorkInstructionApplyModal.tsx +++ b/frontend/app/(main)/COMPANY_30/production/cutting-plan/WorkInstructionApplyModal.tsx @@ -2,15 +2,16 @@ /** * 절단계획 → 작업지시 적용 모달 - * 절단계획의 품목을 받아 기본 정보만 입력 후 작업지시 저장. + * jskim-node의 작업지시 모달 구조와 호환 (품목별 일정/설비/작업조/작업자 지정). * 저장 시 마스터에 batch_no(=plan_no) / cutting_plan_id 를 함께 전달. */ import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import { CheckCircle2, Loader2, X } from "lucide-react"; +import { CheckCircle2, ChevronsUpDown, Loader2, X } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -19,61 +20,151 @@ import { import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from "@/components/ui/select"; +import { + Popover, PopoverContent, PopoverTrigger, +} from "@/components/ui/popover"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell, } from "@/components/ui/table"; +import { cn } from "@/lib/utils"; import { previewWorkInstructionNo, saveWorkInstruction, getEquipmentList, getEmployeeList, } from "@/lib/api/workInstruction"; +// ─── 공용 다중선택 Popover (설비/작업조/작업자) ──────────────────── +interface MultiSelectOption { value: string; label: string; sub?: string; } +interface MultiSelectPopoverProps { + options: MultiSelectOption[]; + value: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + searchable?: boolean; + triggerClassName?: string; + emptyMessage?: string; +} +function MultiSelectPopover({ + options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요", +}: MultiSelectPopoverProps) { + const [open, setOpen] = useState(false); + const [keyword, setKeyword] = useState(""); + + const selectedSet = useMemo(() => new Set(value), [value]); + const toggle = (val: string) => { + if (selectedSet.has(val)) onChange(value.filter((v) => v !== val)); + else onChange([...value, val]); + }; + + const filtered = useMemo(() => { + if (!searchable || !keyword.trim()) return options; + const k = keyword.trim().toLowerCase(); + return options.filter((o) => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k)); + }, [options, keyword, searchable]); + + const display = useMemo(() => { + if (value.length === 0) return placeholder; + if (value.length === 1) return options.find((o) => o.value === value[0])?.label || value[0]; + if (value.length === 2) { + return value.map((v) => options.find((o) => o.value === v)?.label || v).join(", "); + } + return `${value.length}개 선택`; + }, [value, options, placeholder]); + + return ( + + + + + + {searchable && ( +
+ setKeyword(e.target.value)} className="h-7 text-xs" /> +
+ )} +
+ {filtered.length === 0 ? ( +
{emptyMessage}
+ ) : filtered.map((opt) => ( + + ))} +
+ {value.length > 0 && ( +
+ {value.length}개 선택됨 + +
+ )} +
+
+ ); +} + +// ─── 모달 인터페이스 ──────────────────────────────────────────────── export interface WorkInstructionApplyItem { - itemCode: string; // 품목코드 (part_code) + itemCode: string; itemName: string; spec?: string; qty: number; remark?: string; - sourceTable?: string; // 기본 'cutting_plan' - sourceId?: string; // 수주번호 또는 cutting_plan_id + sourceTable?: string; + sourceId?: string; + // 품목별 일정/설비/작업조/작업자 + startDate?: string; + endDate?: string; + equipmentIds?: string[]; + workTeams?: string[]; + workers?: string[]; } export interface WorkInstructionApplyModalProps { open: boolean; onOpenChange: (v: boolean) => void; initialItems: WorkInstructionApplyItem[]; - batchNo?: string | null; // 예: CP-2026-0001 + batchNo?: string | null; cuttingPlanId?: number | null; onSaved?: (result: { id: string; workInstructionNo: string }) => void; } -const NV = "__none__"; -const nv = (v?: string | null) => (v && v.length ? v : NV); -const fromNv = (v: string) => (v === NV ? "" : v); - export default function WorkInstructionApplyModal({ open, onOpenChange, initialItems, batchNo, cuttingPlanId, onSaved, }: WorkInstructionApplyModalProps) { const [wiNo, setWiNo] = useState(""); const [status, setStatus] = useState("일반"); - const [startDate, setStartDate] = useState(() => new Date().toISOString().slice(0, 10)); - const [endDate, setEndDate] = useState(""); - const [equipmentId, setEquipmentId] = useState(""); - const [workTeam, setWorkTeam] = useState(""); - const [worker, setWorker] = useState(""); const [remark, setRemark] = useState(""); const [items, setItems] = useState([]); const [saving, setSaving] = useState(false); - const [equipmentOptions, setEquipmentOptions] = useState<{ id: string; equipment_name: string }[]>([]); + const [equipmentOptions, setEquipmentOptions] = useState<{ id: string; equipment_code: string; equipment_name: string }[]>([]); const [workerOptions, setWorkerOptions] = useState<{ user_id: string; user_name: string; dept_name: string | null }[]>([]); - // 모달이 열릴 때마다 기본값 재초기화 + WI 번호 프리뷰 + 셀렉트 옵션 로드 + // 모달 오픈 시 초기화 + 옵션 로드 useEffect(() => { if (!open) return; - setItems(initialItems.map((x) => ({ ...x }))); - setStatus("일반"); setEndDate(""); setEquipmentId(""); setWorkTeam(""); setWorker(""); setRemark(""); - setStartDate(new Date().toISOString().slice(0, 10)); + const today = new Date().toISOString().slice(0, 10); + setItems(initialItems.map((x) => ({ + ...x, + startDate: x.startDate || today, + endDate: x.endDate || "", + equipmentIds: x.equipmentIds || [], + workTeams: x.workTeams || [], + workers: x.workers || [], + }))); + setStatus("일반"); + setRemark(""); previewWorkInstructionNo().then((r) => { if (r.success) setWiNo(r.instructionNo); }).catch(() => {}); getEquipmentList().then((r) => { if (r.success) setEquipmentOptions(r.data || []); }).catch(() => {}); @@ -86,9 +177,17 @@ export default function WorkInstructionApplyModal({ if (!canSave) { toast.error("품목/수량을 확인해주세요"); return; } setSaving(true); try { + // 하위호환: 마스터에는 첫 품목의 대표값을 실음 (jskim 스타일) + const first = items[0]; const payload = { - status, startDate, endDate, - equipmentId, workTeam, worker, remark, + status, + startDate: first?.startDate || "", + endDate: first?.endDate || "", + equipmentId: first?.equipmentIds?.[0] || "", + workTeam: first?.workTeams?.[0] || "", + worker: first?.workers?.[0] || "", + remark, + routing: null, batchNo: batchNo || null, cuttingPlanId: cuttingPlanId ?? null, items: items.map((i) => ({ @@ -97,6 +196,11 @@ export default function WorkInstructionApplyModal({ sourceTable: i.sourceTable || "cutting_plan", sourceId: i.sourceId || (cuttingPlanId != null ? String(cuttingPlanId) : ""), routing: null, + startDate: i.startDate || "", + endDate: i.endDate || "", + equipmentIds: (i.equipmentIds || []).join(","), + workTeams: (i.workTeams || []).join(","), + workers: (i.workers || []).join(","), })), }; const r = await saveWorkInstruction(payload); @@ -111,9 +215,18 @@ export default function WorkInstructionApplyModal({ } }; + const equipmentSelectOptions = useMemo( + () => equipmentOptions.map((eq) => ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code })), + [equipmentOptions] + ); + const workerSelectOptions = useMemo( + () => workerOptions.map((w) => ({ value: w.user_id, label: w.user_name, sub: w.dept_name || undefined })), + [workerOptions] + ); + return ( - + 작업지시 적용 확인 @@ -126,7 +239,8 @@ export default function WorkInstructionApplyModal({

작업지시 기본 정보

-
+

시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.

+
@@ -141,51 +255,6 @@ export default function WorkInstructionApplyModal({
-
- - setStartDate(e.target.value)} className="h-9" /> -
-
- - setEndDate(e.target.value)} className="h-9" /> -
-
- - -
-
- - -
-
- - -
setRemark(e.target.value)} /> @@ -196,16 +265,21 @@ export default function WorkInstructionApplyModal({

품목 목록

- +
- 순번 - 배치번호 - 품목코드 - 품목명 - 규격 - 수량 - 비고 + 순번 + 배치번호 + 품목코드 + 품목명 + 규격 + 수량 + 시작일 + 완료예정일 + 설비 + 작업조 + 작업자 + 비고 @@ -215,22 +289,58 @@ export default function WorkInstructionApplyModal({ {idx + 1}{batchNo || "-"}{item.itemCode || "-"} - {item.itemName || "-"} + + {item.itemName || "-"} + {item.spec || "-"} - setItems((prev) => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} + onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> + + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} /> + + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} /> + + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))} + placeholder="설비 선택" + searchable + emptyMessage="설비가 없어요" /> - setItems((prev) => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))} + placeholder="작업조 선택" /> + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))} + placeholder="작업자 선택" + searchable + emptyMessage="사원을 찾을 수 없어요" + /> + + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> +
+
순번 @@ -815,7 +816,7 @@ export default function WorkInstructionPage() { 설비 작업조 작업자 - 비고 + 비고 @@ -923,10 +924,13 @@ export default function WorkInstructionPage() { {editItems.length}건
-
+
순번 + {editOrder?.batch_no ? ( + 배치번호 + ) : null} 품목코드 품목명 규격 @@ -938,16 +942,19 @@ export default function WorkInstructionPage() { 설비 작업조 작업자 - 비고 + 비고 {editItems.length === 0 ? ( - 등록된 품목이 없어요 + 등록된 품목이 없어요 ) : editItems.map((item, idx) => ( {idx + 1} + {editOrder?.batch_no ? ( + {editOrder.batch_no} + ) : null} {item.itemCode} {item.itemName || "-"} {item.spec || "-"} diff --git a/frontend/components/admin/report/ReportEngine.tsx b/frontend/components/admin/report/ReportEngine.tsx index 92537f23..4fcddf33 100644 --- a/frontend/components/admin/report/ReportEngine.tsx +++ b/frontend/components/admin/report/ReportEngine.tsx @@ -12,6 +12,7 @@ import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { cn } from "@/lib/utils"; import { apiClient } from "@/lib/api/client"; @@ -388,6 +389,141 @@ function renderCellValue(row: Record, col: ReportColumnDef): React. } } +// ============================================ +// 헤더 필터 - 라벨 컬럼 (고유값 체크박스) +// ============================================ +function LabelFilterContent({ + values, + selected, + onChange, + onClear, +}: { + values: string[]; + selected: Set | null; + onChange: (next: Set | null) => void; + onClear: () => void; +}) { + const [search, setSearch] = useState(""); + const filtered = useMemo( + () => values.filter((v) => v.toLowerCase().includes(search.toLowerCase())), + [values, search] + ); + const effective = selected ?? new Set(values); + const toggle = (v: string) => { + const next = new Set(effective); + if (next.has(v)) next.delete(v); else next.add(v); + onChange(next); + }; + const selectAll = () => onChange(new Set(values)); + const deselectAll = () => onChange(new Set()); + return ( +
+ setSearch(e.target.value)} + placeholder="값 검색..." + className="h-7 text-xs" + /> +
+ + + +
+
+ {filtered.map((v) => { + const checked = effective.has(v); + return ( + + ); + })} + {filtered.length === 0 && ( +

결과 없음

+ )} +
+

+ {selected === null ? "전체 표시" : `${effective.size} / ${values.length} 선택`} +

+
+ ); +} + +// ============================================ +// 헤더 필터 - 메트릭(숫자) 컬럼 (min/max 범위) +// ============================================ +function MetricFilterContent({ + range, + onApply, + onClear, +}: { + range?: { min?: number; max?: number }; + onApply: (r: { min?: number; max?: number }) => void; + onClear: () => void; +}) { + const [minInput, setMinInput] = useState(range?.min !== undefined ? String(range.min) : ""); + const [maxInput, setMaxInput] = useState(range?.max !== undefined ? String(range.max) : ""); + const apply = () => { + const minRaw = minInput.trim(); + const maxRaw = maxInput.trim(); + const minNum = minRaw === "" ? undefined : Number(minRaw); + const maxNum = maxRaw === "" ? undefined : Number(maxRaw); + onApply({ + min: minNum !== undefined && !isNaN(minNum) ? minNum : undefined, + max: maxNum !== undefined && !isNaN(maxNum) ? maxNum : undefined, + }); + }; + return ( +
+
+ + setMinInput(e.target.value)} + className="h-7 text-xs" + placeholder="예: 100" + /> +
+
+ + setMaxInput(e.target.value)} + className="h-7 text-xs" + placeholder="예: 10000" + /> +
+
+ + +
+
+ ); +} + // ============================================ // ReportEngine 컴포넌트 // ============================================ @@ -419,6 +555,9 @@ export default function ReportEngine({ config }: ReportEngineProps) { const [tableSearchQuery, setTableSearchQuery] = useState(""); const [tableSortColumn, setTableSortColumn] = useState(null); const [tableSortDirection, setTableSortDirection] = useState<"asc" | "desc">("desc"); + // 집계 테이블 헤더 필터: 라벨(그룹) 컬럼은 값 체크박스, 메트릭 컬럼은 숫자 범위 + const [labelColumnFilter, setLabelColumnFilter] = useState | null>(null); + const [metricColumnFilters, setMetricColumnFilters] = useState>({}); // 집계 테이블 그룹핑 (1차 그룹으로 묶기) const [tableGrouped, setTableGrouped] = useState(false); const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); @@ -878,13 +1017,44 @@ export default function ReportEngine({ config }: ReportEngineProps) { return { series: seriesList, labels, chartData }; }, [rawData, conditions, groupBy, extraGroupBys, filterFields, config.metrics]); - // 집계 테이블 표시용 라벨 (검색 + 헤더 정렬 적용) — 미리 계산된 values 사용 + // 집계 테이블 표시용 라벨 (검색 + 헤더 정렬 + 헤더 필터 적용) — 미리 계산된 values 사용 const displayLabels = useMemo(() => { let list = analysisResult.labels; if (tableSearchQuery) { const q = tableSearchQuery.toLowerCase(); list = list.filter((l) => l.toLowerCase().includes(q)); } + // 라벨 컬럼 필터 (체크박스 선택값만 통과) + if (labelColumnFilter) { + const sel = labelColumnFilter; + list = list.filter((l) => { + if (tableGrouped) { + const parts = l.split(" / "); + if (parts.length >= 2) { + const primary = parts[primaryGroupIndex] ?? parts[0] ?? l; + return sel.has(primary); + } + } + return sel.has(l); + }); + } + // 메트릭 컬럼 필터 (AND 조건, min/max 범위) + const activeMetricKeys = Object.keys(metricColumnFilters); + if (activeMetricKeys.length > 0) { + list = list.filter((l) => { + for (const key of activeMetricKeys) { + const range = metricColumnFilters[Number(key)]; + if (!range) continue; + if (range.min === undefined && range.max === undefined) continue; + const s = analysisResult.series[Number(key)]; + if (!s) continue; + const v = s.values[l] || 0; + if (range.min !== undefined && v < range.min) return false; + if (range.max !== undefined && v > range.max) return false; + } + return true; + }); + } if (tableSortColumn !== null) { list = [...list].sort((a, b) => { if (tableSortColumn === "__label__") { @@ -900,7 +1070,39 @@ export default function ReportEngine({ config }: ReportEngineProps) { }); } return list; - }, [analysisResult, tableSearchQuery, tableSortColumn, tableSortDirection]); + }, [ + analysisResult, + tableSearchQuery, + tableSortColumn, + tableSortDirection, + labelColumnFilter, + metricColumnFilters, + tableGrouped, + primaryGroupIndex, + ]); + + // 라벨 필터 고유값 (그룹핑 상태에 따라 primary part 또는 전체 라벨 기준) + const labelFilterUniqueValues = useMemo(() => { + const set = new Set(); + for (const l of analysisResult.labels) { + if (tableGrouped) { + const parts = l.split(" / "); + if (parts.length >= 2) { + set.add(parts[primaryGroupIndex] ?? parts[0] ?? l); + continue; + } + } + set.add(l); + } + return Array.from(set).sort((a, b) => + a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }) + ); + }, [analysisResult.labels, tableGrouped, primaryGroupIndex]); + + // 그룹 기준 변경 시 기존 라벨 필터는 리셋 (값 기준이 달라지므로) + useEffect(() => { + setLabelColumnFilter(null); + }, [tableGrouped, primaryGroupIndex, groupBy, extraGroupBys]); const toggleTableSort = (col: string) => { if (tableSortColumn === col) { @@ -1738,42 +1940,109 @@ export default function ReportEngine({ config }: ReportEngineProps) {
- {analysisResult.series.map((s, si) => { const colKey = String(si); + const hasFilter = !!metricColumnFilters[si]; return ( ); })} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 25dbad70..df34f0cb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -74,6 +74,7 @@ "leaflet": "^1.9.4", "lucide-react": "^0.525.0", "mammoth": "^1.11.0", + "maxrects-packer": "^2.7.3", "modern-screenshot": "^4.6.8", "next": "^15.4.8", "next-themes": "^0.4.6", @@ -12347,6 +12348,12 @@ "node": ">= 0.4" } }, + "node_modules/maxrects-packer": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/maxrects-packer/-/maxrects-packer-2.7.3.tgz", + "integrity": "sha512-bG6qXujJ1QgttZVIH4WDanhoJtvbud/xP/XPyf6A69C9RdA61BM4TomFALCq2nrTa+tARRIBB4LuIFsnUQU2wA==", + "license": "MIT" + }, "node_modules/mdn-data": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index b7523fc2..1edcfab3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -83,6 +83,7 @@ "leaflet": "^1.9.4", "lucide-react": "^0.525.0", "mammoth": "^1.11.0", + "maxrects-packer": "^2.7.3", "modern-screenshot": "^4.6.8", "next": "^15.4.8", "next-themes": "^0.4.6",
toggleTableSort("__label__")} - > - - {tableGrouped && canGroupTable && currentGroupParts[primaryGroupIndex] - ? `${currentGroupParts[primaryGroupIndex]}별` - : (currentGroupParts.length > 0 - ? currentGroupParts.map((p) => `${p}별`).join(" × ") - : config.groupByOptions.find((o) => o.id === groupBy)?.name)} - {tableSortColumn === "__label__" && ( - {tableSortDirection === "asc" ? "▲" : "▼"} - )} - + +
+ + + + + + + setLabelColumnFilter(null)} + /> + + +
toggleTableSort(colKey)} > - - - {s.condName} -
- - {s.metricName}({aggLabel(s.aggMethod)}) +
+ + + + + + + + setMetricColumnFilters((prev) => { + if (r.min === undefined && r.max === undefined) { + const next = { ...prev }; + delete next[si]; + return next; + } + return { ...prev, [si]: r }; + }) + } + onClear={() => + setMetricColumnFilters((prev) => { + const next = { ...prev }; + delete next[si]; + return next; + }) + } + /> + + +