feat(pop-card-list): 3섹션 분리 + 포장 2단계 계산기 + 설정 패널 개편

- 입력 필드/포장등록/담기 버튼 독립 ON/OFF 분리
- NumberInputModal을 4단계 상태 머신으로 재작성
  (수량 -> 포장 수 -> 개당 수량 -> summary)
- 포장 단위 커스텀 지원 (기본 6종 + 디자이너 추가)
- 본문 필드에 계산식 통합 (3-드롭다운 수식 빌더)
- 입력 필드: limitColumn(동적 상한), saveTable/saveColumn(저장 대상)
- 저장 대상 테이블 선택을 TableCombobox로 교체 (검색 가능)
- 다중 정렬 지원 + 하위 호환 (sorts.map 에러 수정)
- GroupedColumnSelect 항상 테이블명 헤더 표시
- 반응형 표시 우선순위 (required/shrink/hidden) 설정
- PackageEntry/CartItem 타입 확장, CardPackageConfig 신규

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SeongHyun Kim
2026-02-25 17:03:47 +09:00
parent 8cfd4024e1
commit 7a97603106
10 changed files with 2173 additions and 1469 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { Delete } from "lucide-react";
import React, { useState, useEffect, useMemo } from "react";
import { Delete, Trash2, Plus, ArrowLeft } from "lucide-react";
import {
Dialog,
DialogPortal,
@@ -11,8 +11,14 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import {
PackageUnitModal,
PACKAGE_UNITS,
type PackageUnit,
} from "./PackageUnitModal";
import type { CardPackageConfig, PackageEntry } from "../types";
type InputStep =
| "quantity" // 기본: 직접 수량 입력 (포장 OFF)
| "package_count" // 포장: 포장 수량 (N개)
| "quantity_per_unit" // 포장: 개당 수량 (M EA)
| "summary"; // 포장: 결과 확인 + 추가/완료
interface NumberInputModalProps {
open: boolean;
@@ -22,7 +28,10 @@ interface NumberInputModalProps {
initialPackageUnit?: string;
min?: number;
maxValue?: number;
onConfirm: (value: number, packageUnit?: string) => void;
/** @deprecated packageConfig 사용 */
showPackageUnit?: boolean;
packageConfig?: CardPackageConfig;
onConfirm: (value: number, packageUnit?: string, packageEntries?: PackageEntry[]) => void;
}
export function NumberInputModal({
@@ -33,51 +42,184 @@ export function NumberInputModal({
initialPackageUnit,
min = 0,
maxValue = 999999,
showPackageUnit,
packageConfig,
onConfirm,
}: NumberInputModalProps) {
const [displayValue, setDisplayValue] = useState("");
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
const [step, setStep] = useState<InputStep>("quantity");
const [isPackageModalOpen, setIsPackageModalOpen] = useState(false);
// 포장 2단계 플로우용 상태
const [selectedUnit, setSelectedUnit] = useState<{ id: string; label: string } | null>(null);
const [packageCount, setPackageCount] = useState(0);
const [entries, setEntries] = useState<PackageEntry[]>([]);
const isPackageEnabled = packageConfig?.enabled ?? showPackageUnit ?? true;
const showSummary = packageConfig?.showSummaryMessage !== false;
const entriesTotal = useMemo(
() => entries.reduce((sum, e) => sum + e.totalQuantity, 0),
[entries]
);
const remainingQuantity = maxValue - entriesTotal;
useEffect(() => {
if (open) {
setDisplayValue(initialValue > 0 ? String(initialValue) : "");
setPackageUnit(initialPackageUnit);
setStep("quantity");
setSelectedUnit(null);
setPackageCount(0);
setEntries([]);
}
}, [open, initialValue, initialPackageUnit]);
}, [open, initialValue]);
// --- 키패드 핸들러 ---
const currentMax = step === "quantity"
? maxValue
: step === "package_count"
? 9999
: step === "quantity_per_unit"
? remainingQuantity > 0 ? remainingQuantity : maxValue
: maxValue;
const handleNumberClick = (num: string) => {
const newStr = displayValue + num;
const numericValue = parseInt(newStr, 10);
setDisplayValue(numericValue > maxValue ? String(maxValue) : newStr);
setDisplayValue(numericValue > currentMax ? String(currentMax) : newStr);
};
const handleBackspace = () =>
setDisplayValue((prev) => prev.slice(0, -1));
const handleClear = () => setDisplayValue("");
const handleMax = () => setDisplayValue(String(maxValue));
const handleMax = () => setDisplayValue(String(currentMax));
// --- 확인 버튼: step에 따라 다르게 동작 ---
const handleConfirm = () => {
const numericValue = parseInt(displayValue, 10) || 0;
const finalValue = Math.max(min, Math.min(maxValue, numericValue));
onConfirm(finalValue, packageUnit);
if (step === "quantity") {
const finalValue = Math.max(min, Math.min(maxValue, numericValue));
onConfirm(finalValue, undefined, undefined);
onOpenChange(false);
return;
}
if (step === "package_count") {
if (numericValue <= 0) return;
setPackageCount(numericValue);
setDisplayValue("");
setStep("quantity_per_unit");
return;
}
if (step === "quantity_per_unit") {
if (numericValue <= 0 || !selectedUnit) return;
const total = packageCount * numericValue;
const newEntry: PackageEntry = {
unitId: selectedUnit.id,
unitLabel: selectedUnit.label,
packageCount,
quantityPerUnit: numericValue,
totalQuantity: total,
};
setEntries((prev) => [...prev, newEntry]);
setDisplayValue("");
setStep("summary");
return;
}
};
// --- 포장 단위 선택 콜백 ---
const handlePackageUnitSelect = (unitId: string) => {
const matched = PACKAGE_UNITS.find((u) => u.value === unitId);
const matchedCustom = packageConfig?.customUnits?.find((cu) => cu.id === unitId);
const label = matched?.label ?? matchedCustom?.label ?? unitId;
setSelectedUnit({ id: unitId, label });
setDisplayValue("");
setStep("package_count");
};
// --- summary 액션 ---
const handleAddMore = () => {
setIsPackageModalOpen(true);
};
const handleRemoveEntry = (index: number) => {
setEntries((prev) => prev.filter((_, i) => i !== index));
};
const handleComplete = () => {
if (entries.length === 0) return;
const total = entries.reduce((sum, e) => sum + e.totalQuantity, 0);
const lastUnit = entries[entries.length - 1].unitId;
onConfirm(total, lastUnit, entries);
onOpenChange(false);
};
const handlePackageUnitSelect = (selected: PackageUnit) => {
setPackageUnit(selected);
const handleBack = () => {
if (step === "package_count") {
setStep("quantity");
setSelectedUnit(null);
setDisplayValue("");
} else if (step === "quantity_per_unit") {
setStep("package_count");
setDisplayValue(String(packageCount));
} else if (step === "summary") {
if (entries.length > 0) {
const last = entries[entries.length - 1];
setEntries((prev) => prev.slice(0, -1));
setSelectedUnit({ id: last.unitId, label: last.unitLabel });
setPackageCount(last.packageCount);
setDisplayValue(String(last.quantityPerUnit));
setStep("quantity_per_unit");
} else {
setStep("quantity");
setDisplayValue("");
}
}
};
const matchedUnit = packageUnit
? PACKAGE_UNITS.find((u) => u.value === packageUnit)
: null;
const packageUnitLabel = matchedUnit?.label ?? null;
const packageUnitEmoji = matchedUnit?.emoji ?? "📦";
// --- 안내 메시지 ---
const guideMessage = useMemo(() => {
switch (step) {
case "quantity":
return "수량을 입력하세요";
case "package_count":
return `${selectedUnit?.label || "포장"}을(를) 몇 개 사용하시나요?`;
case "quantity_per_unit":
return `${selectedUnit?.label || "포장"} 1개에 몇 ${unit} 넣으시나요?`;
case "summary":
return "";
default:
return "";
}
}, [step, selectedUnit, unit]);
// --- 헤더 정보 ---
const headerLabel = useMemo(() => {
if (step === "summary") {
return `등록: ${entriesTotal.toLocaleString()} ${unit} / 남은: ${remainingQuantity.toLocaleString()} ${unit}`;
}
if (entries.length > 0) {
return `남은 ${remainingQuantity.toLocaleString()} ${unit}`;
}
return `최대 ${maxValue.toLocaleString()} ${unit}`;
}, [step, entriesTotal, remainingQuantity, maxValue, unit, entries.length]);
const displayText = displayValue
? parseInt(displayValue, 10).toLocaleString()
: "";
const isBackVisible = step !== "quantity";
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -86,112 +228,199 @@ export function NumberInputModal({
<DialogPrimitive.Content
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] w-full max-w-[95vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden border shadow-lg duration-200 sm:max-w-[360px] sm:rounded-lg"
>
{/* 파란 헤더 */}
{/* 헤더 */}
<div className="flex items-center justify-between bg-blue-500 px-4 py-3">
<span className="rounded-full bg-blue-400/50 px-3 py-1 text-sm font-medium text-white">
{maxValue.toLocaleString()} {unit}
</span>
<button
type="button"
onClick={() => setIsPackageModalOpen(true)}
className="flex items-center gap-1 rounded-full bg-white/20 px-3 py-1.5 text-sm font-medium text-white hover:bg-white/30 active:bg-white/40"
>
{packageUnitEmoji} {packageUnitLabel ? `${packageUnitLabel}` : "포장등록"}
</button>
<div className="flex items-center gap-2">
{isBackVisible && (
<button
type="button"
onClick={handleBack}
className="flex items-center justify-center rounded-full bg-white/20 p-1.5 text-white hover:bg-white/30 active:bg-white/40"
>
<ArrowLeft className="h-4 w-4" />
</button>
)}
<span className="rounded-full bg-blue-400/50 px-3 py-1 text-sm font-medium text-white">
{headerLabel}
</span>
</div>
{isPackageEnabled && step === "quantity" && (
<button
type="button"
onClick={() => setIsPackageModalOpen(true)}
className="flex items-center gap-1 rounded-full bg-white/20 px-3 py-1.5 text-sm font-medium text-white hover:bg-white/30 active:bg-white/40"
>
</button>
)}
</div>
<div className="space-y-3 p-4">
{/* 숫자 표시 영역 */}
<div className="flex min-h-[72px] items-center justify-end rounded-xl border-2 border-gray-200 bg-gray-50 px-4 py-4">
{displayText ? (
<span className="text-4xl font-bold tracking-tight text-gray-900">
{displayText}
</span>
) : (
<span className="text-2xl text-gray-300">0</span>
)}
</div>
{/* summary 단계: 포장 내역 리스트 */}
{step === "summary" ? (
<div className="space-y-3">
{/* 안내 메시지 - 마지막 등록 결과 */}
{showSummary && entries.length > 0 && (
<div className="rounded-lg bg-blue-50 px-3 py-2 text-center text-sm font-medium text-blue-700">
{(() => {
const last = entries[entries.length - 1];
return `${last.packageCount}${last.unitLabel} x ${last.quantityPerUnit}${unit} = ${last.totalQuantity.toLocaleString()}${unit}`;
})()}
</div>
)}
{/* 안내 텍스트 */}
<p className="text-muted-foreground text-center text-sm">
</p>
{/* 포장 내역 리스트 */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-gray-500"> </p>
{entries.map((entry, idx) => (
<div
key={idx}
className="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
>
<span className="text-sm text-gray-700">
{entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}{unit} = {entry.totalQuantity.toLocaleString()}{unit}
</span>
<button
type="button"
onClick={() => handleRemoveEntry(idx)}
className="rounded-full p-1 text-gray-400 hover:bg-red-50 hover:text-red-500"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
{/* 키패드 4x4 */}
<div className="grid grid-cols-4 gap-2">
{/* 1행: 7 8 9 ← (주황) */}
{["7", "8", "9"].map((n) => (
<button
key={n}
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick(n)}
>
{n}
</button>
))}
<button
type="button"
className="flex h-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-600 active:bg-amber-200"
onClick={handleBackspace}
>
<Delete className="h-5 w-5" />
</button>
{/* 합계 */}
<div className="flex items-center justify-between rounded-lg bg-green-50 px-3 py-2">
<span className="text-sm font-medium text-green-700"></span>
<span className="text-lg font-bold text-green-700">
{entriesTotal.toLocaleString()} {unit}
</span>
</div>
{/* 2행: 4 5 6 C (주황) */}
{["4", "5", "6"].map((n) => (
<button
key={n}
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick(n)}
>
{n}
</button>
))}
<button
type="button"
className="h-14 rounded-2xl bg-amber-100 text-base font-bold text-amber-600 active:bg-amber-200"
onClick={handleClear}
>
C
</button>
{/* 남은 수량 */}
{remainingQuantity > 0 && (
<div className="flex items-center justify-between rounded-lg bg-amber-50 px-3 py-2">
<span className="text-sm font-medium text-amber-700"> </span>
<span className="text-sm font-bold text-amber-700">
{remainingQuantity.toLocaleString()} {unit}
</span>
</div>
)}
{/* 3행: 1 2 3 MAX (파란) */}
{["1", "2", "3"].map((n) => (
<button
key={n}
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick(n)}
>
{n}
</button>
))}
<button
type="button"
className="h-14 rounded-2xl bg-blue-100 text-sm font-bold text-blue-600 active:bg-blue-200"
onClick={handleMax}
>
MAX
</button>
{/* 액션 버튼 */}
<div className="grid grid-cols-2 gap-2">
{remainingQuantity > 0 && (
<button
type="button"
className="flex h-12 items-center justify-center gap-1.5 rounded-2xl border border-blue-200 bg-blue-50 text-sm font-bold text-blue-600 active:bg-blue-100"
onClick={handleAddMore}
>
<Plus className="h-4 w-4" />
</button>
)}
<button
type="button"
className={`flex h-12 items-center justify-center rounded-2xl bg-green-500 text-base font-bold text-white active:bg-green-600 ${remainingQuantity <= 0 ? "col-span-2" : ""}`}
onClick={handleComplete}
>
</button>
</div>
</div>
) : (
<>
{/* 숫자 표시 영역 */}
<div className="flex min-h-[72px] items-center justify-end rounded-xl border-2 border-gray-200 bg-gray-50 px-4 py-4">
{displayText ? (
<span className="text-4xl font-bold tracking-tight text-gray-900">
{displayText}
</span>
) : (
<span className="text-2xl text-gray-300">0</span>
)}
</div>
{/* 4행: 0 / 확인 (초록, 3칸) */}
<button
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick("0")}
>
0
</button>
<button
type="button"
className="col-span-3 h-14 rounded-2xl bg-green-500 text-base font-bold text-white active:bg-green-600"
onClick={handleConfirm}
>
</button>
</div>
{/* 단계별 안내 텍스트 */}
<p className="text-muted-foreground text-center text-sm">
{guideMessage}
</p>
{/* 키패드 4x4 */}
<div className="grid grid-cols-4 gap-2">
{["7", "8", "9"].map((n) => (
<button
key={n}
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick(n)}
>
{n}
</button>
))}
<button
type="button"
className="flex h-14 items-center justify-center rounded-2xl bg-amber-100 text-amber-600 active:bg-amber-200"
onClick={handleBackspace}
>
<Delete className="h-5 w-5" />
</button>
{["4", "5", "6"].map((n) => (
<button
key={n}
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick(n)}
>
{n}
</button>
))}
<button
type="button"
className="h-14 rounded-2xl bg-amber-100 text-base font-bold text-amber-600 active:bg-amber-200"
onClick={handleClear}
>
C
</button>
{["1", "2", "3"].map((n) => (
<button
key={n}
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick(n)}
>
{n}
</button>
))}
<button
type="button"
className="h-14 rounded-2xl bg-blue-100 text-sm font-bold text-blue-600 active:bg-blue-200"
onClick={handleMax}
>
MAX
</button>
<button
type="button"
className="h-14 rounded-2xl border border-gray-200 bg-gray-50 text-xl font-semibold text-gray-800 active:bg-gray-200"
onClick={() => handleNumberClick("0")}
>
0
</button>
<button
type="button"
className="col-span-3 h-14 rounded-2xl bg-green-500 text-base font-bold text-white active:bg-green-600"
onClick={handleConfirm}
>
{step === "package_count" ? "다음" : "확인"}
</button>
</div>
</>
)}
</div>
</DialogPrimitive.Content>
@@ -201,8 +430,18 @@ export function NumberInputModal({
{/* 포장 단위 선택 모달 */}
<PackageUnitModal
open={isPackageModalOpen}
onOpenChange={setIsPackageModalOpen}
onSelect={handlePackageUnitSelect}
onOpenChange={(isOpen) => {
setIsPackageModalOpen(isOpen);
if (!isOpen && step === "summary") {
// summary에서 추가 포장 모달 닫힘 -> 단위 선택 안 한 경우 유지
}
}}
onSelect={(unitId) => {
handlePackageUnitSelect(unitId);
setIsPackageModalOpen(false);
}}
enabledUnits={packageConfig?.enabledUnits}
customUnits={packageConfig?.customUnits}
/>
</>
);

View File

@@ -9,6 +9,7 @@ import {
DialogOverlay,
DialogClose,
} from "@/components/ui/dialog";
import type { CustomPackageUnit } from "../types";
export const PACKAGE_UNITS = [
{ value: "box", label: "박스", emoji: "📦" },
@@ -24,19 +25,33 @@ export type PackageUnit = (typeof PACKAGE_UNITS)[number]["value"];
interface PackageUnitModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (unit: PackageUnit) => void;
onSelect: (unit: string) => void;
enabledUnits?: string[];
customUnits?: CustomPackageUnit[];
}
export function PackageUnitModal({
open,
onOpenChange,
onSelect,
enabledUnits,
customUnits,
}: PackageUnitModalProps) {
const handleSelect = (unit: PackageUnit) => {
onSelect(unit);
const handleSelect = (unitValue: string) => {
onSelect(unitValue);
onOpenChange(false);
};
// enabledUnits가 undefined면 전체 표시, 배열이면 필터링
const filteredDefaults = enabledUnits
? PACKAGE_UNITS.filter((u) => enabledUnits.includes(u.value))
: [...PACKAGE_UNITS];
const allUnits = [
...filteredDefaults.map((u) => ({ value: u.value, label: u.label, emoji: u.emoji })),
...(customUnits || []).map((cu) => ({ value: cu.id, label: cu.label, emoji: "📦" })),
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogPortal>
@@ -45,18 +60,16 @@ export function PackageUnitModal({
<DialogPrimitive.Content
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-1100 w-full max-w-[90vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-lg border shadow-lg duration-200 sm:max-w-[380px]"
>
{/* 헤더 */}
<div className="border-b px-4 py-3 pr-12">
<h2 className="text-base font-semibold">📦 </h2>
</div>
{/* 3x2 그리드 */}
<div className="grid grid-cols-3 gap-3 p-4">
{PACKAGE_UNITS.map((unit) => (
{allUnits.map((unit) => (
<button
key={unit.value}
type="button"
onClick={() => handleSelect(unit.value as PackageUnit)}
onClick={() => handleSelect(unit.value)}
className="hover:bg-muted active:bg-muted/70 flex flex-col items-center justify-center gap-2 rounded-xl border bg-background px-3 py-5 text-sm font-medium transition-colors"
>
<span className="text-2xl">{unit.emoji}</span>
@@ -65,7 +78,12 @@ export function PackageUnitModal({
))}
</div>
{/* X 닫기 버튼 */}
{allUnits.length === 0 && (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
</div>
)}
<DialogClose className="ring-offset-background focus:ring-ring absolute top-3 right-3 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>

View File

@@ -11,8 +11,11 @@
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 {
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight,
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import type {
@@ -20,10 +23,11 @@ import type {
CardTemplateConfig,
CardFieldBinding,
CardInputFieldConfig,
CardCalculatedFieldConfig,
CardCartActionConfig,
CardPackageConfig,
CardPresetSpec,
CartItem,
PackageEntry,
} from "../types";
import {
DEFAULT_CARD_IMAGE,
@@ -33,11 +37,13 @@ import { dataApi } from "@/lib/api/data";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { NumberInputModal } from "./NumberInputModal";
// Lucide 아이콘 동적 렌더링
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 icons = LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>;
const IconComp = icons[name];
const IconComp = LUCIDE_ICON_MAP[name];
if (!IconComp) return <ShoppingCart size={size} />;
return <IconComp size={size} />;
}
@@ -157,25 +163,58 @@ export function PopCardListComponent({
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);
// 외부 필터 조건 (연결 시스템에서 수신, 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]);
// 카드 선택 시 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);
@@ -202,7 +241,8 @@ export function PopCardListComponent({
const missingImageCountRef = useRef(0);
const toastShownRef = useRef(false);
const spec: CardPresetSpec = CARD_PRESET_SPECS.large;
const cardSizeKey = config?.cardSize || "large";
const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large;
// 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열
const maxAllowedColumns = useMemo(() => {
@@ -218,65 +258,80 @@ export function PopCardListComponent({
: maxGridColumns;
const gridColumns = Math.min(autoColumns, maxGridColumns, maxAllowedColumns);
// 컨테이너 높이 기반 행 수 자동 계산 (잘림 방지)
const effectiveGridRows = useMemo(() => {
if (containerHeight <= 0) return configGridRows;
// 행 수: 설정값 그대로 사용 (containerHeight 연동 시 피드백 루프 위험)
const gridRows = 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 기준으로 동적 계산
// 카드 크기: 높이는 프리셋 고정, 너비만 컨테이너 기반 동적 계산
// (높이를 containerHeight에 연동하면 뷰어 모드의 minmax(auto) 그리드와
// ResizeObserver 사이에서 피드백 루프가 발생해 무한 성장함)
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),
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()));
};
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 allFilters = [...externalFilters.values()];
return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f)));
}, [rows, externalFilters]);
// 기본 상태에서 표시할 카드 수
const visibleCardCount = useMemo(() => {
@@ -284,7 +339,7 @@ export function PopCardListComponent({
}, [gridColumns, gridRows]);
// 더보기 버튼 표시 여부
const hasMoreCards = rows.length > visibleCardCount;
const hasMoreCards = filteredRows.length > visibleCardCount;
// 확장 상태에서 표시할 카드 수 계산
const expandedCardsPerPage = useMemo(() => {
@@ -300,19 +355,17 @@ export function PopCardListComponent({
// 현재 표시할 카드 결정
const displayCards = useMemo(() => {
if (!isExpanded) {
// 기본 상태: visibleCardCount만큼만 표시
return rows.slice(0, visibleCardCount);
return filteredRows.slice(0, visibleCardCount);
} else {
// 확장 상태: 현재 페이지의 카드들 (스크롤 가능하도록 더 많이)
const start = (currentPage - 1) * expandedCardsPerPage;
const end = start + expandedCardsPerPage;
return rows.slice(start, end);
return filteredRows.slice(start, end);
}
}, [rows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]);
}, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]);
// 총 페이지 수
const totalPages = isExpanded
? Math.ceil(rows.length / expandedCardsPerPage)
? Math.ceil(filteredRows.length / expandedCardsPerPage)
: 1;
// 페이지네이션 필요: 확장 상태에서 전체 카드가 한 페이지를 초과할 때
const needsPagination = isExpanded && totalPages > 1;
@@ -358,7 +411,12 @@ export function PopCardListComponent({
}
}, [currentPage, isExpanded]);
// 데이터 조회
// dataSource를 직렬화해서 의존성 안정화 (객체 참조 변경에 의한 불필요한 재호출 방지)
const dataSourceKey = useMemo(
() => JSON.stringify(dataSource || null),
[dataSource]
);
useEffect(() => {
if (!dataSource?.tableName) {
setLoading(false);
@@ -373,7 +431,6 @@ export function PopCardListComponent({
toastShownRef.current = false;
try {
// 필터 조건 구성
const filters: Record<string, unknown> = {};
if (dataSource.filters && dataSource.filters.length > 0) {
dataSource.filters.forEach((f) => {
@@ -383,28 +440,25 @@ export function PopCardListComponent({
});
}
// 이벤트로 수신한 company_code 필터 병합
if (eventCompanyCode) {
filters["company_code"] = eventCompanyCode;
}
// 다중 정렬: 첫 번째 기준을 서버 정렬로 전달 (하위 호환: 단일 객체도 처리)
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 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,
sortBy: sortBy || undefined,
sortOrder,
filters: Object.keys(filters).length > 0 ? filters : undefined,
});
@@ -420,7 +474,7 @@ export function PopCardListComponent({
};
fetchData();
}, [dataSource, eventCompanyCode]);
}, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps
// 이미지 URL 없는 항목 체크 및 toast 표시
useEffect(() => {
@@ -503,21 +557,27 @@ export function PopCardListComponent({
justifyContent: isHorizontalMode ? "start" : "center",
}}
>
{displayCards.map((row, index) => (
{displayCards.map((row, index) => {
const rowKey = template?.header?.codeField && row[template.header.codeField]
? String(row[template.header.codeField])
: `card-${index}`;
return (
<Card
key={index}
key={rowKey}
row={row}
template={template}
scaled={scaled}
inputField={config?.inputField}
calculatedField={config?.calculatedField}
packageConfig={config?.packageConfig}
cartAction={config?.cartAction}
publish={publish}
getSharedData={getSharedData}
setSharedData={setSharedData}
router={router}
onSelect={handleCardSelect}
/>
))}
);
})}
</div>
{/* 하단 컨트롤 영역 */}
@@ -544,7 +604,7 @@ export function PopCardListComponent({
)}
</Button>
<span className="text-xs text-muted-foreground">
{rows.length}
{filteredRows.length}{externalFilters.size > 0 && filteredRows.length !== rows.length ? ` / ${rows.length}` : ""}
</span>
</div>
@@ -589,69 +649,60 @@ function Card({
template,
scaled,
inputField,
calculatedField,
packageConfig,
cartAction,
publish,
getSharedData,
setSharedData,
router,
onSelect,
}: {
row: RowData;
template?: CardTemplateConfig;
scaled: ScaledConfig;
inputField?: CardInputFieldConfig;
calculatedField?: CardCalculatedFieldConfig;
packageConfig?: CardPackageConfig;
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>;
onSelect?: (row: RowData) => void;
}) {
const header = template?.header;
const image = template?.image;
const body = template?.body;
// 입력 필드 상태
const [inputValue, setInputValue] = useState<number>(
inputField?.defaultValue || 0
);
const [inputValue, setInputValue] = useState<number>(0);
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
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 폴백
// limitColumn 우선, 하위 호환으로 maxColumn 폴백
const limitCol = inputField?.limitColumn || inputField?.maxColumn;
const effectiveMax = useMemo(() => {
if (inputField?.maxColumn) {
const colVal = Number(row[inputField.maxColumn]);
if (limitCol) {
const colVal = Number(row[limitCol]);
if (!isNaN(colVal) && colVal > 0) return colVal;
}
return inputField?.max ?? 999999;
}, [inputField, row]);
return 999999;
}, [limitCol, row]);
// 기본값이 설정되지 않은 경우 최대값으로 자동 초기화
// 제한 컬럼이 있으면 최대값으로 자동 초기화
useEffect(() => {
if (inputField?.enabled && !inputField?.defaultValue && effectiveMax > 0 && effectiveMax < 999999) {
if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) {
setInputValue(effectiveMax);
}
}, [effectiveMax, inputField?.enabled, inputField?.defaultValue]);
}, [effectiveMax, inputField?.enabled, limitCol]);
const cardStyle: React.CSSProperties = {
height: `${scaled.cardHeight}px`,
@@ -677,9 +728,10 @@ function Card({
setIsModalOpen(true);
};
const handleInputConfirm = (value: number, unit?: string) => {
const handleInputConfirm = (value: number, unit?: string, entries?: PackageEntry[]) => {
setInputValue(value);
setPackageUnit(unit);
setPackageEntries(entries || []);
};
// 담기: sharedData에 CartItem 누적 + 이벤트 발행 + 토글
@@ -688,6 +740,7 @@ function Card({
row,
quantity: inputValue,
packageUnit: packageUnit || undefined,
packageEntries: packageEntries.length > 0 ? packageEntries : undefined,
};
const existing = getSharedData<CartItem[]>("cart_items") || [];
@@ -721,10 +774,18 @@ function Card({
const cartLabel = cartAction?.label || "담기";
const cancelLabel = cartAction?.cancelLabel || "취소";
const handleCardClick = () => {
onSelect?.(row);
};
return (
<div
className="rounded-lg border bg-card shadow-sm transition-all duration-150 hover:border-2 hover:border-blue-500 hover:shadow-md"
className="cursor-pointer rounded-lg border bg-card shadow-sm transition-all duration-150 hover:border-2 hover:border-blue-500 hover:shadow-md"
style={cardStyle}
onClick={handleCardClick}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }}
>
{/* 헤더 영역 */}
{(codeValue !== null || titleValue !== null) && (
@@ -777,7 +838,7 @@ function Card({
<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} />
<FieldRow key={field.id} field={field} row={row} scaled={scaled} inputValue={inputValue} />
))
) : (
<div
@@ -787,80 +848,67 @@ function Card({
</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 && (
{/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (각각 독립 ON/OFF) */}
{(inputField?.enabled || cartAction) && (
<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>
{/* 수량 버튼 (입력 필드 ON일 때만) */}
{inputField?.enabled && (
<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>
{/* 담기/취소 버튼 (cartAction 존재 시 항상 표시) */}
{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>
) : (
<DynamicLucideIcon name={cartAction?.iconValue} size={iconSize} />
<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>
)}
<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}
@@ -868,8 +916,9 @@ function Card({
unit={inputField.unit || "EA"}
initialValue={inputValue}
initialPackageUnit={packageUnit}
min={inputField.min || 0}
maxValue={effectiveMax}
packageConfig={packageConfig}
showPackageUnit={inputField.showPackageUnit}
onConfirm={handleInputConfirm}
/>
)}
@@ -883,14 +932,54 @@ function FieldRow({
field,
row,
scaled,
inputValue,
}: {
field: CardFieldBinding;
row: RowData;
scaled: ScaledConfig;
inputValue?: number;
}) {
const value = row[field.columnName];
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 (
@@ -898,19 +987,17 @@ function FieldRow({
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" }}
style={{ color: field.textColor || (isFormula ? "#ea580c" : "#000000") }}
>
{formatValue(value)}
{displayValue}
</MarqueeText>
</div>
);
@@ -982,7 +1069,6 @@ function evaluateFormula(
// 안전한 계산 (기본 산술 연산만 허용)
// 허용: 숫자, +, -, *, /, (, ), 공백, 소수점
if (!/^[\d\s+\-*/().]+$/.test(expression)) {
console.warn("Invalid formula expression:", expression);
return null;
}
@@ -995,7 +1081,7 @@ function evaluateFormula(
return Math.round(result * 100) / 100; // 소수점 2자리까지
} catch (error) {
console.warn("Formula evaluation error:", error);
// 수식 평가 실패 시 null 반환
return null;
}
}

View File

@@ -60,6 +60,15 @@ PopComponentRegistry.registerComponent({
configPanel: PopCardListConfigPanel,
preview: PopCardListPreviewComponent,
defaultProps: defaultConfig,
connectionMeta: {
sendable: [
{ key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" },
{ key: "cart_item_added", label: "담기 완료", type: "event", description: "카드 담기 시 해당 행 + 수량 데이터 전달" },
],
receivable: [
{ key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@@ -364,11 +364,24 @@ export interface CardColumnFilter {
// ----- 본문 필드 바인딩 (라벨-값 쌍) -----
export type FieldValueType = "column" | "formula";
export type FormulaOperator = "+" | "-" | "*" | "/";
export type FormulaRightType = "column" | "input";
export interface CardFieldBinding {
id: string;
columnName: string; // DB 컬럼명
label: string; // 표시 라벨 (예: "발주일")
textColor?: string; // 텍스트 색상 (예: "#ef4444" 빨간색)
label: string;
valueType: FieldValueType;
columnName?: string; // valueType === "column"일 때 DB 컬럼명
// 구조화된 수식 (클릭형 빌더)
formulaLeft?: string; // 왼쪽: DB 컬럼명
formulaOperator?: FormulaOperator;
formulaRightType?: FormulaRightType; // "column" 또는 "input"($input)
formulaRight?: string; // rightType === "column"일 때 DB 컬럼명
/** @deprecated 구조화 수식 필드 사용, 하위 호환용 */
formula?: string;
unit?: string;
textColor?: string;
}
// ----- 카드 헤더 설정 (코드 + 제목) -----
@@ -406,11 +419,16 @@ export interface CardTemplateConfig {
// ----- 데이터 소스 (테이블 단위) -----
export interface CardSortConfig {
column: string;
direction: "asc" | "desc";
}
export interface CardListDataSource {
tableName: string;
joins?: CardColumnJoin[];
filters?: CardColumnFilter[];
sort?: { column: string; direction: "asc" | "desc" };
sort?: CardSortConfig[];
limit?: { mode: "all" | "limited"; count?: number };
}
@@ -437,25 +455,40 @@ export const CARD_SCROLL_DIRECTION_LABELS: Record<CardScrollDirection, string> =
export interface CardInputFieldConfig {
enabled: boolean;
columnName?: string; // 입력값이 저장될 컬럼
label?: string; // 표시 라벨 (예: "발주 수량")
unit?: string; // 단위 (예: "EA", "개")
defaultValue?: number; // 기본값
min?: number; // 최소값
max?: number; // 최대값
maxColumn?: string; // 최대값을 DB 컬럼에서 동적으로 가져올 컬럼명 (설정 시 row[maxColumn] 우선)
step?: number; // 증감 단위
unit?: string; // 단위 (예: "EA")
limitColumn?: string; // 제한 기준 컬럼 (해당 행의 이 컬럼 값이 최대값)
saveTable?: string; // 저장 대상 테이블
saveColumn?: string; // 저장 대상 컬럼
/** @deprecated limitColumn 사용 */
maxColumn?: string;
/** @deprecated 미사용, 하위 호환용 */
label?: string;
/** @deprecated packageConfig로 이동, 하위 호환용 */
showPackageUnit?: boolean;
}
// ----- 카드 내 계산 필드 설정 -----
// ----- 포장등록 설정 -----
export interface CardCalculatedFieldConfig {
enabled: boolean;
label?: string; // 표시 라벨 (예: "미입고")
formula: string; // 계산식 (예: "order_qty - inbound_qty")
sourceColumns: string[]; // 계산에 사용되는 컬럼들
resultColumn?: string; // 결과를 저장할 컬럼 (선택)
unit?: string; // 단위 (예: "EA")
export interface CustomPackageUnit {
id: string;
label: string; // 표시 (예: "파렛트")
}
export interface CardPackageConfig {
enabled: boolean; // 포장등록 기능 ON/OFF
enabledUnits?: string[]; // 활성화된 기본 단위 (예: ["box", "bag"]), undefined면 전체 표시
customUnits?: CustomPackageUnit[]; // 디자이너가 추가한 커스텀 단위
showSummaryMessage?: boolean; // 계산 결과 안내 메시지 표시 (기본 true)
}
// ----- 포장 내역 (2단계 계산 결과) -----
export interface PackageEntry {
unitId: string; // 포장 단위 ID (예: "box")
unitLabel: string; // 포장 단위 표시명 (예: "박스")
packageCount: number; // 포장 수량 (예: 3)
quantityPerUnit: number; // 개당 수량 (예: 80)
totalQuantity: number; // 합계 = packageCount * quantityPerUnit
}
// ----- 담기 버튼 데이터 구조 (추후 API 연동 시 이 shape 그대로 사용) -----
@@ -464,6 +497,7 @@ export interface CartItem {
row: Record<string, unknown>; // 카드 원본 행 데이터
quantity: number; // 입력 수량
packageUnit?: string; // 포장 단위 (box/bag/pack/bundle/roll/barrel)
packageEntries?: PackageEntry[]; // 포장 내역 (2단계 계산 시)
}
// ----- 담기 버튼 액션 설정 (pop-icon 스타일 + 장바구니 연동) -----
@@ -533,31 +567,39 @@ export const CARD_PRESET_SPECS: Record<CardSize, CardPresetSpec> = {
},
};
// ----- 반응형 표시 모드 -----
export type ResponsiveDisplayMode = "required" | "shrink" | "hidden";
export const RESPONSIVE_DISPLAY_LABELS: Record<ResponsiveDisplayMode, string> = {
required: "필수",
shrink: "축소",
hidden: "숨김",
};
export interface CardResponsiveConfig {
code?: ResponsiveDisplayMode;
title?: ResponsiveDisplayMode;
image?: ResponsiveDisplayMode;
fields?: Record<string, ResponsiveDisplayMode>;
}
// ----- pop-card-list 전체 설정 -----
export interface PopCardListConfig {
// 데이터 소스 (테이블 단위)
dataSource: CardListDataSource;
// 카드 템플릿 (헤더 + 이미지 + 본문)
cardTemplate: CardTemplateConfig;
// 스크롤 방향
scrollDirection: CardScrollDirection;
cardsPerRow?: number; // deprecated, gridColumns 사용
cardSize: CardSize; // 프리셋 크기 (small/medium/large)
cardSize: CardSize;
// 그리드 배치 설정 (가로 x 세로)
gridColumns?: number; // 가로 카드 수 (기본값: 3)
gridRows?: number; // 세로 카드 수 (기본값: 2)
gridColumns?: number;
gridRows?: number;
// 확장 설정 (더보기 클릭 시 그리드 rowSpan 변경)
// expandedRowSpan 제거됨 - 항상 원래 크기의 2배로 확장
// 반응형 표시 설정
responsiveDisplay?: CardResponsiveConfig;
// 입력 필드 설정 (수량 입력 등)
inputField?: CardInputFieldConfig;
// 계산 필드 설정 (미입고 등 자동 계산)
calculatedField?: CardCalculatedFieldConfig;
// 담기 버튼 액션 설정 (pop-icon 스타일)
packageConfig?: CardPackageConfig;
cartAction?: CardCartActionConfig;
}