585 lines
19 KiB
TypeScript
585 lines
19 KiB
TypeScript
"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();
|
|
}
|