refactor: POP 디자인 감사 + WYSIWYG 정렬 + MES/장바구니 분리 + 장바구니 코드 제거
POP 컴포넌트 전반의 디자인 일관성을 확보하고, 디자이너-뷰어 간 WYSIWYG를 달성하며, MES 공정흐름 컴포넌트에서 장바구니 코드를 완전 분리한다. [POP 디자인 종합 감사] - PopRenderer 고스트 보더 제거 (border-2 bg-white -> 투명 래퍼) - 하드코딩 배경색 -> CSS 변수 hsl(var(--background)) - 빈 상태 메시지 border-dashed bg-muted/30 제거 - 콘텐츠 컴포넌트만 선택적 보더 (rounded-lg border-border/40 bg-card) [뷰어-디자이너 WYSIWYG 통일] - PopRenderer 행 높이: 디자이너/뷰어 모두 고정 24px (minmax 제거) - page.tsx mx-auto + maxWidth 제거 -> 뷰어 전체 폭 채움 - pop-icon 셀 내 스케일링 (maxWidth/maxHeight 100% + aspectRatio) - pop-icon 라벨 표시 + 배경 투명/아이콘 색상 설정 UI 추가 - pop-profile 반응형, pop-button 오버플로 클리핑 [마키(흐르는 텍스트) 추가] - pop-text에 marquee 옵션 (marqueeSpeed, marqueeIcon 설정) - CSS animation + paddingRight 100vw 연속 스크롤 [컴포넌트 이름 명확화] - pop-card-list-v2: "카드 목록 V2" -> "MES 공정흐름" - pop-card-list: "카드 목록" -> "장바구니 목록" [MES 공정흐름에서 장바구니 코드 완전 제거] - PopCardListV2Component: useCartSync, parseCartRow, isCartListMode, selectedKeys, cartRef, cart 이벤트 3개, fetchCartData, handleDeleteItem/UpdateQuantity/CartAdd/Cancel/Delete 제거 (~250줄) - cell-renderers: CartButtonCell, DynamicLucideIcon, ShoppingCart 제거 (~50줄) - PopCardListV2Config: cart-button 셀 편집 UI 제거 - index.tsx: cart_updated/cart_save_completed/selected_items/ cart_save_trigger/confirm_trigger 이벤트 메타 제거 - migrate.ts: cartAction/cartListMode 마이그레이션 제거 - types.ts: PopCardListV2Config cartAction/cartListMode 필드, CardCellDefinitionV2 cart 4필드, CardCellType "cart-button" 제거 - pop-card-list(장바구니 목록)의 타입/훅은 그대로 유지 - 매 Phase마다 tsc --noEmit 검증, 신규 에러 0건
This commit is contained in:
parent
73674385be
commit
461ff6dbf7
|
|
@ -291,10 +291,10 @@ function PopScreenViewPage() {
|
|||
{/* 일반 모드 네비게이션 바 제거 (프로필 컴포넌트에서 PC 모드 전환 가능) */}
|
||||
|
||||
{/* POP 화면 컨텐츠 */}
|
||||
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}>
|
||||
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-background"}`}>
|
||||
|
||||
<div
|
||||
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-foreground" : "w-full min-h-full"}`}
|
||||
className={`bg-background transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-foreground" : "w-full min-h-full"}`}
|
||||
style={isPreviewMode ? {
|
||||
width: currentDevice.width,
|
||||
maxHeight: "80vh",
|
||||
|
|
@ -304,8 +304,8 @@ function PopScreenViewPage() {
|
|||
{/* v5 그리드 렌더러 */}
|
||||
{hasComponents ? (
|
||||
<div
|
||||
className="mx-auto min-h-full"
|
||||
style={{ maxWidth: 1366 }}
|
||||
className="min-h-full"
|
||||
style={isPreviewMode ? { maxWidth: currentDevice.width, margin: "0 auto" } : undefined}
|
||||
>
|
||||
{(() => {
|
||||
const adjustedGap = BLOCK_GAP;
|
||||
|
|
|
|||
|
|
@ -70,8 +70,8 @@ const COMPONENT_TYPE_LABELS: Record<string, string> = {
|
|||
"pop-text": "텍스트",
|
||||
"pop-icon": "아이콘",
|
||||
"pop-dashboard": "대시보드",
|
||||
"pop-card-list": "카드 목록",
|
||||
"pop-card-list-v2": "카드 목록 V2",
|
||||
"pop-card-list": "장바구니 목록",
|
||||
"pop-card-list-v2": "MES 공정흐름",
|
||||
"pop-field": "필드",
|
||||
"pop-button": "버튼",
|
||||
"pop-string-list": "리스트 목록",
|
||||
|
|
|
|||
|
|
@ -41,13 +41,13 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||
},
|
||||
{
|
||||
type: "pop-card-list",
|
||||
label: "카드 목록",
|
||||
label: "장바구니 목록",
|
||||
icon: LayoutGrid,
|
||||
description: "테이블 데이터를 카드 형태로 표시",
|
||||
},
|
||||
{
|
||||
type: "pop-card-list-v2",
|
||||
label: "카드 목록 V2",
|
||||
label: "MES 공정흐름",
|
||||
icon: LayoutGrid,
|
||||
description: "슬롯 기반 카드 (CSS Grid + 셀 타입별 렌더링)",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -75,8 +75,8 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
|||
"pop-text": "텍스트",
|
||||
"pop-icon": "아이콘",
|
||||
"pop-dashboard": "대시보드",
|
||||
"pop-card-list": "카드 목록",
|
||||
"pop-card-list-v2": "카드 목록 V2",
|
||||
"pop-card-list": "장바구니 목록",
|
||||
"pop-card-list-v2": "MES 공정흐름",
|
||||
"pop-button": "버튼",
|
||||
"pop-string-list": "리스트 목록",
|
||||
"pop-search": "검색",
|
||||
|
|
@ -145,13 +145,9 @@ export default function PopRenderer({
|
|||
return Math.max(10, maxRowEnd + 3);
|
||||
}, [components, overrides, mode, hiddenIds]);
|
||||
|
||||
// V6: CSS Grid - 열은 1fr(뷰포트 꽉 채움), 행은 고정 BLOCK_SIZE
|
||||
const rowTemplate = isDesignMode
|
||||
? `repeat(${dynamicRowCount}, ${BLOCK_SIZE}px)`
|
||||
: `repeat(${dynamicRowCount}, minmax(${BLOCK_SIZE}px, auto))`;
|
||||
const autoRowHeight = isDesignMode
|
||||
? `${BLOCK_SIZE}px`
|
||||
: `minmax(${BLOCK_SIZE}px, auto)`;
|
||||
// V6: CSS Grid - 열은 1fr(뷰포트 꽉 채움), 행은 고정 BLOCK_SIZE (디자이너/뷰어 동일 = WYSIWYG)
|
||||
const rowTemplate = `repeat(${dynamicRowCount}, ${BLOCK_SIZE}px)`;
|
||||
const autoRowHeight = `${BLOCK_SIZE}px`;
|
||||
|
||||
const gridStyle = useMemo((): React.CSSProperties => ({
|
||||
display: "grid",
|
||||
|
|
@ -161,7 +157,7 @@ export default function PopRenderer({
|
|||
gap: `${finalGap}px`,
|
||||
padding: `${finalPadding}px`,
|
||||
minHeight: "100%",
|
||||
backgroundColor: "#ffffff",
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
position: "relative",
|
||||
}), [columns, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]);
|
||||
|
||||
|
|
@ -296,11 +292,20 @@ export default function PopRenderer({
|
|||
);
|
||||
}
|
||||
|
||||
// 뷰어 모드: 드래그 없는 일반 렌더링 (overflow visible로 컨텐츠 확장 허용)
|
||||
// 콘텐츠 영역 컴포넌트는 라운드 테두리 표시
|
||||
const contentTypes = new Set([
|
||||
"pop-dashboard", "pop-card-list", "pop-card-list-v2",
|
||||
"pop-string-list", "pop-work-detail", "pop-sample",
|
||||
]);
|
||||
const needsBorder = contentTypes.has(comp.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="relative overflow-hidden rounded-lg border-2 border-border bg-white transition-all z-10"
|
||||
className={cn(
|
||||
"relative overflow-hidden transition-all z-10",
|
||||
needsBorder && "rounded-lg border border-border/40 bg-card"
|
||||
)}
|
||||
style={positionStyle}
|
||||
>
|
||||
<ComponentContent
|
||||
|
|
|
|||
|
|
@ -1072,10 +1072,10 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
|
|||
const cartButtonClass = useMemo(() => {
|
||||
if (!isCartMode) return "";
|
||||
if (cartCount > 0 && !cartIsDirty) {
|
||||
return "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600";
|
||||
return "bg-primary hover:bg-primary/90 text-primary-foreground border-primary";
|
||||
}
|
||||
if (cartIsDirty) {
|
||||
return "bg-amber-500 hover:bg-orange-600 text-white border-orange-500 animate-pulse";
|
||||
return "bg-warning hover:bg-warning/90 text-warning-foreground border-warning animate-pulse";
|
||||
}
|
||||
return "";
|
||||
}, [isCartMode, cartCount, cartIsDirty]);
|
||||
|
|
@ -1089,19 +1089,19 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
|
|||
// 데이터 작업 버튼 2상태 색상: 미선택(기본) / 선택됨(초록)
|
||||
const inboundButtonClass = useMemo(() => {
|
||||
if (isCartMode) return "";
|
||||
return inboundSelectedCount > 0 ? "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600" : "";
|
||||
return inboundSelectedCount > 0 ? "bg-primary hover:bg-primary/90 text-primary-foreground border-primary" : "";
|
||||
}, [isCartMode, inboundSelectedCount]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="relative">
|
||||
<div className="flex h-full w-full items-center justify-center overflow-hidden">
|
||||
<div className="relative max-h-full">
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={handleClick}
|
||||
disabled={isLoading || cartSaving || confirmProcessing}
|
||||
className={cn(
|
||||
"transition-transform active:scale-95",
|
||||
"max-h-full transition-transform active:scale-95",
|
||||
isIconOnly && "px-2",
|
||||
cartButtonClass,
|
||||
inboundButtonClass,
|
||||
|
|
@ -1121,8 +1121,8 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
|
|||
{isCartMode && cartCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-2 -right-2 flex items-center justify-center rounded-full text-[10px] font-bold",
|
||||
cartIsDirty ? "bg-amber-500 text-white" : "bg-emerald-600 text-white",
|
||||
"absolute top-0 right-0 translate-x-1/3 -translate-y-1/3 flex items-center justify-center rounded-full text-[10px] font-bold",
|
||||
cartIsDirty ? "bg-warning text-warning-foreground" : "bg-primary text-primary-foreground",
|
||||
)}
|
||||
style={{ minWidth: 18, height: 18, padding: "0 4px" }}
|
||||
>
|
||||
|
|
@ -1133,7 +1133,7 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
|
|||
{/* 선택 개수 배지 (v1 inbound-confirm + v2 data tasks) */}
|
||||
{!isCartMode && hasDataTasks && inboundSelectedCount > 0 && (
|
||||
<div
|
||||
className="absolute -top-2 -right-2 flex items-center justify-center rounded-full bg-emerald-600 text-[10px] font-bold text-white"
|
||||
className="absolute top-0 right-0 translate-x-1/3 -translate-y-1/3 flex items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground"
|
||||
style={{ minWidth: 18, height: 18, padding: "0 4px" }}
|
||||
>
|
||||
{inboundSelectedCount}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* pop-card-list-v2 런타임 컴포넌트
|
||||
* pop-card-list-v2 런타임 컴포넌트 (MES 공정흐름)
|
||||
*
|
||||
* pop-card-list의 데이터 로딩/필터링/페이징/장바구니 로직을 재활용하되,
|
||||
* 카드 내부 렌더링은 CSS Grid + 셀 타입별 렌더러(cell-renderers.tsx)로 대체.
|
||||
* 데이터 로딩/필터링/페이징 로직 + CSS Grid + 셀 타입별 렌더러(cell-renderers.tsx).
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Trash2, Check, X,
|
||||
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Check, X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -22,10 +21,8 @@ import type {
|
|||
CardGridConfigV2,
|
||||
CardCellDefinitionV2,
|
||||
CardInputFieldConfig,
|
||||
CardCartActionConfig,
|
||||
CardPackageConfig,
|
||||
CardPresetSpec,
|
||||
CartItem,
|
||||
PackageEntry,
|
||||
CollectDataRequest,
|
||||
CollectedDataResponse,
|
||||
|
|
@ -46,7 +43,6 @@ 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 { useAuth } from "@/hooks/useAuth";
|
||||
import { NumberInputModal } from "../pop-card-list/NumberInputModal";
|
||||
import { renderCellV2 } from "./cell-renderers";
|
||||
|
|
@ -95,28 +91,6 @@ function calculateMaxQty(
|
|||
return maxVal;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
|
@ -153,28 +127,11 @@ export function PopCardListV2Component({
|
|||
const { subscribe, publish, setSharedData } = usePopEvent(screenId || "default");
|
||||
const { userId: currentUserId } = useAuth();
|
||||
|
||||
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 isHorizontalMode = (config?.scrollDirection || "vertical") === "horizontal";
|
||||
const maxGridColumns = config?.gridColumns || 2;
|
||||
const configGridRows = config?.gridRows || 3;
|
||||
const dataSource = config?.dataSource;
|
||||
const cardGrid = config?.cardGrid;
|
||||
|
||||
const [rows, setRows] = useState<RowData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -220,32 +177,6 @@ export function PopCardListV2Component({
|
|||
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 [selectMode, setSelectMode] = useState(false);
|
||||
const [selectModeStatus, setSelectModeStatus] = useState<string>("");
|
||||
|
|
@ -294,7 +225,7 @@ export function PopCardListV2Component({
|
|||
const handleCardSelect = useCallback((row: RowData) => {
|
||||
if (row.__isAcceptClone) return;
|
||||
|
||||
if (effectiveConfig?.cardClickAction === "built-in-work-detail") {
|
||||
if (config?.cardClickAction === "built-in-work-detail") {
|
||||
const subStatus = row[VIRTUAL_SUB_STATUS] as string | undefined;
|
||||
if (subStatus && subStatus !== "in_progress") return;
|
||||
setWorkDetailRow(row);
|
||||
|
|
@ -302,8 +233,8 @@ export function PopCardListV2Component({
|
|||
return;
|
||||
}
|
||||
|
||||
if (effectiveConfig?.cardClickAction === "modal-open" && effectiveConfig?.cardClickModalConfig?.screenId) {
|
||||
const mc = effectiveConfig.cardClickModalConfig;
|
||||
if (config?.cardClickAction === "modal-open" && config?.cardClickModalConfig?.screenId) {
|
||||
const mc = config.cardClickModalConfig;
|
||||
|
||||
// 작업상세는 "진행(in_progress)" 탭 카드만 열 수 있음
|
||||
const subStatus = row[VIRTUAL_SUB_STATUS] as string | undefined;
|
||||
|
|
@ -330,7 +261,7 @@ export function PopCardListV2Component({
|
|||
}
|
||||
if (!componentId) return;
|
||||
publish(`__comp_output__${componentId}__selected_row`, row);
|
||||
}, [componentId, publish, effectiveConfig, openPopModal]);
|
||||
}, [componentId, publish, config, openPopModal]);
|
||||
|
||||
const enterSelectMode = useCallback((whenStatus: string, buttonConfig: Record<string, unknown>) => {
|
||||
const smConfig = buttonConfig.selectModeConfig as SelectModeConfig | undefined;
|
||||
|
|
@ -391,7 +322,7 @@ export function PopCardListV2Component({
|
|||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const cardSizeKey = effectiveConfig?.cardSize || "large";
|
||||
const cardSizeKey = config?.cardSize || "large";
|
||||
const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large;
|
||||
|
||||
const maxAllowedColumns = useMemo(() => {
|
||||
|
|
@ -756,7 +687,7 @@ export function PopCardListV2Component({
|
|||
});
|
||||
}, [componentId, rowsForStatusCount, loading, publish, hasActiveSubFilter]);
|
||||
|
||||
const overflowCfg = effectiveConfig?.overflow;
|
||||
const overflowCfg = config?.overflow;
|
||||
const baseVisibleCount = overflowCfg?.visibleCount ?? gridColumns * gridRows;
|
||||
const visibleCardCount = useMemo(() => Math.max(1, baseVisibleCount), [baseVisibleCount]);
|
||||
const hasMoreCards = statusFilteredRows.length > visibleCardCount;
|
||||
|
|
@ -823,8 +754,6 @@ export function PopCardListV2Component({
|
|||
|
||||
// 데이터 조회
|
||||
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[],
|
||||
|
|
@ -1167,52 +1096,8 @@ export function PopCardListV2Component({
|
|||
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 };
|
||||
}));
|
||||
}, []);
|
||||
}, [dataSourceKey, fetchData]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 데이터 수집
|
||||
useEffect(() => {
|
||||
|
|
@ -1221,33 +1106,22 @@ export function PopCardListV2Component({
|
|||
`__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,
|
||||
data: { items: rows } 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]);
|
||||
}, [componentId, subscribe, publish, rows, config]);
|
||||
|
||||
// 공정 완료 이벤트 수신 시 목록 갱신
|
||||
useEffect(() => {
|
||||
|
|
@ -1258,7 +1132,7 @@ export function PopCardListV2Component({
|
|||
}, [subscribe]);
|
||||
|
||||
// 카드 영역 스타일
|
||||
const cardGap = effectiveConfig?.cardGap ?? spec.gap;
|
||||
const cardGap = config?.cardGap ?? spec.gap;
|
||||
const cardMinHeight = spec.height;
|
||||
const cardAreaStyle: React.CSSProperties = {
|
||||
gap: `${cardGap}px`,
|
||||
|
|
@ -1282,28 +1156,24 @@ export function PopCardListV2Component({
|
|||
|
||||
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">
|
||||
{!dataSource?.tableName ? (
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<p className="text-sm text-muted-foreground">데이터 소스를 설정해주세요.</p>
|
||||
</div>
|
||||
) : effectiveConfig?.hideUntilFiltered && effectiveExternalFilters.size === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center rounded-md border border-dashed bg-muted/30 p-4">
|
||||
) : config?.hideUntilFiltered && effectiveExternalFilters.size === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center 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">
|
||||
<div className="flex flex-1 items-center justify-center 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">
|
||||
<div className="flex flex-1 items-center justify-center rounded-md 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">
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<p className="text-sm text-muted-foreground">데이터가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -1331,27 +1201,6 @@ export function PopCardListV2Component({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 장바구니 모드 상단 바 */}
|
||||
{!selectMode && isCartListMode && (
|
||||
<div className="flex shrink-0 items-center gap-3 border-b px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKeys.size === statusFilteredRows.length && statusFilteredRows.length > 0}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedKeys(new Set(statusFilteredRows.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>
|
||||
)}
|
||||
|
||||
{/* 내장 MES 상태 탭 */}
|
||||
{config?.showStatusTabs && statusCounts && hasProcessFlow && !selectMode && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b bg-background px-4 py-2">
|
||||
|
|
@ -1407,20 +1256,10 @@ export function PopCardListV2Component({
|
|||
row={row}
|
||||
cardGrid={cardGrid}
|
||||
spec={spec}
|
||||
config={effectiveConfig}
|
||||
config={config}
|
||||
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 ?? ""))}
|
||||
|
|
@ -1500,7 +1339,7 @@ export function PopCardListV2Component({
|
|||
{workDetailRow && (
|
||||
<LazyPopWorkDetail
|
||||
parentRow={workDetailRow}
|
||||
config={effectiveConfig?.workDetailConfig}
|
||||
config={config?.workDetailConfig}
|
||||
screenId={screenId}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1518,7 +1357,7 @@ export function PopCardListV2Component({
|
|||
}}>
|
||||
<DialogContent className="flex h-dvh w-screen max-w-none flex-col gap-0 rounded-none border-none p-0 [&>button]:z-50">
|
||||
<DialogHeader className="flex shrink-0 flex-row items-center justify-between border-b px-4 py-2">
|
||||
<DialogTitle className="text-base">{effectiveConfig?.cardClickModalConfig?.modalTitle || "상세 작업"}</DialogTitle>
|
||||
<DialogTitle className="text-base">{config?.cardClickModalConfig?.modalTitle || "상세 작업"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{popModalLayout && (
|
||||
|
|
@ -1545,14 +1384,8 @@ interface CardV2Props {
|
|||
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;
|
||||
|
|
@ -1565,16 +1398,13 @@ interface CardV2Props {
|
|||
}
|
||||
|
||||
function CardV2({
|
||||
row, cardGrid, spec, config, onSelect, cart, publish,
|
||||
parentComponentId, isCartListMode, isSelected, onToggleSelect,
|
||||
onDeleteItem, onUpdateQuantity, onRefresh,
|
||||
row, cardGrid, spec, config, onSelect, publish,
|
||||
parentComponentId, onRefresh,
|
||||
selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode,
|
||||
onOpenPopModal, currentUserId, isLockedByOther,
|
||||
}: 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);
|
||||
|
|
@ -1692,27 +1522,6 @@ function CardV2({
|
|||
closeQtyModal();
|
||||
}, [qtyModalState, onRefresh, closeQtyModal]);
|
||||
|
||||
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(() => {
|
||||
|
|
@ -1721,41 +1530,16 @@ function CardV2({
|
|||
}, [limitCol, row]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCartListMode) return;
|
||||
if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) {
|
||||
setInputValue(effectiveMax);
|
||||
}
|
||||
}, [effectiveMax, inputField?.enabled, limitCol, isCartListMode]);
|
||||
}, [effectiveMax, inputField?.enabled, limitCol]);
|
||||
|
||||
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
|
||||
|
|
@ -1764,9 +1548,7 @@ function CardV2({
|
|||
: 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";
|
||||
: "hover:border-2 hover:border-blue-500";
|
||||
|
||||
// mes-process-card 전용 카드일 때 래퍼 스타일 변경
|
||||
const isMesCard = cardGrid?.cells.some((c) => c.type === "mes-process-card");
|
||||
|
|
@ -1844,22 +1626,6 @@ function CardV2({
|
|||
</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={cn("flex-1 overflow-hidden", !isMesCard && "p-1")} style={gridStyle}>
|
||||
{cardGrid.cells.map((cell) => (
|
||||
|
|
@ -1880,10 +1646,7 @@ function CardV2({
|
|||
cell,
|
||||
row,
|
||||
inputValue,
|
||||
isCarted,
|
||||
onInputClick: handleInputClick,
|
||||
onCartAdd: handleCartAdd,
|
||||
onCartCancel: handleCartCancel,
|
||||
onEnterSelectMode,
|
||||
onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => {
|
||||
const cfg = buttonConfig as Record<string, unknown> | undefined;
|
||||
|
|
|
|||
|
|
@ -1556,15 +1556,6 @@ function CellDetailEditor({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{cell.type === "cart-button" && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[9px] font-medium text-muted-foreground">담기 버튼 설정</span>
|
||||
<div className="flex gap-1">
|
||||
<Input value={cell.cartLabel || ""} onChange={(e) => onUpdate({ cartLabel: e.target.value })} placeholder="담기" className="h-7 flex-1 text-[10px]" />
|
||||
<Input value={cell.cartCancelLabel || ""} onChange={(e) => onUpdate({ cartCancelLabel: e.target.value })} placeholder="취소" className="h-7 flex-1 text-[10px]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function PopCardListV2PreviewComponent({ config }: PopCardListV2PreviewPr
|
|||
<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>
|
||||
<span className="text-xs font-medium">MES 공정흐름</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[9px] text-primary">
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
|
||||
X, Package, Truck, Box, Archive, Heart, Star,
|
||||
Loader2, CheckCircle2, CircleDot, Clock, Check, ChevronRight,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
|
@ -27,16 +27,9 @@ type RowData = Record<string, unknown>;
|
|||
// ===== 공통 유틸 =====
|
||||
|
||||
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
||||
ShoppingCart, Package, Truck, Box, Archive, Heart, Star,
|
||||
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();
|
||||
|
|
@ -60,11 +53,8 @@ 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;
|
||||
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
|
||||
|
|
@ -89,8 +79,6 @@ export function renderCellV2(props: CellRendererProps): React.ReactNode {
|
|||
return <ButtonCell {...props} />;
|
||||
case "number-input":
|
||||
return <NumberInputCell {...props} />;
|
||||
case "cart-button":
|
||||
return <CartButtonCell {...props} />;
|
||||
case "package-summary":
|
||||
return <PackageSummaryCell {...props} />;
|
||||
case "status-badge":
|
||||
|
|
@ -262,43 +250,7 @@ function NumberInputCell({ cell, row, inputValue, onInputClick }: CellRendererPr
|
|||
);
|
||||
}
|
||||
|
||||
// ===== 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 =====
|
||||
// ===== 7. package-summary =====
|
||||
|
||||
function PackageSummaryCell({ cell, packageEntries, inputUnit }: CellRendererProps) {
|
||||
if (!packageEntries || packageEntries.length === 0) return null;
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ const defaultConfig: PopCardListV2Config = {
|
|||
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-card-list-v2",
|
||||
name: "카드 목록 V2",
|
||||
description: "슬롯 기반 카드 레이아웃 (CSS Grid + 셀 타입별 렌더링)",
|
||||
name: "MES 공정흐름",
|
||||
description: "MES 생산실적 카드 레이아웃 (공정 흐름 + 상태 관리)",
|
||||
category: "display",
|
||||
icon: "LayoutGrid",
|
||||
component: PopCardListV2Component,
|
||||
|
|
@ -44,15 +44,10 @@ PopComponentRegistry.registerComponent({
|
|||
sendable: [
|
||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
|
||||
{ key: "all_rows", label: "전체 데이터", type: "all_rows", 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: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" },
|
||||
{ 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: "버튼에서 데이터+매핑 수집 요청 수신" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Conf
|
|||
// 3. 본문 필드들 (이미지 오른쪽)
|
||||
const fieldStartCol = old.cardTemplate?.image?.enabled ? 2 : 1;
|
||||
const fieldColSpan = old.cardTemplate?.image?.enabled ? 2 : 3;
|
||||
const hasRightActions = !!(old.inputField?.enabled || old.cartAction);
|
||||
const hasRightActions = !!old.inputField?.enabled;
|
||||
|
||||
(old.cardTemplate?.body?.fields || []).forEach((field, i) => {
|
||||
cells.push({
|
||||
|
|
@ -102,20 +102,7 @@ export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Conf
|
|||
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) {
|
||||
|
|
@ -156,8 +143,6 @@ export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Conf
|
|||
responsiveDisplay: old.responsiveDisplay,
|
||||
inputField: old.inputField,
|
||||
packageConfig: old.packageConfig,
|
||||
cartAction: old.cartAction,
|
||||
cartListMode: old.cartListMode,
|
||||
saveMapping: old.saveMapping,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function PopCardListPreviewComponent({
|
|||
<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">카드 목록</span>
|
||||
<span className="text-xs font-medium">장바구니 목록</span>
|
||||
</div>
|
||||
|
||||
{/* 설정 배지 */}
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ const defaultConfig: PopCardListConfig = {
|
|||
// 레지스트리 등록
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-card-list",
|
||||
name: "카드 목록",
|
||||
description: "테이블 데이터를 카드 형태로 표시 (헤더 + 이미지 + 필드 목록)",
|
||||
name: "장바구니 목록",
|
||||
description: "장바구니 담기/확정 카드 목록 (입고, 출고, 수주 등)",
|
||||
category: "display",
|
||||
icon: "LayoutGrid",
|
||||
component: PopCardListComponent,
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export interface PopIconConfig {
|
|||
labelColor?: string;
|
||||
labelFontSize?: number;
|
||||
backgroundColor?: string;
|
||||
iconColor?: string;
|
||||
gradient?: GradientConfig;
|
||||
borderRadiusPercent?: number;
|
||||
sizeMode: IconSizeMode;
|
||||
|
|
@ -337,12 +338,14 @@ export function PopIconComponent({
|
|||
setPendingNavigate(null);
|
||||
};
|
||||
|
||||
// 배경 스타일 (이미지 타입일 때는 배경 없음)
|
||||
// 배경 스타일: transparent 설정이 최우선
|
||||
const backgroundStyle: React.CSSProperties = iconType === "image"
|
||||
? { backgroundColor: "transparent" }
|
||||
: config?.gradient
|
||||
? buildGradientStyle(config.gradient)
|
||||
: { backgroundColor: config?.backgroundColor || "#e0e0e0" };
|
||||
: config?.backgroundColor === "transparent"
|
||||
? { backgroundColor: "transparent" }
|
||||
: config?.gradient
|
||||
? buildGradientStyle(config.gradient)
|
||||
: { backgroundColor: config?.backgroundColor || "hsl(var(--muted))" };
|
||||
|
||||
// 테두리 반경 (0% = 사각형, 100% = 원형)
|
||||
const radiusPercent = config?.borderRadiusPercent ?? 20;
|
||||
|
|
@ -352,6 +355,8 @@ export function PopIconComponent({
|
|||
const isLabelRight = config?.labelPosition === "right";
|
||||
const showLabel = config?.labelPosition !== "none" && (config?.label || label);
|
||||
|
||||
const effectiveIconColor = config?.iconColor || "#ffffff";
|
||||
|
||||
// 아이콘 렌더링
|
||||
const renderIcon = () => {
|
||||
// 빠른 선택
|
||||
|
|
@ -361,7 +366,7 @@ export function PopIconComponent({
|
|||
<DynamicLucideIcon
|
||||
name={config.quickSelectValue}
|
||||
size={iconSize * 0.5}
|
||||
className="text-white"
|
||||
style={{ color: effectiveIconColor }}
|
||||
/>
|
||||
);
|
||||
} else if (config?.quickSelectType === "emoji" && config?.quickSelectValue) {
|
||||
|
|
@ -398,36 +403,40 @@ export function PopIconComponent({
|
|||
return <span style={{ fontSize: iconSize * 0.5 }}>📦</span>;
|
||||
};
|
||||
|
||||
const hasLabel = showLabel && (config?.label || label);
|
||||
const labelFontSize = config?.labelFontSize || 12;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center cursor-pointer transition-transform hover:scale-105",
|
||||
"flex h-full w-full items-center justify-center cursor-pointer transition-transform hover:scale-105",
|
||||
isLabelRight ? "flex-row gap-2" : "flex-col"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 아이콘 컨테이너 */}
|
||||
{/* 아이콘 컨테이너: 라벨이 있으면 라벨 공간만큼 축소 */}
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{
|
||||
...backgroundStyle,
|
||||
borderRadius,
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
minWidth: iconSize,
|
||||
minHeight: iconSize,
|
||||
maxWidth: "100%",
|
||||
maxHeight: hasLabel && !isLabelRight ? `calc(100% - ${labelFontSize + 6}px)` : "100%",
|
||||
aspectRatio: "1 / 1",
|
||||
}}
|
||||
>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
|
||||
{/* 라벨 */}
|
||||
{showLabel && (
|
||||
{hasLabel && (
|
||||
<span
|
||||
className={cn("truncate max-w-full", !isLabelRight && "mt-1")}
|
||||
className={cn("shrink-0 truncate max-w-full leading-tight", !isLabelRight && "mt-0.5")}
|
||||
style={{
|
||||
color: config?.labelColor || "hsl(var(--foreground))",
|
||||
fontSize: config?.labelFontSize || 12,
|
||||
fontSize: labelFontSize,
|
||||
}}
|
||||
>
|
||||
{config?.label || label}
|
||||
|
|
@ -453,8 +462,6 @@ export function PopIconComponent({
|
|||
<AlertDialogFooter>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmNavigate}
|
||||
className="text-white"
|
||||
style={{ backgroundColor: "#0984e3" }}
|
||||
>
|
||||
확인 후 이동
|
||||
</AlertDialogAction>
|
||||
|
|
@ -853,23 +860,69 @@ function LabelSettings({ config, onUpdate }: PopIconConfigPanelProps) {
|
|||
|
||||
// 스타일 설정
|
||||
function StyleSettings({ config, onUpdate }: PopIconConfigPanelProps) {
|
||||
const bgColor = config?.backgroundColor || "";
|
||||
const iconColor = config?.iconColor || "#ffffff";
|
||||
const isTransparent = bgColor === "transparent";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">
|
||||
모서리: {config?.borderRadiusPercent ?? 20}%
|
||||
</Label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={config?.borderRadiusPercent ?? 20}
|
||||
onChange={(e) => onUpdate({
|
||||
...config,
|
||||
borderRadiusPercent: Number(e.target.value)
|
||||
})}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{/* 배경색 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">배경색</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isTransparent}
|
||||
onChange={(e) => onUpdate({
|
||||
...config,
|
||||
backgroundColor: e.target.checked ? "transparent" : "",
|
||||
iconColor: e.target.checked && iconColor === "#ffffff" ? "hsl(var(--foreground))" : iconColor,
|
||||
})}
|
||||
className="h-3.5 w-3.5 rounded"
|
||||
/>
|
||||
투명
|
||||
</label>
|
||||
{!isTransparent && (
|
||||
<Input
|
||||
type="color"
|
||||
value={bgColor || "#d1d5db"}
|
||||
onChange={(e) => onUpdate({ ...config, backgroundColor: e.target.value })}
|
||||
className="h-8 w-12 cursor-pointer p-0.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 아이콘 색상 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">아이콘 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={iconColor.startsWith("hsl") ? "#000000" : iconColor}
|
||||
onChange={(e) => onUpdate({ ...config, iconColor: e.target.value })}
|
||||
className="h-8 w-12 cursor-pointer p-0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 모서리 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">
|
||||
모서리: {config?.borderRadiusPercent ?? 20}%
|
||||
</Label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={config?.borderRadiusPercent ?? 20}
|
||||
onChange={(e) => onUpdate({
|
||||
...config,
|
||||
borderRadiusPercent: Number(e.target.value)
|
||||
})}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ function PopProfileComponent({ config: rawConfig }: PopProfileComponentProps) {
|
|||
sizeInfo.container,
|
||||
sizeInfo.text,
|
||||
)}
|
||||
style={{ minWidth: sizeInfo.px, minHeight: sizeInfo.px }}
|
||||
style={{ width: sizeInfo.px, height: sizeInfo.px, maxWidth: "100%", maxHeight: "100%" }}
|
||||
>
|
||||
{user?.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
||||
<img
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { icons as lucideIcons } from "lucide-react";
|
||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||
import {
|
||||
FontSize,
|
||||
|
|
@ -70,6 +71,9 @@ export interface PopTextConfig {
|
|||
fontWeight?: FontWeight;
|
||||
textAlign?: TextAlign;
|
||||
verticalAlign?: VerticalAlign; // 상하 정렬
|
||||
marquee?: boolean; // 마키(흐르는 텍스트) 활성화
|
||||
marqueeSpeed?: number; // 마키 속도 (초, 기본 15)
|
||||
marqueeIcon?: string; // 마키 앞 아이콘 (lucide 이름)
|
||||
}
|
||||
|
||||
const TEXT_TYPE_LABELS: Record<PopTextType, string> = {
|
||||
|
|
@ -223,6 +227,16 @@ function DesignModePreview({
|
|||
);
|
||||
default:
|
||||
// 일반 텍스트 미리보기
|
||||
if (config?.marquee) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center overflow-hidden">
|
||||
<span className="shrink-0 pl-1 pr-2 text-muted-foreground text-[10px]">[마키]</span>
|
||||
<span className={cn("truncate", FONT_SIZE_CLASSES[config?.fontSize || "base"])}>
|
||||
{config?.content || label || "텍스트"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={alignWrapperClass}>
|
||||
<span
|
||||
|
|
@ -369,8 +383,12 @@ function TextDisplay({
|
|||
label?: string;
|
||||
}) {
|
||||
const sizeClass = FONT_SIZE_CLASSES[config?.fontSize || "base"];
|
||||
const text = config?.content || label || "텍스트";
|
||||
|
||||
if (config?.marquee) {
|
||||
return <MarqueeDisplay config={config} text={text} sizeClass={sizeClass} />;
|
||||
}
|
||||
|
||||
// 정렬 래퍼 클래스
|
||||
const alignWrapperClass = cn(
|
||||
"flex w-full h-full",
|
||||
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
|
||||
|
|
@ -380,12 +398,56 @@ function TextDisplay({
|
|||
return (
|
||||
<div className={alignWrapperClass}>
|
||||
<span className={cn("whitespace-pre-wrap", sizeClass)}>
|
||||
{config?.content || label || "텍스트"}
|
||||
{text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MarqueeDisplay({
|
||||
config,
|
||||
text,
|
||||
sizeClass,
|
||||
}: {
|
||||
config?: PopTextConfig;
|
||||
text: string;
|
||||
sizeClass: string;
|
||||
}) {
|
||||
const speed = config?.marqueeSpeed || 15;
|
||||
const iconName = config?.marqueeIcon;
|
||||
const weightClass = FONT_WEIGHT_CLASSES[config?.fontWeight || "normal"];
|
||||
const uniqueId = React.useId().replace(/:/g, "");
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center overflow-hidden">
|
||||
{iconName && (() => {
|
||||
const pascalName = iconName.replace(/(^|-)(\w)/g, (_: string, __: string, c: string) => c.toUpperCase());
|
||||
const LucideIcon = (lucideIcons as Record<string, React.ComponentType<{ size?: number; className?: string }>>)[pascalName];
|
||||
return LucideIcon ? (
|
||||
<div className="shrink-0 pl-2 pr-3 text-muted-foreground">
|
||||
<LucideIcon size={18} />
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
<div className="relative flex-1 overflow-hidden">
|
||||
<div
|
||||
className="inline-flex whitespace-nowrap"
|
||||
style={{ animation: `marquee-${uniqueId} ${speed}s linear infinite` }}
|
||||
>
|
||||
<span className={cn("inline-block", sizeClass, weightClass)} style={{ paddingRight: "100vw" }}>{text}</span>
|
||||
<span className={cn("inline-block", sizeClass, weightClass)} style={{ paddingRight: "100vw" }}>{text}</span>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes marquee-${uniqueId} {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 설정 패널
|
||||
// ========================================
|
||||
|
|
@ -450,6 +512,44 @@ export function PopTextConfigPanel({
|
|||
className="text-xs resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 마키(흐르는 텍스트) 설정 */}
|
||||
<SectionDivider label="흐르는 텍스트" />
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">활성화</Label>
|
||||
<Switch
|
||||
checked={config?.marquee ?? false}
|
||||
onCheckedChange={(v) => onUpdate({ ...config, marquee: v })}
|
||||
/>
|
||||
</div>
|
||||
{config?.marquee && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">속도: {config?.marqueeSpeed || 15}초</Label>
|
||||
<input
|
||||
type="range"
|
||||
min={5}
|
||||
max={60}
|
||||
step={5}
|
||||
value={config?.marqueeSpeed || 15}
|
||||
onChange={(e) => onUpdate({ ...config, marqueeSpeed: Number(e.target.value) })}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">앞 아이콘 (lucide 이름)</Label>
|
||||
<Input
|
||||
value={config?.marqueeIcon || ""}
|
||||
onChange={(e) => onUpdate({ ...config, marqueeIcon: e.target.value })}
|
||||
placeholder="예: flag, megaphone, info"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionDivider label="스타일 설정" />
|
||||
<FontSizeSelect config={config} onUpdate={onUpdate} />
|
||||
<AlignmentSelect config={config} onUpdate={onUpdate} />
|
||||
|
|
|
|||
|
|
@ -736,7 +736,6 @@ export type CardCellType =
|
|||
| "badge"
|
||||
| "button"
|
||||
| "number-input"
|
||||
| "cart-button"
|
||||
| "package-summary"
|
||||
| "status-badge"
|
||||
| "timeline"
|
||||
|
|
@ -822,12 +821,6 @@ export interface CardCellDefinitionV2 {
|
|||
limitColumn?: string;
|
||||
autoInitMax?: boolean;
|
||||
|
||||
// cart-button 타입 전용
|
||||
cartLabel?: string;
|
||||
cartCancelLabel?: string;
|
||||
cartIconType?: "lucide" | "emoji";
|
||||
cartIconValue?: string;
|
||||
|
||||
// status-badge 타입 전용
|
||||
statusColumn?: string;
|
||||
statusMap?: Array<{ value: string; label: string; color: string }>;
|
||||
|
|
@ -1000,8 +993,6 @@ export interface PopCardListV2Config {
|
|||
responsiveDisplay?: CardResponsiveConfig;
|
||||
inputField?: CardInputFieldConfig;
|
||||
packageConfig?: CardPackageConfig;
|
||||
cartAction?: CardCartActionConfig;
|
||||
cartListMode?: CartListModeConfig;
|
||||
saveMapping?: CardListSaveMapping;
|
||||
ownerSortColumn?: string;
|
||||
ownerFilterMode?: "priority" | "only";
|
||||
|
|
|
|||
Loading…
Reference in New Issue