668 lines
24 KiB
TypeScript
668 lines
24 KiB
TypeScript
"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 } from "../types";
|
|
import { DEFAULT_CARD_IMAGE, VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC } from "../types";
|
|
import type { ButtonVariant } from "../pop-button";
|
|
|
|
type RowData = Record<string, unknown>;
|
|
|
|
// ===== 공통 유틸 =====
|
|
|
|
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
|
ShoppingCart, Package, Truck, Box, Archive, Heart, Star,
|
|
};
|
|
|
|
function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) {
|
|
if (!name) return <ShoppingCart size={size} />;
|
|
const IconComp = LUCIDE_ICON_MAP[name];
|
|
if (!IconComp) return <ShoppingCart size={size} />;
|
|
return <IconComp size={size} />;
|
|
}
|
|
|
|
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<string, unknown>) => void;
|
|
packageEntries?: PackageEntry[];
|
|
inputUnit?: string;
|
|
}
|
|
|
|
// ===== 메인 디스패치 =====
|
|
|
|
export function renderCellV2(props: CellRendererProps): React.ReactNode {
|
|
switch (props.cell.type) {
|
|
case "text":
|
|
return <TextCell {...props} />;
|
|
case "field":
|
|
return <FieldCell {...props} />;
|
|
case "image":
|
|
return <ImageCell {...props} />;
|
|
case "badge":
|
|
return <BadgeCell {...props} />;
|
|
case "button":
|
|
return <ButtonCell {...props} />;
|
|
case "number-input":
|
|
return <NumberInputCell {...props} />;
|
|
case "cart-button":
|
|
return <CartButtonCell {...props} />;
|
|
case "package-summary":
|
|
return <PackageSummaryCell {...props} />;
|
|
case "status-badge":
|
|
return <StatusBadgeCell {...props} />;
|
|
case "timeline":
|
|
return <TimelineCell {...props} />;
|
|
case "action-buttons":
|
|
return <ActionButtonsCell {...props} />;
|
|
case "footer-status":
|
|
return <FooterStatusCell {...props} />;
|
|
default:
|
|
return <span className="text-[10px] text-muted-foreground">알 수 없는 셀 타입</span>;
|
|
}
|
|
}
|
|
|
|
// ===== 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 (
|
|
<span
|
|
className="truncate"
|
|
style={{ fontSize: fs, fontWeight: fw, color: cell.textColor || undefined }}
|
|
>
|
|
{formatValue(value)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ===== 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 (
|
|
<div
|
|
className={isLabelLeft ? "flex items-baseline gap-1" : "flex flex-col"}
|
|
style={{ fontSize: fs }}
|
|
>
|
|
{cell.label && (
|
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
|
{cell.label}{isLabelLeft ? ":" : ""}
|
|
</span>
|
|
)}
|
|
<span
|
|
className="truncate font-medium"
|
|
style={{ color: cell.textColor || (isFormula ? "#ea580c" : undefined) }}
|
|
>
|
|
{displayValue}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 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 (
|
|
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-md border bg-muted/30">
|
|
<img
|
|
src={imageUrl}
|
|
alt={cell.label || ""}
|
|
className="h-full w-full object-contain p-1"
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement;
|
|
if (target.src !== DEFAULT_CARD_IMAGE) target.src = DEFAULT_CARD_IMAGE;
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 4. badge =====
|
|
|
|
function BadgeCell({ cell, row }: CellRendererProps) {
|
|
const value = cell.columnName ? row[cell.columnName] : "";
|
|
return (
|
|
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
|
{formatValue(value)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ===== 5. button =====
|
|
|
|
function ButtonCell({ cell, row, isButtonLoading, onButtonClick }: CellRendererProps) {
|
|
return (
|
|
<Button
|
|
variant={cell.buttonVariant || "outline"}
|
|
size="sm"
|
|
className="h-7 text-[10px]"
|
|
disabled={isButtonLoading}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onButtonClick?.(cell, row);
|
|
}}
|
|
>
|
|
{isButtonLoading ? (
|
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
|
) : null}
|
|
{cell.label || formatValue(cell.columnName ? row[cell.columnName] : "")}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
// ===== 6. number-input =====
|
|
|
|
function NumberInputCell({ cell, row, inputValue, onInputClick }: CellRendererProps) {
|
|
const unit = cell.inputUnit || "EA";
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onInputClick?.(e);
|
|
}}
|
|
className="w-full rounded-lg border-2 border-input bg-background px-2 py-1.5 text-center hover:border-primary active:bg-muted"
|
|
>
|
|
<span className="block text-lg font-bold leading-tight">
|
|
{(inputValue ?? 0).toLocaleString()}
|
|
</span>
|
|
<span className="block text-[12px] text-muted-foreground">{unit}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ===== 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 (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); onCartCancel?.(); }}
|
|
className="flex w-full flex-col items-center justify-center gap-0.5 rounded-xl bg-destructive px-2 py-1.5 text-destructive-foreground transition-colors duration-150 hover:bg-destructive/90 active:bg-destructive/80"
|
|
>
|
|
<X size={iconSize} />
|
|
<span className="text-[10px] font-semibold leading-tight">{cancelLabel}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => { e.stopPropagation(); onCartAdd?.(); }}
|
|
className="flex w-full flex-col items-center justify-center gap-0.5 rounded-xl bg-linear-to-br from-amber-400 to-orange-500 px-2 py-1.5 text-white transition-colors duration-150 hover:from-amber-500 hover:to-orange-600 active:from-amber-600 active:to-orange-700"
|
|
>
|
|
{cell.cartIconType === "emoji" && cell.cartIconValue ? (
|
|
<span style={{ fontSize: `${iconSize}px` }}>{cell.cartIconValue}</span>
|
|
) : (
|
|
<DynamicLucideIcon name={cell.cartIconValue} size={iconSize} />
|
|
)}
|
|
<span className="text-[10px] font-semibold leading-tight">{label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ===== 8. package-summary =====
|
|
|
|
function PackageSummaryCell({ cell, packageEntries, inputUnit }: CellRendererProps) {
|
|
if (!packageEntries || packageEntries.length === 0) return null;
|
|
|
|
return (
|
|
<div className="w-full border-t bg-emerald-50">
|
|
{packageEntries.map((entry, idx) => (
|
|
<div key={idx} className="flex items-center justify-between px-3 py-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] font-bold text-white">
|
|
포장완료
|
|
</span>
|
|
<Package className="h-4 w-4 text-emerald-600" />
|
|
<span className="text-xs font-medium text-emerald-700">
|
|
{entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs font-bold text-emerald-700">
|
|
= {entry.totalQuantity.toLocaleString()}{inputUnit || "EA"}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 9. status-badge =====
|
|
|
|
const STATUS_COLORS: Record<string, { bg: string; text: string }> = {
|
|
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 (
|
|
<span
|
|
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
|
|
style={{ backgroundColor: `${mapped.color}20`, color: mapped.color }}
|
|
>
|
|
{mapped.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const defaultColors = STATUS_COLORS[strValue];
|
|
if (defaultColors) {
|
|
const labelMap: Record<string, string> = {
|
|
waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료",
|
|
};
|
|
return (
|
|
<span
|
|
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
|
|
style={{ backgroundColor: defaultColors.bg, color: defaultColors.text }}
|
|
>
|
|
{labelMap[strValue] || strValue}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
|
{formatValue(effectiveValue)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ===== 10. timeline =====
|
|
|
|
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;
|
|
|
|
if (!processFlow || processFlow.length === 0) {
|
|
const fallback = cell.processColumn ? row[cell.processColumn] : "";
|
|
return (
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{formatValue(fallback)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<div
|
|
className={cn(
|
|
"flex w-full items-center gap-0.5 overflow-hidden px-0.5",
|
|
cell.showDetailModal !== false && "cursor-pointer",
|
|
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}
|
|
>
|
|
{displayItems.map((item, idx) => {
|
|
const isLast = idx === displayItems.length - 1;
|
|
|
|
if (item.kind === "count") {
|
|
return (
|
|
<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}개`}
|
|
>
|
|
{item.count}
|
|
</div>
|
|
{!isLast && <div className="h-px w-1.5 shrink-0 border-t border-dashed border-muted-foreground/40" />}
|
|
</React.Fragment>
|
|
);
|
|
}
|
|
|
|
const styles = getTimelineStyle(item.step);
|
|
|
|
return (
|
|
<React.Fragment key={item.step.seqNo}>
|
|
<div
|
|
className="flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5"
|
|
style={{
|
|
backgroundColor: styles.chipBg,
|
|
color: styles.chipText,
|
|
outline: item.step.isCurrent ? "2px solid #2563eb" : "none",
|
|
outlineOffset: "1px",
|
|
}}
|
|
title={`${item.step.seqNo}. ${item.step.processName} (${item.step.status})`}
|
|
>
|
|
{styles.icon}
|
|
<span className="max-w-[48px] truncate text-[9px] font-medium leading-tight">
|
|
{item.step.processName}
|
|
</span>
|
|
</div>
|
|
{!isLast && <div className="h-px w-1.5 shrink-0 border-t border-dashed border-muted-foreground/40" />}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">전체 현황</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">
|
|
총 {totalCount}개 중 {completedCount}개 완료
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-0">
|
|
{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 (
|
|
<div key={step.seqNo} className="flex items-center">
|
|
{/* 세로 연결선 + 아이콘 */}
|
|
<div className="flex w-8 shrink-0 flex-col items-center">
|
|
{idx > 0 && <div className="h-3 w-px bg-border" />}
|
|
<div
|
|
className="flex h-6 w-6 items-center justify-center rounded-full"
|
|
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
|
|
>
|
|
{styles.icon}
|
|
</div>
|
|
{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",
|
|
)}>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">{step.seqNo}</span>
|
|
<span className={cn(
|
|
"text-sm",
|
|
step.isCurrent ? "font-semibold" : "font-medium",
|
|
)}>
|
|
{step.processName}
|
|
</span>
|
|
{step.isCurrent && (
|
|
<Star className="h-3 w-3 fill-primary text-primary" />
|
|
)}
|
|
</div>
|
|
<span
|
|
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
|
|
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
|
|
>
|
|
{statusLabel}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 하단 진행률 바 */}
|
|
<div className="space-y-1 pt-2">
|
|
<div className="flex justify-between text-xs text-muted-foreground">
|
|
<span>진행률</span>
|
|
<span>{totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0}%</span>
|
|
</div>
|
|
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
|
<div
|
|
className="h-full rounded-full bg-primary transition-all duration-300"
|
|
style={{ width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ===== 11. action-buttons =====
|
|
|
|
function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps) {
|
|
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;
|
|
}
|
|
|
|
// __processFlow__에서 isCurrent 공정의 processId 추출
|
|
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
|
|
const currentProcess = processFlow?.find((s) => s.isCurrent);
|
|
const currentProcessId = currentProcess?.processId;
|
|
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
{matchedRule.buttons.map((btn, idx) => (
|
|
<Button
|
|
key={idx}
|
|
variant={btn.variant || "outline"}
|
|
size="sm"
|
|
className="h-7 text-[10px]"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const config = { ...(btn as Record<string, unknown>) };
|
|
if (currentProcessId !== undefined) {
|
|
config.__processId = currentProcessId;
|
|
}
|
|
onActionButtonClick?.(btn.taskPreset, row, config);
|
|
}}
|
|
>
|
|
{btn.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 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 (
|
|
<div
|
|
className="flex w-full items-center justify-between px-2 py-1"
|
|
style={{ borderTop: cell.showTopBorder !== false ? "1px solid hsl(var(--border))" : "none" }}
|
|
>
|
|
{cell.footerLabel && (
|
|
<span className="text-[10px] text-muted-foreground">{cell.footerLabel}</span>
|
|
)}
|
|
{mapped ? (
|
|
<span
|
|
className="inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-semibold"
|
|
style={{ backgroundColor: `${mapped.color}20`, color: mapped.color }}
|
|
>
|
|
{mapped.label}
|
|
</span>
|
|
) : strValue ? (
|
|
<span className="text-[10px] font-medium text-muted-foreground">
|
|
{strValue}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|