refactor(pop): 타임라인 라벨 범용화 + 상태 값 매핑 동적 배열 전환

설정 패널의 도메인 특화 라벨("공정")을 범용 라벨("하위 데이터/표시명")로
교체하고, 상태 값 매핑을 고정 4키 객체에서 동적 배열(statusMappings)로
전환하여 임의 개수의 워크플로우 상태를 지원한다.
[라벨 범용화]
- "공정 데이터 소스" → "하위 데이터 소스"
- "공정 테이블" → "하위 테이블"
- "공정명" → "표시명"
- "현재 공정 강조" → "현재 항목 강조"
- "전체 공정 모달" → "전체 목록 모달"
- cell-renderers 내 "공정" 텍스트 전부 범용 교체
[상태 값 매핑 동적 배열]
- types.ts: statusValues(고정 4키) → statusMappings(StatusValueMapping[])
  TimelineStatusSemantic("pending"|"active"|"done"), StatusValueMapping 타입 추가
  TimelineProcessStep에 semantic? 필드 추가
- PopCardListV2Config: StatusMappingsEditor 컴포넌트 신규
  (행 추가/삭제 + 시맨틱 Select + 기본값 적용 버튼)
- PopCardListV2Component: resolveStatusMappings() 레거시 자동 변환 함수
  injectProcessFlow 동적 맵 기반 정규화로 전환
- cell-renderers: TIMELINE_STATUS_STYLES → TIMELINE_SEMANTIC_STYLES
  getTimelineStyle() + LEGACY_STATUS_TO_SEMANTIC 레거시 호환
  completedCount/statusLabel/isAcceptable 모두 semantic 기반으로 전환
This commit is contained in:
SeongHyun Kim 2026-03-10 17:33:25 +09:00
parent 599b5a4426
commit ed3707a681
4 changed files with 192 additions and 101 deletions

View File

