feat(pop): pop-card-list-v2 슬롯 기반 카드 컴포넌트 신규 + 타임라인 범용화 + 액션 인라인 설정
CSS Grid 기반 슬롯 구조의 pop-card-list-v2 컴포넌트를 추가한다. 기존 pop-card-list의 데이터 로딩/필터링/장바구니 로직을 재활용하되, 카드 내부는 12종 셀 타입(text/field/image/badge/button/number-input/ cart-button/package-summary/status-badge/timeline/action-buttons/ footer-status)의 조합으로 자유롭게 구성할 수 있다. [신규 컴포넌트: pop-card-list-v2] - PopCardListV2Component: 런타임 렌더링 (데이터 조회 + CSS Grid 카드) - PopCardListV2Config: 3탭 설정 패널 (데이터/카드 디자인/동작) - PopCardListV2Preview: 디자이너 미리보기 - cell-renderers: 셀 타입별 독립 렌더러 12종 - migrate: v1 -> v2 설정 마이그레이션 함수 - index: PopComponentRegistry 자동 등록 [타임라인 데이터 소스 범용화] - TimelineDataSource 인터페이스로 공정 테이블/FK/컬럼/상태값 매핑 설정 - 하드코딩(work_orders+work_order_process) 제거 -> 설정 기반 동적 조회 - injectProcessFlow: 설정 기반 공정 데이터 조회 + __processFlow__ 가상 컬럼 주입 - 상태값 정규화(DB값 -> waiting/accepted/in_progress/completed) [액션 버튼 인라인 설정] - actionRules 내 updates 배열로 동작 정의 (별도 DB 테이블 불필요) - execute-action API 재활용 (targetTable/column/valueType) - 백엔드 __CURRENT_USER__/__CURRENT_TIME__ 특수값 치환 [디자이너 통합] - PopComponentType에 "pop-card-list-v2" 추가 - ComponentEditorPanel/ComponentPalette/PopRenderer 등록 - PopDesigner loadLayout: components 존재 확인 null 체크 추가 [기타] - .gitignore: .gradle/ 추가
This commit is contained in:
parent
712f81f6cb
commit
599b5a4426
|
|
@ -31,6 +31,10 @@ dist/
|
||||||
build/
|
build/
|
||||||
build/Release
|
build/Release
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
**/backend/.gradle/
|
||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
.npm
|
.npm
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
|
||||||
|
|
@ -353,7 +353,14 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
if (valSource === "linked") {
|
if (valSource === "linked") {
|
||||||
value = item[task.sourceField ?? ""] ?? null;
|
value = item[task.sourceField ?? ""] ?? null;
|
||||||
} else {
|
} else {
|
||||||
value = task.fixedValue ?? "";
|
const raw = task.fixedValue ?? "";
|
||||||
|
if (raw === "__CURRENT_USER__") {
|
||||||
|
value = userId;
|
||||||
|
} else if (raw === "__CURRENT_TIME__") {
|
||||||
|
value = new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
value = raw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let setSql: string;
|
let setSql: string;
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ export default function PopDesigner({
|
||||||
try {
|
try {
|
||||||
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||||
|
|
||||||
if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) {
|
if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) {
|
||||||
// v5 레이아웃 로드
|
// v5 레이아웃 로드
|
||||||
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
|
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
|
||||||
if (!loadedLayout.settings.gapPreset) {
|
if (!loadedLayout.settings.gapPreset) {
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ const COMPONENT_TYPE_LABELS: Record<string, string> = {
|
||||||
"pop-icon": "아이콘",
|
"pop-icon": "아이콘",
|
||||||
"pop-dashboard": "대시보드",
|
"pop-dashboard": "대시보드",
|
||||||
"pop-card-list": "카드 목록",
|
"pop-card-list": "카드 목록",
|
||||||
|
"pop-card-list-v2": "카드 목록 V2",
|
||||||
"pop-field": "필드",
|
"pop-field": "필드",
|
||||||
"pop-button": "버튼",
|
"pop-button": "버튼",
|
||||||
"pop-string-list": "리스트 목록",
|
"pop-string-list": "리스트 목록",
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
description: "테이블 데이터를 카드 형태로 표시",
|
description: "테이블 데이터를 카드 형태로 표시",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "pop-card-list-v2",
|
||||||
|
label: "카드 목록 V2",
|
||||||
|
icon: LayoutGrid,
|
||||||
|
description: "슬롯 기반 카드 (CSS Grid + 셀 타입별 렌더링)",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "pop-button",
|
type: "pop-button",
|
||||||
label: "버튼",
|
label: "버튼",
|
||||||
|
|
|
||||||
|
|
@ -72,10 +72,12 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||||
"pop-icon": "아이콘",
|
"pop-icon": "아이콘",
|
||||||
"pop-dashboard": "대시보드",
|
"pop-dashboard": "대시보드",
|
||||||
"pop-card-list": "카드 목록",
|
"pop-card-list": "카드 목록",
|
||||||
|
"pop-card-list-v2": "카드 목록 V2",
|
||||||
"pop-button": "버튼",
|
"pop-button": "버튼",
|
||||||
"pop-string-list": "리스트 목록",
|
"pop-string-list": "리스트 목록",
|
||||||
"pop-search": "검색",
|
"pop-search": "검색",
|
||||||
"pop-field": "입력",
|
"pop-field": "입력",
|
||||||
|
"pop-scanner": "스캐너",
|
||||||
"pop-profile": "프로필",
|
"pop-profile": "프로필",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -555,7 +557,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
|
||||||
if (ActualComp) {
|
if (ActualComp) {
|
||||||
// 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
|
// 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용
|
||||||
// CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용
|
// CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용
|
||||||
const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list";
|
const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list" || component.type === "pop-card-list-v2";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
/**
|
/**
|
||||||
* POP 컴포넌트 타입
|
* POP 컴포넌트 타입
|
||||||
*/
|
*/
|
||||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field" | "pop-scanner" | "pop-profile";
|
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field" | "pop-scanner" | "pop-profile";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 흐름 정의
|
* 데이터 흐름 정의
|
||||||
|
|
@ -358,6 +358,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
|
||||||
"pop-icon": { colSpan: 1, rowSpan: 2 },
|
"pop-icon": { colSpan: 1, rowSpan: 2 },
|
||||||
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
|
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
|
||||||
"pop-card-list": { colSpan: 4, rowSpan: 3 },
|
"pop-card-list": { colSpan: 4, rowSpan: 3 },
|
||||||
|
"pop-card-list-v2": { colSpan: 4, rowSpan: 3 },
|
||||||
"pop-button": { colSpan: 2, rowSpan: 1 },
|
"pop-button": { colSpan: 2, rowSpan: 1 },
|
||||||
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
"pop-string-list": { colSpan: 4, rowSpan: 3 },
|
||||||
"pop-search": { colSpan: 2, rowSpan: 1 },
|
"pop-search": { colSpan: 2, rowSpan: 1 },
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import "./pop-text";
|
||||||
import "./pop-icon";
|
import "./pop-icon";
|
||||||
import "./pop-dashboard";
|
import "./pop-dashboard";
|
||||||
import "./pop-card-list";
|
import "./pop-card-list";
|
||||||
|
import "./pop-card-list-v2";
|
||||||
|
|
||||||
import "./pop-button";
|
import "./pop-button";
|
||||||
import "./pop-string-list";
|
import "./pop-string-list";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,907 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pop-card-list-v2 런타임 컴포넌트
|
||||||
|
*
|
||||||
|
* pop-card-list의 데이터 로딩/필터링/페이징/장바구니 로직을 재활용하되,
|
||||||
|
* 카드 내부 렌더링은 CSS Grid + 셀 타입별 렌더러(cell-renderers.tsx)로 대체.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type {
|
||||||
|
PopCardListV2Config,
|
||||||
|
CardGridConfigV2,
|
||||||
|
CardCellDefinitionV2,
|
||||||
|
CardInputFieldConfig,
|
||||||
|
CardCartActionConfig,
|
||||||
|
CardPackageConfig,
|
||||||
|
CardPresetSpec,
|
||||||
|
CartItem,
|
||||||
|
PackageEntry,
|
||||||
|
CollectDataRequest,
|
||||||
|
CollectedDataResponse,
|
||||||
|
TimelineProcessStep,
|
||||||
|
TimelineDataSource,
|
||||||
|
ActionButtonUpdate,
|
||||||
|
} from "../types";
|
||||||
|
import { CARD_PRESET_SPECS, DEFAULT_CARD_IMAGE } from "../types";
|
||||||
|
import { dataApi } from "@/lib/api/data";
|
||||||
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||||
|
import { useCartSync } from "@/hooks/pop/useCartSync";
|
||||||
|
import { NumberInputModal } from "../pop-card-list/NumberInputModal";
|
||||||
|
import { renderCellV2 } from "./cell-renderers";
|
||||||
|
|
||||||
|
type RowData = Record<string, unknown>;
|
||||||
|
|
||||||
|
// cart_items 행 파싱 (pop-card-list에서 그대로 차용)
|
||||||
|
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 PopCardListV2ComponentProps {
|
||||||
|
config?: PopCardListV2Config;
|
||||||
|
className?: string;
|
||||||
|
screenId?: string;
|
||||||
|
componentId?: string;
|
||||||
|
currentRowSpan?: number;
|
||||||
|
currentColSpan?: number;
|
||||||
|
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopCardListV2Component({
|
||||||
|
config,
|
||||||
|
className,
|
||||||
|
screenId,
|
||||||
|
componentId,
|
||||||
|
currentRowSpan,
|
||||||
|
currentColSpan,
|
||||||
|
onRequestResize,
|
||||||
|
}: PopCardListV2ComponentProps) {
|
||||||
|
const { subscribe, publish } = usePopEvent(screenId || "default");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isCartListMode = config?.cartListMode?.enabled === true;
|
||||||
|
const [inheritedConfig, setInheritedConfig] = useState<Partial<PopCardListV2Config> | null>(null);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const effectiveConfig = useMemo<PopCardListV2Config | undefined>(() => {
|
||||||
|
if (!isCartListMode || !inheritedConfig) return config;
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
...inheritedConfig,
|
||||||
|
cartListMode: config?.cartListMode,
|
||||||
|
dataSource: config?.dataSource,
|
||||||
|
} as PopCardListV2Config;
|
||||||
|
}, [config, inheritedConfig, isCartListMode]);
|
||||||
|
|
||||||
|
const isHorizontalMode = (effectiveConfig?.scrollDirection || "vertical") === "horizontal";
|
||||||
|
const maxGridColumns = effectiveConfig?.gridColumns || 2;
|
||||||
|
const configGridRows = effectiveConfig?.gridRows || 3;
|
||||||
|
const dataSource = effectiveConfig?.dataSource;
|
||||||
|
const cardGrid = effectiveConfig?.cardGrid;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 외부 필터
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const cartRef = useRef(cart);
|
||||||
|
cartRef.current = cart;
|
||||||
|
|
||||||
|
// 저장 요청 수신
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 초기 장바구니 상태 전달
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cardSizeKey = effectiveConfig?.cardSize || "large";
|
||||||
|
const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large;
|
||||||
|
|
||||||
|
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));
|
||||||
|
const gridRows = configGridRows;
|
||||||
|
|
||||||
|
// 외부 필터
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
if (externalFilters.size === 0) return rows;
|
||||||
|
const allFilters = [...externalFilters.values()];
|
||||||
|
return rows.filter((row) =>
|
||||||
|
allFilters.every((filter) => {
|
||||||
|
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";
|
||||||
|
return columns.some((col) => {
|
||||||
|
const cellValue = String(row[col] ?? "").toLowerCase();
|
||||||
|
switch (mode) {
|
||||||
|
case "equals": return cellValue === searchValue;
|
||||||
|
case "starts_with": return cellValue.startsWith(searchValue);
|
||||||
|
default: return cellValue.includes(searchValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [rows, externalFilters]);
|
||||||
|
|
||||||
|
const overflowCfg = effectiveConfig?.overflow;
|
||||||
|
const baseVisibleCount = overflowCfg?.visibleCount ?? gridColumns * gridRows;
|
||||||
|
const visibleCardCount = useMemo(() => Math.max(1, baseVisibleCount), [baseVisibleCount]);
|
||||||
|
const hasMoreCards = filteredRows.length > visibleCardCount;
|
||||||
|
const expandedCardsPerPage = useMemo(() => {
|
||||||
|
if (overflowCfg?.mode === "pagination" && overflowCfg.pageSize) return overflowCfg.pageSize;
|
||||||
|
if (overflowCfg?.mode === "loadMore" && overflowCfg.loadMoreCount) return overflowCfg.loadMoreCount + visibleCardCount;
|
||||||
|
return Math.max(1, visibleCardCount * 2 + gridColumns);
|
||||||
|
}, [visibleCardCount, gridColumns, overflowCfg]);
|
||||||
|
|
||||||
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const displayCards = useMemo(() => {
|
||||||
|
if (!isExpanded) return filteredRows.slice(0, visibleCardCount);
|
||||||
|
const start = (currentPage - 1) * expandedCardsPerPage;
|
||||||
|
return filteredRows.slice(start, start + expandedCardsPerPage);
|
||||||
|
}, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]);
|
||||||
|
|
||||||
|
const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1;
|
||||||
|
const needsPagination = isExpanded && totalPages > 1;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 데이터 조회
|
||||||
|
const dataSourceKey = useMemo(() => JSON.stringify(dataSource || null), [dataSource]);
|
||||||
|
const cartListModeKey = useMemo(() => JSON.stringify(config?.cartListMode || null), [config?.cartListMode]);
|
||||||
|
|
||||||
|
// 셀 설정에서 timelineSource 탐색 (timeline/status-badge/action-buttons 중 하나에 설정됨)
|
||||||
|
const timelineSource = useMemo<TimelineDataSource | undefined>(() => {
|
||||||
|
const cells = cardGrid?.cells || [];
|
||||||
|
for (const c of cells) {
|
||||||
|
if ((c.type === "timeline" || c.type === "status-badge" || c.type === "action-buttons") && c.timelineSource?.processTable) {
|
||||||
|
return c.timelineSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [cardGrid?.cells]);
|
||||||
|
|
||||||
|
// 공정 데이터 조회 + __processFlow__ 가상 컬럼 주입
|
||||||
|
const injectProcessFlow = useCallback(async (
|
||||||
|
fetchedRows: RowData[],
|
||||||
|
src: TimelineDataSource,
|
||||||
|
): Promise<RowData[]> => {
|
||||||
|
if (fetchedRows.length === 0) return fetchedRows;
|
||||||
|
const rowIds = fetchedRows.map((r) => String(r.id)).filter(Boolean);
|
||||||
|
if (rowIds.length === 0) return fetchedRows;
|
||||||
|
|
||||||
|
const sv = src.statusValues || {};
|
||||||
|
const waitingVal = sv.waiting || "waiting";
|
||||||
|
const acceptedVal = sv.accepted || "accepted";
|
||||||
|
const inProgressVal = sv.inProgress || "in_progress";
|
||||||
|
const completedVal = sv.completed || "completed";
|
||||||
|
|
||||||
|
const processResult = await dataApi.getTableData(src.processTable, {
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
sortBy: src.seqColumn || "seq_no",
|
||||||
|
sortOrder: "asc",
|
||||||
|
});
|
||||||
|
const allProcesses = processResult.data || [];
|
||||||
|
|
||||||
|
const processMap = new Map<string, TimelineProcessStep[]>();
|
||||||
|
for (const p of allProcesses) {
|
||||||
|
const fkValue = String(p[src.foreignKey] || "");
|
||||||
|
if (!fkValue || !rowIds.includes(fkValue)) continue;
|
||||||
|
if (!processMap.has(fkValue)) processMap.set(fkValue, []);
|
||||||
|
|
||||||
|
const rawStatus = String(p[src.statusColumn] || waitingVal);
|
||||||
|
let normalizedStatus = rawStatus;
|
||||||
|
if (rawStatus === waitingVal) normalizedStatus = "waiting";
|
||||||
|
else if (rawStatus === acceptedVal) normalizedStatus = "accepted";
|
||||||
|
else if (rawStatus === inProgressVal) normalizedStatus = "in_progress";
|
||||||
|
else if (rawStatus === completedVal) normalizedStatus = "completed";
|
||||||
|
|
||||||
|
processMap.get(fkValue)!.push({
|
||||||
|
seqNo: parseInt(String(p[src.seqColumn] || "0"), 10),
|
||||||
|
processName: String(p[src.nameColumn] || ""),
|
||||||
|
status: normalizedStatus,
|
||||||
|
isCurrent: normalizedStatus === "in_progress" || normalizedStatus === "accepted",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCurrent 보정: in_progress가 없으면 첫 waiting을 current로
|
||||||
|
for (const [, steps] of processMap) {
|
||||||
|
steps.sort((a, b) => a.seqNo - b.seqNo);
|
||||||
|
const hasInProgress = steps.some((s) => s.status === "in_progress");
|
||||||
|
if (!hasInProgress) {
|
||||||
|
const firstWaiting = steps.find((s) => s.status === "waiting");
|
||||||
|
if (firstWaiting) {
|
||||||
|
steps.forEach((s) => { s.isCurrent = false; });
|
||||||
|
firstWaiting.isCurrent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchedRows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
__processFlow__: processMap.get(String(row.id)) || [],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!dataSource?.tableName) { setLoading(false); setRows([]); return; }
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const filters: Record<string, unknown> = {};
|
||||||
|
if (dataSource.filters?.length) {
|
||||||
|
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[0];
|
||||||
|
const size = dataSource.limit?.mode === "limited" && dataSource.limit?.count ? dataSource.limit.count : 100;
|
||||||
|
|
||||||
|
const result = await dataApi.getTableData(dataSource.tableName, {
|
||||||
|
page: 1,
|
||||||
|
size,
|
||||||
|
sortBy: primarySort?.column || undefined,
|
||||||
|
sortOrder: primarySort?.direction,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// timelineSource 설정이 있으면 공정 데이터 조회하여 __processFlow__ 주입
|
||||||
|
if (timelineSource) {
|
||||||
|
try {
|
||||||
|
fetchedRows = await injectProcessFlow(fetchedRows, timelineSource);
|
||||||
|
} catch {
|
||||||
|
// 공정 데이터 조회 실패 시 무시 (메인 데이터는 정상 표시)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setRows(fetchedRows);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "데이터 조회 실패");
|
||||||
|
setRows([]);
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, [dataSource, timelineSource, injectProcessFlow]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCartListMode) {
|
||||||
|
const cartListMode = config!.cartListMode!;
|
||||||
|
if (!cartListMode.sourceScreenId) { setLoading(false); setRows([]); return; }
|
||||||
|
|
||||||
|
const fetchCartData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
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-v2" || 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 });
|
||||||
|
setRows((result.data || []).map(parseCartRow));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "장바구니 데이터 조회 실패");
|
||||||
|
setRows([]);
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
fetchCartData();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [dataSourceKey, isCartListMode, cartListModeKey, fetchData]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 장바구니 목록 모드 콜백
|
||||||
|
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; });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 데이터 수집
|
||||||
|
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;
|
||||||
|
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 cartChanges = cart.isDirty ? cart.getChanges() : undefined;
|
||||||
|
const response: CollectedDataResponse = {
|
||||||
|
requestId: request?.requestId ?? "",
|
||||||
|
componentId: componentId,
|
||||||
|
componentType: "pop-card-list-v2",
|
||||||
|
data: { items: selectedItems, cartChanges } as any,
|
||||||
|
mapping,
|
||||||
|
};
|
||||||
|
publish(`__comp_output__${componentId}__collected_data`, response);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return unsub;
|
||||||
|
}, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys, cart]);
|
||||||
|
|
||||||
|
// 선택 항목 이벤트
|
||||||
|
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 cardGap = effectiveConfig?.cardGap ?? spec.gap;
|
||||||
|
const cardMinHeight = spec.height;
|
||||||
|
const cardAreaStyle: React.CSSProperties = {
|
||||||
|
gap: `${cardGap}px`,
|
||||||
|
...(isHorizontalMode
|
||||||
|
? {
|
||||||
|
gridTemplateRows: `repeat(${gridRows}, minmax(${cardMinHeight}px, auto))`,
|
||||||
|
gridAutoFlow: "column",
|
||||||
|
gridAutoColumns: `${Math.round(cardMinHeight * 1.6)}px`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
||||||
|
gridAutoRows: `minmax(${cardMinHeight}px, auto)`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => (
|
||||||
|
<CardV2
|
||||||
|
key={`card-${index}`}
|
||||||
|
row={row}
|
||||||
|
cardGrid={cardGrid}
|
||||||
|
spec={spec}
|
||||||
|
config={effectiveConfig}
|
||||||
|
onSelect={handleCardSelect}
|
||||||
|
cart={cart}
|
||||||
|
publish={publish}
|
||||||
|
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}
|
||||||
|
onRefresh={fetchData}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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={() => setCurrentPage((p) => Math.max(1, p - 1))} 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={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages} className="h-9 w-9 p-0">
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 카드 V2 =====
|
||||||
|
|
||||||
|
interface CardV2Props {
|
||||||
|
row: RowData;
|
||||||
|
cardGrid?: CardGridConfigV2;
|
||||||
|
spec: CardPresetSpec;
|
||||||
|
config?: PopCardListV2Config;
|
||||||
|
onSelect?: (row: RowData) => void;
|
||||||
|
cart: ReturnType<typeof useCartSync>;
|
||||||
|
publish: (eventName: string, payload?: unknown) => void;
|
||||||
|
parentComponentId?: string;
|
||||||
|
isCartListMode?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onToggleSelect?: () => void;
|
||||||
|
onDeleteItem?: (cartId: string) => void;
|
||||||
|
onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardV2({
|
||||||
|
row, cardGrid, spec, config, onSelect, cart, publish,
|
||||||
|
parentComponentId, isCartListMode, isSelected, onToggleSelect,
|
||||||
|
onDeleteItem, onUpdateQuantity, onRefresh,
|
||||||
|
}: CardV2Props) {
|
||||||
|
const inputField = config?.inputField;
|
||||||
|
const cartAction = config?.cartAction;
|
||||||
|
const packageConfig = config?.packageConfig;
|
||||||
|
const keyColumnName = cartAction?.keyColumn || "id";
|
||||||
|
|
||||||
|
const [inputValue, setInputValue] = useState<number>(0);
|
||||||
|
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
|
||||||
|
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 장바구니 목록 모드 초기값
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCartListMode) return;
|
||||||
|
setInputValue(Number(row.__cart_quantity) || 0);
|
||||||
|
setPackageUnit(row.__cart_package_unit ? String(row.__cart_package_unit) : undefined);
|
||||||
|
}, [isCartListMode, row.__cart_quantity, row.__cart_package_unit]);
|
||||||
|
|
||||||
|
// 제한 컬럼 자동 초기화
|
||||||
|
const limitCol = inputField?.limitColumn || inputField?.maxColumn;
|
||||||
|
const effectiveMax = useMemo(() => {
|
||||||
|
if (limitCol) { const v = Number(row[limitCol]); if (!isNaN(v) && v > 0) return v; }
|
||||||
|
return 999999;
|
||||||
|
}, [limitCol, row]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCartListMode) return;
|
||||||
|
if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) {
|
||||||
|
setInputValue(effectiveMax);
|
||||||
|
}
|
||||||
|
}, [effectiveMax, inputField?.enabled, limitCol, isCartListMode]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
cart.addItem({ row, quantity: inputValue, packageUnit, packageEntries: packageEntries.length > 0 ? packageEntries : undefined }, 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;
|
||||||
|
if (!window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?")) return;
|
||||||
|
try {
|
||||||
|
await dataApi.updateRecord("cart_items", cartId, { status: "cancelled" });
|
||||||
|
onDeleteItem?.(cartId);
|
||||||
|
} catch { toast.error("삭제에 실패했습니다."); }
|
||||||
|
};
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
if (!cardGrid || cardGrid.cells.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center rounded-lg border p-4 ${borderClass}`} style={{ minHeight: `${spec.height}px` }}>
|
||||||
|
<span className="text-xs text-muted-foreground">카드 레이아웃을 설정하세요</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridStyle: React.CSSProperties = {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: cardGrid.colWidths.length > 0
|
||||||
|
? cardGrid.colWidths.map((w) => `minmax(30px, ${w || "1fr"})`).join(" ")
|
||||||
|
: "1fr",
|
||||||
|
gridTemplateRows: cardGrid.rowHeights?.length
|
||||||
|
? cardGrid.rowHeights.map((h) => {
|
||||||
|
if (!h) return "minmax(24px, auto)";
|
||||||
|
if (h.endsWith("px")) return `minmax(${h}, auto)`;
|
||||||
|
const px = Math.round(parseFloat(h) * 24) || 24;
|
||||||
|
return `minmax(${px}px, auto)`;
|
||||||
|
}).join(" ")
|
||||||
|
: `repeat(${cardGrid.rows || 1}, minmax(24px, auto))`,
|
||||||
|
gap: `${cardGrid.gap || 0}px`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const justifyMap = { left: "flex-start", center: "center", right: "flex-end" } as const;
|
||||||
|
const alignItemsMap = { top: "flex-start", middle: "center", bottom: "flex-end" } as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative flex cursor-pointer flex-col rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${borderClass}`}
|
||||||
|
style={{ minHeight: `${spec.height}px` }}
|
||||||
|
onClick={() => onSelect?.(row)}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelect?.(row); }}
|
||||||
|
>
|
||||||
|
{/* 장바구니 목록 모드: 체크박스 + 삭제 */}
|
||||||
|
{isCartListMode && (
|
||||||
|
<div className="absolute right-1 top-1 z-10 flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={(e) => { e.stopPropagation(); onToggleSelect?.(); }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="h-4 w-4 rounded border-input"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={handleCartDelete} className="rounded p-0.5 hover:bg-destructive/10">
|
||||||
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CSS Grid 기반 셀 렌더링 */}
|
||||||
|
<div className="flex-1 overflow-hidden p-1" style={gridStyle}>
|
||||||
|
{cardGrid.cells.map((cell) => (
|
||||||
|
<div
|
||||||
|
key={cell.id}
|
||||||
|
className="overflow-hidden p-1"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: alignItemsMap[cell.verticalAlign || "top"],
|
||||||
|
alignItems: justifyMap[cell.align || "left"],
|
||||||
|
gridColumn: `${cell.col} / span ${cell.colSpan || 1}`,
|
||||||
|
gridRow: `${cell.row} / span ${cell.rowSpan || 1}`,
|
||||||
|
border: cardGrid.showCellBorder ? "1px solid hsl(var(--border))" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderCellV2({
|
||||||
|
cell,
|
||||||
|
row,
|
||||||
|
inputValue,
|
||||||
|
isCarted,
|
||||||
|
onInputClick: handleInputClick,
|
||||||
|
onCartAdd: handleCartAdd,
|
||||||
|
onCartCancel: handleCartCancel,
|
||||||
|
onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => {
|
||||||
|
const cfg = buttonConfig as {
|
||||||
|
updates?: ActionButtonUpdate[];
|
||||||
|
targetTable?: string;
|
||||||
|
confirmMessage?: string;
|
||||||
|
} | undefined;
|
||||||
|
|
||||||
|
if (cfg?.updates && cfg.updates.length > 0 && cfg.targetTable) {
|
||||||
|
if (cfg.confirmMessage) {
|
||||||
|
if (!window.confirm(cfg.confirmMessage)) return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rowId = actionRow.id ?? actionRow.pk;
|
||||||
|
if (!rowId) {
|
||||||
|
toast.error("대상 레코드의 ID를 찾을 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tasks = cfg.updates.map((u, idx) => ({
|
||||||
|
id: `btn-update-${idx}`,
|
||||||
|
type: "data-update" as const,
|
||||||
|
targetTable: cfg.targetTable!,
|
||||||
|
targetColumn: u.column,
|
||||||
|
operationType: "assign" as const,
|
||||||
|
valueSource: "fixed" as const,
|
||||||
|
fixedValue: u.valueType === "static" ? (u.value ?? "") :
|
||||||
|
u.valueType === "currentUser" ? "__CURRENT_USER__" :
|
||||||
|
u.valueType === "currentTime" ? "__CURRENT_TIME__" :
|
||||||
|
u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") :
|
||||||
|
(u.value ?? ""),
|
||||||
|
}));
|
||||||
|
const result = await apiClient.post("/pop/execute-action", {
|
||||||
|
tasks,
|
||||||
|
data: { items: [actionRow], fieldValues: {} },
|
||||||
|
mappings: {},
|
||||||
|
});
|
||||||
|
if (result.data?.success) {
|
||||||
|
toast.success(result.data.message || "처리 완료");
|
||||||
|
onRefresh?.();
|
||||||
|
} else {
|
||||||
|
toast.error(result.data?.message || "처리 실패");
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (parentComponentId) {
|
||||||
|
publish(`__comp_output__${parentComponentId}__action`, {
|
||||||
|
taskPreset,
|
||||||
|
row: actionRow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
packageEntries,
|
||||||
|
inputUnit: inputField?.unit,
|
||||||
|
})}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,104 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pop-card-list-v2 디자인 모드 미리보기
|
||||||
|
*
|
||||||
|
* 디자이너 캔버스에서 표시되는 미리보기.
|
||||||
|
* CSS Grid 기반 셀 배치를 시각적으로 보여준다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { LayoutGrid, Package } from "lucide-react";
|
||||||
|
import type { PopCardListV2Config } from "../types";
|
||||||
|
import { CARD_SCROLL_DIRECTION_LABELS, CARD_SIZE_LABELS } from "../types";
|
||||||
|
|
||||||
|
interface PopCardListV2PreviewProps {
|
||||||
|
config?: PopCardListV2Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PopCardListV2PreviewComponent({ config }: PopCardListV2PreviewProps) {
|
||||||
|
const scrollDirection = config?.scrollDirection || "vertical";
|
||||||
|
const cardSize = config?.cardSize || "medium";
|
||||||
|
const dataSource = config?.dataSource;
|
||||||
|
const cardGrid = config?.cardGrid;
|
||||||
|
const hasTable = !!dataSource?.tableName;
|
||||||
|
const cellCount = cardGrid?.cells?.length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col bg-muted/30 p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
<span className="text-xs font-medium">카드 목록 V2</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[9px] text-primary">
|
||||||
|
{CARD_SCROLL_DIRECTION_LABELS[scrollDirection]}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-secondary px-1.5 py-0.5 text-[9px] text-secondary-foreground">
|
||||||
|
{CARD_SIZE_LABELS[cardSize]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasTable ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Package className="mx-auto mb-2 h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p className="text-xs text-muted-foreground">데이터 소스를 설정하세요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 text-center">
|
||||||
|
<span className="rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{dataSource!.tableName}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1 text-[10px] text-muted-foreground/60">
|
||||||
|
({cellCount}셀)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
|
{[0, 1].map((cardIdx) => (
|
||||||
|
<div key={cardIdx} className="rounded-md border bg-card p-2">
|
||||||
|
{cellCount === 0 ? (
|
||||||
|
<div className="flex h-12 items-center justify-center">
|
||||||
|
<span className="text-[10px] text-muted-foreground">셀을 추가하세요</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: cardGrid!.colWidths?.length
|
||||||
|
? cardGrid!.colWidths.map((w) => w || "1fr").join(" ")
|
||||||
|
: `repeat(${cardGrid!.cols || 1}, 1fr)`,
|
||||||
|
gridTemplateRows: `repeat(${cardGrid!.rows || 1}, minmax(16px, auto))`,
|
||||||
|
gap: "2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cardGrid!.cells.map((cell) => (
|
||||||
|
<div
|
||||||
|
key={cell.id}
|
||||||
|
className="rounded border border-dashed border-border/50 bg-muted/20 px-1 py-0.5"
|
||||||
|
style={{
|
||||||
|
gridColumn: `${cell.col} / span ${cell.colSpan || 1}`,
|
||||||
|
gridRow: `${cell.row} / span ${cell.rowSpan || 1}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-[8px] text-muted-foreground">
|
||||||
|
{cell.type}
|
||||||
|
{cell.columnName ? `: ${cell.columnName}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,732 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pop-card-list-v2 셀 타입별 렌더러
|
||||||
|
*
|
||||||
|
* 각 셀 타입은 독립 함수로 구현되어 CardV2Grid에서 type별 dispatch로 호출된다.
|
||||||
|
* 기존 pop-card-list의 카드 내부 렌더링과 pop-string-list의 CardModeView 패턴을 결합.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
|
||||||
|
Loader2, Play, CheckCircle2, CircleDot, Clock,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep } from "../types";
|
||||||
|
import { DEFAULT_CARD_IMAGE } from "../types";
|
||||||
|
import type { ButtonVariant } from "../pop-button";
|
||||||
|
|
||||||
|
type RowData = Record<string, unknown>;
|
||||||
|
|
||||||
|
// ===== 공통 유틸 =====
|
||||||
|
|
||||||
|
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
||||||
|
ShoppingCart, Package, Truck, Box, Archive, Heart, Star,
|
||||||
|
};
|
||||||
|
|
||||||
|
function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) {
|
||||||
|
if (!name) return <ShoppingCart size={size} />;
|
||||||
|
const IconComp = LUCIDE_ICON_MAP[name];
|
||||||
|
if (!IconComp) return <ShoppingCart size={size} />;
|
||||||
|
return <IconComp size={size} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "-";
|
||||||
|
if (typeof value === "number") return value.toLocaleString();
|
||||||
|
if (typeof value === "boolean") return value ? "예" : "아니오";
|
||||||
|
if (value instanceof Date) return value.toLocaleDateString();
|
||||||
|
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FONT_SIZE_MAP = { xs: "10px", sm: "11px", md: "12px", lg: "14px" } as const;
|
||||||
|
const FONT_WEIGHT_MAP = { normal: 400, medium: 500, bold: 700 } as const;
|
||||||
|
|
||||||
|
// ===== 셀 렌더러 Props =====
|
||||||
|
|
||||||
|
export interface CellRendererProps {
|
||||||
|
cell: CardCellDefinitionV2;
|
||||||
|
row: RowData;
|
||||||
|
inputValue?: number;
|
||||||
|
isCarted?: boolean;
|
||||||
|
isButtonLoading?: boolean;
|
||||||
|
onInputClick?: (e: React.MouseEvent) => void;
|
||||||
|
onCartAdd?: () => void;
|
||||||
|
onCartCancel?: () => void;
|
||||||
|
onButtonClick?: (cell: CardCellDefinitionV2, row: RowData) => void;
|
||||||
|
onActionButtonClick?: (taskPreset: string, row: RowData, buttonConfig?: Record<string, unknown>) => void;
|
||||||
|
packageEntries?: PackageEntry[];
|
||||||
|
inputUnit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 메인 디스패치 =====
|
||||||
|
|
||||||
|
export function renderCellV2(props: CellRendererProps): React.ReactNode {
|
||||||
|
switch (props.cell.type) {
|
||||||
|
case "text":
|
||||||
|
return <TextCell {...props} />;
|
||||||
|
case "field":
|
||||||
|
return <FieldCell {...props} />;
|
||||||
|
case "image":
|
||||||
|
return <ImageCell {...props} />;
|
||||||
|
case "badge":
|
||||||
|
return <BadgeCell {...props} />;
|
||||||
|
case "button":
|
||||||
|
return <ButtonCell {...props} />;
|
||||||
|
case "number-input":
|
||||||
|
return <NumberInputCell {...props} />;
|
||||||
|
case "cart-button":
|
||||||
|
return <CartButtonCell {...props} />;
|
||||||
|
case "package-summary":
|
||||||
|
return <PackageSummaryCell {...props} />;
|
||||||
|
case "status-badge":
|
||||||
|
return <StatusBadgeCell {...props} />;
|
||||||
|
case "timeline":
|
||||||
|
return <TimelineCell {...props} />;
|
||||||
|
case "action-buttons":
|
||||||
|
return <ActionButtonsCell {...props} />;
|
||||||
|
case "footer-status":
|
||||||
|
return <FooterStatusCell {...props} />;
|
||||||
|
default:
|
||||||
|
return <span className="text-[10px] text-muted-foreground">알 수 없는 셀 타입</span>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 1. text =====
|
||||||
|
|
||||||
|
function TextCell({ cell, row }: CellRendererProps) {
|
||||||
|
const value = cell.columnName ? row[cell.columnName] : "";
|
||||||
|
const fs = FONT_SIZE_MAP[cell.fontSize || "md"];
|
||||||
|
const fw = FONT_WEIGHT_MAP[cell.fontWeight || "normal"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="truncate"
|
||||||
|
style={{ fontSize: fs, fontWeight: fw, color: cell.textColor || undefined }}
|
||||||
|
>
|
||||||
|
{formatValue(value)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 2. field (라벨+값) =====
|
||||||
|
|
||||||
|
function FieldCell({ cell, row, inputValue }: CellRendererProps) {
|
||||||
|
const valueType = cell.valueType || "column";
|
||||||
|
const fs = FONT_SIZE_MAP[cell.fontSize || "md"];
|
||||||
|
|
||||||
|
const displayValue = useMemo(() => {
|
||||||
|
if (valueType !== "formula") {
|
||||||
|
const raw = cell.columnName ? row[cell.columnName] : undefined;
|
||||||
|
const formatted = formatValue(raw);
|
||||||
|
return cell.unit ? `${formatted} ${cell.unit}` : formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell.formulaLeft && cell.formulaOperator) {
|
||||||
|
const rightVal =
|
||||||
|
(cell.formulaRightType || "input") === "input"
|
||||||
|
? (inputValue ?? 0)
|
||||||
|
: Number(row[cell.formulaRight || ""] ?? 0);
|
||||||
|
const leftVal = Number(row[cell.formulaLeft] ?? 0);
|
||||||
|
|
||||||
|
let result: number | null = null;
|
||||||
|
switch (cell.formulaOperator) {
|
||||||
|
case "+": result = leftVal + rightVal; break;
|
||||||
|
case "-": result = leftVal - rightVal; break;
|
||||||
|
case "*": result = leftVal * rightVal; break;
|
||||||
|
case "/": result = rightVal !== 0 ? leftVal / rightVal : null; break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result !== null && isFinite(result)) {
|
||||||
|
const formatted = (Math.round(result * 100) / 100).toLocaleString();
|
||||||
|
return cell.unit ? `${formatted} ${cell.unit}` : formatted;
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return "-";
|
||||||
|
}, [valueType, cell, row, inputValue]);
|
||||||
|
|
||||||
|
const isFormula = valueType === "formula";
|
||||||
|
const isLabelLeft = cell.labelPosition === "left";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={isLabelLeft ? "flex items-baseline gap-1" : "flex flex-col"}
|
||||||
|
style={{ fontSize: fs }}
|
||||||
|
>
|
||||||
|
{cell.label && (
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
|
{cell.label}{isLabelLeft ? ":" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="truncate font-medium"
|
||||||
|
style={{ color: cell.textColor || (isFormula ? "#ea580c" : undefined) }}
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 3. image =====
|
||||||
|
|
||||||
|
function ImageCell({ cell, row }: CellRendererProps) {
|
||||||
|
const value = cell.columnName ? row[cell.columnName] : "";
|
||||||
|
const imageUrl = value ? String(value) : (cell.defaultImage || DEFAULT_CARD_IMAGE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-md border bg-muted/30">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={cell.label || ""}
|
||||||
|
className="h-full w-full object-contain p-1"
|
||||||
|
onError={(e) => {
|
||||||
|
const target = e.target as HTMLImageElement;
|
||||||
|
if (target.src !== DEFAULT_CARD_IMAGE) target.src = DEFAULT_CARD_IMAGE;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 4. badge =====
|
||||||
|
|
||||||
|
function BadgeCell({ cell, row }: CellRendererProps) {
|
||||||
|
const value = cell.columnName ? row[cell.columnName] : "";
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||||
|
{formatValue(value)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 5. button =====
|
||||||
|
|
||||||
|
function ButtonCell({ cell, row, isButtonLoading, onButtonClick }: CellRendererProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant={cell.buttonVariant || "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[10px]"
|
||||||
|
disabled={isButtonLoading}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onButtonClick?.(cell, row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isButtonLoading ? (
|
||||||
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
|
) : null}
|
||||||
|
{cell.label || formatValue(cell.columnName ? row[cell.columnName] : "")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 6. number-input =====
|
||||||
|
|
||||||
|
function NumberInputCell({ cell, row, inputValue, onInputClick }: CellRendererProps) {
|
||||||
|
const unit = cell.inputUnit || "EA";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onInputClick?.(e);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border-2 border-input bg-background px-2 py-1.5 text-center hover:border-primary active:bg-muted"
|
||||||
|
>
|
||||||
|
<span className="block text-lg font-bold leading-tight">
|
||||||
|
{(inputValue ?? 0).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="block text-[12px] text-muted-foreground">{unit}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 7. cart-button =====
|
||||||
|
|
||||||
|
function CartButtonCell({ cell, row, isCarted, onCartAdd, onCartCancel }: CellRendererProps) {
|
||||||
|
const iconSize = 18;
|
||||||
|
const label = cell.cartLabel || "담기";
|
||||||
|
const cancelLabel = cell.cartCancelLabel || "취소";
|
||||||
|
|
||||||
|
if (isCarted) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onCartCancel?.(); }}
|
||||||
|
className="flex w-full flex-col items-center justify-center gap-0.5 rounded-xl bg-destructive px-2 py-1.5 text-destructive-foreground transition-colors duration-150 hover:bg-destructive/90 active:bg-destructive/80"
|
||||||
|
>
|
||||||
|
<X size={iconSize} />
|
||||||
|
<span className="text-[10px] font-semibold leading-tight">{cancelLabel}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onCartAdd?.(); }}
|
||||||
|
className="flex w-full flex-col items-center justify-center gap-0.5 rounded-xl bg-linear-to-br from-amber-400 to-orange-500 px-2 py-1.5 text-white transition-colors duration-150 hover:from-amber-500 hover:to-orange-600 active:from-amber-600 active:to-orange-700"
|
||||||
|
>
|
||||||
|
{cell.cartIconType === "emoji" && cell.cartIconValue ? (
|
||||||
|
<span style={{ fontSize: `${iconSize}px` }}>{cell.cartIconValue}</span>
|
||||||
|
) : (
|
||||||
|
<DynamicLucideIcon name={cell.cartIconValue} size={iconSize} />
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-semibold leading-tight">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 8. package-summary =====
|
||||||
|
|
||||||
|
function PackageSummaryCell({ cell, packageEntries, inputUnit }: CellRendererProps) {
|
||||||
|
if (!packageEntries || packageEntries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full border-t bg-emerald-50">
|
||||||
|
{packageEntries.map((entry, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between px-3 py-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||||
|
포장완료
|
||||||
|
</span>
|
||||||
|
<Package className="h-4 w-4 text-emerald-600" />
|
||||||
|
<span className="text-xs font-medium text-emerald-700">
|
||||||
|
{entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-bold text-emerald-700">
|
||||||
|
= {entry.totalQuantity.toLocaleString()}{inputUnit || "EA"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 9. status-badge =====
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, { bg: string; text: string }> = {
|
||||||
|
waiting: { bg: "#94a3b820", text: "#64748b" },
|
||||||
|
accepted: { bg: "#3b82f620", text: "#2563eb" },
|
||||||
|
in_progress: { bg: "#f59e0b20", text: "#d97706" },
|
||||||
|
completed: { bg: "#10b98120", text: "#059669" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusBadgeCell({ cell, row }: CellRendererProps) {
|
||||||
|
const value = cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : "");
|
||||||
|
const strValue = String(value || "");
|
||||||
|
const mapped = cell.statusMap?.find((m) => m.value === strValue);
|
||||||
|
|
||||||
|
// 접수가능 자동 판별: work_order_process 기반
|
||||||
|
// 직전 공정이 completed이고 현재 공정이 waiting이면 "접수가능"
|
||||||
|
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
|
||||||
|
const isAcceptable = useMemo(() => {
|
||||||
|
if (!processFlow || strValue !== "waiting") return false;
|
||||||
|
const currentIdx = processFlow.findIndex((s) => s.isCurrent);
|
||||||
|
if (currentIdx < 0) return false;
|
||||||
|
if (currentIdx === 0) return true;
|
||||||
|
return processFlow[currentIdx - 1]?.status === "completed";
|
||||||
|
}, [processFlow, strValue]);
|
||||||
|
|
||||||
|
if (isAcceptable) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
|
||||||
|
style={{ backgroundColor: "#3b82f620", color: "#2563eb" }}
|
||||||
|
>
|
||||||
|
<Play className="h-2.5 w-2.5" />
|
||||||
|
접수가능
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mapped) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
|
||||||
|
style={{ backgroundColor: `${mapped.color}20`, color: mapped.color }}
|
||||||
|
>
|
||||||
|
{mapped.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultColors = STATUS_COLORS[strValue];
|
||||||
|
if (defaultColors) {
|
||||||
|
const labelMap: Record<string, string> = {
|
||||||
|
waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
|
||||||
|
style={{ backgroundColor: defaultColors.bg, color: defaultColors.text }}
|
||||||
|
>
|
||||||
|
{labelMap[strValue] || strValue}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
|
{formatValue(value)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 10. timeline =====
|
||||||
|
|
||||||
|
const TIMELINE_STATUS_STYLES: Record<string, { chipBg: string; chipText: string; icon: React.ReactNode }> = {
|
||||||
|
completed: {
|
||||||
|
chipBg: "#10b981",
|
||||||
|
chipText: "#ffffff",
|
||||||
|
icon: <CheckCircle2 className="h-2.5 w-2.5" />,
|
||||||
|
},
|
||||||
|
in_progress: {
|
||||||
|
chipBg: "#f59e0b",
|
||||||
|
chipText: "#ffffff",
|
||||||
|
icon: <CircleDot className="h-2.5 w-2.5 animate-pulse" />,
|
||||||
|
},
|
||||||
|
accepted: {
|
||||||
|
chipBg: "#3b82f6",
|
||||||
|
chipText: "#ffffff",
|
||||||
|
icon: <Play className="h-2.5 w-2.5" />,
|
||||||
|
},
|
||||||
|
waiting: {
|
||||||
|
chipBg: "#e2e8f0",
|
||||||
|
chipText: "#64748b",
|
||||||
|
icon: <Clock className="h-2.5 w-2.5" />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function TimelineCell({ cell, row }: CellRendererProps) {
|
||||||
|
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
|
||||||
|
|
||||||
|
if (!processFlow || processFlow.length === 0) {
|
||||||
|
const fallback = cell.processColumn ? row[cell.processColumn] : "";
|
||||||
|
return (
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{formatValue(fallback)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxVisible = cell.visibleCount || 5;
|
||||||
|
const currentIdx = processFlow.findIndex((s) => s.isCurrent);
|
||||||
|
|
||||||
|
type DisplayItem =
|
||||||
|
| { kind: "step"; step: TimelineProcessStep }
|
||||||
|
| { kind: "count"; count: number; side: "before" | "after" };
|
||||||
|
|
||||||
|
// 현재 공정 기준으로 앞뒤 배분하여 축약
|
||||||
|
// 예: 10공정 중 4번이 현재, maxVisible=5 → [2]...[3공정]...[●4공정]...[5공정]...[5]
|
||||||
|
const displayItems = useMemo((): DisplayItem[] => {
|
||||||
|
if (processFlow.length <= maxVisible) {
|
||||||
|
return processFlow.map((s) => ({ kind: "step" as const, step: s }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveIdx = Math.max(0, currentIdx);
|
||||||
|
const priority = cell.timelinePriority || "before";
|
||||||
|
// 숫자칩 2개를 제외한 나머지를 앞뒤로 배분 (priority에 따라 여분 슬롯 방향 결정)
|
||||||
|
const slotForSteps = maxVisible - 2;
|
||||||
|
const half = Math.floor(slotForSteps / 2);
|
||||||
|
const extra = slotForSteps - half - 1; // -1은 현재 공정
|
||||||
|
const beforeSlots = priority === "before" ? Math.max(half, extra) : Math.min(half, extra);
|
||||||
|
const afterSlots = slotForSteps - beforeSlots - 1;
|
||||||
|
|
||||||
|
let startIdx = effectiveIdx - beforeSlots;
|
||||||
|
let endIdx = effectiveIdx + afterSlots;
|
||||||
|
|
||||||
|
// 경계 보정
|
||||||
|
if (startIdx < 0) {
|
||||||
|
endIdx = Math.min(processFlow.length - 1, endIdx + Math.abs(startIdx));
|
||||||
|
startIdx = 0;
|
||||||
|
}
|
||||||
|
if (endIdx >= processFlow.length) {
|
||||||
|
startIdx = Math.max(0, startIdx - (endIdx - processFlow.length + 1));
|
||||||
|
endIdx = processFlow.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: DisplayItem[] = [];
|
||||||
|
const beforeCount = startIdx;
|
||||||
|
const afterCount = processFlow.length - 1 - endIdx;
|
||||||
|
|
||||||
|
if (beforeCount > 0) {
|
||||||
|
items.push({ kind: "count", count: beforeCount, side: "before" });
|
||||||
|
}
|
||||||
|
for (let i = startIdx; i <= endIdx; i++) {
|
||||||
|
items.push({ kind: "step", step: processFlow[i] });
|
||||||
|
}
|
||||||
|
if (afterCount > 0) {
|
||||||
|
items.push({ kind: "count", count: afterCount, side: "after" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [processFlow, maxVisible, currentIdx]);
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const completedCount = processFlow.filter((s) => s.status === "completed").length;
|
||||||
|
const totalCount = processFlow.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-0.5 overflow-hidden px-0.5",
|
||||||
|
cell.showDetailModal !== false && "cursor-pointer",
|
||||||
|
cell.align === "center" ? "justify-center" : cell.align === "right" ? "justify-end" : "justify-start",
|
||||||
|
)}
|
||||||
|
onClick={cell.showDetailModal !== false ? (e) => { e.stopPropagation(); setModalOpen(true); } : undefined}
|
||||||
|
title={cell.showDetailModal !== false ? "클릭하여 전체 공정 현황 보기" : undefined}
|
||||||
|
>
|
||||||
|
{displayItems.map((item, idx) => {
|
||||||
|
const isLast = idx === displayItems.length - 1;
|
||||||
|
|
||||||
|
if (item.kind === "count") {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={`cnt-${item.side}`}>
|
||||||
|
<div
|
||||||
|
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-bold text-muted-foreground"
|
||||||
|
title={item.side === "before" ? `이전 ${item.count}개 공정` : `이후 ${item.count}개 공정`}
|
||||||
|
>
|
||||||
|
{item.count}
|
||||||
|
</div>
|
||||||
|
{!isLast && <div className="h-px w-1.5 shrink-0 border-t border-dashed border-muted-foreground/40" />}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = TIMELINE_STATUS_STYLES[item.step.status] || TIMELINE_STATUS_STYLES.waiting;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={item.step.seqNo}>
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center gap-0.5 rounded-full px-1.5 py-0.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: styles.chipBg,
|
||||||
|
color: styles.chipText,
|
||||||
|
outline: item.step.isCurrent ? "2px solid #2563eb" : "none",
|
||||||
|
outlineOffset: "1px",
|
||||||
|
}}
|
||||||
|
title={`${item.step.seqNo}. ${item.step.processName} (${item.step.status})`}
|
||||||
|
>
|
||||||
|
{styles.icon}
|
||||||
|
<span className="max-w-[48px] truncate text-[9px] font-medium leading-tight">
|
||||||
|
{item.step.processName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isLast && <div className="h-px w-1.5 shrink-0 border-t border-dashed border-muted-foreground/40" />}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">전체 공정 현황</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
총 {totalCount}개 공정 중 {completedCount}개 완료
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-0">
|
||||||
|
{processFlow.map((step, idx) => {
|
||||||
|
const styles = TIMELINE_STATUS_STYLES[step.status] || TIMELINE_STATUS_STYLES.waiting;
|
||||||
|
const statusLabel =
|
||||||
|
step.status === "completed" ? "완료" :
|
||||||
|
step.status === "in_progress" ? "진행중" :
|
||||||
|
step.status === "accepted" ? "접수" :
|
||||||
|
step.status === "hold" ? "보류" : "대기";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.seqNo} className="flex items-center">
|
||||||
|
{/* 세로 연결선 + 아이콘 */}
|
||||||
|
<div className="flex w-8 shrink-0 flex-col items-center">
|
||||||
|
{idx > 0 && <div className="h-3 w-px bg-border" />}
|
||||||
|
<div
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full"
|
||||||
|
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
|
||||||
|
>
|
||||||
|
{styles.icon}
|
||||||
|
</div>
|
||||||
|
{idx < processFlow.length - 1 && <div className="h-3 w-px bg-border" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 공정 정보 */}
|
||||||
|
<div className={cn(
|
||||||
|
"ml-2 flex flex-1 items-center justify-between rounded-md px-3 py-2",
|
||||||
|
step.isCurrent && "bg-primary/5 ring-1 ring-primary/30",
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">{step.seqNo}</span>
|
||||||
|
<span className={cn(
|
||||||
|
"text-sm",
|
||||||
|
step.isCurrent ? "font-semibold" : "font-medium",
|
||||||
|
)}>
|
||||||
|
{step.processName}
|
||||||
|
</span>
|
||||||
|
{step.isCurrent && (
|
||||||
|
<Star className="h-3 w-3 fill-primary text-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||||
|
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 진행률 바 */}
|
||||||
|
<div className="space-y-1 pt-2">
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>진행률</span>
|
||||||
|
<span>{totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||||
|
style={{ width: `${totalCount > 0 ? (completedCount / totalCount) * 100 : 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 11. action-buttons =====
|
||||||
|
|
||||||
|
function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps) {
|
||||||
|
const statusValue = cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : "");
|
||||||
|
const rules = cell.actionRules || [];
|
||||||
|
|
||||||
|
// 접수가능 자동 판별
|
||||||
|
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
|
||||||
|
const isAcceptable = useMemo(() => {
|
||||||
|
if (!processFlow || statusValue !== "waiting") return false;
|
||||||
|
const currentIdx = processFlow.findIndex((s) => s.isCurrent);
|
||||||
|
if (currentIdx < 0) return false;
|
||||||
|
if (currentIdx === 0) return true;
|
||||||
|
return processFlow[currentIdx - 1]?.status === "completed";
|
||||||
|
}, [processFlow, statusValue]);
|
||||||
|
|
||||||
|
const effectiveStatus = isAcceptable ? "acceptable" : statusValue;
|
||||||
|
const matchedRule = rules.find((r) => r.whenStatus === effectiveStatus)
|
||||||
|
|| rules.find((r) => r.whenStatus === statusValue);
|
||||||
|
|
||||||
|
// 매칭 규칙이 없을 때 기본 동작
|
||||||
|
if (!matchedRule) {
|
||||||
|
if (isAcceptable) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[10px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onActionButtonClick?.("accept", row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="mr-0.5 h-3 w-3" />
|
||||||
|
접수
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (statusValue === "in_progress") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 bg-emerald-600 text-[10px] hover:bg-emerald-700"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onActionButtonClick?.("complete", row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="mr-0.5 h-3 w-3" />
|
||||||
|
완료
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{matchedRule.buttons.map((btn, idx) => (
|
||||||
|
<Button
|
||||||
|
key={idx}
|
||||||
|
variant={btn.variant || "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-[10px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onActionButtonClick?.(btn.taskPreset, row, btn as Record<string, unknown>);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{btn.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 12. footer-status =====
|
||||||
|
|
||||||
|
function FooterStatusCell({ cell, row }: CellRendererProps) {
|
||||||
|
const value = cell.footerStatusColumn ? row[cell.footerStatusColumn] : "";
|
||||||
|
const strValue = String(value || "");
|
||||||
|
const mapped = cell.footerStatusMap?.find((m) => m.value === strValue);
|
||||||
|
|
||||||
|
if (!strValue && !cell.footerLabel) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex w-full items-center justify-between px-2 py-1"
|
||||||
|
style={{ borderTop: cell.showTopBorder !== false ? "1px solid hsl(var(--border))" : "none" }}
|
||||||
|
>
|
||||||
|
{cell.footerLabel && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{cell.footerLabel}</span>
|
||||||
|
)}
|
||||||
|
{mapped ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-semibold"
|
||||||
|
style={{ backgroundColor: `${mapped.color}20`, color: mapped.color }}
|
||||||
|
>
|
||||||
|
{mapped.label}
|
||||||
|
</span>
|
||||||
|
) : strValue ? (
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground">
|
||||||
|
{strValue}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pop-card-list-v2 컴포넌트 레지스트리 등록 진입점
|
||||||
|
*
|
||||||
|
* import 시 side-effect로 PopComponentRegistry에 자동 등록
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PopComponentRegistry } from "../../PopComponentRegistry";
|
||||||
|
import { PopCardListV2Component } from "./PopCardListV2Component";
|
||||||
|
import { PopCardListV2ConfigPanel } from "./PopCardListV2Config";
|
||||||
|
import { PopCardListV2PreviewComponent } from "./PopCardListV2Preview";
|
||||||
|
import type { PopCardListV2Config } from "../types";
|
||||||
|
|
||||||
|
const defaultConfig: PopCardListV2Config = {
|
||||||
|
dataSource: { tableName: "" },
|
||||||
|
cardGrid: {
|
||||||
|
rows: 1,
|
||||||
|
cols: 1,
|
||||||
|
colWidths: ["1fr"],
|
||||||
|
rowHeights: ["32px"],
|
||||||
|
gap: 4,
|
||||||
|
showCellBorder: true,
|
||||||
|
cells: [],
|
||||||
|
},
|
||||||
|
gridColumns: 3,
|
||||||
|
cardGap: 8,
|
||||||
|
scrollDirection: "vertical",
|
||||||
|
overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 },
|
||||||
|
cardClickAction: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
PopComponentRegistry.registerComponent({
|
||||||
|
id: "pop-card-list-v2",
|
||||||
|
name: "카드 목록 V2",
|
||||||
|
description: "슬롯 기반 카드 레이아웃 (CSS Grid + 셀 타입별 렌더링)",
|
||||||
|
category: "display",
|
||||||
|
icon: "LayoutGrid",
|
||||||
|
component: PopCardListV2Component,
|
||||||
|
configPanel: PopCardListV2ConfigPanel,
|
||||||
|
preview: PopCardListV2PreviewComponent,
|
||||||
|
defaultProps: defaultConfig,
|
||||||
|
connectionMeta: {
|
||||||
|
sendable: [
|
||||||
|
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
|
||||||
|
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
|
||||||
|
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
|
||||||
|
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
|
||||||
|
{ key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" },
|
||||||
|
],
|
||||||
|
receivable: [
|
||||||
|
{ key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
|
||||||
|
{ key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
|
||||||
|
{ key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" },
|
||||||
|
{ key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
touchOptimized: true,
|
||||||
|
supportedDevices: ["mobile", "tablet"],
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
/**
|
||||||
|
* pop-card-list v1 -> v2 마이그레이션 함수
|
||||||
|
*
|
||||||
|
* 기존 PopCardListConfig의 고정 레이아웃(헤더/이미지/필드/입력/담기/포장)을
|
||||||
|
* CardGridConfigV2 셀 배열로 변환하여 PopCardListV2Config를 생성한다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PopCardListConfig,
|
||||||
|
PopCardListV2Config,
|
||||||
|
CardCellDefinitionV2,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Config {
|
||||||
|
const cells: CardCellDefinitionV2[] = [];
|
||||||
|
let nextRow = 1;
|
||||||
|
|
||||||
|
// 1. 헤더 행 (코드 + 제목)
|
||||||
|
if (old.cardTemplate?.header?.codeField || old.cardTemplate?.header?.titleField) {
|
||||||
|
if (old.cardTemplate.header.codeField) {
|
||||||
|
cells.push({
|
||||||
|
id: "h-code",
|
||||||
|
row: nextRow,
|
||||||
|
col: 1,
|
||||||
|
rowSpan: 1,
|
||||||
|
colSpan: 1,
|
||||||
|
type: "text",
|
||||||
|
columnName: old.cardTemplate.header.codeField,
|
||||||
|
fontSize: "sm",
|
||||||
|
textColor: "hsl(var(--muted-foreground))",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (old.cardTemplate.header.titleField) {
|
||||||
|
cells.push({
|
||||||
|
id: "h-title",
|
||||||
|
row: nextRow,
|
||||||
|
col: 2,
|
||||||
|
rowSpan: 1,
|
||||||
|
colSpan: old.cardTemplate.header.codeField ? 2 : 3,
|
||||||
|
type: "text",
|
||||||
|
columnName: old.cardTemplate.header.titleField,
|
||||||
|
fontSize: "md",
|
||||||
|
fontWeight: "bold",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
nextRow++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 이미지 (왼쪽, 본문 높이만큼 rowSpan)
|
||||||
|
const bodyFieldCount = old.cardTemplate?.body?.fields?.length || 0;
|
||||||
|
const bodyRowSpan = Math.max(1, bodyFieldCount);
|
||||||
|
|
||||||
|
if (old.cardTemplate?.image?.enabled) {
|
||||||
|
cells.push({
|
||||||
|
id: "img",
|
||||||
|
row: nextRow,
|
||||||
|
col: 1,
|
||||||
|
rowSpan: bodyRowSpan,
|
||||||
|
colSpan: 1,
|
||||||
|
type: "image",
|
||||||
|
columnName: old.cardTemplate.image.imageColumn || "",
|
||||||
|
defaultImage: old.cardTemplate.image.defaultImage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 본문 필드들 (이미지 오른쪽)
|
||||||
|
const fieldStartCol = old.cardTemplate?.image?.enabled ? 2 : 1;
|
||||||
|
const fieldColSpan = old.cardTemplate?.image?.enabled ? 2 : 3;
|
||||||
|
const hasRightActions = !!(old.inputField?.enabled || old.cartAction);
|
||||||
|
|
||||||
|
(old.cardTemplate?.body?.fields || []).forEach((field, i) => {
|
||||||
|
cells.push({
|
||||||
|
id: `f-${i}`,
|
||||||
|
row: nextRow + i,
|
||||||
|
col: fieldStartCol,
|
||||||
|
rowSpan: 1,
|
||||||
|
colSpan: hasRightActions ? fieldColSpan - 1 : fieldColSpan,
|
||||||
|
type: "field",
|
||||||
|
columnName: field.columnName,
|
||||||
|
label: field.label,
|
||||||
|
valueType: field.valueType,
|
||||||
|
formulaLeft: field.formulaLeft,
|
||||||
|
formulaOperator: field.formulaOperator as CardCellDefinitionV2["formulaOperator"],
|
||||||
|
formulaRight: field.formulaRight,
|
||||||
|
formulaRightType: field.formulaRightType as CardCellDefinitionV2["formulaRightType"],
|
||||||
|
unit: field.unit,
|
||||||
|
textColor: field.textColor,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 수량 입력 + 담기 버튼 (오른쪽 열)
|
||||||
|
const rightCol = 3;
|
||||||
|
if (old.inputField?.enabled) {
|
||||||
|
cells.push({
|
||||||
|
id: "input",
|
||||||
|
row: nextRow,
|
||||||
|
col: rightCol,
|
||||||
|
rowSpan: Math.ceil(bodyRowSpan / 2),
|
||||||
|
colSpan: 1,
|
||||||
|
type: "number-input",
|
||||||
|
inputUnit: old.inputField.unit,
|
||||||
|
limitColumn: old.inputField.limitColumn || old.inputField.maxColumn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (old.cartAction) {
|
||||||
|
cells.push({
|
||||||
|
id: "cart",
|
||||||
|
row: nextRow + Math.ceil(bodyRowSpan / 2),
|
||||||
|
col: rightCol,
|
||||||
|
rowSpan: Math.floor(bodyRowSpan / 2) || 1,
|
||||||
|
colSpan: 1,
|
||||||
|
type: "cart-button",
|
||||||
|
cartLabel: old.cartAction.label,
|
||||||
|
cartCancelLabel: old.cartAction.cancelLabel,
|
||||||
|
cartIconType: old.cartAction.iconType,
|
||||||
|
cartIconValue: old.cartAction.iconValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 포장 요약 (마지막 행, full-width)
|
||||||
|
if (old.packageConfig?.enabled) {
|
||||||
|
const summaryRow = nextRow + bodyRowSpan;
|
||||||
|
cells.push({
|
||||||
|
id: "pkg",
|
||||||
|
row: summaryRow,
|
||||||
|
col: 1,
|
||||||
|
rowSpan: 1,
|
||||||
|
colSpan: 3,
|
||||||
|
type: "package-summary",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그리드 크기 계산
|
||||||
|
const maxRow = cells.length > 0 ? Math.max(...cells.map((c) => c.row + c.rowSpan - 1)) : 1;
|
||||||
|
const maxCol = 3;
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataSource: old.dataSource,
|
||||||
|
cardGrid: {
|
||||||
|
rows: maxRow,
|
||||||
|
cols: maxCol,
|
||||||
|
colWidths: old.cardTemplate?.image?.enabled
|
||||||
|
? ["1fr", "2fr", "1fr"]
|
||||||
|
: ["1fr", "2fr", "1fr"],
|
||||||
|
gap: 2,
|
||||||
|
showCellBorder: false,
|
||||||
|
cells,
|
||||||
|
},
|
||||||
|
scrollDirection: old.scrollDirection,
|
||||||
|
cardSize: old.cardSize,
|
||||||
|
gridColumns: old.gridColumns,
|
||||||
|
gridRows: old.gridRows,
|
||||||
|
cardGap: 8,
|
||||||
|
overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 },
|
||||||
|
cardClickAction: "none",
|
||||||
|
responsiveDisplay: old.responsiveDisplay,
|
||||||
|
inputField: old.inputField,
|
||||||
|
packageConfig: old.packageConfig,
|
||||||
|
cartAction: old.cartAction,
|
||||||
|
cartListMode: old.cartListMode,
|
||||||
|
saveMapping: old.saveMapping,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -722,3 +722,177 @@ export interface PopCardListConfig {
|
||||||
cartListMode?: CartListModeConfig;
|
cartListMode?: CartListModeConfig;
|
||||||
saveMapping?: CardListSaveMapping;
|
saveMapping?: CardListSaveMapping;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// pop-card-list-v2 전용 타입 (슬롯 기반 카드)
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
import type { ButtonMainAction, ButtonVariant, ConfirmConfig } from "./pop-button";
|
||||||
|
|
||||||
|
export type CardCellType =
|
||||||
|
| "text"
|
||||||
|
| "field"
|
||||||
|
| "image"
|
||||||
|
| "badge"
|
||||||
|
| "button"
|
||||||
|
| "number-input"
|
||||||
|
| "cart-button"
|
||||||
|
| "package-summary"
|
||||||
|
| "status-badge"
|
||||||
|
| "timeline"
|
||||||
|
| "action-buttons"
|
||||||
|
| "footer-status";
|
||||||
|
|
||||||
|
// timeline 셀에서 사용하는 공정 단계 데이터
|
||||||
|
export interface TimelineProcessStep {
|
||||||
|
seqNo: number;
|
||||||
|
processName: string;
|
||||||
|
status: string;
|
||||||
|
isCurrent: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// timeline/status-badge/action-buttons가 참조하는 공정 테이블 설정
|
||||||
|
export interface TimelineDataSource {
|
||||||
|
processTable: string; // 공정 데이터 테이블명 (예: work_order_process)
|
||||||
|
foreignKey: string; // 메인 테이블 id와 매칭되는 FK 컬럼 (예: wo_id)
|
||||||
|
seqColumn: string; // 순서 컬럼 (예: seq_no)
|
||||||
|
nameColumn: string; // 공정명 컬럼 (예: process_name)
|
||||||
|
statusColumn: string; // 상태 컬럼 (예: status)
|
||||||
|
statusValues?: { // 상태 값 매핑 (미설정 시 기본값 사용)
|
||||||
|
waiting?: string; // 대기 (기본: "waiting")
|
||||||
|
accepted?: string; // 접수 (기본: "accepted")
|
||||||
|
inProgress?: string; // 진행중 (기본: "in_progress")
|
||||||
|
completed?: string; // 완료 (기본: "completed")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardCellDefinitionV2 {
|
||||||
|
id: string;
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
rowSpan: number;
|
||||||
|
colSpan: number;
|
||||||
|
type: CardCellType;
|
||||||
|
|
||||||
|
// 공통
|
||||||
|
columnName?: string;
|
||||||
|
label?: string;
|
||||||
|
labelPosition?: "top" | "left";
|
||||||
|
fontSize?: "xs" | "sm" | "md" | "lg";
|
||||||
|
fontWeight?: "normal" | "medium" | "bold";
|
||||||
|
textColor?: string;
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
verticalAlign?: "top" | "middle" | "bottom";
|
||||||
|
|
||||||
|
// field 타입 전용 (CardFieldBinding 흡수)
|
||||||
|
valueType?: "column" | "formula";
|
||||||
|
formulaLeft?: string;
|
||||||
|
formulaOperator?: "+" | "-" | "*" | "/";
|
||||||
|
formulaRight?: string;
|
||||||
|
formulaRightType?: "input" | "column";
|
||||||
|
unit?: string;
|
||||||
|
|
||||||
|
// image 타입 전용
|
||||||
|
defaultImage?: string;
|
||||||
|
|
||||||
|
// button 타입 전용
|
||||||
|
buttonAction?: ButtonMainAction;
|
||||||
|
buttonVariant?: ButtonVariant;
|
||||||
|
buttonConfirm?: ConfirmConfig;
|
||||||
|
|
||||||
|
// number-input 타입 전용
|
||||||
|
inputUnit?: string;
|
||||||
|
limitColumn?: string;
|
||||||
|
autoInitMax?: boolean;
|
||||||
|
|
||||||
|
// cart-button 타입 전용
|
||||||
|
cartLabel?: string;
|
||||||
|
cartCancelLabel?: string;
|
||||||
|
cartIconType?: "lucide" | "emoji";
|
||||||
|
cartIconValue?: string;
|
||||||
|
|
||||||
|
// status-badge 타입 전용 (CARD-3에서 구현)
|
||||||
|
statusColumn?: string;
|
||||||
|
statusMap?: Array<{ value: string; label: string; color: string }>;
|
||||||
|
|
||||||
|
// timeline 타입 전용: 공정 데이터 소스 설정
|
||||||
|
timelineSource?: TimelineDataSource;
|
||||||
|
processColumn?: string;
|
||||||
|
processStatusColumn?: string;
|
||||||
|
currentHighlight?: boolean;
|
||||||
|
visibleCount?: number;
|
||||||
|
timelinePriority?: "before" | "after";
|
||||||
|
showDetailModal?: boolean;
|
||||||
|
|
||||||
|
// action-buttons 타입 전용
|
||||||
|
actionRules?: Array<{
|
||||||
|
whenStatus: string;
|
||||||
|
buttons: Array<{
|
||||||
|
label: string;
|
||||||
|
variant: ButtonVariant;
|
||||||
|
taskPreset: string;
|
||||||
|
confirm?: ConfirmConfig;
|
||||||
|
targetTable?: string;
|
||||||
|
confirmMessage?: string;
|
||||||
|
allowMultiSelect?: boolean;
|
||||||
|
updates?: ActionButtonUpdate[];
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// footer-status 타입 전용
|
||||||
|
footerLabel?: string;
|
||||||
|
footerStatusColumn?: string;
|
||||||
|
footerStatusMap?: Array<{ value: string; label: string; color: string }>;
|
||||||
|
showTopBorder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionButtonUpdate {
|
||||||
|
column: string;
|
||||||
|
value?: string;
|
||||||
|
valueType: "static" | "currentUser" | "currentTime" | "columnRef";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardGridConfigV2 {
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
colWidths: string[];
|
||||||
|
rowHeights?: string[];
|
||||||
|
gap: number;
|
||||||
|
showCellBorder: boolean;
|
||||||
|
cells: CardCellDefinitionV2[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- V2 카드 선택 동작 -----
|
||||||
|
|
||||||
|
export type V2CardClickAction = "none" | "publish" | "navigate";
|
||||||
|
|
||||||
|
// ----- V2 오버플로우 설정 -----
|
||||||
|
|
||||||
|
export interface V2OverflowConfig {
|
||||||
|
mode: "loadMore" | "pagination";
|
||||||
|
visibleCount: number;
|
||||||
|
loadMoreCount?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- pop-card-list-v2 전체 설정 -----
|
||||||
|
|
||||||
|
export interface PopCardListV2Config {
|
||||||
|
dataSource: CardListDataSource;
|
||||||
|
cardGrid: CardGridConfigV2;
|
||||||
|
selectedColumns?: string[];
|
||||||
|
gridColumns?: number;
|
||||||
|
gridRows?: number;
|
||||||
|
scrollDirection?: CardScrollDirection;
|
||||||
|
/** @deprecated 열 수(gridColumns)로 카드 크기 결정. 하위 호환용 */
|
||||||
|
cardSize?: CardSize;
|
||||||
|
cardGap?: number;
|
||||||
|
overflow?: V2OverflowConfig;
|
||||||
|
cardClickAction?: V2CardClickAction;
|
||||||
|
responsiveDisplay?: CardResponsiveConfig;
|
||||||
|
inputField?: CardInputFieldConfig;
|
||||||
|
packageConfig?: CardPackageConfig;
|
||||||
|
cartAction?: CardCartActionConfig;
|
||||||
|
cartListMode?: CartListModeConfig;
|
||||||
|
saveMapping?: CardListSaveMapping;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue