주
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" aria-expanded={open}
|
||||
className={cn("w-full justify-between font-normal", triggerClassName || "h-7 text-xs")}>
|
||||
<span className={cn("truncate", value.length === 0 && "text-muted-foreground")}>{display}</span>
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)", minWidth: 200 }} align="start">
|
||||
{searchable && (
|
||||
<div className="p-2 border-b">
|
||||
<Input placeholder="검색..." value={keyword} onChange={(e) => setKeyword(e.target.value)} className="h-7 text-xs" />
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-56 overflow-y-auto py-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground">{emptyMessage}</div>
|
||||
) : filtered.map((opt) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-muted/50 text-xs"
|
||||
onClick={(e) => { e.preventDefault(); toggle(opt.value); }}
|
||||
>
|
||||
<Checkbox checked={selectedSet.has(opt.value)} onCheckedChange={() => toggle(opt.value)} className="h-3.5 w-3.5" />
|
||||
<span className="flex-1 truncate">
|
||||
{opt.label}{opt.sub ? <span className="text-muted-foreground ml-1">({opt.sub})</span> : null}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{value.length > 0 && (
|
||||
<div className="p-1.5 border-t flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">{value.length}개 선택됨</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 text-[11px] px-2" onClick={() => onChange([])}>초기화</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 모달 인터페이스 ────────────────────────────────────────────────
|
||||
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<WorkInstructionApplyItem[]>([]);
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>작업지시 적용 확인</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -126,7 +239,8 @@ export default function WorkInstructionApplyModal({
|
||||
<div className="space-y-5">
|
||||
<div className="bg-muted/30 border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground">작업지시 기본 정보</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
|
||||
<p className="text-[11px] text-muted-foreground mt-2">시작일·완료예정일·설비·작업조·작업자는 아래 품목별로 지정해주세요.</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">작업지시번호</Label>
|
||||
<Input value={wiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" />
|
||||
@@ -141,51 +255,6 @@ export default function WorkInstructionApplyModal({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">시작일</Label>
|
||||
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">완료예정일</Label>
|
||||
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">설비</Label>
|
||||
<Select value={nv(equipmentId)} onValueChange={(v) => setEquipmentId(fromNv(v))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NV}>선택 안 함</SelectItem>
|
||||
{equipmentOptions.map((eq) => (
|
||||
<SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">작업조</Label>
|
||||
<Select value={nv(workTeam)} onValueChange={(v) => setWorkTeam(fromNv(v))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NV}>선택 안 함</SelectItem>
|
||||
<SelectItem value="주간">주간</SelectItem>
|
||||
<SelectItem value="야간">야간</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">작업자</Label>
|
||||
<Select value={nv(worker)} onValueChange={(v) => setWorker(fromNv(v))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="작업자 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NV}>선택 안 함</SelectItem>
|
||||
{workerOptions.map((w) => (
|
||||
<SelectItem key={w.user_id} value={w.user_id}>
|
||||
{w.user_name}{w.dept_name ? ` · ${w.dept_name}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">비고</Label>
|
||||
<Input className="h-9" placeholder="비고를 입력해주세요" value={remark} onChange={(e) => setRemark(e.target.value)} />
|
||||
@@ -196,16 +265,21 @@ export default function WorkInstructionApplyModal({
|
||||
<div className="border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3">품목 목록</h4>
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<Table className="min-w-[1700px]">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold text-muted-foreground">순번</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold text-muted-foreground">배치번호</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="text-[11px] font-bold text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">순번</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">배치번호</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">시작일</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">완료예정일</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업조</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업자</TableHead>
|
||||
<TableHead className="w-[240px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -215,22 +289,58 @@ export default function WorkInstructionApplyModal({
|
||||
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-[13px] font-mono text-primary">{batchNo || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] font-medium">{item.itemCode || "-"}</TableCell>
|
||||
<TableCell className="text-sm">{item.itemName || "-"}</TableCell>
|
||||
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName || item.itemCode}>
|
||||
{item.itemName || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number" className="h-7 text-[13px] w-24"
|
||||
<Input type="number" className="h-7 text-[13px] w-20"
|
||||
value={item.qty}
|
||||
onChange={(e) => 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))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input type="date" className="h-7 text-[13px]"
|
||||
value={item.startDate || ""}
|
||||
onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input type="date" className="h-7 text-[13px]"
|
||||
value={item.endDate || ""}
|
||||
onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={equipmentSelectOptions}
|
||||
value={item.equipmentIds || []}
|
||||
onChange={(next) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))}
|
||||
placeholder="설비 선택"
|
||||
searchable
|
||||
emptyMessage="설비가 없어요"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
className="h-7 text-[13px]" placeholder="비고"
|
||||
value={item.remark || ""}
|
||||
onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))}
|
||||
<MultiSelectPopover
|
||||
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
|
||||
value={item.workTeams || []}
|
||||
onChange={(next) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
|
||||
placeholder="작업조 선택"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MultiSelectPopover
|
||||
options={workerSelectOptions}
|
||||
value={item.workers || []}
|
||||
onChange={(next) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))}
|
||||
placeholder="작업자 선택"
|
||||
searchable
|
||||
emptyMessage="사원을 찾을 수 없어요"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input className="h-7 text-[13px]" placeholder="비고"
|
||||
value={item.remark || ""}
|
||||
onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6"
|
||||
onClick={() => setItems((prev) => prev.filter((_, i) => i !== idx))}>
|
||||
@@ -241,7 +351,7 @@ export default function WorkInstructionApplyModal({
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground text-[12px] py-6">
|
||||
<TableCell colSpan={13} className="text-center text-muted-foreground text-[12px] py-6">
|
||||
품목이 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -968,7 +968,8 @@ export default function CuttingPlanPage() {
|
||||
onChange={(v) => setCutType(v as CutType)}
|
||||
options={[
|
||||
{ value: "area", label: "면적형 (판재)", icon: <LayoutGrid className="h-3 w-3" /> },
|
||||
{ value: "length", label: "길이형 (파이프)", icon: <Ruler className="h-3 w-3" /> },
|
||||
// COMPANY_30(유리 전용)은 파이프 탭 숨김 — 필요 시 주석 해제
|
||||
// { value: "length", label: "길이형 (파이프)", icon: <Ruler className="h-3 w-3" /> },
|
||||
]}
|
||||
activeColor="blue"
|
||||
/>
|
||||
|
||||
@@ -657,6 +657,7 @@ export default function WorkInstructionPage() {
|
||||
{ key: "worker", label: "작업자", width: "w-[100px]", render: (v, row) => Number(row.detail_seq) === 1 ? getWorkerName(v) : "" },
|
||||
{ key: "start_date", label: "시작일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
|
||||
{ key: "end_date", label: "완료일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
|
||||
{ key: "batch_no", label: "배치번호", width: "w-[130px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v ? <span className="font-mono text-primary">{v}</span> : <span className="text-muted-foreground">-</span>) : "" },
|
||||
{ key: "actions", label: "작업", width: "w-[150px]", align: "center", sortable: false, filterable: false, render: (_v, row) => {
|
||||
const isFirstOfGroup = Number(row.detail_seq) === 1;
|
||||
if (!isFirstOfGroup) return null;
|
||||
@@ -801,7 +802,7 @@ export default function WorkInstructionPage() {
|
||||
<div className="border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3">품목 목록</h4>
|
||||
<div className="overflow-auto">
|
||||
<Table className="min-w-[1500px]">
|
||||
<Table className="min-w-[1600px]">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">순번</TableHead>
|
||||
@@ -815,7 +816,7 @@ export default function WorkInstructionPage() {
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업조</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업자</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[240px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -923,10 +924,13 @@ export default function WorkInstructionPage() {
|
||||
<span className="text-[11px] font-semibold text-primary bg-primary/8 border border-primary/15 px-2 py-0.5 rounded-full font-mono">{editItems.length}건</span>
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<Table className="min-w-[1500px]">
|
||||
<Table className="min-w-[1700px]">
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">순번</TableHead>
|
||||
{editOrder?.batch_no ? (
|
||||
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">배치번호</TableHead>
|
||||
) : null}
|
||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
@@ -938,16 +942,19 @@ export default function WorkInstructionPage() {
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비</TableHead>
|
||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업조</TableHead>
|
||||
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업자</TableHead>
|
||||
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[240px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{editItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={14} className="text-center py-8 text-sm text-muted-foreground">등록된 품목이 없어요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={editOrder?.batch_no ? 15 : 14} className="text-center py-8 text-sm text-muted-foreground">등록된 품목이 없어요</TableCell></TableRow>
|
||||
) : editItems.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
|
||||
{editOrder?.batch_no ? (
|
||||
<TableCell className="text-[13px] font-mono text-primary">{editOrder.batch_no}</TableCell>
|
||||
) : null}
|
||||
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
|
||||
|
||||
@@ -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<string, any>, col: ReportColumnDef): React.
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헤더 필터 - 라벨 컬럼 (고유값 체크박스)
|
||||
// ============================================
|
||||
function LabelFilterContent({
|
||||
values,
|
||||
selected,
|
||||
onChange,
|
||||
onClear,
|
||||
}: {
|
||||
values: string[];
|
||||
selected: Set<string> | null;
|
||||
onChange: (next: Set<string> | 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 (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="값 검색..."
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<div className="flex gap-1 text-[11px]">
|
||||
<button type="button" onClick={selectAll} className="flex-1 rounded border px-1 py-1 hover:bg-muted">전체</button>
|
||||
<button type="button" onClick={deselectAll} className="flex-1 rounded border px-1 py-1 hover:bg-muted">해제</button>
|
||||
<button type="button" onClick={onClear} className="flex-1 rounded border px-1 py-1 hover:bg-muted">초기화</button>
|
||||
</div>
|
||||
<div className="max-h-64 space-y-0.5 overflow-y-auto rounded border">
|
||||
{filtered.map((v) => {
|
||||
const checked = effective.has(v);
|
||||
return (
|
||||
<label
|
||||
key={v}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1 hover:bg-muted"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggle(v)}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="truncate text-xs">{v}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<p className="py-2 text-center text-xs text-muted-foreground">결과 없음</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{selected === null ? "전체 표시" : `${effective.size} / ${values.length} 선택`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 헤더 필터 - 메트릭(숫자) 컬럼 (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 (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px]">최소값 (이상)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={minInput}
|
||||
onChange={(e) => setMinInput(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="예: 100"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px]">최대값 (이하)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={maxInput}
|
||||
onChange={(e) => setMaxInput(e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="예: 10000"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={apply}
|
||||
className="flex-1 rounded bg-primary py-1 text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
적용
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setMinInput(""); setMaxInput(""); onClear(); }}
|
||||
className="flex-1 rounded border py-1 hover:bg-muted"
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ReportEngine 컴포넌트
|
||||
// ============================================
|
||||
@@ -419,6 +555,9 @@ export default function ReportEngine({ config }: ReportEngineProps) {
|
||||
const [tableSearchQuery, setTableSearchQuery] = useState("");
|
||||
const [tableSortColumn, setTableSortColumn] = useState<string | null>(null);
|
||||
const [tableSortDirection, setTableSortDirection] = useState<"asc" | "desc">("desc");
|
||||
// 집계 테이블 헤더 필터: 라벨(그룹) 컬럼은 값 체크박스, 메트릭 컬럼은 숫자 범위
|
||||
const [labelColumnFilter, setLabelColumnFilter] = useState<Set<string> | null>(null);
|
||||
const [metricColumnFilters, setMetricColumnFilters] = useState<Record<number, { min?: number; max?: number }>>({});
|
||||
// 집계 테이블 그룹핑 (1차 그룹으로 묶기)
|
||||
const [tableGrouped, setTableGrouped] = useState(false);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(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<string>();
|
||||
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) {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<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 className="p-2 text-left text-xs font-medium text-muted-foreground select-none">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||
onClick={() => 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__" && (
|
||||
<span className="text-primary">{tableSortDirection === "asc" ? "▲" : "▼"}</span>
|
||||
)}
|
||||
</button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-5 w-5 items-center justify-center rounded hover:bg-muted",
|
||||
labelColumnFilter && "bg-primary/10 text-primary"
|
||||
)}
|
||||
title="값 필터"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="start">
|
||||
<LabelFilterContent
|
||||
values={labelFilterUniqueValues}
|
||||
selected={labelColumnFilter}
|
||||
onChange={setLabelColumnFilter}
|
||||
onClear={() => setLabelColumnFilter(null)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
{analysisResult.series.map((s, si) => {
|
||||
const colKey = String(si);
|
||||
const hasFilter = !!metricColumnFilters[si];
|
||||
return (
|
||||
<th
|
||||
key={si}
|
||||
className="p-2 text-right text-xs font-medium cursor-pointer hover:bg-muted/50 select-none"
|
||||
className="p-2 text-right text-xs font-medium 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)})
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 justify-end text-right hover:text-foreground"
|
||||
onClick={() => toggleTableSort(colKey)}
|
||||
>
|
||||
<span>
|
||||
{s.condName}
|
||||
<br />
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{s.metricName}({aggLabel(s.aggMethod)})
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{tableSortColumn === colKey && (
|
||||
<span className="text-primary">{tableSortDirection === "asc" ? "▲" : "▼"}</span>
|
||||
)}
|
||||
</span>
|
||||
{tableSortColumn === colKey && (
|
||||
<span className="text-primary">{tableSortDirection === "asc" ? "▲" : "▼"}</span>
|
||||
)}
|
||||
</button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex h-5 w-5 items-center justify-center rounded hover:bg-muted",
|
||||
hasFilter && "bg-primary/10 text-primary"
|
||||
)}
|
||||
title="범위 필터"
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-60 p-2" align="end">
|
||||
<MetricFilterContent
|
||||
range={metricColumnFilters[si]}
|
||||
onApply={(r) =>
|
||||
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;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
|
||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user