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

1420 lines
58 KiB
TypeScript

"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, Check, X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type {
PopCardListV2Config,
CardGridConfigV2,
CardCellDefinitionV2,
CardInputFieldConfig,
CardCartActionConfig,
CardPackageConfig,
CardPresetSpec,
CartItem,
PackageEntry,
CollectDataRequest,
CollectedDataResponse,
TimelineProcessStep,
TimelineDataSource,
ActionButtonUpdate,
StatusValueMapping,
SelectModeConfig,
SelectModeButtonConfig,
} from "../types";
import {
CARD_PRESET_SPECS, DEFAULT_CARD_IMAGE,
VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC, VIRTUAL_SUB_PROCESS, VIRTUAL_SUB_SEQ,
} 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";
import type { PopLayoutDataV5 } from "@/components/pop/designer/types/pop-layout";
import { isV5Layout, detectGridMode } from "@/components/pop/designer/types/pop-layout";
import dynamic from "next/dynamic";
const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopViewerWithModals"), { ssr: false });
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,
};
}
// 레거시 statusValues(고정 4키 객체) → statusMappings(동적 배열) 자동 변환
function resolveStatusMappings(src: TimelineDataSource): StatusValueMapping[] {
if (src.statusMappings && src.statusMappings.length > 0) return src.statusMappings;
// 레거시 호환: 기존 statusValues 객체가 있으면 변환
const sv = (src as Record<string, unknown>).statusValues as Record<string, string> | undefined;
return [
{ dbValue: sv?.waiting || "waiting", label: "대기", semantic: "pending" as const },
{ dbValue: sv?.accepted || "accepted", label: "접수", semantic: "active" as const },
{ dbValue: sv?.inProgress || "in_progress", label: "진행중", semantic: "active" as const },
{ dbValue: sv?.completed || "completed", label: "완료", semantic: "done" as const },
];
}
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; isSubTable?: boolean };
_source?: 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; _source?: string };
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean };
_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,
_source: data.value._source,
});
} 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 [selectMode, setSelectMode] = useState(false);
const [selectModeStatus, setSelectModeStatus] = useState<string>("");
const [selectModeConfig, setSelectModeConfig] = useState<SelectModeConfig | null>(null);
const [selectedRowIds, setSelectedRowIds] = useState<Set<string>>(new Set());
const [selectProcessing, setSelectProcessing] = useState(false);
// ===== 모달 열기 (POP 화면) =====
const [popModalOpen, setPopModalOpen] = useState(false);
const [popModalLayout, setPopModalLayout] = useState<PopLayoutDataV5 | null>(null);
const [popModalScreenId, setPopModalScreenId] = useState<string>("");
const [popModalRow, setPopModalRow] = useState<RowData | null>(null);
const openPopModal = useCallback(async (screenIdStr: string, row: RowData) => {
try {
const sid = parseInt(screenIdStr, 10);
if (isNaN(sid)) {
toast.error("올바른 화면 ID가 아닙니다.");
return;
}
const popLayout = await screenApi.getLayoutPop(sid);
if (popLayout && isV5Layout(popLayout)) {
setPopModalLayout(popLayout);
setPopModalScreenId(String(sid));
setPopModalRow(row);
setPopModalOpen(true);
} else {
toast.error("해당 POP 화면을 찾을 수 없습니다.");
}
} catch {
toast.error("POP 화면을 불러오는데 실패했습니다.");
}
}, []);
const enterSelectMode = useCallback((whenStatus: string, buttonConfig: Record<string, unknown>) => {
const smConfig = buttonConfig.selectModeConfig as SelectModeConfig | undefined;
if (!smConfig) return;
setSelectMode(true);
setSelectModeStatus(smConfig.filterStatus || whenStatus);
setSelectModeConfig(smConfig);
setSelectedRowIds(new Set());
}, []);
const exitSelectMode = useCallback(() => {
setSelectMode(false);
setSelectModeStatus("");
setSelectModeConfig(null);
setSelectedRowIds(new Set());
}, []);
const toggleRowSelection = useCallback((row: RowData) => {
const rowId = String(row.id ?? row.pk ?? "");
if (!rowId) return;
setSelectedRowIds((prev) => {
const next = new Set(prev);
if (next.has(rowId)) next.delete(rowId); else next.add(rowId);
return next;
});
}, []);
const isRowSelectable = useCallback((row: RowData) => {
if (!selectMode) return false;
const subStatus = row[VIRTUAL_SUB_STATUS];
if (subStatus !== undefined) return String(subStatus) === selectModeStatus;
return true;
}, [selectMode, selectModeStatus]);
// 확장/페이지네이션
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;
// 셀 설정에서 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]);
// 외부 필터 (메인 테이블 + 하위 테이블 분기)
const filteredRows = useMemo(() => {
if (externalFilters.size === 0) return rows;
const allFilters = [...externalFilters.values()];
const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable);
const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable);
// 1단계: 하위 테이블 필터 → __subStatus__ 주입
const afterSubFilter = subFilters.length === 0
? rows
: rows
.map((row) => {
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
if (!processFlow || processFlow.length === 0) return null;
const matchingSteps = processFlow.filter((step) =>
subFilters.every((filter) => {
const searchValue = String(filter.value).toLowerCase();
if (!searchValue) return true;
const fc = filter.filterConfig;
const col = fc?.targetColumn || filter.fieldName || "";
if (!col) return true;
const cellValue = String(step.rawData?.[col] ?? "").toLowerCase();
const mode = fc?.filterMode || "contains";
switch (mode) {
case "equals": return cellValue === searchValue;
case "starts_with": return cellValue.startsWith(searchValue);
default: return cellValue.includes(searchValue);
}
}),
);
if (matchingSteps.length === 0) return null;
const matched = matchingSteps[0];
// 매칭된 공정을 타임라인에서 강조
const updatedFlow = processFlow.map((s) => ({
...s,
isCurrent: s.seqNo === matched.seqNo,
}));
return {
...row,
__processFlow__: updatedFlow,
[VIRTUAL_SUB_STATUS]: matched.status,
[VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending",
[VIRTUAL_SUB_PROCESS]: matched.processName,
[VIRTUAL_SUB_SEQ]: matched.seqNo,
};
})
.filter((row): row is RowData => row !== null);
// 2단계: 메인 테이블 필터 (__subStatus__ 주입된 데이터 기반)
if (mainFilters.length === 0) return afterSubFilter;
return afterSubFilter.filter((row) =>
mainFilters.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";
// 하위 필터 활성 시: 상태 컬럼(status 등)을 __subStatus__로 대체
const subCol = subFilters.length > 0 ? VIRTUAL_SUB_STATUS : null;
const statusCol = timelineSource?.statusColumn || "status";
const effectiveColumns = subCol
? columns.map((col) => col === statusCol || col === "status" ? subCol : col)
: columns;
return effectiveColumns.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, timelineSource]);
// 하위 필터 활성 여부
const hasActiveSubFilter = useMemo(() => {
if (externalFilters.size === 0) return false;
return [...externalFilters.values()].some((f) => f.filterConfig?.isSubTable);
}, [externalFilters]);
// 선택 모드 일괄 처리
const handleSelectModeAction = useCallback(async (btnConfig: SelectModeButtonConfig) => {
if (btnConfig.clickMode === "cancel-select") {
exitSelectMode();
return;
}
if (btnConfig.clickMode === "status-change" && btnConfig.updates && btnConfig.targetTable) {
if (selectedRowIds.size === 0) {
toast.error("선택된 항목이 없습니다.");
return;
}
if (btnConfig.confirmMessage && !window.confirm(btnConfig.confirmMessage)) return;
setSelectProcessing(true);
try {
const selectedRows = filteredRows.filter((r) => {
const rowId = String(r.id ?? r.pk ?? "");
return selectedRowIds.has(rowId);
});
let successCount = 0;
for (const row of selectedRows) {
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
const currentProcess = processFlow?.find((s) => s.isCurrent);
const targetId = currentProcess?.processId ?? row.id ?? row.pk;
if (!targetId) continue;
const tasks = btnConfig.updates.map((u, idx) => ({
id: `sel-update-${idx}`,
type: "data-update" as const,
targetTable: btnConfig.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.value ?? ""),
lookupMode: "manual" as const,
manualItemField: "id",
manualPkColumn: "id",
}));
const result = await apiClient.post("/pop/execute-action", {
tasks,
data: { items: [{ ...row, id: targetId }], fieldValues: {} },
mappings: {},
});
if (result.data?.success) successCount++;
}
if (successCount > 0) {
toast.success(`${successCount}건 처리 완료`);
exitSelectMode();
fetchDataRef.current();
} else {
toast.error("처리에 실패했습니다.");
}
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
} finally {
setSelectProcessing(false);
}
return;
}
if (btnConfig.clickMode === "modal-open" && btnConfig.modalScreenId) {
const selectedRows = filteredRows.filter((r) => {
const rowId = String(r.id ?? r.pk ?? "");
return selectedRowIds.has(rowId);
});
openPopModal(btnConfig.modalScreenId, selectedRows[0] || {});
return;
}
}, [selectedRowIds, filteredRows, exitSelectMode]);
// status-bar 필터를 제외한 rows (카운트 집계용)
// status-bar에서 "접수가능" 등 선택해도 전체 카운트가 유지되어야 함
const rowsForStatusCount = useMemo(() => {
const hasStatusBarFilter = [...externalFilters.values()].some((f) => f._source === "status-bar");
if (!hasStatusBarFilter) return filteredRows;
// status-bar 필터를 제외한 필터만 적용
const nonStatusFilters = new Map(
[...externalFilters.entries()].filter(([, f]) => f._source !== "status-bar")
);
if (nonStatusFilters.size === 0) return rows;
const allFilters = [...nonStatusFilters.values()];
const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable);
const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable);
const afterSubFilter = subFilters.length === 0
? rows
: rows
.map((row) => {
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
if (!processFlow || processFlow.length === 0) return null;
const matchingSteps = processFlow.filter((step) =>
subFilters.every((filter) => {
const searchValue = String(filter.value).toLowerCase();
if (!searchValue) return true;
const fc = filter.filterConfig;
const col = fc?.targetColumn || filter.fieldName || "";
if (!col) return true;
const cellValue = String(step.rawData?.[col] ?? "").toLowerCase();
const mode = fc?.filterMode || "contains";
switch (mode) {
case "equals": return cellValue === searchValue;
case "starts_with": return cellValue.startsWith(searchValue);
default: return cellValue.includes(searchValue);
}
}),
);
if (matchingSteps.length === 0) return null;
const matched = matchingSteps[0];
const updatedFlow = processFlow.map((s) => ({
...s,
isCurrent: s.seqNo === matched.seqNo,
}));
return {
...row,
__processFlow__: updatedFlow,
[VIRTUAL_SUB_STATUS]: matched.status,
[VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending",
[VIRTUAL_SUB_PROCESS]: matched.processName,
[VIRTUAL_SUB_SEQ]: matched.seqNo,
};
})
.filter((row): row is RowData => row !== null);
if (mainFilters.length === 0) return afterSubFilter;
return afterSubFilter.filter((row) =>
mainFilters.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";
const subCol = subFilters.length > 0 ? VIRTUAL_SUB_STATUS : null;
const statusCol = timelineSource?.statusColumn || "status";
const effectiveColumns = subCol
? columns.map((col) => col === statusCol || col === "status" ? subCol : col)
: columns;
return effectiveColumns.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, filteredRows, externalFilters, timelineSource]);
// 카운트 집계용 rows 발행 (status-bar 필터 제외)
useEffect(() => {
if (!componentId || loading) return;
publish(`__comp_output__${componentId}__all_rows`, {
rows: rowsForStatusCount,
subStatusColumn: hasActiveSubFilter ? VIRTUAL_SUB_STATUS : null,
});
}, [componentId, rowsForStatusCount, loading, publish, hasActiveSubFilter]);
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]);
// 하위 데이터 조회 + __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;
// statusMappings 동적 배열 → dbValue-to-내부키 맵 구축
// 레거시 statusValues 객체도 자동 변환
const mappings = resolveStatusMappings(src);
const dbToInternal = new Map<string, string>();
const dbToSemantic = new Map<string, string>();
for (const m of mappings) {
dbToInternal.set(m.dbValue, m.dbValue);
dbToSemantic.set(m.dbValue, m.semantic);
}
const processResult = await dataApi.getTableData(src.processTable, {
page: 1,
size: 1000,
sortBy: src.seqColumn || "seq_no",
sortOrder: "asc",
});
const allProcesses = processResult.data || [];
// isDerived 매핑: DB에 없는 자동 판별 상태
// 같은 시맨틱의 DB 원본 상태를 자동으로 찾아 변환 조건 구축
const derivedRules: { sourceStatus: string; targetDbValue: string; targetSemantic: string }[] = [];
for (const dm of mappings.filter((m) => m.isDerived)) {
const source = mappings.find((m) => !m.isDerived && m.semantic === dm.semantic);
if (source) {
derivedRules.push({ sourceStatus: source.dbValue, targetDbValue: dm.dbValue, targetSemantic: dm.semantic });
}
}
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] || "");
const normalizedStatus = dbToInternal.get(rawStatus) || rawStatus;
const semantic = dbToSemantic.get(rawStatus) || "pending";
processMap.get(fkValue)!.push({
seqNo: parseInt(String(p[src.seqColumn] || "0"), 10),
processName: String(p[src.nameColumn] || ""),
status: normalizedStatus,
semantic: semantic as "pending" | "active" | "done",
isCurrent: semantic === "active",
processId: p.id as string | number | undefined,
rawData: p as Record<string, unknown>,
});
}
// 파생 상태 자동 변환: 이전 공정이 완료된 경우 변환
if (derivedRules.length > 0) {
for (const [, steps] of processMap) {
steps.sort((a, b) => a.seqNo - b.seqNo);
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
const prevStep = i > 0 ? steps[i - 1] : null;
for (const rule of derivedRules) {
if (step.status !== rule.sourceStatus) continue;
const prevIsDone = prevStep ? prevStep.semantic === "done" : true;
if (prevIsDone) {
step.status = rule.targetDbValue;
step.semantic = rule.targetSemantic as "pending" | "active" | "done";
}
}
}
}
}
// isCurrent 결정: "기준" 체크된 상태와 일치하는 공정을 강조
// 기준 상태가 없으면 기존 로직 (active → 첫 pending) 폴백
const pivotDbValues = mappings.filter((m) => m.isDerived).map((m) => m.dbValue);
for (const [, steps] of processMap) {
steps.sort((a, b) => a.seqNo - b.seqNo);
steps.forEach((s) => { s.isCurrent = false; });
if (pivotDbValues.length > 0) {
const pivotStep = steps.find((s) => pivotDbValues.includes(s.status));
if (pivotStep) {
pivotStep.isCurrent = true;
continue;
}
}
// 폴백: active가 있으면 첫 active, 없으면 첫 pending
const firstActive = steps.find((s) => s.semantic === "active");
if (firstActive) { firstActive.isCurrent = true; continue; }
const firstPending = steps.find((s) => s.semantic === "pending");
if (firstPending) { firstPending.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]);
const fetchDataRef = useRef(fetchData);
fetchDataRef.current = fetchData;
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>
) : (
<>
{/* 선택 모드 상단 바 */}
{selectMode && (
<div className="flex shrink-0 items-center justify-between border-b bg-primary/5 px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
<span className="text-xs font-bold">{selectedRowIds.size}</span>
</div>
<span className="text-sm font-medium">
{selectedRowIds.size > 0 ? `${selectedRowIds.size}개 선택됨` : "카드를 선택하세요"}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={exitSelectMode}
className="h-7 px-2 text-xs"
>
<X className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
)}
{/* 장바구니 모드 상단 바 */}
{!selectMode && 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}
selectMode={selectMode}
isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))}
isSelectable={isRowSelectable(row)}
onToggleRowSelect={() => toggleRowSelection(row)}
onEnterSelectMode={enterSelectMode}
onOpenPopModal={openPopModal}
/>
))}
</div>
{/* 선택 모드 하단 액션 바 */}
{selectMode && selectModeConfig && (
<div className="shrink-0 border-t bg-background px-3 py-2.5 shadow-[0_-2px_8px_rgba(0,0,0,0.08)]">
<div className="flex items-center justify-end gap-2">
{selectModeConfig.buttons.map((btn, idx) => (
<Button
key={idx}
variant={btn.variant || (btn.clickMode === "cancel-select" ? "outline" : "default")}
size="sm"
className="h-10 min-w-[80px] px-4 text-sm font-medium"
disabled={selectProcessing || (btn.clickMode !== "cancel-select" && selectedRowIds.size === 0)}
onClick={() => handleSelectModeAction(btn)}
>
{selectProcessing && btn.clickMode !== "cancel-select" && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
{btn.label}
</Button>
))}
</div>
</div>
)}
{/* 더보기/페이지네이션 */}
{!selectMode && 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>
)}
</>
)}
{/* POP 화면 모달 */}
<Dialog open={popModalOpen} onOpenChange={(open) => {
setPopModalOpen(open);
if (!open) {
setPopModalLayout(null);
setPopModalRow(null);
}
}}>
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-auto p-0 sm:max-w-[800px]">
<DialogHeader className="px-4 pt-4">
<DialogTitle className="text-base"> </DialogTitle>
</DialogHeader>
<div className="min-h-[300px] px-2 pb-4">
{popModalLayout && (
<PopViewerWithModals
layout={popModalLayout}
viewportWidth={760}
screenId={popModalScreenId}
currentMode={detectGridMode(760)}
/>
)}
</div>
</DialogContent>
</Dialog>
</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;
selectMode?: boolean;
isSelectModeSelected?: boolean;
isSelectable?: boolean;
onToggleRowSelect?: () => void;
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
onOpenPopModal?: (screenId: string, row: RowData) => void;
}
function CardV2({
row, cardGrid, spec, config, onSelect, cart, publish,
parentComponentId, isCartListMode, isSelected, onToggleSelect,
onDeleteItem, onUpdateQuantity, onRefresh,
selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode,
onOpenPopModal,
}: 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 = selectMode
? isSelectModeSelected
? "border-primary border-2 bg-primary/5"
: isSelectable
? "hover:border-2 hover:border-primary/50"
: "opacity-40 pointer-events-none"
: 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={() => {
if (selectMode && isSelectable) { onToggleRowSelect?.(); return; }
if (!selectMode) onSelect?.(row);
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (selectMode && isSelectable) { onToggleRowSelect?.(); return; }
if (!selectMode) onSelect?.(row);
}
}}
>
{/* 선택 모드: 체크 인디케이터 */}
{selectMode && isSelectable && (
<div className="absolute left-1.5 top-1.5 z-10">
<div
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full border-2 transition-colors duration-150",
isSelectModeSelected
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/40 bg-background"
)}
onClick={(e) => { e.stopPropagation(); onToggleRowSelect?.(); }}
>
{isSelectModeSelected && <Check className="h-3.5 w-3.5" />}
</div>
</div>
)}
{/* 장바구니 목록 모드: 체크박스 + 삭제 */}
{!selectMode && 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,
onEnterSelectMode,
onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => {
const cfg = buttonConfig as {
updates?: ActionButtonUpdate[];
targetTable?: string;
confirmMessage?: string;
__processId?: string | number;
} | undefined;
if (cfg?.updates && cfg.updates.length > 0 && cfg.targetTable) {
if (cfg.confirmMessage) {
if (!window.confirm(cfg.confirmMessage)) return;
}
try {
// 공정 테이블 대상이면 processId 우선 사용
const rowId = cfg.__processId ?? 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 ?? ""),
lookupMode: "manual" as const,
manualItemField: "id",
manualPkColumn: "id",
}));
const targetRow = cfg.__processId
? { ...actionRow, id: cfg.__processId }
: actionRow;
const result = await apiClient.post("/pop/execute-action", {
tasks,
data: { items: [targetRow], 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;
}
const actionCfg = buttonConfig as { type?: string; modalScreenId?: string } | undefined;
if (actionCfg?.type === "modal-open" && actionCfg.modalScreenId) {
onOpenPopModal?.(actionCfg.modalScreenId, actionRow);
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>
);
}