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

1445 lines
50 KiB
TypeScript

"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, Package, Truck, Box, Archive, Heart, Star,
Trash2,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import type {
PopCardListConfig,
CardTemplateConfig,
CardFieldBinding,
CardInputFieldConfig,
CardCartActionConfig,
CardPackageConfig,
CardPresetSpec,
CartItem,
PackageEntry,
CollectDataRequest,
CollectedDataResponse,
} from "../types";
import {
DEFAULT_CARD_IMAGE,
CARD_PRESET_SPECS,
} from "../types";
import { dataApi } from "@/lib/api/data";
import { screenApi } from "@/lib/api/screen";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useCartSync } from "@/hooks/pop/useCartSync";
import { NumberInputModal } from "./NumberInputModal";
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} />;
}
// 마퀴 애니메이션 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>
);
}
// cart_items 행의 row_data JSON을 풀어서 __cart_ 접두사 메타데이터와 병합
function parseCartRow(dbRow: Record<string, unknown>): Record<string, unknown> {
let rowData: Record<string, unknown> = {};
try {
const raw = dbRow.row_data;
if (typeof raw === "string" && raw.trim()) rowData = JSON.parse(raw);
else if (typeof raw === "object" && raw !== null) rowData = raw as Record<string, unknown>;
} catch { rowData = {}; }
return {
...rowData,
__cart_id: dbRow.id,
__cart_quantity: Number(dbRow.quantity) || 0,
__cart_package_unit: dbRow.package_unit || "",
__cart_package_entries: dbRow.package_entries,
__cart_status: dbRow.status || "in_cart",
__cart_memo: dbRow.memo || "",
__cart_row_key: dbRow.row_key || "",
__cart_modified: false,
};
}
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 { subscribe, publish } = usePopEvent(screenId || "default");
const router = useRouter();
// 장바구니 목록 모드 플래그 및 상태
const isCartListMode = config?.cartListMode?.enabled === true;
const [inheritedConfig, setInheritedConfig] = useState<Partial<PopCardListConfig> | null>(null);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
// 장바구니 목록 모드: 원본 화면의 설정 전체를 병합 (cardTemplate, inputField, packageConfig, cardSize 등)
const effectiveConfig = useMemo<PopCardListConfig | undefined>(() => {
if (!isCartListMode || !inheritedConfig) return config;
return {
...config,
...inheritedConfig,
cartListMode: config?.cartListMode,
dataSource: config?.dataSource,
} as PopCardListConfig;
}, [config, inheritedConfig, isCartListMode]);
const isHorizontalMode = (effectiveConfig?.scrollDirection || "vertical") === "horizontal";
const maxGridColumns = effectiveConfig?.gridColumns || 2;
const configGridRows = effectiveConfig?.gridRows || 3;
const dataSource = effectiveConfig?.dataSource;
const effectiveTemplate = effectiveConfig?.cardTemplate;
// 장바구니 DB 동기화 (장바구니 목록 모드에서는 비활성화)
const sourceTableName = (!isCartListMode && dataSource?.tableName) || "";
const cart = useCartSync(screenId || "", sourceTableName);
// 데이터 상태
const [rows, setRows] = useState<RowData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 외부 필터 조건 (연결 시스템에서 수신, connectionId별 Map으로 복수 필터 AND 결합)
const [externalFilters, setExternalFilters] = useState<
Map<string, {
fieldName: string;
value: unknown;
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
}>
>(new Map());
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__filter_condition`,
(payload: unknown) => {
const data = payload as {
value?: { fieldName?: string; value?: unknown };
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
_connectionId?: string;
};
const connId = data?._connectionId || "default";
setExternalFilters(prev => {
const next = new Map(prev);
if (data?.value?.value) {
next.set(connId, {
fieldName: data.value.fieldName || "",
value: data.value.value,
filterConfig: data.filterConfig,
});
} else {
next.delete(connId);
}
return next;
});
}
);
return unsub;
}, [componentId, subscribe]);
// cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용
const cartRef = useRef(cart);
cartRef.current = cart;
// "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행 (장바구니 목록 모드 제외)
useEffect(() => {
if (!componentId || isCartListMode) return;
const unsub = subscribe(
`__comp_input__${componentId}__cart_save_trigger`,
async (payload: unknown) => {
const data = payload as { value?: { selectedColumns?: string[] } } | undefined;
const ok = await cartRef.current.saveToDb(data?.value?.selectedColumns);
publish(`__comp_output__${componentId}__cart_save_completed`, {
success: ok,
});
}
);
return unsub;
}, [componentId, subscribe, publish, isCartListMode]);
// DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달 (장바구니 목록 모드 제외)
useEffect(() => {
if (!componentId || cart.loading || isCartListMode) return;
publish(`__comp_output__${componentId}__cart_updated`, {
count: cart.cartCount,
isDirty: cart.isDirty,
});
}, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish, isCartListMode]);
// 카드 선택 시 selected_row 이벤트 발행
const handleCardSelect = useCallback((row: RowData) => {
if (!componentId) return;
publish(`__comp_output__${componentId}__selected_row`, row);
}, [componentId, publish]);
// 확장/페이지네이션 상태
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 entry = entries[0];
if (!entry) return;
const { width, height } = entry.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 cardSizeKey = effectiveConfig?.cardSize || "large";
const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || 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.max(1, Math.min(autoColumns, maxGridColumns, maxAllowedColumns));
// 행 수: 설정값 그대로 사용 (containerHeight 연동 시 피드백 루프 위험)
const gridRows = configGridRows;
// 카드 크기: 높이는 프리셋 고정, 너비만 컨테이너 기반 동적 계산
// (높이를 containerHeight에 연동하면 뷰어 모드의 minmax(auto) 그리드와
// ResizeObserver 사이에서 피드백 루프가 발생해 무한 성장함)
const scaled = useMemo((): ScaledConfig => {
const gap = spec.gap;
const cardHeight = spec.height;
const minCardWidth = Math.round(spec.height * 1.6);
const cardWidth = containerWidth > 0
? Math.max(minCardWidth,
Math.floor((containerWidth - gap * (gridColumns - 1)) / gridColumns))
: minCardWidth;
return {
cardHeight,
cardWidth,
imageSize: spec.imageSize,
padding: spec.padding,
gap,
headerPaddingX: spec.headerPadX,
headerPaddingY: spec.headerPadY,
codeTextSize: spec.codeText,
titleTextSize: spec.titleText,
bodyTextSize: spec.bodyText,
};
}, [spec, containerWidth, gridColumns]);
// 외부 필터 적용 (복수 필터 AND 결합)
const filteredRows = useMemo(() => {
if (externalFilters.size === 0) return rows;
const matchSingleFilter = (
row: RowData,
filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } }
): boolean => {
const searchValue = String(filter.value).toLowerCase();
if (!searchValue) return true;
const fc = filter.filterConfig;
const columns: string[] =
fc?.targetColumns?.length
? fc.targetColumns
: fc?.targetColumn
? [fc.targetColumn]
: filter.fieldName
? [filter.fieldName]
: [];
if (columns.length === 0) return true;
const mode = fc?.filterMode || "contains";
const matchCell = (cellValue: string) => {
switch (mode) {
case "equals":
return cellValue === searchValue;
case "starts_with":
return cellValue.startsWith(searchValue);
case "contains":
default:
return cellValue.includes(searchValue);
}
};
return columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase()));
};
const allFilters = [...externalFilters.values()];
return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f)));
}, [rows, externalFilters]);
// 기본 상태에서 표시할 카드 수
const visibleCardCount = useMemo(() => {
return gridColumns * gridRows;
}, [gridColumns, gridRows]);
// 더보기 버튼 표시 여부
const hasMoreCards = filteredRows.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) {
return filteredRows.slice(0, visibleCardCount);
} else {
const start = (currentPage - 1) * expandedCardsPerPage;
const end = start + expandedCardsPerPage;
return filteredRows.slice(start, end);
}
}, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]);
// 총 페이지 수
const totalPages = isExpanded
? Math.ceil(filteredRows.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]);
// dataSource를 직렬화해서 의존성 안정화 (객체 참조 변경에 의한 불필요한 재호출 방지)
const dataSourceKey = useMemo(
() => JSON.stringify(dataSource || null),
[dataSource]
);
// 장바구니 목록 모드 설정을 직렬화 (의존성 안정화)
const cartListModeKey = useMemo(
() => JSON.stringify(config?.cartListMode || null),
[config?.cartListMode]
);
useEffect(() => {
// 장바구니 목록 모드: cart_items에서 직접 조회
if (isCartListMode) {
const cartListMode = config!.cartListMode!;
// 원본 화면 미선택 시 데이터 조회하지 않음
if (!cartListMode.sourceScreenId) {
setLoading(false);
setRows([]);
return;
}
const fetchCartData = async () => {
setLoading(true);
setError(null);
try {
// 원본 화면 레이아웃에서 설정 전체 상속 (cardTemplate, inputField, packageConfig, cardSize 등)
try {
const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId);
const componentsMap = layoutJson?.components || {};
const componentList = Object.values(componentsMap) as any[];
const matched = cartListMode.sourceComponentId
? componentList.find((c: any) => c.id === cartListMode.sourceComponentId)
: componentList.find((c: any) => c.type === "pop-card-list");
if (matched?.config) {
setInheritedConfig(matched.config);
}
} catch {
// 레이아웃 로드 실패 시 자체 config 폴백
}
const cartFilters: Record<string, unknown> = {
status: cartListMode.statusFilter || "in_cart",
};
if (cartListMode.sourceScreenId) {
cartFilters.screen_id = String(cartListMode.sourceScreenId);
}
const result = await dataApi.getTableData("cart_items", {
size: 500,
filters: cartFilters,
});
const parsed = (result.data || []).map(parseCartRow);
setRows(parsed);
} catch (err) {
const message = err instanceof Error ? err.message : "장바구니 데이터 조회 실패";
setError(message);
setRows([]);
} finally {
setLoading(false);
}
};
fetchCartData();
return;
}
// 기본 모드: 데이터 소스에서 조회
if (!dataSource?.tableName) {
setLoading(false);
setRows([]);
return;
}
const fetchData = async () => {
setLoading(true);
setError(null);
missingImageCountRef.current = 0;
try {
// 서버에는 = 연산자 필터만 전달, 나머지는 클라이언트 후처리
const filters: Record<string, unknown> = {};
if (dataSource.filters && dataSource.filters.length > 0) {
dataSource.filters.forEach((f) => {
if (f.column && f.value && (!f.operator || f.operator === "=")) {
filters[f.column] = f.value;
}
});
}
// 다중 정렬: 첫 번째 기준을 서버 정렬로 전달 (하위 호환: 단일 객체도 처리)
const sortArray = Array.isArray(dataSource.sort)
? dataSource.sort
: dataSource.sort && typeof dataSource.sort === "object"
? [dataSource.sort as { column: string; direction: "asc" | "desc" }]
: [];
const primarySort = sortArray.length > 0 ? sortArray[0] : undefined;
const sortBy = primarySort?.column;
const sortOrder = primarySort?.direction;
const size =
dataSource.limit?.mode === "limited" && dataSource.limit?.count
? dataSource.limit.count
: 100;
const result = await dataApi.getTableData(dataSource.tableName, {
page: 1,
size,
sortBy: sortBy || undefined,
sortOrder,
filters: Object.keys(filters).length > 0 ? filters : undefined,
});
let fetchedRows = result.data || [];
// 서버에서 처리하지 못한 연산자 필터 클라이언트 후처리
const clientFilters = (dataSource.filters || []).filter(
(f) => f.column && f.value && f.operator && f.operator !== "="
);
if (clientFilters.length > 0) {
fetchedRows = fetchedRows.filter((row) =>
clientFilters.every((f) => {
const cellVal = row[f.column];
const filterVal = f.value;
switch (f.operator) {
case "!=": return String(cellVal ?? "") !== filterVal;
case ">": return Number(cellVal) > Number(filterVal);
case ">=": return Number(cellVal) >= Number(filterVal);
case "<": return Number(cellVal) < Number(filterVal);
case "<=": return Number(cellVal) <= Number(filterVal);
case "like": return String(cellVal ?? "").toLowerCase().includes(filterVal.toLowerCase());
default: return true;
}
})
);
}
setRows(fetchedRows);
} catch (err) {
const message = err instanceof Error ? err.message : "데이터 조회 실패";
setError(message);
setRows([]);
} finally {
setLoading(false);
}
};
fetchData();
}, [dataSourceKey, isCartListMode, cartListModeKey]); // eslint-disable-line react-hooks/exhaustive-deps
// 이미지 URL 없는 항목 카운트 (내부 추적용, toast 미표시)
useEffect(() => {
if (!loading && rows.length > 0 && effectiveTemplate?.image?.enabled && effectiveTemplate?.image?.imageColumn) {
const imageColumn = effectiveTemplate.image.imageColumn;
missingImageCountRef.current = rows.filter((row) => !row[imageColumn]).length;
}
}, [loading, rows, effectiveTemplate?.image]);
// 장바구니 목록 모드: 항목 삭제 콜백
const handleDeleteItem = useCallback((cartId: string) => {
setRows(prev => prev.filter(r => String(r.__cart_id) !== cartId));
setSelectedKeys(prev => {
const next = new Set(prev);
next.delete(cartId);
return next;
});
}, []);
// 장바구니 목록 모드: 수량 수정 콜백 (로컬만 업데이트, DB 미반영)
const handleUpdateQuantity = useCallback((
cartId: string,
quantity: number,
unit?: string,
entries?: PackageEntry[],
) => {
setRows(prev => prev.map(r => {
if (String(r.__cart_id) !== cartId) return r;
return {
...r,
__cart_quantity: quantity,
__cart_package_unit: unit || r.__cart_package_unit,
__cart_package_entries: entries || r.__cart_package_entries,
__cart_modified: true,
};
}));
}, []);
// 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → 선택 항목 + 매핑 응답
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__collect_data`,
(payload: unknown) => {
const request = (payload as Record<string, unknown>)?.value as CollectDataRequest | undefined;
const selectedItems = isCartListMode
? filteredRows.filter(r => selectedKeys.has(String(r.__cart_id ?? "")))
: rows;
// CardListSaveMapping → SaveMapping 변환
const sm = config?.saveMapping;
const mapping = sm?.targetTable && sm.mappings.length > 0
? {
targetTable: sm.targetTable,
columnMapping: Object.fromEntries(
sm.mappings
.filter(m => m.sourceField && m.targetColumn)
.map(m => [m.sourceField, m.targetColumn])
),
}
: null;
const response: CollectedDataResponse = {
requestId: request?.requestId ?? "",
componentId: componentId,
componentType: "pop-card-list",
data: { items: selectedItems },
mapping,
};
publish(`__comp_output__${componentId}__collected_data`, response);
}
);
return unsub;
}, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys]);
// 장바구니 목록 모드: 선택 항목 이벤트 발행
useEffect(() => {
if (!componentId || !isCartListMode) return;
const selectedItems = filteredRows.filter(r => selectedKeys.has(String(r.__cart_id ?? "")));
publish(`__comp_output__${componentId}__selected_items`, selectedItems);
}, [selectedKeys, filteredRows, componentId, isCartListMode, publish]);
// 카드 영역 스타일
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 || ""}`}
>
{isCartListMode && !config?.cartListMode?.sourceScreenId ? (
<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>
) : !isCartListMode && !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>
) : (
<>
{/* 장바구니 목록 모드: 선택 바 */}
{isCartListMode && (
<div className="flex shrink-0 items-center gap-3 border-b px-3 py-2">
<input
type="checkbox"
checked={selectedKeys.size === filteredRows.length && filteredRows.length > 0}
onChange={(e) => {
if (e.target.checked) {
setSelectedKeys(new Set(filteredRows.map(r => String(r.__cart_id ?? ""))));
} else {
setSelectedKeys(new Set());
}
}}
className="h-4 w-4 rounded border-input"
/>
<span className="text-sm text-muted-foreground">
{selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"}
</span>
</div>
)}
{/* 카드 영역 (스크롤 가능) */}
<div
ref={scrollAreaRef}
className={`min-h-0 flex-1 grid ${scrollClassName}`}
style={{
...cardAreaStyle,
alignContent: "start",
justifyContent: isHorizontalMode ? "start" : "center",
}}
>
{displayCards.map((row, index) => {
const codeValue = effectiveTemplate?.header?.codeField && row[effectiveTemplate.header.codeField]
? String(row[effectiveTemplate.header.codeField])
: null;
const rowKey = codeValue ? `${codeValue}-${index}` : `card-${index}`;
return (
<Card
key={rowKey}
row={row}
template={effectiveTemplate}
scaled={scaled}
inputField={effectiveConfig?.inputField}
packageConfig={effectiveConfig?.packageConfig}
cartAction={effectiveConfig?.cartAction}
publish={publish}
router={router}
onSelect={handleCardSelect}
cart={cart}
keyColumnName={effectiveConfig?.cartAction?.keyColumn || "id"}
parentComponentId={componentId}
isCartListMode={isCartListMode}
isSelected={selectedKeys.has(String(row.__cart_id ?? ""))}
onToggleSelect={() => {
const cartId = row.__cart_id != null ? String(row.__cart_id) : "";
if (!cartId) return;
setSelectedKeys(prev => {
const next = new Set(prev);
if (next.has(cartId)) next.delete(cartId);
else next.add(cartId);
return next;
});
}}
onDeleteItem={handleDeleteItem}
onUpdateQuantity={handleUpdateQuantity}
/>
);
})}
</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">
{filteredRows.length}{externalFilters.size > 0 && filteredRows.length !== rows.length ? ` / ${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,
packageConfig,
cartAction,
publish,
router,
onSelect,
cart,
keyColumnName,
parentComponentId,
isCartListMode,
isSelected,
onToggleSelect,
onDeleteItem,
onUpdateQuantity,
}: {
row: RowData;
template?: CardTemplateConfig;
scaled: ScaledConfig;
inputField?: CardInputFieldConfig;
packageConfig?: CardPackageConfig;
cartAction?: CardCartActionConfig;
publish: (eventName: string, payload?: unknown) => void;
router: ReturnType<typeof useRouter>;
onSelect?: (row: RowData) => void;
cart: ReturnType<typeof useCartSync>;
keyColumnName?: string;
parentComponentId?: string;
isCartListMode?: boolean;
isSelected?: boolean;
onToggleSelect?: () => void;
onDeleteItem?: (cartId: string) => void;
onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void;
}) {
const header = template?.header;
const image = template?.image;
const body = template?.body;
const [inputValue, setInputValue] = useState<number>(0);
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const codeValue = header?.codeField ? row[header.codeField] : null;
const titleValue = header?.titleField ? row[header.titleField] : null;
const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : "";
const isCarted = cart.isItemInCart(rowKey);
const existingCartItem = cart.getCartItem(rowKey);
// DB에서 로드된 장바구니 품목이면 입력값 복원 (기본 모드)
useEffect(() => {
if (isCartListMode) return;
if (existingCartItem && existingCartItem._origin === "db") {
setInputValue(existingCartItem.quantity);
setPackageUnit(existingCartItem.packageUnit);
setPackageEntries(existingCartItem.packageEntries || []);
}
}, [isCartListMode, existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]);
// 장바구니 목록 모드: __cart_quantity에서 초기값 복원
useEffect(() => {
if (!isCartListMode) return;
const cartQty = Number(row.__cart_quantity) || 0;
setInputValue(cartQty);
const cartUnit = row.__cart_package_unit ? String(row.__cart_package_unit) : undefined;
setPackageUnit(cartUnit);
}, [isCartListMode, row.__cart_quantity, row.__cart_package_unit]);
const imageUrl =
image?.enabled && image?.imageColumn && row[image.imageColumn]
? String(row[image.imageColumn])
: image?.defaultImage || DEFAULT_CARD_IMAGE;
// limitColumn 우선, 하위 호환으로 maxColumn 폴백
const limitCol = inputField?.limitColumn || inputField?.maxColumn;
const effectiveMax = useMemo(() => {
if (limitCol) {
const colVal = Number(row[limitCol]);
if (!isNaN(colVal) && colVal > 0) return colVal;
}
return 999999;
}, [limitCol, row]);
// 제한 컬럼이 있으면 최대값으로 자동 초기화 (장바구니 목록 모드에서는 cart 수량 유지)
useEffect(() => {
if (isCartListMode) return;
if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) {
setInputValue(effectiveMax);
}
}, [effectiveMax, inputField?.enabled, limitCol, isCartListMode]);
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, entries?: PackageEntry[]) => {
setInputValue(value);
setPackageUnit(unit);
setPackageEntries(entries || []);
if (isCartListMode) {
onUpdateQuantity?.(String(row.__cart_id), value, unit, entries);
}
};
// 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달
const handleCartAdd = () => {
if (!rowKey) return;
const cartItem: CartItem = {
row,
quantity: inputValue,
packageUnit: packageUnit || undefined,
packageEntries: packageEntries.length > 0 ? packageEntries : undefined,
};
cart.addItem(cartItem, rowKey);
if (parentComponentId) {
publish(`__comp_output__${parentComponentId}__cart_updated`, {
count: cart.cartCount + 1,
isDirty: true,
});
}
};
// 취소: 로컬 상태에서만 제거 + 연결 시스템으로 카운트 전달
const handleCartCancel = () => {
if (!rowKey) return;
cart.removeItem(rowKey);
if (parentComponentId) {
publish(`__comp_output__${parentComponentId}__cart_updated`, {
count: Math.max(0, cart.cartCount - 1),
isDirty: true,
});
}
};
// 장바구니 목록 모드: 개별 삭제
const handleCartDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
const cartId = row.__cart_id != null ? String(row.__cart_id) : "";
if (!cartId) return;
const ok = window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?");
if (!ok) return;
try {
await dataApi.updateRecord("cart_items", cartId, { status: "cancelled" });
onDeleteItem?.(cartId);
} catch {
toast.error("삭제에 실패했습니다.");
}
};
// pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영)
const iconSize = Math.max(14, Math.round(18 * (scaled.bodyTextSize / 11)));
const cartLabel = cartAction?.label || "담기";
const cancelLabel = cartAction?.cancelLabel || "취소";
const handleCardClick = () => {
onSelect?.(row);
};
// 카드 테두리: 장바구니 목록 모드에서는 선택 상태, 기본 모드에서는 담기 상태 기준
const borderClass = isCartListMode
? isSelected
? "border-primary border-2 hover:border-primary/80"
: "hover:border-2 hover:border-blue-500"
: isCarted
? "border-emerald-500 border-2 hover:border-emerald-600"
: "hover:border-2 hover:border-blue-500";
// 카드 헤더 배경: 장바구니 목록 모드에서는 선택 상태, 기본 모드에서는 담기 상태 기준
const headerBgClass = isCartListMode
? isSelected ? "bg-primary/10 dark:bg-primary/20" : "bg-muted/30"
: isCarted ? "bg-emerald-50 dark:bg-emerald-950/30" : "bg-muted/30";
return (
<div
className={`relative cursor-pointer rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${borderClass}`}
style={cardStyle}
onClick={handleCardClick}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }}
>
{/* 헤더 영역 */}
{(codeValue !== null || titleValue !== null || isCartListMode) && (
<div className={`border-b ${headerBgClass}`} style={headerStyle}>
<div className="flex items-center gap-2">
{isCartListMode && (
<input
type="checkbox"
checked={isSelected}
onChange={(e) => { e.stopPropagation(); onToggleSelect?.(); }}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4 shrink-0 rounded border-input"
/>
)}
{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} inputValue={inputValue} />
))
) : (
<div
className="flex items-center justify-center text-muted-foreground"
style={{ fontSize: `${scaled.bodyTextSize}px` }}
>
</div>
)}
</div>
</div>
{/* 오른쪽: 수량 버튼 + 담기/취소/삭제 버튼 */}
{(inputField?.enabled || cartAction || isCartListMode) && (
<div
className="ml-2 flex shrink-0 flex-col items-stretch justify-center gap-2"
style={{ minWidth: "100px" }}
>
{/* 수량 버튼 (입력 필드 ON일 때만) */}
{inputField?.enabled && (
<button
type="button"
onClick={handleInputClick}
className="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.toLocaleString()}
</span>
<span className="text-muted-foreground block text-[12px]">
{inputField.unit || "EA"}
</span>
</button>
)}
{/* 장바구니 목록 모드: 삭제 버튼 */}
{isCartListMode && (
<button
type="button"
onClick={handleCartDelete}
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"
>
<Trash2 size={iconSize} />
<span style={{ fontSize: `${Math.max(9, scaled.bodyTextSize - 2)}px` }} className="font-semibold leading-tight">
</span>
</button>
)}
{/* 기본 모드: 담기/취소 버튼 (cartAction 존재 시) */}
{!isCartListMode && cartAction && (
<>
{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-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"
>
{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}
maxValue={effectiveMax}
packageConfig={packageConfig}
showPackageUnit={inputField.showPackageUnit}
onConfirm={handleInputConfirm}
/>
)}
</div>
);
}
// ===== 필드 행 컴포넌트 =====
function FieldRow({
field,
row,
scaled,
inputValue,
}: {
field: CardFieldBinding;
row: RowData;
scaled: ScaledConfig;
inputValue?: number;
}) {
const valueType = field.valueType || "column";
const displayValue = useMemo(() => {
if (valueType !== "formula") {
return formatValue(field.columnName ? row[field.columnName] : undefined);
}
// 구조화된 수식 우선
if (field.formulaLeft && field.formulaOperator) {
const rightVal = field.formulaRightType === "input"
? (inputValue ?? 0)
: Number(row[field.formulaRight || ""] ?? 0);
const leftVal = Number(row[field.formulaLeft] ?? 0);
let result: number | null = null;
switch (field.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;
return field.unit ? `${formatted.toLocaleString()} ${field.unit}` : formatted.toLocaleString();
}
return "-";
}
// 하위 호환: 레거시 formula 문자열
if (field.formula) {
const result = evaluateFormula(field.formula, row, inputValue ?? 0);
if (result !== null) {
const formatted = result.toLocaleString();
return field.unit ? `${formatted} ${field.unit}` : formatted;
}
}
return "-";
}, [valueType, field, row, inputValue]);
const isFormula = valueType === "formula";
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>
<MarqueeText
className="font-medium"
style={{ color: field.textColor || (isFormula ? "#ea580c" : "#000000") }}
>
{displayValue}
</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)) {
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) {
// 수식 평가 실패 시 null 반환
return null;
}
}