@ -29,6 +29,7 @@ import type {
TimelineProcessStep,
TimelineDataSource,
ActionButtonUpdate,
StatusValueMapping,
} from "../types";
import { CARD_PRESET_SPECS, DEFAULT_CARD_IMAGE } from "../types";
import { dataApi } from "@/lib/api/data";
@ -63,6 +64,20 @@ function parseCartRow(dbRow: Record<string, unknown>): Record<string, unknown> {
};
}
// 레거시 statusValues(고정 4키 객체) → statusMappings(동적 배열) 자동 변환
function resolveStatusMappings(src: TimelineDataSource): StatusValueMapping[] {
if (src.statusMappings && src.statusMappings.length > 0) return src.statusMappings;
// 레거시 호환: 기존 statusValues 객체가 있으면 변환
const sv = (src as Record<string, unknown>).statusValues as Record<string, string> | undefined;
return [
{ dbValue: sv?.waiting || "waiting", label: "대기", semantic: "pending" as const },
{ dbValue: sv?.accepted || "accepted", label: "접수", semantic: "active" as const },
{ dbValue: sv?.inProgress || "in_progress", label: "진행중", semantic: "active" as const },
{ dbValue: sv?.completed || "completed", label: "완료", semantic: "done" as const },
];
}
interface PopCardListV2ComponentProps {
config?: PopCardListV2Config;
className?: string;
@ -309,7 +324,7 @@ export function PopCardListV2Component({
return undefined;
}, [cardGrid?.cells]);
// 공정 데이터 조회 + __processFlow__ 가상 컬럼 주입
// 하위 데이터 조회 + __processFlow__ 가상 컬럼 주입
const injectProcessFlow = useCallback(async (
fetchedRows: RowData[],
src: TimelineDataSource,
@ -318,11 +333,15 @@ export function PopCardListV2Component({
const rowIds = fetchedRows.map((r) => String(r.id)).filter(Boolean);
if (rowIds.length === 0) return fetchedRows;
const sv = src.statusValues || {};
const waitingVal = sv.waiting || "waiting";
const acceptedVal = sv.accepted || "accepted";
const inProgressVal = sv.inProgress || "in_progress";
const completedVal = sv.completed || "completed";
// statusMappings 동적 배열 → dbValue-to-내부키 맵 구축
// 레거시 statusValues 객체도 자동 변환
const mappings = resolveStatusMappings(src);
const dbToInternal = new Map<string, string>();
const dbToSemantic = new Map<string, string>();
for (const m of mappings) {
dbToInternal.set(m.dbValue, m.dbValue);
dbToSemantic.set(m.dbValue, m.semantic);
}
const processResult = await dataApi.getTableData(src.processTable, {
page: 1,
@ -338,30 +357,31 @@ export function PopCardListV2Component({
if (!fkValue || !rowIds.includes(fkValue)) continue;
if (!processMap.has(fkValue)) processMap.set(fkValue, []);
const rawStatus = String(p[src.statusColumn] || waitingVal);
let normalizedStatus = rawStatus;
if (rawStatus === waitingVal) normalizedStatus = "waiting";
else if (rawStatus === acceptedVal) normalizedStatus = "accepted";
else if (rawStatus === inProgressVal) normalizedStatus = "in_progress";
else if (rawStatus === completedVal) normalizedStatus = "completed";
const rawStatus = String(p[src.statusColumn] || "");
const normalizedStatus = dbToInternal.get(rawStatus) || rawStatus;
const semantic = dbToSemantic.get(rawStatus) || "pending";
processMap.get(fkValue)!.push({
seqNo: parseInt(String(p[src.seqColumn] || "0"), 10),
processName: String(p[src.nameColumn] || ""),
status: normalizedStatus,
isCurrent: normalizedStatus === "in_progress" || normalizedStatus === "accepted",
semantic: semantic as "pending" | "active" | "done",
isCurrent: semantic === "active",
});
}
// isCurrent 보정: in_progress가 없으면 첫 waiting을 current로
// isCurrent 보정: active가 없으면 첫 pending을 current로
for (const [, steps] of processMap) {
steps.sort((a, b) => a.seqNo - b.seqNo);
const hasInProgress = steps.some((s) => s.status === "in_progress");
if (!hasInProgress) {
const firstWaiting = steps.find((s) => s.status === "waiting");
if (firstWaiting) {
const hasActive = steps.some((s) => s.isCurrent);
if (!hasActive) {
const firstPending = steps.find((s) => {
const sem = dbToSemantic.get(s.status) || "pending";
return sem === "pending";
});
if (firstPending) {
steps.forEach((s) => { s.isCurrent = false; });
firstWaiting.isCurrent = true;
firstPending.isCurrent = true;
}
}
}

View File

@ -47,6 +47,8 @@ import type {
V2CardClickAction,
ActionButtonUpdate,
TimelineDataSource,
StatusValueMapping,
TimelineStatusSemantic,
} from "../types";
import type { ButtonVariant } from "../pop-button";
import {
@ -1487,11 +1489,11 @@ function TimelineConfigEditor({
return (
<div className="space-y-2">
<span className="text-[9px] font-medium text-muted-foreground"> </span>
<span className="text-[9px] font-medium text-muted-foreground"> </span>
{/* 공정 테이블 선택 */}
{/* 하위 테이블 선택 */}
<div>
<Label className="text-[9px] text-muted-foreground"> </Label>
<Label className="text-[9px] text-muted-foreground"> </Label>
<Popover open={tableOpen} onOpenChange={setTableOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="mt-0.5 h-7 w-full justify-between text-[10px] font-normal">
@ -1526,7 +1528,7 @@ function TimelineConfigEditor({
</Popover>
</div>
{/* 컬럼 매핑 (공정 테이블 선택 후) */}
{/* 컬럼 매핑 (하위 테이블 선택 후) */}
{src.processTable && processColumns.length > 0 && (
<div className="space-y-1">
<Label className="text-[9px] text-muted-foreground"> </Label>
@ -1552,7 +1554,7 @@ function TimelineConfigEditor({
</Select>
</div>
<div>
<span className="text-[8px] text-muted-foreground"></span>
<span className="text-[8px] text-muted-foreground"></span>
<Select value={src.nameColumn || "__none__"} onValueChange={(v) => updateSource({ nameColumn: v === "__none__" ? "" : v })}>
<SelectTrigger className="h-6 text-[9px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
@ -1575,30 +1577,12 @@ function TimelineConfigEditor({
</div>
)}
{/* 상태 값 매핑 */}
{/* 상태 값 매핑 (동적 배열) */}
{src.processTable && src.statusColumn && (
<div className="space-y-1">
<Label className="text-[9px] text-muted-foreground"> </Label>
<p className="text-[8px] text-muted-foreground">DB에 . .</p>
<div className="grid grid-cols-2 gap-1">
{([
{ key: "waiting", label: "대기", def: "waiting" },
{ key: "accepted", label: "접수", def: "accepted" },
{ key: "inProgress", label: "진행중", def: "in_progress" },
{ key: "completed", label: "완료", def: "completed" },
] as const).map((item) => (
<div key={item.key}>
<span className="text-[8px] text-muted-foreground">{item.label}</span>
<Input
value={src.statusValues?.[item.key] || ""}
placeholder={item.def}
onChange={(e) => updateSource({ statusValues: { ...src.statusValues, [item.key]: e.target.value } })}
className="h-6 text-[9px]"
/>
</div>
))}
</div>
</div>
<StatusMappingsEditor
mappings={src.statusMappings || []}
onChange={(mappings) => updateSource({ statusMappings: mappings })}
/>
)}
{/* 구분선 */}
@ -1630,7 +1614,7 @@ function TimelineConfigEditor({
</Select>
</div>
<div className="flex items-center gap-1">
<Label className="w-20 shrink-0 text-[9px] text-muted-foreground"> </Label>
<Label className="w-20 shrink-0 text-[9px] text-muted-foreground"> </Label>
<Switch
checked={cell.currentHighlight !== false}
onCheckedChange={(v) => onUpdate({ currentHighlight: v })}
@ -1642,12 +1626,97 @@ function TimelineConfigEditor({
checked={cell.showDetailModal !== false}
onCheckedChange={(v) => onUpdate({ showDetailModal: v })}
/>
<span className="text-[8px] text-muted-foreground"> </span>
<span className="text-[8px] text-muted-foreground"> </span>
</div>
</div>
);
}
// ===== 상태 값 매핑 에디터 (동적 배열) =====
const SEMANTIC_OPTIONS: { value: TimelineStatusSemantic; label: string }[] = [
{ value: "pending", label: "대기" },
{ value: "active", label: "진행" },
{ value: "done", label: "완료" },
];
const DEFAULT_STATUS_MAPPINGS: StatusValueMapping[] = [
{ dbValue: "waiting", label: "대기", semantic: "pending" },
{ dbValue: "accepted", label: "접수", semantic: "active" },
{ dbValue: "in_progress", label: "진행중", semantic: "active" },
{ dbValue: "completed", label: "완료", semantic: "done" },
];
function StatusMappingsEditor({
mappings,
onChange,
}: {
mappings: StatusValueMapping[];
onChange: (mappings: StatusValueMapping[]) => void;
}) {
const addMapping = () => {
onChange([...mappings, { dbValue: "", label: "", semantic: "pending" }]);
};
const updateMapping = (index: number, partial: Partial<StatusValueMapping>) => {
onChange(mappings.map((m, i) => (i === index ? { ...m, ...partial } : m)));
};
const removeMapping = (index: number) => {
onChange(mappings.filter((_, i) => i !== index));
};
const applyDefaults = () => {
onChange([...DEFAULT_STATUS_MAPPINGS]);
};
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-[9px] text-muted-foreground"> </Label>
<div className="flex gap-1">
{mappings.length === 0 && (
<Button variant="ghost" size="sm" onClick={applyDefaults} className="h-5 px-1.5 text-[9px]">
</Button>
)}
<Button variant="ghost" size="sm" onClick={addMapping} className="h-5 px-1.5 text-[9px]">
<Plus className="mr-0.5 h-3 w-3" />
</Button>
</div>
</div>
<p className="text-[8px] text-muted-foreground">DB , , (//) .</p>
{mappings.map((m, i) => (
<div key={i} className="flex items-center gap-1">
<Input
value={m.dbValue}
onChange={(e) => updateMapping(i, { dbValue: e.target.value })}
placeholder="DB 값"
className="h-6 flex-1 text-[10px]"
/>
<Input
value={m.label}
onChange={(e) => updateMapping(i, { label: e.target.value })}
placeholder="라벨"
className="h-6 flex-1 text-[10px]"
/>
<Select value={m.semantic} onValueChange={(v) => updateMapping(i, { semantic: v as TimelineStatusSemantic })}>
<SelectTrigger className="h-6 w-16 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
{SEMANTIC_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value} className="text-[10px]">{o.label}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" onClick={() => removeMapping(i)} className="h-5 w-5 p-0">
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
))}
</div>
);
}
// ===== 액션 버튼 에디터 =====
function ActionButtonsEditor({

View File

@ -333,15 +333,17 @@ function StatusBadgeCell({ cell, row }: CellRendererProps) {
const strValue = String(value || "");
const mapped = cell.statusMap?.find((m) => m.value === strValue);
// 접수가능 자동 판별: work_order_process 기반
// 직전 공정이 completed이고 현재 공정이 waiting이면 "접수가능"
// 접수가능 자동 판별: 하위 데이터 기반
// 직전 항목이 done이고 현재 항목이 pending이면 "접수가능"
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
const isAcceptable = useMemo(() => {
if (!processFlow || strValue !== "waiting") return false;
const currentIdx = processFlow.findIndex((s) => s.isCurrent);
if (currentIdx < 0) return false;
if (currentIdx === 0) return true;
return processFlow[currentIdx - 1]?.status === "completed";
const prevStep = processFlow[currentIdx - 1];
const prevSem = prevStep?.semantic || LEGACY_STATUS_TO_SEMANTIC[prevStep?.status || ""] || "pending";
return prevSem === "done";
}, [processFlow, strValue]);
if (isAcceptable) {
@ -391,29 +393,25 @@ function StatusBadgeCell({ cell, row }: CellRendererProps) {
// ===== 10. timeline =====
const TIMELINE_STATUS_STYLES: Record<string, { chipBg: string; chipText: string; icon: React.ReactNode }> = {
completed: {
chipBg: "#10b981",
chipText: "#ffffff",
icon: <CheckCircle2 className="h-2.5 w-2.5" />,
},
in_progress: {
chipBg: "#f59e0b",
chipText: "#ffffff",
icon: <CircleDot className="h-2.5 w-2.5 animate-pulse" />,
},
accepted: {
chipBg: "#3b82f6",
chipText: "#ffffff",
icon: <Play className="h-2.5 w-2.5" />,
},
waiting: {
chipBg: "#e2e8f0",
chipText: "#64748b",
icon: <Clock className="h-2.5 w-2.5" />,
},
type TimelineStyle = { chipBg: string; chipText: string; icon: React.ReactNode };
const TIMELINE_SEMANTIC_STYLES: Record<string, TimelineStyle> = {
done: { chipBg: "#10b981", chipText: "#ffffff", icon: <CheckCircle2 className="h-2.5 w-2.5" /> },
active: { chipBg: "#3b82f6", chipText: "#ffffff", icon: <CircleDot className="h-2.5 w-2.5 animate-pulse" /> },
pending: { chipBg: "#e2e8f0", chipText: "#64748b", icon: <Clock className="h-2.5 w-2.5" /> },
};
// 레거시 status 값 → semantic 매핑 (기존 데이터 호환)
const LEGACY_STATUS_TO_SEMANTIC: Record<string, string> = {
completed: "done", in_progress: "active", accepted: "active", waiting: "pending",
};
function getTimelineStyle(step: TimelineProcessStep): TimelineStyle {
if (step.semantic) return TIMELINE_SEMANTIC_STYLES[step.semantic] || TIMELINE_SEMANTIC_STYLES.pending;
const fallback = LEGACY_STATUS_TO_SEMANTIC[step.status];
return TIMELINE_SEMANTIC_STYLES[fallback || "pending"];
}
function TimelineCell({ cell, row }: CellRendererProps) {
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
@ -433,8 +431,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
| { kind: "step"; step: TimelineProcessStep }
| { kind: "count"; count: number; side: "before" | "after" };
// 현재 공정 기준으로 앞뒤 배분하여 축약
// 예: 10공정 중 4번이 현재, maxVisible=5 → [2]...[3공정]...[●4공정]...[5공정]...[5]
// 현재 항목 기준으로 앞뒤 배분하여 축약
const displayItems = useMemo((): DisplayItem[] => {
if (processFlow.length <= maxVisible) {
return processFlow.map((s) => ({ kind: "step" as const, step: s }));
@ -445,7 +442,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
// 숫자칩 2개를 제외한 나머지를 앞뒤로 배분 (priority에 따라 여분 슬롯 방향 결정)
const slotForSteps = maxVisible - 2;
const half = Math.floor(slotForSteps / 2);
const extra = slotForSteps - half - 1; // -1은 현재 공정
const extra = slotForSteps - half - 1;
const beforeSlots = priority === "before" ? Math.max(half, extra) : Math.min(half, extra);
const afterSlots = slotForSteps - beforeSlots - 1;
@ -481,7 +478,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
const [modalOpen, setModalOpen] = useState(false);
const completedCount = processFlow.filter((s) => s.status === "completed").length;
const completedCount = processFlow.filter((s) => (s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status]) === "done").length;
const totalCount = processFlow.length;
return (
@ -493,7 +490,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
cell.align === "center" ? "justify-center" : cell.align === "right" ? "justify-end" : "justify-start",
)}
onClick={cell.showDetailModal !== false ? (e) => { e.stopPropagation(); setModalOpen(true); } : undefined}
title={cell.showDetailModal !== false ? "클릭하여 전체 공정 현황 보기" : undefined}
title={cell.showDetailModal !== false ? "클릭하여 전체 현황 보기" : undefined}
>
{displayItems.map((item, idx) => {
const isLast = idx === displayItems.length - 1;
@ -503,7 +500,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
<React.Fragment key={`cnt-${item.side}`}>
<div
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-bold text-muted-foreground"
title={item.side === "before" ? `이전 ${item.count} 공정` : `이후 ${item.count} 공정`}
title={item.side === "before" ? `이전 ${item.count}` : `이후 ${item.count}`}
>
{item.count}
</div>
@ -512,7 +509,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
);
}
const styles = TIMELINE_STATUS_STYLES[item.step.status] || TIMELINE_STATUS_STYLES.waiting;
const styles = getTimelineStyle(item.step);
return (
<React.Fragment key={item.step.seqNo}>
@ -540,20 +537,17 @@ function TimelineCell({ cell, row }: CellRendererProps) {
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<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"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{totalCount} {completedCount}
{totalCount} {completedCount}
</DialogDescription>
</DialogHeader>
<div className="space-y-0">
{processFlow.map((step, idx) => {
const styles = TIMELINE_STATUS_STYLES[step.status] || TIMELINE_STATUS_STYLES.waiting;
const statusLabel =
step.status === "completed" ? "완료" :
step.status === "in_progress" ? "진행중" :
step.status === "accepted" ? "접수" :
step.status === "hold" ? "보류" : "대기";
const styles = getTimelineStyle(step);
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
const statusLabel = sem === "done" ? "완료" : sem === "active" ? "진행" : "대기";
return (
<div key={step.seqNo} className="flex items-center">
@ -569,7 +563,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
{idx < processFlow.length - 1 && <div className="h-3 w-px bg-border" />}
</div>
{/* 공정 정보 */}
{/* 항목 정보 */}
<div className={cn(
"ml-2 flex flex-1 items-center justify-between rounded-md px-3 py-2",
step.isCurrent && "bg-primary/5 ring-1 ring-primary/30",
@ -630,7 +624,9 @@ function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps
const currentIdx = processFlow.findIndex((s) => s.isCurrent);
if (currentIdx < 0) return false;
if (currentIdx === 0) return true;
return processFlow[currentIdx - 1]?.status === "completed";
const prevStep = processFlow[currentIdx - 1];
const prevSem = prevStep?.semantic || LEGACY_STATUS_TO_SEMANTIC[prevStep?.status || ""] || "pending";
return prevSem === "done";
}, [processFlow, statusValue]);
const effectiveStatus = isAcceptable ? "acceptable" : statusValue;

View File

@ -743,27 +743,33 @@ export type CardCellType =
| "action-buttons"
| "footer-status";
// timeline 셀에서 사용하는 공정 단계 데이터
// timeline 셀에서 사용하는 하위 단계 데이터
export interface TimelineProcessStep {
seqNo: number;
processName: string;
status: string;
status: string; // DB 원본 값
semantic?: "pending" | "active" | "done"; // 시각적 의미 (렌더러 색상 결정)
isCurrent: boolean;
}
// timeline/status-badge/action-buttons가 참조하는 공정 테이블 설정
// timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정
export interface TimelineDataSource {
processTable: string; // 공정 데이터 테이블명 (예: work_order_process)
processTable: string; // 하위 데이터 테이블명 (예: work_order_process)
foreignKey: string; // 메인 테이블 id와 매칭되는 FK 컬럼 (예: wo_id)
seqColumn: string; // 순서 컬럼 (예: seq_no)
nameColumn: string; // 공정명 컬럼 (예: process_name)
nameColumn: string; // 표시명 컬럼 (예: process_name)
statusColumn: string; // 상태 컬럼 (예: status)
statusValues?: { // 상태 값 매핑 (미설정 시 기본값 사용)
waiting?: string; // 대기 (기본: "waiting")
accepted?: string; // 접수 (기본: "accepted")
inProgress?: string; // 진행중 (기본: "in_progress")
completed?: string; // 완료 (기본: "completed")
};
// 상태 값 매핑: DB값 → 시맨틱 (동적 배열, 순서대로 표시)
// 레거시 호환: 기존 { waiting, accepted, inProgress, completed } 객체도 런타임에서 자동 변환
statusMappings?: StatusValueMapping[];
}
export type TimelineStatusSemantic = "pending" | "active" | "done";
export interface StatusValueMapping {
dbValue: string; // DB에 저장된 실제 값
label: string; // 화면에 보이는 이름
semantic: TimelineStatusSemantic; // 타임라인 색상 결정 (pending=회색, active=파랑, done=초록)
}
export interface CardCellDefinitionV2 {