feat: pop-card-list 업그레이드 — cart-outbound 삭제 + 프리셋 + 검사 연동 + UI 수정

- pop-cart-outbound 컴포넌트 완전 삭제 (4개 화면 pop-card-list로 교체 완료)
- 레지스트리/팔레트/타입에서 cart-outbound 참조 제거
- PopRenderer 뷰모드 label prop 제거 (컴포넌트 라벨 노출 버그 수정)
- dataFetcher SUM/AVG varchar CAST 처리 (Dashboard 500 수정)
- PopCardListConfig 장바구니 목록모드 섹션 프리셋 사용 시 숨김
- types.ts에 CardListPresetMode, CardListInspectionConfig 타입 추가
This commit is contained in:
SeongHyun Kim 2026-03-27 18:11:08 +09:00
parent eacfe60f89
commit 6fe7bfbefc
10 changed files with 21 additions and 1323 deletions

View File

@ -3,7 +3,7 @@
import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2, ClipboardCheck, Truck } from "lucide-react";
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2, ClipboardCheck } from "lucide-react";
import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의
@ -99,12 +99,6 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: ClipboardCheck,
description: "공정별 체크리스트/검사/실적 상세 작업 화면",
},
{
type: "pop-cart-outbound",
label: "장바구니 출고",
icon: Truck,
description: "출고 전용 카드 리스트 (판매/기타/외주 출고)",
},
];
// 드래그 가능한 컴포넌트 아이템

View File

