1002 lines
33 KiB
TypeScript
1002 lines
33 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 } 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;
|
|
}
|
|
}
|