ERP-node/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx

1002 lines
33 KiB
TypeScript
Raw Normal View History

"use client";
/**
* pop-card-list
*
* .
* :
* - 헤더: 코드 +
* - 본문: 이미지() + - ()
*/
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ShoppingCart, X } from "lucide-react";
import * as LucideIcons from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import type {
PopCardListConfig,
CardTemplateConfig,
CardFieldBinding,
CardInputFieldConfig,
CardCalculatedFieldConfig,
CardCartActionConfig,
CardPresetSpec,
CartItem,
} from "../types";
import {
DEFAULT_CARD_IMAGE,
CARD_PRESET_SPECS,
} from "../types";
import { dataApi } from "@/lib/api/data";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { NumberInputModal } from "./NumberInputModal";
// Lucide 아이콘 동적 렌더링
function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) {
if (!name) return <ShoppingCart size={size} />;
const icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
const IconComp = icons[name];
if (!IconComp) return <ShoppingCart size={size} />;
return <IconComp size={size} />;
}
// 마퀴 애니메이션 keyframes (한 번만 삽입)
const MARQUEE_STYLE_ID = "pop-card-marquee-style";
if (typeof document !== "undefined" && !document.getElementById(MARQUEE_STYLE_ID)) {
const style = document.createElement("style");
style.id = MARQUEE_STYLE_ID;
style.textContent = `
@keyframes pop-marquee {
0%, 15% { transform: translateX(0); }
85%, 100% { transform: translateX(var(--marquee-offset)); }
}
`;
document.head.appendChild(style);
}
// 텍스트가 컨테이너보다 넓을 때 자동 슬라이딩하는 컴포넌트
function MarqueeText({
children,
className,
style,
}: {
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
const [overflowPx, setOverflowPx] = useState(0);
const measure = useCallback(() => {
const container = containerRef.current;
const text = textRef.current;
if (!container || !text) return;
const diff = text.scrollWidth - container.clientWidth;
setOverflowPx(diff > 1 ? diff : 0);
}, []);
useEffect(() => {
measure();
}, [children, measure]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const ro = new ResizeObserver(() => measure());
ro.observe(container);
return () => ro.disconnect();
}, [measure]);
return (
<div
ref={containerRef}
className={`overflow-hidden ${className || ""}`}
style={style}
>
<span
ref={textRef}
className="inline-block whitespace-nowrap"
style={
overflowPx > 0
? {
["--marquee-offset" as string]: `-${overflowPx}px`,
animation: "pop-marquee 5s ease-in-out infinite alternate",
}
: undefined
}
>
{children}
</span>
</div>
);
}
interface PopCardListComponentProps {
config?: PopCardListConfig;
className?: string;
screenId?: string;
// 동적 크기 변경을 위한 props (PopRenderer에서 전달)
componentId?: string;
currentRowSpan?: number;
currentColSpan?: number;
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
}
// 테이블 행 데이터 타입
type RowData = Record<string, unknown>;
// 카드 내부 스타일 규격 (프리셋에서 매핑)
interface ScaledConfig {
cardHeight: number;
cardWidth: number;
imageSize: number;
padding: number;
gap: number;
headerPaddingX: number;
headerPaddingY: number;
codeTextSize: number;
titleTextSize: number;
bodyTextSize: number;
}
export function PopCardListComponent({
config,
className,
screenId,
componentId,
currentRowSpan,
currentColSpan,
onRequestResize,
}: PopCardListComponentProps) {
const isHorizontalMode = (config?.scrollDirection || "vertical") === "horizontal";
const maxGridColumns = config?.gridColumns || 2;
const configGridRows = config?.gridRows || 3;
const dataSource = config?.dataSource;
const template = config?.cardTemplate;
// 이벤트 기반 company_code 필터링
const [eventCompanyCode, setEventCompanyCode] = useState<string | undefined>();
const { subscribe, publish, getSharedData, setSharedData } = usePopEvent(screenId || "default");
const router = useRouter();
useEffect(() => {
if (!screenId) return;
const unsub = subscribe("company_selected", (payload: unknown) => {
const p = payload as { companyCode?: string } | undefined;
setEventCompanyCode(p?.companyCode);
});
return unsub;
}, [screenId, subscribe]);
// 데이터 상태
const [rows, setRows] = useState<RowData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 확장/페이지네이션 상태
const [isExpanded, setIsExpanded] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [originalRowSpan, setOriginalRowSpan] = useState<number | null>(null);
// 컨테이너 ref + 크기 측정
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [containerHeight, setContainerHeight] = useState(0);
const baseContainerHeight = useRef(0);
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
const { width, height } = entries[0].contentRect;
if (width > 0) setContainerWidth(width);
if (height > 0) setContainerHeight(height);
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// 이미지 URL 없는 항목 카운트 (toast 중복 방지용)
const missingImageCountRef = useRef(0);
const toastShownRef = useRef(false);
const spec: CardPresetSpec = CARD_PRESET_SPECS.large;
// 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열
const maxAllowedColumns = useMemo(() => {
if (!currentColSpan) return maxGridColumns;
if (currentColSpan >= 8) return maxGridColumns;
return 1;
}, [currentColSpan, maxGridColumns]);
// 카드 최소 너비 기준으로 컨테이너에 들어갈 수 있는 열 개수 자동 계산
const minCardWidth = Math.round(spec.height * 1.6);
const autoColumns = containerWidth > 0
? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap)))
: maxGridColumns;
const gridColumns = Math.min(autoColumns, maxGridColumns, maxAllowedColumns);
// 컨테이너 높이 기반 행 수 자동 계산 (잘림 방지)
const effectiveGridRows = useMemo(() => {
if (containerHeight <= 0) return configGridRows;
const controlBarHeight = 44;
const effectiveHeight = baseContainerHeight.current > 0
? baseContainerHeight.current
: containerHeight;
const availableHeight = effectiveHeight - controlBarHeight;
const cardHeightWithGap = spec.height + spec.gap;
const fittableRows = Math.max(1, Math.floor(
(availableHeight + spec.gap) / cardHeightWithGap
));
return Math.min(configGridRows, fittableRows);
}, [containerHeight, configGridRows, spec]);
const gridRows = effectiveGridRows;
// 카드 크기: 컨테이너 실측 크기에서 gridColumns x gridRows 기준으로 동적 계산
const scaled = useMemo((): ScaledConfig => {
const gap = spec.gap;
const controlBarHeight = 44;
const buildScaledConfig = (cardWidth: number, cardHeight: number): ScaledConfig => {
const scale = cardHeight / spec.height;
return {
cardHeight,
cardWidth,
imageSize: Math.round(spec.imageSize * scale),
padding: Math.round(spec.padding * scale),
gap,
headerPaddingX: Math.round(spec.headerPadX * scale),
headerPaddingY: Math.round(spec.headerPadY * scale),
codeTextSize: Math.round(spec.codeText * scale),
titleTextSize: Math.round(spec.titleText * scale),
bodyTextSize: Math.round(spec.bodyText * scale),
};
};
if (containerWidth <= 0 || containerHeight <= 0) {
return buildScaledConfig(Math.round(spec.height * 1.6), spec.height);
}
const effectiveHeight = baseContainerHeight.current > 0
? baseContainerHeight.current
: containerHeight;
const availableHeight = effectiveHeight - controlBarHeight;
const availableWidth = containerWidth;
const cardHeight = Math.max(spec.height,
Math.floor((availableHeight - gap * (gridRows - 1)) / gridRows));
const cardWidth = Math.max(Math.round(spec.height * 1.6),
Math.floor((availableWidth - gap * (gridColumns - 1)) / gridColumns));
return buildScaledConfig(cardWidth, cardHeight);
}, [spec, containerWidth, containerHeight, gridColumns, gridRows]);
// 기본 상태에서 표시할 카드 수
const visibleCardCount = useMemo(() => {
return gridColumns * gridRows;
}, [gridColumns, gridRows]);
// 더보기 버튼 표시 여부
const hasMoreCards = rows.length > visibleCardCount;
// 확장 상태에서 표시할 카드 수 계산
const expandedCardsPerPage = useMemo(() => {
// 가로/세로 모두: 기본 표시 수의 2배 + 스크롤 유도를 위해 1줄 추가
// 가로: 컴포넌트 크기 변경 없이 카드 2배 → 가로 스크롤로 탐색
// 세로: rowSpan 2배 → 2배 영역에 카드 채움 + 세로 스크롤
return Math.max(1, visibleCardCount * 2 + gridColumns);
}, [visibleCardCount, gridColumns]);
// 스크롤 영역 ref
const scrollAreaRef = useRef<HTMLDivElement>(null);
// 현재 표시할 카드 결정
const displayCards = useMemo(() => {
if (!isExpanded) {
// 기본 상태: visibleCardCount만큼만 표시
return rows.slice(0, visibleCardCount);
} else {
// 확장 상태: 현재 페이지의 카드들 (스크롤 가능하도록 더 많이)
const start = (currentPage - 1) * expandedCardsPerPage;
const end = start + expandedCardsPerPage;
return rows.slice(start, end);
}
}, [rows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]);
// 총 페이지 수
const totalPages = isExpanded
? Math.ceil(rows.length / expandedCardsPerPage)
: 1;
// 페이지네이션 필요: 확장 상태에서 전체 카드가 한 페이지를 초과할 때
const needsPagination = isExpanded && totalPages > 1;
// 페이지 변경 핸들러
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const handleNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
};
// 확장/접기 토글: 세로 모드에서만 rowSpan 2배 확장, 가로 모드에서는 크기 변경 없이 카드만 추가 표시
const toggleExpand = () => {
if (isExpanded) {
if (!isHorizontalMode && originalRowSpan !== null && componentId && onRequestResize) {
onRequestResize(componentId, originalRowSpan);
}
setCurrentPage(1);
setOriginalRowSpan(null);
baseContainerHeight.current = 0;
setIsExpanded(false);
} else {
baseContainerHeight.current = containerHeight;
if (!isHorizontalMode && componentId && onRequestResize && currentRowSpan !== undefined) {
setOriginalRowSpan(currentRowSpan);
onRequestResize(componentId, currentRowSpan * 2);
}
setIsExpanded(true);
}
};
// 페이지 변경 시 스크롤 위치 초기화 (가로/세로 모두)
useEffect(() => {
if (scrollAreaRef.current && isExpanded) {
scrollAreaRef.current.scrollTop = 0;
scrollAreaRef.current.scrollLeft = 0;
}
}, [currentPage, isExpanded]);
// 데이터 조회
useEffect(() => {
if (!dataSource?.tableName) {
setLoading(false);
setRows([]);
return;
}
const fetchData = async () => {
setLoading(true);
setError(null);
missingImageCountRef.current = 0;
toastShownRef.current = false;
try {
// 필터 조건 구성
const filters: Record<string, unknown> = {};
if (dataSource.filters && dataSource.filters.length > 0) {
dataSource.filters.forEach((f) => {
if (f.column && f.value) {
filters[f.column] = f.value;
}
});
}
// 이벤트로 수신한 company_code 필터 병합
if (eventCompanyCode) {
filters["company_code"] = eventCompanyCode;
}
// 정렬 조건
const sortBy = dataSource.sort?.column;
const sortOrder = dataSource.sort?.direction;
// 개수 제한
const size =
dataSource.limit?.mode === "limited" && dataSource.limit?.count
? dataSource.limit.count
: 100;
// TODO: 조인 지원은 추후 구현
// 현재는 단일 테이블 조회만 지원
const result = await dataApi.getTableData(dataSource.tableName, {
page: 1,
size,
sortBy: sortOrder ? sortBy : undefined,
sortOrder,
filters: Object.keys(filters).length > 0 ? filters : undefined,
});
setRows(result.data || []);
} catch (err) {
const message = err instanceof Error ? err.message : "데이터 조회 실패";
setError(message);
setRows([]);
} finally {
setLoading(false);
}
};
fetchData();
}, [dataSource, eventCompanyCode]);
// 이미지 URL 없는 항목 체크 및 toast 표시
useEffect(() => {
if (
!loading &&
rows.length > 0 &&
template?.image?.enabled &&
template?.image?.imageColumn &&
!toastShownRef.current
) {
const imageColumn = template.image.imageColumn;
const missingCount = rows.filter((row) => !row[imageColumn]).length;
if (missingCount > 0) {
missingImageCountRef.current = missingCount;
toastShownRef.current = true;
toast.warning(
`${missingCount}개 항목의 이미지 URL이 없어 기본 이미지로 표시됩니다`
);
}
}
}, [loading, rows, template?.image]);
// 카드 영역 스타일
const cardAreaStyle: React.CSSProperties = {
gap: `${scaled.gap}px`,
...(isHorizontalMode
? {
gridTemplateRows: `repeat(${gridRows}, ${scaled.cardHeight}px)`,
gridAutoFlow: "column",
gridAutoColumns: `${scaled.cardWidth}px`,
}
: {
// 세로 모드: 1fr 비율 기반으로 컨테이너 너비 초과 방지
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
gridAutoRows: `${scaled.cardHeight}px`,
}),
};
// 세로 모드 스크롤 클래스: 비확장 시 overflow hidden, 확장 시에만 세로 스크롤 허용
const scrollClassName = isHorizontalMode
? "overflow-x-auto overflow-y-hidden"
: isExpanded
? "overflow-y-auto overflow-x-hidden"
: "overflow-hidden";
return (
<div
ref={containerRef}
className={`flex h-full w-full flex-col ${className || ""}`}
>
{!dataSource?.tableName ? (
<div className="flex flex-1 items-center justify-center rounded-md border border-dashed bg-muted/30 p-4">
<p className="text-sm text-muted-foreground">
.
</p>
</div>
) : loading ? (
<div className="flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex flex-1 items-center justify-center rounded-md border border-destructive/50 bg-destructive/10 p-4">
<p className="text-sm text-destructive">{error}</p>
</div>
) : rows.length === 0 ? (
<div className="flex flex-1 items-center justify-center rounded-md border border-dashed bg-muted/30 p-4">
<p className="text-sm text-muted-foreground"> .</p>
</div>
) : (
<>
{/* 카드 영역 (스크롤 가능) */}
<div
ref={scrollAreaRef}
className={`min-h-0 flex-1 grid ${scrollClassName}`}
style={{
...cardAreaStyle,
alignContent: "start",
justifyContent: isHorizontalMode ? "start" : "center",
}}
>
{displayCards.map((row, index) => (
<Card
key={index}
row={row}
template={template}
scaled={scaled}
inputField={config?.inputField}
calculatedField={config?.calculatedField}
cartAction={config?.cartAction}
publish={publish}
getSharedData={getSharedData}
setSharedData={setSharedData}
router={router}
/>
))}
</div>
{/* 하단 컨트롤 영역 */}
{hasMoreCards && (
<div className="shrink-0 border-t bg-background px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={toggleExpand}
className="h-9 px-4 text-sm font-medium"
>
{isExpanded ? (
<>
<ChevronUp className="ml-1 h-4 w-4" />
</>
) : (
<>
<ChevronDown className="ml-1 h-4 w-4" />
</>
)}
</Button>
<span className="text-xs text-muted-foreground">
{rows.length}
</span>
</div>
{isExpanded && needsPagination && (
<div className="flex items-center gap-1.5">
<Button
variant="default"
size="sm"
onClick={handlePrevPage}
disabled={currentPage <= 1}
className="h-9 w-9 p-0"
>
<ChevronLeft className="h-5 w-5" />
</Button>
<span className="min-w-[48px] text-center text-sm font-medium">
{currentPage} / {totalPages}
</span>
<Button
variant="default"
size="sm"
onClick={handleNextPage}
disabled={currentPage >= totalPages}
className="h-9 w-9 p-0"
>
<ChevronRight className="h-5 w-5" />
</Button>
</div>
)}
</div>
</div>
)}
</>
)}
</div>
);
}
// ===== 카드 컴포넌트 =====
function Card({
row,
template,
scaled,
inputField,
calculatedField,
cartAction,
publish,
getSharedData,
setSharedData,
router,
}: {
row: RowData;
template?: CardTemplateConfig;
scaled: ScaledConfig;
inputField?: CardInputFieldConfig;
calculatedField?: CardCalculatedFieldConfig;
cartAction?: CardCartActionConfig;
publish: (eventName: string, payload?: unknown) => void;
getSharedData: <T = unknown>(key: string) => T | undefined;
setSharedData: (key: string, value: unknown) => void;
router: ReturnType<typeof useRouter>;
}) {
const header = template?.header;
const image = template?.image;
const body = template?.body;
// 입력 필드 상태
const [inputValue, setInputValue] = useState<number>(
inputField?.defaultValue || 0
);
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
const [isModalOpen, setIsModalOpen] = useState(false);
// 담기/취소 토글 상태
const [isCarted, setIsCarted] = useState(false);
// 헤더 값 추출
const codeValue = header?.codeField ? row[header.codeField] : null;
const titleValue = header?.titleField ? row[header.titleField] : null;
// 이미지 URL 결정
const imageUrl =
image?.enabled && image?.imageColumn && row[image.imageColumn]
? String(row[image.imageColumn])
: image?.defaultImage || DEFAULT_CARD_IMAGE;
// 계산 필드 값 계산
const calculatedValue = useMemo(() => {
if (!calculatedField?.enabled || !calculatedField?.formula) return null;
return evaluateFormula(calculatedField.formula, row, inputValue);
}, [calculatedField, row, inputValue]);
// effectiveMax: DB 컬럼 우선, 없으면 inputField.max 폴백
const effectiveMax = useMemo(() => {
if (inputField?.maxColumn) {
const colVal = Number(row[inputField.maxColumn]);
if (!isNaN(colVal) && colVal > 0) return colVal;
}
return inputField?.max ?? 999999;
}, [inputField, row]);
// 기본값이 설정되지 않은 경우 최대값으로 자동 초기화
useEffect(() => {
if (inputField?.enabled && !inputField?.defaultValue && effectiveMax > 0 && effectiveMax < 999999) {
setInputValue(effectiveMax);
}
}, [effectiveMax, inputField?.enabled, inputField?.defaultValue]);
const cardStyle: React.CSSProperties = {
height: `${scaled.cardHeight}px`,
overflow: "hidden",
};
const headerStyle: React.CSSProperties = {
padding: `${scaled.headerPaddingY}px ${scaled.headerPaddingX}px`,
};
const bodyStyle: React.CSSProperties = {
padding: `${scaled.padding}px`,
gap: `${scaled.gap}px`,
};
const imageContainerStyle: React.CSSProperties = {
width: `${scaled.imageSize}px`,
height: `${scaled.imageSize}px`,
};
const handleInputClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsModalOpen(true);
};
const handleInputConfirm = (value: number, unit?: string) => {
setInputValue(value);
setPackageUnit(unit);
};
// 담기: sharedData에 CartItem 누적 + 이벤트 발행 + 토글
const handleCartAdd = () => {
const cartItem: CartItem = {
row,
quantity: inputValue,
packageUnit: packageUnit || undefined,
};
const existing = getSharedData<CartItem[]>("cart_items") || [];
setSharedData("cart_items", [...existing, cartItem]);
publish("cart_item_added", cartItem);
setIsCarted(true);
toast.success("장바구니에 담겼습니다.");
if (cartAction?.navigateMode === "screen" && cartAction.targetScreenId) {
router.push(`/pop/screens/${cartAction.targetScreenId}`);
}
};
// 취소: sharedData에서 해당 아이템 제거 + 이벤트 발행 + 토글 복원
const handleCartCancel = () => {
const existing = getSharedData<CartItem[]>("cart_items") || [];
const rowKey = JSON.stringify(row);
const filtered = existing.filter(
(item) => JSON.stringify(item.row) !== rowKey
);
setSharedData("cart_items", filtered);
publish("cart_item_removed", { row });
setIsCarted(false);
toast.info("장바구니에서 제거되었습니다.");
};
// pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영)
const iconSize = Math.max(14, Math.round(18 * (scaled.bodyTextSize / 11)));
const cartLabel = cartAction?.label || "담기";
const cancelLabel = cartAction?.cancelLabel || "취소";
return (
<div
className="rounded-lg border bg-card shadow-sm transition-all duration-150 hover:border-2 hover:border-blue-500 hover:shadow-md"
style={cardStyle}
>
{/* 헤더 영역 */}
{(codeValue !== null || titleValue !== null) && (
<div className="border-b bg-muted/30" style={headerStyle}>
<div className="flex items-center gap-2">
{codeValue !== null && (
<span
className="shrink-0 font-medium text-muted-foreground"
style={{ fontSize: `${scaled.codeTextSize}px` }}
>
{formatValue(codeValue)}
</span>
)}
{titleValue !== null && (
<MarqueeText
className="font-bold text-foreground"
style={{ fontSize: `${scaled.titleTextSize}px` }}
>
{formatValue(titleValue)}
</MarqueeText>
)}
</div>
</div>
)}
{/* 본문 영역 */}
<div className="flex" style={bodyStyle}>
{/* 이미지 (왼쪽) */}
{image?.enabled && (
<div className="shrink-0">
<div
className="flex items-center justify-center overflow-hidden rounded-md border bg-muted/30"
style={imageContainerStyle}
>
<img
src={imageUrl}
alt=""
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>
</div>
)}
{/* 필드 목록 (중간, flex-1) */}
<div className="min-w-0 flex-1">
<div style={{ display: "flex", flexDirection: "column", gap: `${Math.round(scaled.gap / 2)}px` }}>
{body?.fields && body.fields.length > 0 ? (
body.fields.map((field) => (
<FieldRow key={field.id} field={field} row={row} scaled={scaled} />
))
) : (
<div
className="flex items-center justify-center text-muted-foreground"
style={{ fontSize: `${scaled.bodyTextSize}px` }}
>
</div>
)}
{/* 계산 필드 */}
{calculatedField?.enabled && calculatedValue !== null && (
<div
className="flex items-baseline"
style={{ gap: `${Math.round(scaled.gap / 2)}px`, fontSize: `${scaled.bodyTextSize}px` }}
>
<span
className="shrink-0 text-muted-foreground"
style={{ minWidth: `${Math.round(50 * (scaled.bodyTextSize / 12))}px` }}
>
{calculatedField.label || "계산값"}
</span>
<MarqueeText className="font-medium text-orange-600">
{calculatedValue.toLocaleString()}{calculatedField.unit ? ` ${calculatedField.unit}` : ""}
</MarqueeText>
</div>
)}
</div>
</div>
{/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (inputField 활성화 시만) */}
{inputField?.enabled && (
<div
className="ml-2 flex shrink-0 flex-col items-stretch justify-center gap-2"
style={{ minWidth: "100px" }}
>
{/* 수량 버튼 */}
<button
type="button"
onClick={handleInputClick}
className="rounded-lg border-2 border-gray-300 bg-white px-2 py-1.5 text-center hover:border-primary active:bg-gray-50"
>
<span className="block text-lg font-bold leading-tight">
{inputValue.toLocaleString()}
</span>
<span className="text-muted-foreground block text-[12px]">
{inputField.unit || "EA"}
</span>
</button>
{/* pop-icon 스타일 담기/취소 토글 버튼 */}
{isCarted ? (
<button
type="button"
onClick={handleCartCancel}
className="flex 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 style={{ fontSize: `${Math.max(9, scaled.bodyTextSize - 2)}px` }} className="font-semibold leading-tight">
{cancelLabel}
</span>
</button>
) : (
<button
type="button"
onClick={handleCartAdd}
className="flex flex-col items-center justify-center gap-0.5 rounded-xl bg-gradient-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"
>
{cartAction?.iconType === "emoji" && cartAction?.iconValue ? (
<span style={{ fontSize: `${iconSize}px` }}>{cartAction.iconValue}</span>
) : (
<DynamicLucideIcon name={cartAction?.iconValue} size={iconSize} />
)}
<span style={{ fontSize: `${Math.max(9, scaled.bodyTextSize - 2)}px` }} className="font-semibold leading-tight">
{cartLabel}
</span>
</button>
)}
</div>
)}
</div>
{/* 숫자 입력 모달 */}
{inputField?.enabled && (
<NumberInputModal
open={isModalOpen}
onOpenChange={setIsModalOpen}
unit={inputField.unit || "EA"}
initialValue={inputValue}
initialPackageUnit={packageUnit}
min={inputField.min || 0}
maxValue={effectiveMax}
onConfirm={handleInputConfirm}
/>
)}
</div>
);
}
// ===== 필드 행 컴포넌트 =====
function FieldRow({
field,
row,
scaled,
}: {
field: CardFieldBinding;
row: RowData;
scaled: ScaledConfig;
}) {
const value = row[field.columnName];
// 비율 기반 라벨 최소 너비
const labelMinWidth = Math.round(50 * (scaled.bodyTextSize / 12));
return (
<div
className="flex items-baseline"
style={{ gap: `${Math.round(scaled.gap / 2)}px`, fontSize: `${scaled.bodyTextSize}px` }}
>
{/* 라벨 */}
<span
className="shrink-0 text-muted-foreground"
style={{ minWidth: `${labelMinWidth}px` }}
>
{field.label}
</span>
{/* 값 - 기본 검정색, textColor 설정 시 해당 색상 */}
<MarqueeText
className="font-medium"
style={{ color: field.textColor || "#000000" }}
>
{formatValue(value)}
</MarqueeText>
</div>
);
}
// ===== 값 포맷팅 =====
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();
}
// ISO 날짜 문자열 감지 및 포맷
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
// MM-DD 형식으로 표시
return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
}
return String(value);
}
// ===== 계산식 평가 =====
/**
* .
* : +, -, *, /
* 변수: $input ( )
*
* @param formula (: "order_qty - inbound_qty", "$input - received_qty")
* @param row
* @param inputValue
* @returns null ( )
*/
function evaluateFormula(
formula: string,
row: RowData,
inputValue: number
): number | null {
try {
// 수식에서 컬럼명과 $input을 실제 값으로 치환
let expression = formula;
// $input을 입력값으로 치환
expression = expression.replace(/\$input/g, String(inputValue));
// 컬럼명을 값으로 치환 (알파벳, 숫자, 언더스코어로 구성된 식별자)
const columnPattern = /[a-zA-Z_][a-zA-Z0-9_]*/g;
expression = expression.replace(columnPattern, (match) => {
// 이미 숫자로 치환된 경우 스킵
if (/^\d+$/.test(match)) return match;
const value = row[match];
if (value === null || value === undefined) return "0";
if (typeof value === "number") return String(value);
const parsed = parseFloat(String(value));
return isNaN(parsed) ? "0" : String(parsed);
});
// 안전한 계산 (기본 산술 연산만 허용)
// 허용: 숫자, +, -, *, /, (, ), 공백, 소수점
if (!/^[\d\s+\-*/().]+$/.test(expression)) {
console.warn("Invalid formula expression:", expression);
return null;
}
// eval 대신 Function 사용 (더 안전)
const result = new Function(`return (${expression})`)();
if (typeof result !== "number" || isNaN(result) || !isFinite(result)) {
return null;
}
return Math.round(result * 100) / 100; // 소수점 2자리까지
} catch (error) {
console.warn("Formula evaluation error:", error);
return null;
}
}