"use client"; /** * pop-card-list-v2 셀 타입별 렌더러 * * 각 셀 타입은 독립 함수로 구현되어 CardV2Grid에서 type별 dispatch로 호출된다. * 기존 pop-card-list의 카드 내부 렌더링과 pop-string-list의 CardModeView 패턴을 결합. */ import React, { useMemo, useState } from "react"; import { ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star, Loader2, CheckCircle2, CircleDot, Clock, type LucideIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep, ActionButtonDef } from "../types"; import { DEFAULT_CARD_IMAGE, VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC } from "../types"; import type { ButtonVariant } from "../pop-button"; type RowData = Record; // ===== 공통 유틸 ===== const LUCIDE_ICON_MAP: Record = { ShoppingCart, Package, Truck, Box, Archive, Heart, Star, }; function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) { if (!name) return ; const IconComp = LUCIDE_ICON_MAP[name]; if (!IconComp) return ; return ; } function formatValue(value: unknown): string { if (value === null || value === undefined) return "-"; if (typeof value === "number") return value.toLocaleString(); if (typeof value === "boolean") return value ? "예" : "아니오"; if (value instanceof Date) return value.toLocaleDateString(); if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) { const date = new Date(value); if (!isNaN(date.getTime())) { return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; } } return String(value); } const FONT_SIZE_MAP = { xs: "10px", sm: "11px", md: "12px", lg: "14px" } as const; const FONT_WEIGHT_MAP = { normal: 400, medium: 500, bold: 700 } as const; // ===== 셀 렌더러 Props ===== export interface CellRendererProps { cell: CardCellDefinitionV2; row: RowData; inputValue?: number; isCarted?: boolean; isButtonLoading?: boolean; onInputClick?: (e: React.MouseEvent) => void; onCartAdd?: () => void; onCartCancel?: () => void; onButtonClick?: (cell: CardCellDefinitionV2, row: RowData) => void; onActionButtonClick?: (taskPreset: string, row: RowData, buttonConfig?: Record) => void; onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; packageEntries?: PackageEntry[]; inputUnit?: string; } // ===== 메인 디스패치 ===== export function renderCellV2(props: CellRendererProps): React.ReactNode { switch (props.cell.type) { case "text": return ; case "field": return ; case "image": return ; case "badge": return ; case "button": return ; case "number-input": return ; case "cart-button": return ; case "package-summary": return ; case "status-badge": return ; case "timeline": return ; case "action-buttons": return ; case "footer-status": return ; default: return 알 수 없는 셀 타입; } } // ===== 1. text ===== function TextCell({ cell, row }: CellRendererProps) { const value = cell.columnName ? row[cell.columnName] : ""; const fs = FONT_SIZE_MAP[cell.fontSize || "md"]; const fw = FONT_WEIGHT_MAP[cell.fontWeight || "normal"]; return ( {formatValue(value)} ); } // ===== 2. field (라벨+값) ===== function FieldCell({ cell, row, inputValue }: CellRendererProps) { const valueType = cell.valueType || "column"; const fs = FONT_SIZE_MAP[cell.fontSize || "md"]; const displayValue = useMemo(() => { if (valueType !== "formula") { const raw = cell.columnName ? row[cell.columnName] : undefined; const formatted = formatValue(raw); return cell.unit ? `${formatted} ${cell.unit}` : formatted; } if (cell.formulaLeft && cell.formulaOperator) { const rightVal = (cell.formulaRightType || "input") === "input" ? (inputValue ?? 0) : Number(row[cell.formulaRight || ""] ?? 0); const leftVal = Number(row[cell.formulaLeft] ?? 0); let result: number | null = null; switch (cell.formulaOperator) { case "+": result = leftVal + rightVal; break; case "-": result = leftVal - rightVal; break; case "*": result = leftVal * rightVal; break; case "/": result = rightVal !== 0 ? leftVal / rightVal : null; break; } if (result !== null && isFinite(result)) { const formatted = (Math.round(result * 100) / 100).toLocaleString(); return cell.unit ? `${formatted} ${cell.unit}` : formatted; } return "-"; } return "-"; }, [valueType, cell, row, inputValue]); const isFormula = valueType === "formula"; const isLabelLeft = cell.labelPosition === "left"; return (
{cell.label && ( {cell.label}{isLabelLeft ? ":" : ""} )} {displayValue}
); } // ===== 3. image ===== function ImageCell({ cell, row }: CellRendererProps) { const value = cell.columnName ? row[cell.columnName] : ""; const imageUrl = value ? String(value) : (cell.defaultImage || DEFAULT_CARD_IMAGE); return (
{cell.label { const target = e.target as HTMLImageElement; if (target.src !== DEFAULT_CARD_IMAGE) target.src = DEFAULT_CARD_IMAGE; }} />
); } // ===== 4. badge ===== function BadgeCell({ cell, row }: CellRendererProps) { const value = cell.columnName ? row[cell.columnName] : ""; return ( {formatValue(value)} ); } // ===== 5. button ===== function ButtonCell({ cell, row, isButtonLoading, onButtonClick }: CellRendererProps) { return ( ); } // ===== 6. number-input ===== function NumberInputCell({ cell, row, inputValue, onInputClick }: CellRendererProps) { const unit = cell.inputUnit || "EA"; return ( ); } // ===== 7. cart-button ===== function CartButtonCell({ cell, row, isCarted, onCartAdd, onCartCancel }: CellRendererProps) { const iconSize = 18; const label = cell.cartLabel || "담기"; const cancelLabel = cell.cartCancelLabel || "취소"; if (isCarted) { return ( ); } return ( ); } // ===== 8. package-summary ===== function PackageSummaryCell({ cell, packageEntries, inputUnit }: CellRendererProps) { if (!packageEntries || packageEntries.length === 0) return null; return (
{packageEntries.map((entry, idx) => (
포장완료 {entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}
= {entry.totalQuantity.toLocaleString()}{inputUnit || "EA"}
))}
); } // ===== 9. status-badge ===== const STATUS_COLORS: Record = { waiting: { bg: "#94a3b820", text: "#64748b" }, accepted: { bg: "#3b82f620", text: "#2563eb" }, in_progress: { bg: "#f59e0b20", text: "#d97706" }, completed: { bg: "#10b98120", text: "#059669" }, }; function StatusBadgeCell({ cell, row }: CellRendererProps) { const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined; const effectiveValue = hasSubStatus ? row[VIRTUAL_SUB_STATUS] : (cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : "")); const strValue = String(effectiveValue || ""); const mapped = cell.statusMap?.find((m) => m.value === strValue); if (mapped) { return ( {mapped.label} ); } const defaultColors = STATUS_COLORS[strValue]; if (defaultColors) { const labelMap: Record = { waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료", }; return ( {labelMap[strValue] || strValue} ); } return ( {formatValue(effectiveValue)} ); } // ===== 10. timeline ===== 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; if (!processFlow || processFlow.length === 0) { const fallback = cell.processColumn ? row[cell.processColumn] : ""; return ( {formatValue(fallback)} ); } const maxVisible = cell.visibleCount || 5; const currentIdx = processFlow.findIndex((s) => s.isCurrent); type DisplayItem = | { kind: "step"; step: TimelineProcessStep } | { kind: "count"; count: number; side: "before" | "after" }; // 현재 항목 기준으로 앞뒤 배분하여 축약 const displayItems = useMemo((): DisplayItem[] => { if (processFlow.length <= maxVisible) { return processFlow.map((s) => ({ kind: "step" as const, step: s })); } const effectiveIdx = Math.max(0, currentIdx); const priority = cell.timelinePriority || "before"; // 숫자칩 2개를 제외한 나머지를 앞뒤로 배분 (priority에 따라 여분 슬롯 방향 결정) const slotForSteps = maxVisible - 2; const half = Math.floor(slotForSteps / 2); const extra = slotForSteps - half - 1; const beforeSlots = priority === "before" ? Math.max(half, extra) : Math.min(half, extra); const afterSlots = slotForSteps - beforeSlots - 1; let startIdx = effectiveIdx - beforeSlots; let endIdx = effectiveIdx + afterSlots; // 경계 보정 if (startIdx < 0) { endIdx = Math.min(processFlow.length - 1, endIdx + Math.abs(startIdx)); startIdx = 0; } if (endIdx >= processFlow.length) { startIdx = Math.max(0, startIdx - (endIdx - processFlow.length + 1)); endIdx = processFlow.length - 1; } const items: DisplayItem[] = []; const beforeCount = startIdx; const afterCount = processFlow.length - 1 - endIdx; if (beforeCount > 0) { items.push({ kind: "count", count: beforeCount, side: "before" }); } for (let i = startIdx; i <= endIdx; i++) { items.push({ kind: "step", step: processFlow[i] }); } if (afterCount > 0) { items.push({ kind: "count", count: afterCount, side: "after" }); } return items; }, [processFlow, maxVisible, currentIdx]); const [modalOpen, setModalOpen] = useState(false); const completedCount = processFlow.filter((s) => (s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status]) === "done").length; const totalCount = processFlow.length; return ( <>
{ e.stopPropagation(); setModalOpen(true); } : undefined} title={cell.showDetailModal !== false ? "클릭하여 전체 현황 보기" : undefined} > {displayItems.map((item, idx) => { const isLast = idx === displayItems.length - 1; if (item.kind === "count") { return (
{item.count}
{!isLast &&
} ); } const styles = getTimelineStyle(item.step); return (
{styles.icon} {item.step.processName}
{!isLast &&
} ); })}
전체 현황 총 {totalCount}개 중 {completedCount}개 완료
{processFlow.map((step, idx) => { const styles = getTimelineStyle(step); const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending"; const statusLabel = sem === "done" ? "완료" : sem === "active" ? "진행" : "대기"; return (
{/* 세로 연결선 + 아이콘 */}
{idx > 0 &&
}
{styles.icon}
{idx < processFlow.length - 1 &&
}
{/* 항목 정보 */}
{step.seqNo} {step.processName} {step.isCurrent && ( )}
{statusLabel}
); })}
{/* 하단 진행률 바 */}
진행률 {totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0}%
0 ? (completedCount / totalCount) * 100 : 0}%` }} />
); } // ===== 11. action-buttons ===== function evaluateShowCondition(btn: ActionButtonDef, row: RowData): "visible" | "disabled" | "hidden" { const cond = btn.showCondition; if (!cond || cond.type === "always") return "visible"; let matched = false; if (cond.type === "timeline-status") { const subStatus = row[VIRTUAL_SUB_STATUS]; matched = subStatus !== undefined && String(subStatus) === cond.value; } else if (cond.type === "column-value" && cond.column) { matched = String(row[cond.column] ?? "") === (cond.value ?? ""); } else { return "visible"; } if (matched) return "visible"; return cond.unmatchBehavior === "disabled" ? "disabled" : "hidden"; } function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode }: CellRendererProps) { const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; const currentProcess = processFlow?.find((s) => s.isCurrent); const currentProcessId = currentProcess?.processId; if (cell.actionButtons && cell.actionButtons.length > 0) { const evaluated = cell.actionButtons.map((btn) => ({ btn, state: evaluateShowCondition(btn, row), })); const activeBtn = evaluated.find((e) => e.state === "visible"); const disabledBtn = activeBtn ? null : evaluated.find((e) => e.state === "disabled"); const pick = activeBtn || disabledBtn; if (!pick) return null; const { btn, state } = pick; return (
); } // 기존 구조 (actionRules) 폴백 const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined; const statusValue = hasSubStatus ? String(row[VIRTUAL_SUB_STATUS] || "") : (cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : "")); const rules = cell.actionRules || []; const matchedRule = rules.find((r) => r.whenStatus === statusValue); if (!matchedRule) return null; return (
{matchedRule.buttons.map((btn, idx) => ( ))}
); } // ===== 12. footer-status ===== function FooterStatusCell({ cell, row }: CellRendererProps) { const value = cell.footerStatusColumn ? row[cell.footerStatusColumn] : ""; const strValue = String(value || ""); const mapped = cell.footerStatusMap?.find((m) => m.value === strValue); if (!strValue && !cell.footerLabel) return null; return (
{cell.footerLabel && ( {cell.footerLabel} )} {mapped ? ( {mapped.label} ) : strValue ? ( {strValue} ) : null}
); }