@ -85,7 +85,6 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-scanner": "스캐너",
"pop-profile": "프로필",
"pop-work-detail": "작업 상세",
"pop-cart-outbound": "장바구니 출고",
};
// ========================================
@ -633,9 +632,8 @@ function renderActualComponent(
if (ActualComp) {
return (
<div className="h-full w-full overflow-hidden">
<ActualComp
config={component.config}
label={component.label}
<ActualComp
config={component.config}
componentId={component.id}
screenId={screenId}
currentRowSpan={effectivePosition?.rowSpan}
@ -646,11 +644,10 @@ function renderActualComponent(
);
}
// 미등록 컴포넌트: 플레이스홀더 (fallback)
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// 미등록 컴포넌트: 플레이스홀더 (fallback) - 뷰 모드에서는 빈 영역 표시
return (
<div className="flex h-full w-full items-center justify-center p-2">
<span className="text-xs text-muted-foreground">{component.label || typeLabel}</span>
<span className="text-xs text-muted-foreground"></span>
</div>
);
}

View File

@ -7,7 +7,7 @@
/**
* POP
*/
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile" | "pop-work-detail" | "pop-cart-outbound";
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile" | "pop-work-detail";
/**
*
@ -378,7 +378,6 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
"pop-scanner": { colSpan: 2, rowSpan: 2 },
"pop-profile": { colSpan: 2, rowSpan: 2 },
"pop-work-detail": { colSpan: 38, rowSpan: 26 },
"pop-cart-outbound": { colSpan: 19, rowSpan: 10 },
};
/**

View File

@ -27,4 +27,3 @@ import "./pop-field";
import "./pop-scanner";
import "./pop-profile";
import "./pop-work-detail";
import "./pop-cart-outbound";

View File

@ -342,13 +342,15 @@ function BasicSettingsTab({
/>
</CollapsibleSection>
{/* 장바구니 목록 모드 */}
<CollapsibleSection sectionKey="basic-cart-mode" title="장바구니 목록 모드" sections={sections}>
<CartListModeSection
cartListMode={config.cartListMode}
onUpdate={(cartListMode) => onUpdate({ cartListMode })}
/>
</CollapsibleSection>
{/* 장바구니 목록 모드 — 프리셋이 없거나 "normal"일 때만 표시 (프리셋 cart-confirm이 자동 처리) */}
{(!config.presetMode || config.presetMode === "normal") && (
<CollapsibleSection sectionKey="basic-cart-mode" title="장바구니 목록 모드" sections={sections}>
<CartListModeSection
cartListMode={config.cartListMode}
onUpdate={(cartListMode) => onUpdate({ cartListMode })}
/>
</CollapsibleSection>
)}
{/* 테이블 선택 (장바구니 모드 시 숨김) */}
{!isCartListMode && (

View File

@ -1,584 +0,0 @@
"use client";
/**
* pop-cart-outbound
*
* .
* - 레이아웃: 헤더 +
* - 로직: useCartSync cart_items DB
* - 버스: filter_changed , cart_updated , collect_data
* - 디자인: ISA-101 (56px , 36px , 24px )
*/
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { ShoppingCart, X, Loader2 } from "lucide-react";
import { toast } from "sonner";
import type {
PopCartOutboundConfig,
OutboundStatField,
CollectDataRequest,
CollectedDataResponse,
} from "../types";
import { dataApi } from "@/lib/api/data";
import { usePopEvent } from "@/hooks/pop/usePopEvent";
import { useCartSync } from "@/hooks/pop/useCartSync";
import { NumberInputModal } from "../pop-card-list/NumberInputModal";
// ===== 기본 설정 =====
const DEFAULT_EMPTY_MESSAGE = "거래처를 선택하면 출고 대상 품목이 표시됩니다";
const DEFAULT_EMPTY_ICON = "📦";
// ===== 디자인 토큰 (ISA-101 산업 현장 기준) =====
const DESIGN = {
card: {
padding: 24,
gap: 20,
borderRadius: 10,
},
title: { size: 22, weight: 600 },
code: { size: 16 },
statValue: { size: 36, weight: 700 },
statLabel: { size: 14 },
button: { height: 56, minWidth: 100 },
input: { height: 56 },
checkbox: { size: 40 },
} as const;
// ===== 반응형 브레이크포인트 =====
type SizeMode = "lg" | "md" | "sm" | "xs";
function detectSizeMode(width: number): SizeMode {
if (width >= 1024) return "lg";
if (width >= 768) return "md";
if (width >= 480) return "sm";
return "xs";
}
function getResponsiveDesign(mode: SizeMode) {
switch (mode) {
case "lg":
return { padding: 24, statValue: 36, statCols: 0, titleSize: 22, codeSize: 16, showCode: true };
case "md":
return { padding: 16, statValue: 36, statCols: 0, titleSize: 22, codeSize: 16, showCode: true };
case "sm":
return { padding: 16, statValue: 32, statCols: 2, titleSize: 20, codeSize: 14, showCode: true };
case "xs":
return { padding: 12, statValue: 28, statCols: 2, titleSize: 18, codeSize: 14, showCode: false };
}
}
// ===== Props =====
interface ComponentProps {
componentId: string;
screenId: string;
config?: PopCartOutboundConfig;
}
// ===== 메인 컴포넌트 =====
export function PopCartOutboundComponent({ componentId, screenId, config }: ComponentProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [sizeMode, setSizeMode] = useState<SizeMode>("lg");
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
const [loading, setLoading] = useState(false);
const [externalFilters, setExternalFilters] = useState<Record<string, unknown>>({});
const [hasReceivedFilter, setHasReceivedFilter] = useState(false);
const [numberModalOpen, setNumberModalOpen] = useState(false);
const [activeRowKey, setActiveRowKey] = useState<string | null>(null);
const { publish, subscribe } = usePopEvent(screenId);
const sourceTable = config?.dataSource?.tableName || "";
const keyCol = config?.keyColumn || "id";
const cart = useCartSync(screenId, sourceTable);
// ---- 반응형 감지 ----
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setSizeMode(detectSizeMode(entry.contentRect.width));
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const responsive = getResponsiveDesign(sizeMode);
// ---- 데이터 조회 ----
const fetchData = useCallback(async () => {
if (!sourceTable) return;
// requireFilter가 설정되어 있고 아직 필터를 받지 못했으면 조회하지 않음
if (config?.requireFilter && !hasReceivedFilter) return;
setLoading(true);
try {
// 정적 필터 (디자이너 설정) + 외부 필터 (이벤트 버스) 병합
const staticFilters: Record<string, unknown> = {};
for (const f of config?.dataSource?.filters || []) {
staticFilters[f.column] = f.value;
}
// __connId_ 접두사 키 제외 (내부 추적용)
const cleanExternalFilters: Record<string, unknown> = {};
for (const [k, v] of Object.entries(externalFilters)) {
if (!k.startsWith("__connId_")) {
cleanExternalFilters[k] = v;
}
}
const mergedFilters = { ...staticFilters, ...cleanExternalFilters };
const res = await dataApi.getTableData(sourceTable, {
size: 500,
filters: Object.keys(mergedFilters).length > 0 ? mergedFilters : undefined,
sortBy: config?.dataSource?.sort?.[0]?.column,
sortOrder: config?.dataSource?.sort?.[0]?.direction,
});
setRows(Array.isArray(res) ? res : res?.data ?? []);
} catch {
setRows([]);
} finally {
setLoading(false);
}
}, [sourceTable, config?.dataSource, externalFilters, config?.requireFilter, hasReceivedFilter]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ---- 이벤트 구독 ----
// 필터 수신: useConnectionResolver가 보내는 구조화된 페이로드를 파싱하여
// API 필터 형식 { column: value } 로 변환
useEffect(() => {
const prefix = `__comp_input__${componentId}__`;
const unsub = subscribe(prefix + "filter_condition", (payload: unknown) => {
if (!payload || typeof payload !== "object") return;
const data = payload as {
value?: { fieldName?: string; value?: unknown; filterColumns?: string[]; filterMode?: string };
filterConfig?: { targetColumn?: string; targetColumns?: string[]; filterMode?: string };
_connectionId?: string;
};
// 연결 해석기(useConnectionResolver)가 전달한 구조에서 실제 필터 값 추출
const filterValue = data.value?.value;
const targetColumn = data.filterConfig?.targetColumn || data.value?.fieldName;
if (!targetColumn || filterValue == null || filterValue === "") {
// 필터 클리어: 해당 connection의 필터 제거
setExternalFilters((prev) => {
const next = { ...prev };
if (data._connectionId) {
// connectionId 기반으로 이전에 설정한 필터 제거
for (const key of Object.keys(next)) {
if (next[`__connId_${key}`] === data._connectionId) {
delete next[key];
delete next[`__connId_${key}`];
}
}
}
return next;
});
return;
}
setHasReceivedFilter(true);
// API 필터 형식으로 변환: { partner_id: "T-CUST-001" }
setExternalFilters((prev) => {
const next = { ...prev };
next[targetColumn] = filterValue;
// 내부 추적용 (API에는 전달되지 않으나, 클리어 시 사용)
if (data._connectionId) {
next[`__connId_${targetColumn}`] = data._connectionId;
}
return next;
});
});
return unsub;
}, [subscribe, componentId]);
// 데이터 수집 요청 수신 (pop-button이 출고 확정 시)
useEffect(() => {
const unsub = subscribe("__collect_data__", (payload: unknown) => {
const req = payload as CollectDataRequest;
const response: CollectedDataResponse = {
requestId: req.requestId,
componentId,
componentType: "pop-cart-outbound",
data: {
items: cart.cartItems.map((ci) => ({
...ci.row,
__cart_qty: ci.quantity,
__cart_id: ci.cartId,
__cart_row_key: ci.rowKey,
})),
},
mapping: null,
};
publish("__collected_data__", response);
});
return unsub;
}, [subscribe, publish, componentId, cart.cartItems]);
// 장바구니 변경 시 이벤트 발행
useEffect(() => {
const prefix = `__comp_output__${componentId}__`;
publish(prefix + "cart_updated", {
count: cart.cartCount,
isDirty: cart.isDirty,
});
}, [publish, componentId, cart.cartCount, cart.isDirty]);
// 저장 트리거 수신
useEffect(() => {
const prefix = `__comp_input__${componentId}__`;
const unsub = subscribe(prefix + "cart_save_trigger", async () => {
const ok = await cart.saveToDb();
const outPrefix = `__comp_output__${componentId}__`;
publish(outPrefix + "cart_save_completed", { success: ok });
});
return unsub;
}, [subscribe, publish, componentId, cart]);
// ---- 담기/취소 핸들러 ----
const handleCartAdd = useCallback(
(row: Record<string, unknown>, quantity: number) => {
const rk = String(row[keyCol] ?? "");
if (!rk) return;
cart.addItem({ row, quantity }, rk);
const titleField = config?.header?.titleField;
const unit = config?.quantityInput?.unit || "EA";
const name = titleField ? String(row[titleField] || "") : rk;
toast.success(`${name} ${quantity}${unit} 담김`);
},
[cart, keyCol, config],
);
const handleCartCancel = useCallback(
(row: Record<string, unknown>) => {
const rk = String(row[keyCol] ?? "");
if (!rk) return;
cart.removeItem(rk);
},
[cart, keyCol],
);
// ---- 수량 입력 모달 ----
const activeRow = useMemo(() => {
if (!activeRowKey) return null;
return rows.find((r) => String(r[keyCol]) === activeRowKey) ?? null;
}, [activeRowKey, rows, keyCol]);
const getMaxValue = useCallback(
(row: Record<string, unknown>) => {
const maxCol = config?.quantityInput?.maxColumn;
if (!maxCol) return 999999;
const val = Number(row[maxCol]);
return isNaN(val) ? 999999 : val;
},
[config?.quantityInput?.maxColumn],
);
const getDefaultValue = useCallback(
(row: Record<string, unknown>) => {
const defCol = config?.quantityInput?.defaultColumn;
if (!defCol) return 1;
const val = Number(row[defCol]);
return isNaN(val) || val <= 0 ? 1 : val;
},
[config?.quantityInput?.defaultColumn],
);
// ---- 렌더링 ----
const statFields = config?.statFields || [];
const emptyMsg = config?.emptyMessage || DEFAULT_EMPTY_MESSAGE;
const emptyIcon = config?.emptyIcon || DEFAULT_EMPTY_ICON;
// 반응형 스탯 열 수: 0이면 statFields 수 그대로
const statCols = responsive.statCols || statFields.length || 3;
return (
<div ref={containerRef} className="flex h-full flex-col">
{/* 헤더 바 */}
<div
className="flex items-center justify-between border-b border-border/40 bg-muted/30"
style={{ padding: `8px ${responsive.padding}px` }}
>
<span className="text-sm font-semibold text-muted-foreground">
</span>
<span className="text-xs text-muted-foreground">{rows.length}</span>
</div>
{/* 카드 리스트 */}
<div
className="flex-1 overflow-y-auto"
style={{ padding: responsive.padding }}
>
{config?.requireFilter && !hasReceivedFilter ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<span className="mb-3 text-5xl">{emptyIcon}</span>
<p className="text-sm">{config?.requireFilterMessage || emptyMsg}</p>
</div>
) : loading ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : rows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<span className="mb-3 text-5xl">{emptyIcon}</span>
<p className="text-sm"> .</p>
</div>
) : (
<div className="flex flex-col" style={{ gap: `${DESIGN.card.gap}px` }}>
{rows.map((row) => {
const rk = String(row[keyCol] ?? "");
const isInCart = cart.isItemInCart(rk);
const cartItem = cart.getCartItem(rk);
return (
<OutboundItemCard
key={rk}
row={row}
rowKey={rk}
config={config!}
statFields={statFields}
statCols={statCols}
responsive={responsive}
isInCart={isInCart}
cartQuantity={cartItem?.quantity}
onAddToCart={(qty) => handleCartAdd(row, qty)}
onCancelCart={() => handleCartCancel(row)}
onQuantityClick={() => {
setActiveRowKey(rk);
setNumberModalOpen(true);
}}
getDefaultValue={getDefaultValue}
/>
);
})}
</div>
)}
</div>
{/* 수량 입력 모달 */}
{activeRow && (
<NumberInputModal
open={numberModalOpen}
onOpenChange={setNumberModalOpen}
unit={config?.quantityInput?.unit || "EA"}
initialValue={
cart.getCartItem(String(activeRow[keyCol]))?.quantity
?? getDefaultValue(activeRow)
}
maxValue={getMaxValue(activeRow)}
packageConfig={{ enabled: false }}
onConfirm={(value) => {
handleCartAdd(activeRow, value);
setNumberModalOpen(false);
setActiveRowKey(null);
}}
/>
)}
</div>
);
}
// ===== 개별 카드 컴포넌트 =====
interface OutboundItemCardProps {
row: Record<string, unknown>;
rowKey: string;
config: PopCartOutboundConfig;
statFields: OutboundStatField[];
statCols: number;
responsive: ReturnType<typeof getResponsiveDesign>;
isInCart: boolean;
cartQuantity?: number;
onAddToCart: (qty: number) => void;
onCancelCart: () => void;
onQuantityClick: () => void;
getDefaultValue: (row: Record<string, unknown>) => number;
}
function OutboundItemCard({
row,
config,
statFields,
statCols,
responsive,
isInCart,
cartQuantity,
onAddToCart,
onCancelCart,
onQuantityClick,
getDefaultValue,
}: OutboundItemCardProps) {
const title = config.header?.titleField ? String(row[config.header.titleField] ?? "") : "";
const code = config.header?.codeField ? String(row[config.header.codeField] ?? "") : "";
const unit = config.header?.unitField ? String(row[config.header.unitField] ?? "") : "";
const codeDisplay = [code, unit].filter(Boolean).join(" · ");
const displayQty = cartQuantity ?? getDefaultValue(row);
const qtyLabel = config.quantityInput?.label || "출고수량";
const qtyUnit = config.quantityInput?.unit || "EA";
return (
<div
className={`rounded-xl border transition-colors ${
isInCart
? "border-primary/50 bg-primary/5"
: "border-border bg-background"
}`}
style={{
padding: `${responsive.padding}px`,
}}
>
{/* 헤더: 품목명 + 코드·단위 */}
<div className="mb-4 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div
className="truncate font-semibold text-foreground"
style={{ fontSize: `${responsive.titleSize}px` }}
>
{title || "—"}
</div>
{responsive.showCode && codeDisplay && (
<div
className="mt-0.5 truncate text-muted-foreground"
style={{ fontSize: `${responsive.codeSize}px` }}
>
{codeDisplay}
</div>
)}
</div>
</div>
{/* 스탯 그리드 */}
{statFields.length > 0 && (
<div
className="mb-4 grid"
style={{
gridTemplateColumns: `repeat(${statCols}, 1fr)`,
gap: "8px",
}}
>
{statFields.slice(0, responsive.statCols || statFields.length).map((sf, idx) => (
<div
key={sf.id || `stat-${idx}`}
className="rounded-lg bg-muted/50 py-2 text-center"
style={{ padding: "8px 4px" }}
>
<span
className="block text-muted-foreground"
style={{ fontSize: `${DESIGN.statLabel.size}px`, marginBottom: "2px" }}
>
{sf.label}
</span>
<span
className="block font-bold text-foreground"
style={{
fontSize: `${responsive.statValue}px`,
fontWeight: DESIGN.statValue.weight,
}}
>
{formatStatValue(row[sf.column], sf.format)}
</span>
</div>
))}
</div>
)}
{/* 하단: 수량 입력 + 담기/취소 버튼 */}
<div className="flex items-center gap-3">
{/* 수량 입력 영역 */}
<div className="flex flex-1 items-center gap-2">
<span
className="shrink-0 font-medium text-foreground"
style={{ fontSize: "14px" }}
>
{qtyLabel}
</span>
<button
type="button"
onClick={onQuantityClick}
className="flex-1 rounded-lg border-2 border-input bg-background text-center font-bold text-foreground transition-colors hover:border-primary active:bg-muted"
style={{
height: `${DESIGN.input.height}px`,
fontSize: "18px",
maxWidth: "140px",
}}
>
{displayQty.toLocaleString()}
<span className="ml-1 text-xs font-normal text-muted-foreground">
{qtyUnit}
</span>
</button>
</div>
{/* 담기/취소 버튼 */}
{isInCart ? (
<button
type="button"
onClick={onCancelCart}
className="flex shrink-0 items-center justify-center gap-1.5 rounded-xl bg-destructive text-destructive-foreground transition-colors hover:bg-destructive/90 active:bg-destructive/80"
style={{
height: `${DESIGN.button.height}px`,
minWidth: `${DESIGN.button.minWidth}px`,
padding: "0 20px",
fontSize: "16px",
fontWeight: 600,
}}
>
<X size={20} />
</button>
) : (
<button
type="button"
onClick={() => onAddToCart(displayQty)}
className="flex shrink-0 items-center justify-center gap-1.5 rounded-xl bg-primary text-primary-foreground transition-colors hover:bg-primary/90 active:bg-primary/80"
style={{
height: `${DESIGN.button.height}px`,
minWidth: `${DESIGN.button.minWidth}px`,
padding: "0 20px",
fontSize: "16px",
fontWeight: 600,
}}
>
<ShoppingCart size={20} />
</button>
)}
</div>
</div>
);
}
// ===== 유틸 =====
function formatStatValue(
value: unknown,
format?: "number" | "currency" | "text",
): string {
if (value == null || value === "") return "—";
if (format === "text") return String(value);
const num = Number(value);
if (isNaN(num)) return String(value);
if (format === "currency") return `${num.toLocaleString()}`;
return num.toLocaleString();
}

View File

@ -1,561 +0,0 @@
"use client";
/**
* pop-cart-outbound
*
* 3 : [] [] []
*
* :
* - (, , , )
* - (, , )
* - (//, ++)
* - (, , , )
* -
*
* ( ):
* - (useCartSync)
* - ()
* - /
*/
import React, { useState, useEffect } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type {
PopCartOutboundConfig,
OutboundStatField,
CardListDataSource,
StatFieldFormat,
} from "../types";
import {
fetchTableList,
fetchTableColumns,
type TableInfo,
type ColumnInfo,
} from "../pop-dashboard/utils/dataFetcher";
import { TableCombobox } from "../pop-shared/TableCombobox";
// ===== Props =====
interface ConfigPanelProps {
config: PopCartOutboundConfig | undefined;
onUpdate: (config: PopCartOutboundConfig) => void;
}
// ===== 기본값 =====
const DEFAULT_CONFIG: PopCartOutboundConfig = {
dataSource: { tableName: "" },
keyColumn: "id",
header: { titleField: "", codeField: "" },
statFields: [],
quantityInput: { label: "출고수량", unit: "EA" },
emptyMessage: "거래처를 선택하면 출고 대상 품목이 표시됩니다",
emptyIcon: "📦",
};
// ===== 메인 =====
export function PopCartOutboundConfigPanel({ config, onUpdate }: ConfigPanelProps) {
const [activeTab, setActiveTab] = useState<"data" | "card" | "cart">("data");
const cfg: PopCartOutboundConfig = config || DEFAULT_CONFIG;
const update = (partial: Partial<PopCartOutboundConfig>) => {
onUpdate({ ...cfg, ...partial });
};
const hasTable = !!cfg.dataSource?.tableName;
const tabs: { key: typeof activeTab; label: string; disabled?: boolean }[] = [
{ key: "data", label: "데이터" },
{ key: "card", label: "카드", disabled: !hasTable },
{ key: "cart", label: "장바구니", disabled: !hasTable },
];
return (
<div className="flex h-full flex-col">
{/* 탭 헤더 */}
<div className="flex border-b">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
className={`flex-1 px-2 py-2 text-xs font-medium transition-colors ${
activeTab === tab.key
? "border-b-2 border-primary text-primary"
: tab.disabled
? "cursor-not-allowed text-muted-foreground/50"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => !tab.disabled && setActiveTab(tab.key)}
disabled={tab.disabled}
>
{tab.label}
</button>
))}
</div>
{/* 탭 내용 */}
<div className="flex-1 overflow-y-auto p-3">
{activeTab === "data" && <DataTab config={cfg} onUpdate={update} />}
{activeTab === "card" && <CardTab config={cfg} onUpdate={update} />}
{activeTab === "cart" && <CartTab config={cfg} onUpdate={update} />}
</div>
</div>
);
}
// ===== 데이터 탭 =====
function DataTab({
config,
onUpdate,
}: {
config: PopCartOutboundConfig;
onUpdate: (p: Partial<PopCartOutboundConfig>) => void;
}) {
const [tables, setTables] = useState<TableInfo[]>([]);
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const tableName = config.dataSource?.tableName || "";
useEffect(() => {
fetchTableList().then(setTables);
}, []);
useEffect(() => {
if (!tableName) {
setColumns([]);
return;
}
fetchTableColumns(tableName).then(setColumns).catch(() => setColumns([]));
}, [tableName]);
return (
<div className="space-y-4">
{/* 테이블 선택 */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="mt-1">
<TableCombobox
tables={tables}
value={tableName}
onSelect={(val: string) =>
onUpdate({
dataSource: { ...config.dataSource, tableName: val },
header: { titleField: "", codeField: "" },
statFields: [],
})
}
/>
</div>
</div>
{/* PK 컬럼 */}
{tableName && (
<div>
<Label className="text-[10px] text-muted-foreground">PK </Label>
<Select
value={config.keyColumn || "id"}
onValueChange={(val) => onUpdate({ keyColumn: val })}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.name} value={col.name} className="text-xs">
{col.name}
{col.comment ? ` (${col.comment})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 고정 안내 */}
<div className="rounded-md bg-muted/50 p-2">
<p className="text-[10px] text-muted-foreground">
(//DB ) .
(+) .
</p>
</div>
</div>
);
}
// ===== 카드 탭 =====
function CardTab({
config,
onUpdate,
}: {
config: PopCartOutboundConfig;
onUpdate: (p: Partial<PopCartOutboundConfig>) => void;
}) {
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const tableName = config.dataSource?.tableName || "";
useEffect(() => {
if (!tableName) return;
fetchTableColumns(tableName).then(setColumns).catch(() => {});
}, [tableName]);
const colOptions = columns.map((c) => ({
value: c.name,
label: c.comment ? `${c.name} (${c.comment})` : c.name,
}));
return (
<div className="space-y-4">
{/* 헤더 매핑 */}
<div className="space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.header?.titleField || ""}
onValueChange={(val) =>
onUpdate({ header: { ...config.header, titleField: val } })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{colOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={config.header?.codeField || ""}
onValueChange={(val) =>
onUpdate({ header: { ...config.header, codeField: val } })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="선택..." />
</SelectTrigger>
<SelectContent>
{colOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px] text-muted-foreground"> ()</Label>
<Select
value={config.header?.unitField || "__none__"}
onValueChange={(val) =>
onUpdate({
header: {
...config.header,
unitField: val === "__none__" ? undefined : val,
},
})
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="없음" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
</SelectItem>
{colOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 스탯 필드 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold"> </Label>
<button
type="button"
onClick={() => {
const newField: OutboundStatField = {
id: `sf_${Date.now()}`,
label: "",
column: "",
format: "number",
};
onUpdate({
statFields: [...(config.statFields || []), newField],
});
}}
className="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] text-primary hover:bg-primary/10"
>
<Plus size={12} />
</button>
</div>
{(config.statFields || []).map((sf, idx) => (
<div key={sf.id || `sf-${idx}`} className="flex items-end gap-1.5 rounded border border-border/50 p-2">
{/* 라벨 */}
<div className="flex-1">
<Label className="text-[9px] text-muted-foreground"></Label>
<Input
className="mt-0.5 h-6 text-xs"
placeholder="주문수량"
value={sf.label}
onChange={(e) => {
const updated = [...config.statFields];
updated[idx] = { ...sf, label: e.target.value };
onUpdate({ statFields: updated });
}}
/>
</div>
{/* 컬럼 */}
<div className="flex-1">
<Label className="text-[9px] text-muted-foreground"></Label>
<Select
value={sf.column || ""}
onValueChange={(val) => {
const updated = [...config.statFields];
updated[idx] = { ...sf, column: val };
onUpdate({ statFields: updated });
}}
>
<SelectTrigger className="mt-0.5 h-6 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{colOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 포맷 */}
<div className="w-20">
<Label className="text-[9px] text-muted-foreground"></Label>
<Select
value={sf.format || "number"}
onValueChange={(val) => {
const updated = [...config.statFields];
updated[idx] = { ...sf, format: val as StatFieldFormat };
onUpdate({ statFields: updated });
}}
>
<SelectTrigger className="mt-0.5 h-6 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="number" className="text-xs"></SelectItem>
<SelectItem value="currency" className="text-xs"></SelectItem>
<SelectItem value="text" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 삭제 */}
<button
type="button"
onClick={() => {
onUpdate({
statFields: config.statFields.filter((_, i) => i !== idx),
});
}}
className="mb-0.5 rounded p-1 text-destructive hover:bg-destructive/10"
>
<Trash2 size={12} />
</button>
</div>
))}
{(config.statFields || []).length === 0 && (
<p className="text-center text-[10px] text-muted-foreground">
,
</p>
)}
</div>
</div>
);
}
// ===== 장바구니 탭 =====
function CartTab({
config,
onUpdate,
}: {
config: PopCartOutboundConfig;
onUpdate: (p: Partial<PopCartOutboundConfig>) => void;
}) {
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const tableName = config.dataSource?.tableName || "";
useEffect(() => {
if (!tableName) return;
fetchTableColumns(tableName).then(setColumns).catch(() => {});
}, [tableName]);
const colOptions = columns.map((c) => ({
value: c.name,
label: c.comment ? `${c.name} (${c.comment})` : c.name,
}));
const qi = config.quantityInput || { label: "출고수량", unit: "EA" };
return (
<div className="space-y-4">
{/* 수량 입력 설정 */}
<div className="space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex gap-2">
<div className="flex-1">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
className="mt-1 h-7 text-xs"
value={qi.label}
onChange={(e) =>
onUpdate({
quantityInput: { ...qi, label: e.target.value },
})
}
/>
</div>
<div className="w-20">
<Label className="text-[10px] text-muted-foreground"></Label>
<Input
className="mt-1 h-7 text-xs"
value={qi.unit || ""}
onChange={(e) =>
onUpdate({
quantityInput: { ...qi, unit: e.target.value },
})
}
/>
</div>
</div>
<div>
<Label className="text-[10px] text-muted-foreground"> ()</Label>
<Select
value={qi.defaultColumn || "__none__"}
onValueChange={(val) =>
onUpdate({
quantityInput: {
...qi,
defaultColumn: val === "__none__" ? undefined : val,
},
})
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="없음 (기본 1)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
( 1)
</SelectItem>
{colOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px] text-muted-foreground"> ()</Label>
<Select
value={qi.maxColumn || "__none__"}
onValueChange={(val) =>
onUpdate({
quantityInput: {
...qi,
maxColumn: val === "__none__" ? undefined : val,
},
})
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="없음 (무제한)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs">
()
</SelectItem>
{colOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 빈 상태 메시지 */}
<div className="space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
className="mt-1 h-7 text-xs"
value={config.emptyMessage || ""}
onChange={(e) => onUpdate({ emptyMessage: e.target.value })}
placeholder="거래처를 선택하면 출고 대상 품목이 표시됩니다"
/>
</div>
<div>
<Label className="text-[10px] text-muted-foreground"> ()</Label>
<Input
className="mt-1 h-7 text-xs"
value={config.emptyIcon || ""}
onChange={(e) => onUpdate({ emptyIcon: e.target.value })}
placeholder="📦"
/>
</div>
</div>
{/* 고정 안내 */}
<div className="rounded-md bg-blue-50 p-2 dark:bg-blue-950/30">
<p className="text-[10px] text-blue-600 dark:text-blue-400">
/ DB (cart_items) .
.
</p>
</div>
</div>
);
}

View File

@ -1,89 +0,0 @@
"use client";
/**
* pop-cart-outbound
*
* import하면 side-effect로 PopComponentRegistry에
*/
import { PopComponentRegistry } from "../../PopComponentRegistry";
import { PopCartOutboundComponent } from "./PopCartOutboundComponent";
import { PopCartOutboundConfigPanel } from "./PopCartOutboundConfig";
import type { PopCartOutboundConfig } from "../types";
const defaultConfig: PopCartOutboundConfig = {
dataSource: { tableName: "" },
keyColumn: "id",
header: {
titleField: "",
codeField: "",
},
statFields: [],
quantityInput: {
label: "출고수량",
unit: "EA",
},
emptyMessage: "거래처를 선택하면 출고 대상 품목이 표시됩니다",
emptyIcon: "📦",
};
PopComponentRegistry.registerComponent({
id: "pop-cart-outbound",
name: "장바구니 출고",
description: "출고 전용 카드 리스트 (판매출고, 기타출고, 외주출고 등)",
category: "display",
icon: "Truck",
component: PopCartOutboundComponent,
configPanel: PopCartOutboundConfigPanel,
defaultProps: defaultConfig,
connectionMeta: {
sendable: [
{
key: "cart_updated",
label: "장바구니 상태",
type: "event",
category: "event",
description: "장바구니 변경 시 count/isDirty 전달",
},
{
key: "cart_save_completed",
label: "저장 완료",
type: "event",
category: "event",
description: "장바구니 DB 저장 완료 후 결과 전달",
},
{
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: "collect_data",
label: "수집 요청",
type: "event",
category: "event",
description: "버튼에서 데이터+매핑 수집 요청 수신",
},
],
},
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});

View File

@ -148,12 +148,17 @@ export function buildAggregationSQL(config: DataSourceConfig): string {
: "";
// COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수
// SUM/AVG 등 숫자 집계 시 varchar 컬럼 대응: CAST AS NUMERIC 적용
const needsCast = ["SUM", "AVG"].includes(aggType);
if (!aggCol) {
selectClause = aggType === "COUNT"
? "COUNT(*) as value"
: `${aggType}(${tableName}.*) as value`;
} else {
selectClause = `${aggType}(${aggCol}) as value`;
const colExpr = needsCast
? `CAST(NULLIF(${aggCol}, '') AS NUMERIC)`
: aggCol;
selectClause = `${aggType}(${colExpr}) as value`;
}
// GROUP BY가 있으면 해당 컬럼도 SELECT에 포함

View File

@ -1125,67 +1125,3 @@ export interface PopWorkDetailConfig {
navigation: WorkDetailNavigationConfig;
resultSections?: ResultSectionConfig[];
}
// =============================================
// pop-cart-outbound 전용 타입 (장바구니 출고)
// =============================================
// ----- 스탯 필드 포맷 -----
export type StatFieldFormat = "number" | "currency" | "text";
// ----- 스탯 필드 바인딩 -----
export interface OutboundStatField {
id: string;
label: string; // "주문수량", "재고수량", "단가" 등
column: string; // DB 컬럼명
format?: StatFieldFormat; // 표시 포맷 (기본: "number")
}
// ----- pop-cart-outbound 전체 설정 -----
export interface PopCartOutboundConfig {
// ===== 사용자 설정 가능 영역 =====
/** 데이터 소스 (어떤 테이블에서 품목을 가져올지) */
dataSource: CardListDataSource;
/** 행 식별 PK 컬럼 (기본: "id") */
keyColumn: string;
/** 카드 헤더 컬럼 매핑 */
header: {
titleField: string; // 품목명 컬럼
codeField: string; // 코드 컬럼
unitField?: string; // 단위 컬럼
};
/** 스탯 그리드 필드 (사용자가 추가/삭제/변경 가능) */
statFields: OutboundStatField[];
/** 수량 입력 설정 */
quantityInput: {
label: string; // "출고수량"
defaultColumn?: string; // 기본값 컬럼 (예: plan_qty)
maxColumn?: string; // 최대값 컬럼 (예: current_stock)
unit?: string; // "EA", "kg" 등
};
/** 빈 상태 메시지 */
emptyMessage?: string;
emptyIcon?: string;
/** 필터 필수 여부 (true이면 외부 필터가 없으면 데이터 조회 안 함) */
requireFilter?: boolean;
/** 필터 미선택 시 표시할 안내 메시지 */
requireFilterMessage?: string;
// ===== 고정 영역 (코드에 내장, 설정 노출 안 함) =====
// - 카드 레이아웃: 세로형 (헤더→스탯 그리드→수량+버튼) 고정
// - 장바구니 로직: useCartSync 자동 연동
// - 담기/취소 버튼: 항상 표시, 동작 고정
// - 수량 입력: 항상 활성화
// - 이벤트 버스: filter_changed 수신, cart_updated 발행, collect_data 응답
}