diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index e6d16fda..eacb0ca6 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -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): Record { }; } +// 레거시 statusValues(고정 4키 객체) → statusMappings(동적 배열) 자동 변환 +function resolveStatusMappings(src: TimelineDataSource): StatusValueMapping[] { + if (src.statusMappings && src.statusMappings.length > 0) return src.statusMappings; + + // 레거시 호환: 기존 statusValues 객체가 있으면 변환 + const sv = (src as Record).statusValues as Record | 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(); + const dbToSemantic = new Map(); + 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; } } } diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx index a24d6402..6b37b569 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Config.tsx @@ -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 (
- 공정 데이터 소스 + 하위 데이터 소스 - {/* 공정 테이블 선택 */} + {/* 하위 테이블 선택 */}
- +
- {/* 컬럼 매핑 (공정 테이블 선택 후) */} + {/* 컬럼 매핑 (하위 테이블 선택 후) */} {src.processTable && processColumns.length > 0 && (
@@ -1552,7 +1554,7 @@ function TimelineConfigEditor({
- 공정명 + 표시명 updateSource({ statusValues: { ...src.statusValues, [item.key]: e.target.value } })} - className="h-6 text-[9px]" - /> -
- ))} -
- + updateSource({ statusMappings: mappings })} + /> )} {/* 구분선 */} @@ -1630,7 +1614,7 @@ function TimelineConfigEditor({
- + onUpdate({ currentHighlight: v })} @@ -1642,12 +1626,97 @@ function TimelineConfigEditor({ checked={cell.showDetailModal !== false} onCheckedChange={(v) => onUpdate({ showDetailModal: v })} /> - 전체 공정 모달 + 전체 목록 모달
); } +// ===== 상태 값 매핑 에디터 (동적 배열) ===== + +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) => { + 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 ( +
+
+ +
+ {mappings.length === 0 && ( + + )} + +
+
+

DB 값, 화면 라벨, 의미(대기/진행/완료)를 매핑합니다.

+ {mappings.map((m, i) => ( +
+ updateMapping(i, { dbValue: e.target.value })} + placeholder="DB 값" + className="h-6 flex-1 text-[10px]" + /> + updateMapping(i, { label: e.target.value })} + placeholder="라벨" + className="h-6 flex-1 text-[10px]" + /> + + +
+ ))} +
+ ); +} + // ===== 액션 버튼 에디터 ===== function ActionButtonsEditor({ diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx index 259a6ac8..500af96e 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx @@ -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 = { - completed: { - chipBg: "#10b981", - chipText: "#ffffff", - icon: , - }, - in_progress: { - chipBg: "#f59e0b", - chipText: "#ffffff", - icon: , - }, - accepted: { - chipBg: "#3b82f6", - chipText: "#ffffff", - icon: , - }, - waiting: { - chipBg: "#e2e8f0", - chipText: "#64748b", - icon: , - }, +type TimelineStyle = { chipBg: string; chipText: string; icon: React.ReactNode }; + +const TIMELINE_SEMANTIC_STYLES: Record = { + done: { chipBg: "#10b981", chipText: "#ffffff", icon: }, + active: { chipBg: "#3b82f6", chipText: "#ffffff", icon: }, + pending: { chipBg: "#e2e8f0", chipText: "#64748b", icon: }, }; +// 레거시 status 값 → semantic 매핑 (기존 데이터 호환) +const LEGACY_STATUS_TO_SEMANTIC: Record = { + 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) {
{item.count}
@@ -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 ( @@ -540,20 +537,17 @@ function TimelineCell({ cell, row }: CellRendererProps) { - 전체 공정 현황 + 전체 현황 - 총 {totalCount}개 공정 중 {completedCount}개 완료 + 총 {totalCount}개 중 {completedCount}개 완료
{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 (
@@ -569,7 +563,7 @@ function TimelineCell({ cell, row }: CellRendererProps) { {idx < processFlow.length - 1 &&
}
- {/* 공정 정보 */} + {/* 항목 정보 */}
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; diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 1632821b..8d478ff3 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -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 {