Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs 2026-03-25 15:18:40 +09:00
commit 782ebb1b33
33 changed files with 5726 additions and 1475 deletions

2
.gitignore vendored
View File

@ -193,7 +193,9 @@ scripts/browser-test-*.js
# 개인 작업 문서
popdocs/
kshdocs/
.cursor/rules/popdocs-safety.mdc
.cursor/rules/overtime-registration.mdc
# 멀티 에이전트 MCP 태스크 큐
mcp-task-queue/

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,14 @@ import { authenticateToken } from "../middleware/authMiddleware";
import {
createWorkProcesses,
controlTimer,
controlGroupTimer,
getDefectTypes,
saveResult,
confirmResult,
getResultHistory,
getAvailableQty,
acceptProcess,
cancelAccept,
} from "../controllers/popProductionController";
const router = Router();
@ -11,5 +19,13 @@ router.use(authenticateToken);
router.post("/create-work-processes", createWorkProcesses);
router.post("/timer", controlTimer);
router.post("/group-timer", controlGroupTimer);
router.get("/defect-types", getDefectTypes);
router.post("/save-result", saveResult);
router.post("/confirm-result", confirmResult);
router.get("/result-history", getResultHistory);
router.get("/available-qty", getAvailableQty);
router.post("/accept-process", acceptProcess);
router.post("/cancel-accept", cancelAccept);
export default router;

View File

@ -3,11 +3,10 @@
import React, { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw, LayoutGrid, Monitor } from "lucide-react";
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth";
@ -21,8 +20,6 @@ import {
GridMode,
isPopLayout,
createEmptyLayout,
GAP_PRESETS,
GRID_BREAKPOINTS,
BLOCK_GAP,
BLOCK_PADDING,
detectGridMode,
@ -64,7 +61,8 @@ function PopScreenViewPage() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const screenId = parseInt(params.screenId as string);
const screenId = parseInt(params.screenId as string, 10);
const isValidScreenId = !isNaN(screenId) && screenId > 0;
const isPreviewMode = searchParams.get("preview") === "true";
@ -86,26 +84,32 @@ function PopScreenViewPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px)
const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로
// 모드 결정:
// - 프리뷰 모드: 수동 선택한 device/orientation 사용
// - 일반 모드: 화면 너비 기준으로 자동 결정 (GRID_BREAKPOINTS와 일치)
const currentModeKey = isPreviewMode
? getModeKey(deviceType, isLandscape)
: detectGridMode(viewportWidth);
// 실제 브라우저 너비 (모드 감지용)
const [rawWidth, setRawWidth] = useState(1024);
useEffect(() => {
const updateViewportWidth = () => {
setViewportWidth(Math.min(window.innerWidth, 1366));
};
updateViewportWidth();
window.addEventListener("resize", updateViewportWidth);
return () => window.removeEventListener("resize", updateViewportWidth);
const updateWidth = () => setRawWidth(window.innerWidth);
updateWidth();
window.addEventListener("resize", updateWidth);
return () => window.removeEventListener("resize", updateWidth);
}, []);
// 모드 결정
const currentModeKey = isPreviewMode
? getModeKey(deviceType, isLandscape)
: detectGridMode(rawWidth);
// 디자이너와 동일한 기준 너비 사용 (모드별 고정 너비)
const MODE_REFERENCE_WIDTH: Record<GridMode, number> = {
mobile_portrait: 375,
mobile_landscape: 600,
tablet_portrait: 820,
tablet_landscape: 1024,
};
const viewportWidth = isPreviewMode
? DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"].width
: MODE_REFERENCE_WIDTH[currentModeKey];
// 화면 및 POP 레이아웃 로드
useEffect(() => {
const loadScreen = async () => {
@ -122,22 +126,15 @@ function PopScreenViewPage() {
if (popLayout && isPopLayout(popLayout)) {
const v6Layout = loadLegacyLayout(popLayout);
setLayout(v6Layout);
const componentCount = Object.keys(popLayout.components).length;
console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
} else if (popLayout) {
// 다른 버전 레이아웃은 빈 v5로 처리
console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version);
setLayout(createEmptyLayout());
} else {
console.log("[POP] 레이아웃 없음");
setLayout(createEmptyLayout());
}
} catch (layoutError) {
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
} catch {
setLayout(createEmptyLayout());
}
} catch (error) {
console.error("[POP] 화면 로드 실패:", error);
setError("화면을 불러오는데 실패했습니다.");
showErrorToast("POP 화면을 불러오는 데 실패했습니다", error, { guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요." });
} finally {
@ -145,10 +142,13 @@ function PopScreenViewPage() {
}
};
if (screenId) {
if (isValidScreenId) {
loadScreen();
} else if (params.screenId) {
setError("유효하지 않은 화면 ID입니다.");
setLoading(false);
}
}, [screenId]);
}, [screenId, isValidScreenId]);
// 뷰어 모드에서도 컴포넌트 크기 변경 지원 (더보기 등)
const handleRequestResize = React.useCallback((componentId: string, newRowSpan: number, newColSpan?: number) => {
@ -288,26 +288,13 @@ function PopScreenViewPage() {
</div>
)}
{/* 일반 모드 네비게이션 바 */}
{!isPreviewMode && (
<div className="sticky top-0 z-50 flex h-10 items-center justify-between border-b bg-white/80 px-3 backdrop-blur">
<Button variant="ghost" size="sm" onClick={() => router.push("/pop")} className="gap-1 text-xs">
<LayoutGrid className="h-3.5 w-3.5" />
POP
</Button>
<span className="text-xs text-gray-500">{screen.screenName}</span>
<Button variant="ghost" size="sm" onClick={() => router.push("/")} className="gap-1 text-xs">
<Monitor className="h-3.5 w-3.5" />
PC
</Button>
</div>
)}
{/* 일반 모드 네비게이션 바 제거 (프로필 컴포넌트에서 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",
@ -317,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;

View File

@ -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": "리스트 목록",

View File

@ -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 + 셀 타입별 렌더링)",
},

View File

@ -4,7 +4,6 @@ import React from "react";
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
@ -172,7 +171,7 @@ function SendSection({
</span>
{conn.filterConfig.isSubTable && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] text-amber-700">
</span>
)}
</div>
@ -229,9 +228,6 @@ function SimpleConnectionForm({
const [selectedTargetId, setSelectedTargetId] = React.useState(
initial?.targetComponent || ""
);
const [isSubTable, setIsSubTable] = React.useState(
initial?.filterConfig?.isSubTable || false
);
const [targetColumn, setTargetColumn] = React.useState(
initial?.filterConfig?.targetColumn || ""
);
@ -255,23 +251,34 @@ function SimpleConnectionForm({
&& targetReg?.connectionMeta?.receivable?.some((r) => r.type === "filter_value");
const subTableName = targetComp ? extractSubTableName(targetComp) : null;
const mainTableName = (() => {
const cfg = targetComp?.config as Record<string, unknown> | undefined;
const ds = cfg?.dataSource as { tableName?: string } | undefined;
return ds?.tableName || null;
})();
React.useEffect(() => {
if (!isSubTable || !subTableName) {
if (!isFilterConnection || !selectedTargetId) {
setSubColumns([]);
return;
}
const tables = [mainTableName, subTableName].filter(Boolean) as string[];
if (tables.length === 0) { setSubColumns([]); return; }
setLoadingColumns(true);
getTableColumns(subTableName)
.then((res) => {
const cols = res.success && res.data?.columns;
if (Array.isArray(cols)) {
setSubColumns(cols.map((c) => c.columnName || "").filter(Boolean));
Promise.all(tables.map((t) => getTableColumns(t)))
.then((results) => {
const allCols = new Set<string>();
for (const res of results) {
const cols = res.success && res.data?.columns;
if (Array.isArray(cols)) {
cols.forEach((c) => { if (c.columnName) allCols.add(c.columnName); });
}
}
setSubColumns([...allCols].sort());
})
.catch(() => setSubColumns([]))
.finally(() => setLoadingColumns(false));
}, [isSubTable, subTableName]);
}, [isFilterConnection, selectedTargetId, mainTableName, subTableName]);
const handleSubmit = () => {
if (!selectedTargetId) return;
@ -290,11 +297,10 @@ function SimpleConnectionForm({
label: `${srcLabel}${tgtLabel}`,
};
if (isFilterConnection && isSubTable && targetColumn) {
if (isFilterConnection && targetColumn) {
conn.filterConfig = {
targetColumn,
filterMode: filterMode as "equals" | "contains" | "starts_with" | "range",
isSubTable: true,
};
}
@ -302,7 +308,6 @@ function SimpleConnectionForm({
if (!initial) {
setSelectedTargetId("");
setIsSubTable(false);
setTargetColumn("");
setFilterMode("equals");
}
@ -328,7 +333,6 @@ function SimpleConnectionForm({
value={selectedTargetId}
onValueChange={(v) => {
setSelectedTargetId(v);
setIsSubTable(false);
setTargetColumn("");
}}
>
@ -345,62 +349,47 @@ function SimpleConnectionForm({
</Select>
</div>
{isFilterConnection && selectedTargetId && subTableName && (
{isFilterConnection && selectedTargetId && (
<div className="space-y-2 rounded bg-muted/50 p-2">
<div className="flex items-center gap-2">
<Checkbox
id={`isSubTable_${component.id}`}
checked={isSubTable}
onCheckedChange={(v) => {
setIsSubTable(v === true);
if (!v) setTargetColumn("");
}}
/>
<label htmlFor={`isSubTable_${component.id}`} className="text-[10px] text-muted-foreground cursor-pointer">
({subTableName})
</label>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
{loadingColumns ? (
<div className="flex items-center gap-1 py-1">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : (
<Select value={targetColumn} onValueChange={setTargetColumn}>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subColumns.filter(Boolean).map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{isSubTable && (
<div className="space-y-2 pl-5">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
{loadingColumns ? (
<div className="flex items-center gap-1 py-1">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : (
<Select value={targetColumn} onValueChange={setTargetColumn}>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subColumns.filter(Boolean).map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={filterMode} onValueChange={setFilterMode}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals" className="text-xs"> (equals)</SelectItem>
<SelectItem value="contains" className="text-xs"> (contains)</SelectItem>
<SelectItem value="starts_with" className="text-xs"> (starts_with)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={filterMode} onValueChange={setFilterMode}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals" className="text-xs"> (equals)</SelectItem>
<SelectItem value="contains" className="text-xs"> (contains)</SelectItem>
<SelectItem value="starts_with" className="text-xs"> (starts_with)</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-[9px] text-muted-foreground">
/
</p>
</div>
)}

View File

@ -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

View File

@ -651,8 +651,12 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
const { subscribe, publish } = usePopEvent(screenId || "default");
// 장바구니 모드 상태
const isCartMode = config?.preset === "cart";
// 장바구니 모드 상태 (v1 preset 또는 v2 tasks에 cart-save가 있으면 활성)
const v2Tasks = (config && "tasks" in config && Array.isArray((config as any).tasks))
? (config as any).tasks as PopButtonTask[]
: null;
const hasCartSaveTask = !!v2Tasks?.some((t) => t.type === "cart-save");
const isCartMode = config?.preset === "cart" || hasCartSaveTask;
const isInboundConfirmMode = config?.preset === "inbound-confirm";
const [cartCount, setCartCount] = useState(0);
const [cartIsDirty, setCartIsDirty] = useState(false);
@ -746,8 +750,10 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
}, [isCartMode, componentId, subscribe]);
// 저장 완료 수신 (카드 목록에서 saveToDb 완료 후 전달)
const cartScreenIdRef = React.useRef(config?.cart?.cartScreenId);
cartScreenIdRef.current = config?.cart?.cartScreenId;
const resolvedCartScreenId = config?.cart?.cartScreenId
|| v2Tasks?.find((t) => t.type === "cart-save")?.cartScreenId;
const cartScreenIdRef = React.useRef(resolvedCartScreenId);
cartScreenIdRef.current = resolvedCartScreenId;
useEffect(() => {
if (!isCartMode || !componentId) return;
@ -990,7 +996,28 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
return;
}
// v2 경로: tasks 배열이 있으면 새 실행 엔진 사용
// 장바구니 모드 (v1 preset: "cart" 또는 v2 tasks에 cart-save 포함)
if (isCartMode) {
if (cartCount === 0 && !cartIsDirty) {
toast.info("장바구니가 비어 있습니다.");
return;
}
if (cartIsDirty) {
setShowCartConfirm(true);
} else {
const targetScreenId = resolvedCartScreenId;
if (targetScreenId) {
const cleanId = String(targetScreenId).replace(/^.*\/(\d+)$/, "$1").trim();
window.location.href = `/pop/screens/${cleanId}`;
} else {
toast.info("장바구니 화면이 설정되지 않았습니다.");
}
}
return;
}
// v2 경로: tasks 배열이 있으면 새 실행 엔진 사용 (cart-save 제외)
if (v2Config) {
if (v2Config.confirm?.enabled) {
setShowInboundConfirm(true);
@ -1012,27 +1039,6 @@ export function PopButtonComponent({ config, label, isDesignMode, screenId, comp
return;
}
// 장바구니 모드: isDirty 여부에 따라 분기
if (isCartMode) {
if (cartCount === 0 && !cartIsDirty) {
toast.info("장바구니가 비어 있습니다.");
return;
}
if (cartIsDirty) {
setShowCartConfirm(true);
} else {
const targetScreenId = config?.cart?.cartScreenId;
if (targetScreenId) {
const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim();
window.location.href = `/pop/screens/${cleanId}`;
} else {
toast.info("장바구니 화면이 설정되지 않았습니다.");
}
}
return;
}
const action = config?.action;
if (!action) return;
@ -1072,10 +1078,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 +1095,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 +1127,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 +1139,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}

View File

@ -48,7 +48,6 @@ import type {
CardSortConfig,
V2OverflowConfig,
V2CardClickAction,
V2CardClickModalConfig,
ActionButtonUpdate,
TimelineDataSource,
StatusValueMapping,
@ -117,37 +116,35 @@ const V2_DEFAULT_CONFIG: PopCardListV2Config = {
cardGap: 8,
scrollDirection: "vertical",
overflow: { mode: "loadMore", visibleCount: 6, loadMoreCount: 6 },
cardClickAction: "none",
cardClickAction: "modal-open",
};
// ===== 탭 정의 =====
type V2ConfigTab = "data" | "design" | "actions";
type V2ConfigTab = "info" | "actions";
const TAB_LABELS: { id: V2ConfigTab; label: string }[] = [
{ id: "data", label: "데이터" },
{ id: "design", label: "카드 디자인" },
{ id: "info", label: "정보" },
{ id: "actions", label: "동작" },
];
// ===== 셀 타입 라벨 =====
const V2_CELL_TYPE_LABELS: Record<CardCellType, { label: string; group: string }> = {
const V2_CELL_TYPE_LABELS: Record<string, { label: string; group: string }> = {
text: { label: "텍스트", group: "기본" },
field: { label: "필드 (라벨+값)", group: "기본" },
image: { label: "이미지", group: "기본" },
badge: { label: "배지", group: "기본" },
button: { label: "버튼", group: "동작" },
"number-input": { label: "숫자 입력", group: "입력" },
"cart-button": { label: "담기 버튼", group: "입력" },
"package-summary": { label: "포장 요약", group: "요약" },
"status-badge": { label: "상태 배지", group: "표시" },
timeline: { label: "타임라인", group: "표시" },
"footer-status": { label: "하단 상태", group: "표시" },
"action-buttons": { label: "액션 버튼", group: "동작" },
"process-qty-summary": { label: "공정 수량 요약", group: "표시" },
"mes-process-card": { label: "MES 공정 카드", group: "표시" },
};
const CELL_TYPE_GROUPS = ["기본", "표시", "입력", "동작", "요약"] as const;
const CELL_TYPE_GROUPS = ["기본", "표시", "입력", "동작"] as const;
// ===== 그리드 유틸 =====
@ -197,10 +194,8 @@ const shortType = (t: string): string => {
// ===== 메인 컴포넌트 =====
export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps) {
const [tab, setTab] = useState<V2ConfigTab>("data");
const [tables, setTables] = useState<TableInfo[]>([]);
const [tab, setTab] = useState<V2ConfigTab>("info");
const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
const cfg: PopCardListV2Config = {
...V2_DEFAULT_CONFIG,
@ -215,28 +210,12 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps)
};
useEffect(() => {
fetchTableList()
.then(setTables)
.catch(() => setTables([]));
}, []);
useEffect(() => {
if (!cfg.dataSource.tableName) {
setColumns([]);
return;
}
if (!cfg.dataSource.tableName) { setColumns([]); return; }
fetchTableColumns(cfg.dataSource.tableName)
.then(setColumns)
.catch(() => setColumns([]));
}, [cfg.dataSource.tableName]);
useEffect(() => {
if (cfg.selectedColumns && cfg.selectedColumns.length > 0) {
setSelectedColumns(cfg.selectedColumns);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cfg.dataSource.tableName]);
return (
<div className="flex flex-col gap-3">
{/* 탭 바 */}
@ -257,56 +236,142 @@ export function PopCardListV2ConfigPanel({ config, onUpdate }: ConfigPanelProps)
))}
</div>
{/* 탭 컨텐츠 */}
{tab === "data" && (
<TabData
cfg={cfg}
tables={tables}
columns={columns}
selectedColumns={selectedColumns}
onTableChange={(tableName) => {
setSelectedColumns([]);
update({
dataSource: { ...cfg.dataSource, tableName },
selectedColumns: [],
cardGrid: { ...cfg.cardGrid, cells: [] },
});
}}
onColumnsChange={(cols) => {
setSelectedColumns(cols);
update({ selectedColumns: cols });
}}
onDataSourceChange={(dataSource) => update({ dataSource })}
onSortChange={(sort) =>
update({ dataSource: { ...cfg.dataSource, sort } })
}
/>
)}
{tab === "design" && (
<TabCardDesign
cfg={cfg}
columns={columns}
selectedColumns={selectedColumns}
tables={tables}
onGridChange={(cardGrid) => update({ cardGrid })}
onGridColumnsChange={(gridColumns) => update({ gridColumns })}
onCardGapChange={(cardGap) => update({ cardGap })}
/>
)}
{tab === "info" && <TabInfo cfg={cfg} onUpdate={update} />}
{tab === "actions" && (
<TabActions
cfg={cfg}
onUpdate={update}
columns={columns}
/>
<TabActions cfg={cfg} onUpdate={update} columns={columns} />
)}
</div>
);
}
// ===== 탭 1: 데이터 =====
// ===== 탭 1: 정보 (연결 흐름 요약) =====
function TabInfo({
cfg,
onUpdate,
}: {
cfg: PopCardListV2Config;
onUpdate: (partial: Partial<PopCardListV2Config>) => void;
}) {
const ds = cfg.dataSource;
const joins = ds.joins || [];
const clickAction = cfg.cardClickAction || "none";
const cellTypes = cfg.cardGrid.cells.map((c) => c.type);
const hasTimeline = cellTypes.includes("timeline");
const hasActionButtons = cellTypes.includes("action-buttons");
const currentCols = cfg.gridColumns || 3;
return (
<div className="space-y-3">
{/* 카드 열 수 (편집 가능) */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex gap-1">
{[1, 2, 3, 4].map((n) => (
<button
key={n}
type="button"
onClick={() => onUpdate({ gridColumns: n })}
className={cn(
"flex-1 rounded border py-1.5 text-xs font-medium transition-colors",
currentCols === n
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{n}
</button>
))}
</div>
</div>
{/* 데이터 소스 */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="mt-1 rounded border bg-muted/10 p-2 space-y-1">
{ds.tableName ? (
<>
<div className="text-xs font-medium">{ds.tableName}</div>
{joins.map((j, i) => (
<div key={i} className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span className="text-[8px]">+</span>
<span>{j.targetTable}</span>
<span className="text-[8px]">({j.joinType})</span>
</div>
))}
{ds.sort?.[0] && (
<div className="text-[10px] text-muted-foreground">
: {ds.sort[0].column} ({ds.sort[0].direction === "asc" ? "오름차순" : "내림차순"})
</div>
)}
</>
) : (
<span className="text-[10px] text-muted-foreground"> </span>
)}
</div>
</div>
{/* 카드 구성 */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="mt-1 rounded border bg-muted/10 p-2 space-y-1 text-[10px]">
<div>{cfg.cardGrid.rows} x {cfg.cardGrid.cols} , {cfg.cardGrid.cells.length}</div>
<div className="flex flex-wrap gap-1 mt-1">
{hasTimeline && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[9px] text-blue-700"></span>
)}
{hasActionButtons && (
<span className="rounded bg-green-100 px-1.5 py-0.5 text-[9px] text-green-700"> </span>
)}
{cellTypes.includes("status-badge") && (
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-[9px] text-purple-700"> </span>
)}
{cellTypes.includes("number-input") && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] text-amber-700"> </span>
)}
{cellTypes.filter((t) => t === "field" || t === "text").length > 0 && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[9px] text-gray-700">
/ {cellTypes.filter((t) => t === "field" || t === "text").length}
</span>
)}
</div>
</div>
</div>
{/* 동작 흐름 */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<div className="mt-1 rounded border bg-muted/10 p-2 text-[10px]">
{clickAction === "none" && (
<span className="text-muted-foreground"> </span>
)}
{clickAction === "modal-open" && (
<div className="space-y-0.5">
<div className="font-medium"> </div>
{cfg.cardClickModalConfig?.screenId ? (
<div className="text-muted-foreground">
: {cfg.cardClickModalConfig.screenId}
{cfg.cardClickModalConfig.modalTitle && ` (${cfg.cardClickModalConfig.modalTitle})`}
</div>
) : (
<div className="text-muted-foreground"> - </div>
)}
</div>
)}
{clickAction === "built-in-work-detail" && (
<div className="space-y-0.5">
<div className="font-medium"> ()</div>
<div className="text-muted-foreground">(in_progress) </div>
</div>
)}
</div>
</div>
</div>
);
}
// ===== (레거시) 탭: 데이터 =====
function TabData({
cfg,
@ -1414,7 +1479,7 @@ function CellDetailEditor({
<SelectTrigger className={cn("h-7 text-[10px]", cell.type === "action-buttons" ? "flex-1" : "w-24")}><SelectValue /></SelectTrigger>
<SelectContent>
{CELL_TYPE_GROUPS.map((group) => {
const types = (Object.entries(V2_CELL_TYPE_LABELS) as [CardCellType, { label: string; group: string }][]).filter(([, v]) => v.group === group);
const types = Object.entries(V2_CELL_TYPE_LABELS).filter(([, v]) => v.group === group);
if (types.length === 0) return null;
return (
<Fragment key={group}>
@ -1491,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>
);
}
@ -2942,9 +2998,9 @@ function TabActions({
columns: ColumnInfo[];
}) {
const designerCtx = usePopDesignerContext();
const overflow = cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 };
const clickAction = cfg.cardClickAction || "none";
const modalConfig = cfg.cardClickModalConfig || { screenId: "" };
const [advancedOpen, setAdvancedOpen] = useState(false);
const [processColumns, setProcessColumns] = useState<ColumnInfo[]>([]);
const timelineCell = cfg.cardGrid?.cells?.find((c) => c.type === "timeline" && c.timelineSource?.processTable);
@ -2971,31 +3027,11 @@ function TabActions({
return (
<div className="space-y-3">
{/* 소유자 우선 정렬 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex items-center gap-1">
<Select
value={cfg.ownerSortColumn || "__none__"}
onValueChange={(v) => onUpdate({ ownerSortColumn: v === "__none__" ? undefined : v })}
>
<SelectTrigger className="h-7 flex-1 text-[10px]"><SelectValue placeholder="사용 안 함" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-[10px]"> </SelectItem>
{renderColumnOptionGroups(ownerColumnGroups)}
</SelectContent>
</Select>
</div>
<p className="mt-0.5 text-[9px] text-muted-foreground">
</p>
</div>
{/* 카드 선택 시 */}
{/* 카드 선택 시 동작 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 space-y-1">
{(["none", "publish", "navigate", "modal-open"] as V2CardClickAction[]).map((action) => (
{(["none", "modal-open", "built-in-work-detail"] as V2CardClickAction[]).map((action) => (
<label key={action} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
<input
type="radio"
@ -3006,16 +3042,16 @@ function TabActions({
/>
<span className="text-xs">
{action === "none" && "없음"}
{action === "publish" && "상세 데이터 전달 (다른 컴포넌트 연결)"}
{action === "navigate" && "화면 이동"}
{action === "modal-open" && "모달 열기"}
{action === "built-in-work-detail" && "작업 상세 (내장)"}
</span>
</label>
))}
</div>
{/* 모달 열기 설정 */}
{clickAction === "modal-open" && (
<div className="mt-2 space-y-1.5 rounded border bg-muted/20 p-2">
{/* 모달 캔버스 (디자이너 모드) */}
{designerCtx && (
<div>
{modalConfig.screenId?.startsWith("modal-") ? (
@ -3049,7 +3085,6 @@ function TabActions({
)}
</div>
)}
{/* 뷰어 모드 또는 직접 입력 폴백 */}
{!designerCtx && (
<div className="flex items-center gap-1">
<span className="w-16 shrink-0 text-[9px] text-muted-foreground"> ID</span>
@ -3122,118 +3157,137 @@ function TabActions({
)}
</div>
)}
{/* 작업 상세 내장 모드 안내 */}
{clickAction === "built-in-work-detail" && (
<p className="mt-2 text-[9px] text-muted-foreground rounded border bg-muted/20 p-2">
.
(in_progress) .
.
</p>
)}
</div>
{/* 필터 전 비표시 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={!!cfg.hideUntilFiltered}
onCheckedChange={(checked) => onUpdate({ hideUntilFiltered: checked })}
/>
</div>
{cfg.hideUntilFiltered && (
<p className="text-[9px] text-muted-foreground -mt-2 pl-1">
.
{/* 내 작업 표시 모드 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex gap-1">
{([
{ value: "off", label: "전체 보기" },
{ value: "priority", label: "우선 표시" },
{ value: "only", label: "내 작업만" },
] as const).map((opt) => {
const current = !cfg.ownerSortColumn
? "off"
: cfg.ownerFilterMode === "only"
? "only"
: "priority";
return (
<button
key={opt.value}
type="button"
onClick={() => {
if (opt.value === "off") {
onUpdate({ ownerSortColumn: undefined, ownerFilterMode: undefined });
} else {
onUpdate({ ownerSortColumn: "worker", ownerFilterMode: opt.value });
}
}}
className={cn(
"flex-1 rounded border py-1.5 text-[10px] font-medium transition-colors",
current === opt.value
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{opt.label}
</button>
);
})}
</div>
<p className="mt-1 text-[9px] text-muted-foreground">
{!cfg.ownerSortColumn
? "모든 작업자의 카드가 동일하게 표시됩니다"
: cfg.ownerFilterMode === "only"
? "내가 담당인 작업만 표시되고, 다른 작업은 숨겨집니다"
: "내가 담당인 작업이 상단에 표시되고, 다른 작업은 비활성화로 표시됩니다"}
</p>
)}
{/* 스크롤 방향 */}
<div>
<Label className="text-xs"> </Label>
<div className="mt-1 flex gap-1">
{(["vertical", "horizontal"] as const).map((dir) => (
<button
key={dir}
type="button"
onClick={() => onUpdate({ scrollDirection: dir })}
className={cn(
"flex-1 rounded border py-1 text-xs transition-colors",
(cfg.scrollDirection || "vertical") === dir
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{dir === "vertical" ? "세로" : "가로"}
</button>
))}
</div>
</div>
{/* 오버플로우 */}
{/* 고급 설정 (접이식) */}
<div>
<Label className="text-xs"></Label>
<div className="mt-1 flex gap-1">
{(["loadMore", "pagination"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => onUpdate({ overflow: { ...overflow, mode } })}
className={cn(
"flex-1 rounded border py-1 text-xs transition-colors",
overflow.mode === mode
? "border-primary bg-primary/10 text-primary"
: "border-border hover:bg-muted"
)}
>
{mode === "loadMore" ? "더보기" : "페이지네이션"}
</button>
))}
</div>
<div className="mt-2 space-y-2">
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={50}
value={overflow.visibleCount}
onChange={(e) => onUpdate({ overflow: { ...overflow, visibleCount: Number(e.target.value) || 6 } })}
className="mt-0.5 h-7 text-[10px]"
/>
</div>
{overflow.mode === "loadMore" && (
<button
type="button"
onClick={() => setAdvancedOpen(!advancedOpen)}
className="flex w-full items-center gap-1 text-xs font-medium text-muted-foreground"
>
{advancedOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</button>
{advancedOpen && (
<div className="mt-2 space-y-3 rounded border bg-muted/10 p-2">
{/* 내장 상태 탭 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={!!cfg.showStatusTabs}
onCheckedChange={(checked) => onUpdate({ showStatusTabs: checked })}
/>
</div>
{cfg.showStatusTabs && (
<p className="text-[9px] text-muted-foreground -mt-2 pl-1">
MES (////) .
.
</p>
)}
{/* 필터 전 비표시 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch
checked={!!cfg.hideUntilFiltered}
onCheckedChange={(checked) => onUpdate({ hideUntilFiltered: checked })}
/>
</div>
{cfg.hideUntilFiltered && (
<div className="space-y-1.5 -mt-1">
<p className="text-[9px] text-muted-foreground pl-1">
.
</p>
<div>
<Label className="text-[9px] text-muted-foreground"> </Label>
<Input
value={cfg.hideUntilFilteredMessage || ""}
onChange={(e) => onUpdate({ hideUntilFilteredMessage: e.target.value })}
placeholder="필터를 먼저 선택해주세요."
className="h-7 text-[10px]"
/>
</div>
</div>
)}
{/* 기본 표시 수 */}
<div>
<Label className="text-[10px]"> </Label>
<Label className="text-xs"> </Label>
<Input
type="number"
min={1}
max={50}
value={overflow.loadMoreCount ?? 6}
onChange={(e) => onUpdate({ overflow: { ...overflow, loadMoreCount: Number(e.target.value) || 6 } })}
value={(cfg.overflow || { visibleCount: 6 }).visibleCount}
onChange={(e) => onUpdate({
overflow: {
...(cfg.overflow || { mode: "loadMore" as const, visibleCount: 6 }),
visibleCount: Number(e.target.value) || 6,
},
})}
className="mt-0.5 h-7 text-[10px]"
/>
<p className="mt-0.5 text-[9px] text-muted-foreground">
(기본: 6개)
</p>
</div>
)}
{overflow.mode === "pagination" && (
<div>
<Label className="text-[10px]"> </Label>
<Input
type="number"
min={1}
max={100}
value={overflow.pageSize ?? overflow.visibleCount}
onChange={(e) => onUpdate({ overflow: { ...overflow, pageSize: Number(e.target.value) || 6 } })}
className="mt-0.5 h-7 text-[10px]"
/>
</div>
)}
</div>
</div>
{/* 장바구니 */}
<div className="flex items-center justify-between">
<Label className="text-xs">() </Label>
<Switch
checked={!!cfg.cartAction}
onCheckedChange={(checked) => {
if (checked) {
onUpdate({ cartAction: { saveMode: "cart", label: "담기", cancelLabel: "취소" } });
} else {
onUpdate({ cartAction: undefined });
}
}}
/>
</div>
)}
</div>
</div>
);

View File

@ -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">

View File

@ -9,8 +9,8 @@
import React, { useMemo, useState } from "react";
import {
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
Loader2, CheckCircle2, CircleDot, Clock,
X, Package, Truck, Box, Archive, Heart, Star,
Loader2, CheckCircle2, CircleDot, Clock, Check, ChevronRight,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@ -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":
@ -101,6 +89,10 @@ export function renderCellV2(props: CellRendererProps): React.ReactNode {
return <ActionButtonsCell {...props} />;
case "footer-status":
return <FooterStatusCell {...props} />;
case "process-qty-summary":
return <ProcessQtySummaryCell {...props} />;
case "mes-process-card":
return <MesProcessCardCell {...props} />;
default:
return <span className="text-[10px] text-muted-foreground"> </span>;
}
@ -258,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;
@ -349,17 +305,21 @@ function StatusBadgeCell({ cell, row }: CellRendererProps) {
);
}
const defaultColors = STATUS_COLORS[strValue];
// in_progress + 접수분 전부 생산 완료 → "접수분완료" 표시
const displayValue = strValue;
const defaultColors = STATUS_COLORS[displayValue];
if (defaultColors) {
const labelMap: Record<string, string> = {
waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료",
waiting: "대기", accepted: "접수", in_progress: "진행중",
completed: "완료",
};
return (
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
style={{ backgroundColor: defaultColors.bg, color: defaultColors.text }}
>
{labelMap[strValue] || strValue}
{labelMap[displayValue] || displayValue}
</span>
);
}
@ -514,6 +474,8 @@ function TimelineCell({ cell, row }: CellRendererProps) {
})}
</div>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
@ -587,6 +549,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
</div>
</DialogContent>
</Dialog>
</div>
</>
);
}
@ -601,7 +564,11 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId
if (cond.type === "timeline-status") {
const subStatus = row[VIRTUAL_SUB_STATUS];
matched = subStatus !== undefined && String(subStatus) === cond.value;
if (Array.isArray(cond.value)) {
matched = subStatus !== undefined && cond.value.includes(String(subStatus));
} else {
matched = subStatus !== undefined && String(subStatus) === cond.value;
}
} else if (cond.type === "column-value" && cond.column) {
matched = String(row[cond.column] ?? "") === (cond.value ?? "");
} else if (cond.type === "owner-match" && cond.column) {
@ -618,13 +585,25 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId
function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, currentUserId }: CellRendererProps) {
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
const currentProcess = processFlow?.find((s) => s.isCurrent);
const currentProcessId = currentProcess?.processId;
const currentProcessId = (row.__splitProcessId ?? row.__process_id ?? currentProcess?.processId) as string | number | undefined;
if (cell.actionButtons && cell.actionButtons.length > 0) {
const evaluated = cell.actionButtons.map((btn) => ({
btn,
state: evaluateShowCondition(btn, row, currentUserId),
}));
const evaluated = cell.actionButtons.map((btn) => {
let state = evaluateShowCondition(btn, row, currentUserId);
// 접수가능 조건 버튼이 원본 카드의 in_progress에서 보이지 않도록 차단
// (접수는 접수가능 탭의 클론 카드에서만 가능)
if (state === "visible" && !row.__isAcceptClone) {
const cond = btn.showCondition;
if (cond?.type === "timeline-status") {
const condValues = Array.isArray(cond.value) ? cond.value : [cond.value];
const currentSubStatus = String(row[VIRTUAL_SUB_STATUS] ?? "");
if (condValues.includes("acceptable") && currentSubStatus === "in_progress") {
state = "hidden";
}
}
}
return { btn, state };
});
const activeBtn = evaluated.find((e) => e.state === "visible");
const disabledBtn = activeBtn ? null : evaluated.find((e) => e.state === "disabled");
@ -633,6 +612,14 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode,
const { btn, state } = pick;
// in_progress 상태 + 미소진 접수분 존재 시 접수취소 버튼 추가
const subStatus = row[VIRTUAL_SUB_STATUS];
const effectiveStatus = subStatus !== undefined ? String(subStatus) : "";
const rowInputQty = parseInt(String(row.__process_input_qty ?? row.input_qty ?? "0"), 10) || 0;
const totalProduced = parseInt(String(row.__process_total_production_qty ?? row.total_production_qty ?? "0"), 10) || 0;
const hasUnproduced = rowInputQty > totalProduced;
const showCancelBtn = effectiveStatus === "in_progress" && hasUnproduced && currentProcessId;
return (
<div className="flex items-center gap-1">
<Button
@ -644,6 +631,7 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode,
e.stopPropagation();
const actions = (btn.clickActions && btn.clickActions.length > 0) ? btn.clickActions : [btn.clickAction];
const firstAction = actions[0];
if (!firstAction) return;
const config: Record<string, unknown> = {
...firstAction,
@ -664,6 +652,22 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode,
>
{btn.label}
</Button>
{showCancelBtn && (
<Button
variant="ghost"
size="sm"
className="h-7 text-[10px] text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onActionButtonClick?.("__cancelAccept", row, {
__processId: currentProcessId,
type: "cancel-accept",
});
}}
>
</Button>
)}
</div>
);
}
@ -703,7 +707,199 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode,
);
}
// ===== 12. footer-status =====
// ===== 12. process-qty-summary =====
function ProcessQtySummaryCell({ cell, row }: CellRendererProps) {
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
const status = String(row[VIRTUAL_SUB_STATUS] ?? row.status ?? "");
const isClone = !!row.__isAcceptClone;
const instructionQty = parseInt(String(row.instruction_qty ?? row.qty ?? "0"), 10) || 0;
const inputQty = parseInt(String(row.__process_input_qty ?? row.input_qty ?? "0"), 10) || 0;
const totalProd = parseInt(String(row.__process_total_production_qty ?? row.total_production_qty ?? "0"), 10) || 0;
const goodQty = parseInt(String(row.__process_good_qty ?? row.good_qty ?? "0"), 10) || 0;
const defectQty = parseInt(String(row.__process_defect_qty ?? row.defect_qty ?? "0"), 10) || 0;
const availableQty = parseInt(String(row.__availableQty ?? "0"), 10) || 0;
const prevGoodQty = parseInt(String(row.__prevGoodQty ?? String(instructionQty)), 10) || 0;
const currentStep = processFlow?.find((s) => s.isCurrent);
const currentIdx = processFlow?.findIndex((s) => s.isCurrent) ?? -1;
const isFirstProcess = currentIdx === 0;
const totalSteps = processFlow?.length ?? 0;
const remainingQty = Math.max(0, inputQty - totalProd);
const progressPct = inputQty > 0 ? Math.min(100, Math.round((totalProd / inputQty) * 100)) : 0;
const yieldRate = totalProd > 0 ? Math.round((goodQty / totalProd) * 100) : 0;
// 접수가능 탭 (클론 카드) - 접수 가능 수량 중심
if (isClone || status === "acceptable" || status === "waiting") {
const showQty = isClone ? availableQty : (status === "acceptable" ? availableQty || prevGoodQty : 0);
return (
<div className="flex w-full flex-col gap-1 px-1">
{/* 미니 공정 흐름 바 */}
{processFlow && processFlow.length > 1 && (
<MiniProcessBar steps={processFlow} currentIdx={currentIdx} />
)}
{/* 핵심 수량 */}
<div className="flex items-center justify-between gap-1">
<div className="flex items-baseline gap-1">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-xs font-semibold">{instructionQty.toLocaleString()}</span>
</div>
{!isFirstProcess && (
<div className="flex items-baseline gap-1">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-xs font-medium text-emerald-600">{prevGoodQty.toLocaleString()}</span>
</div>
)}
<div className="flex items-baseline gap-1">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-xs font-bold text-primary">{(showQty || prevGoodQty).toLocaleString()}</span>
</div>
</div>
</div>
);
}
// 진행중 / 접수분완료 - 작업 현황 중심
if (status === "in_progress") {
const isBatchDone = inputQty > 0 && totalProd >= inputQty;
return (
<div className="flex w-full flex-col gap-1 px-1">
{/* 미니 공정 흐름 바 */}
{processFlow && processFlow.length > 1 && (
<MiniProcessBar steps={processFlow} currentIdx={currentIdx} />
)}
{/* 프로그레스 바 */}
<div className="flex items-center gap-1.5">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
<div
className={cn(
"h-full rounded-full transition-all duration-300",
isBatchDone ? "bg-violet-500" : "bg-primary",
)}
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
{/* 수량 상세 */}
<div className="flex items-center justify-between gap-0.5">
<QtyChip label="접수" value={inputQty} color="#3b82f6" />
<QtyChip label="양품" value={goodQty} color="#10b981" />
<QtyChip label="불량" value={defectQty} color="#ef4444" showZero={false} />
<QtyChip label="잔여" value={remainingQty} color={remainingQty > 0 ? "#f59e0b" : "#10b981"} />
</div>
{/* 추가접수가능 수량 (있을 때만) */}
{availableQty > 0 && (
<div className="flex items-center justify-end gap-1">
<span className="text-[9px] text-muted-foreground"></span>
<span className="text-[10px] font-semibold text-violet-600">{availableQty.toLocaleString()}</span>
</div>
)}
</div>
);
}
// 완료 상태 - 최종 결과 요약
if (status === "completed") {
return (
<div className="flex w-full flex-col gap-1 px-1">
{/* 미니 공정 흐름 바 */}
{processFlow && processFlow.length > 1 && (
<MiniProcessBar steps={processFlow} currentIdx={currentIdx} />
)}
{/* 완료 프로그레스 */}
<div className="flex items-center gap-1.5">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
<div className="h-full rounded-full bg-emerald-500" style={{ width: "100%" }} />
</div>
<span className="text-[10px] font-bold text-emerald-600"></span>
</div>
{/* 최종 수량 */}
<div className="flex items-center justify-between gap-0.5">
<QtyChip label="총생산" value={totalProd} color="#059669" />
<QtyChip label="양품" value={goodQty} color="#10b981" />
<QtyChip label="불량" value={defectQty} color="#ef4444" showZero={false} />
{totalProd > 0 && (
<div className="flex items-baseline gap-0.5">
<span className="text-[9px] text-muted-foreground"></span>
<span className="text-[10px] font-bold text-emerald-600">{yieldRate}%</span>
</div>
)}
</div>
</div>
);
}
// fallback
return (
<div className="flex w-full items-center justify-between px-1">
<span className="text-[10px] text-muted-foreground"></span>
<span className="text-xs font-semibold">{instructionQty.toLocaleString()}</span>
</div>
);
}
// --- 미니 공정 흐름 바 ---
function MiniProcessBar({ steps, currentIdx }: { steps: TimelineProcessStep[]; currentIdx: number }) {
return (
<div className="flex items-center gap-px">
{steps.map((step, idx) => {
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
const isCurrent = idx === currentIdx;
let bg = "#e2e8f0"; // pending
if (sem === "done") bg = "#10b981";
else if (sem === "active") bg = "#3b82f6";
const pct = step.totalProductionQty && step.inputQty && step.inputQty > 0
? Math.round((step.totalProductionQty / step.inputQty) * 100)
: undefined;
return (
<div
key={step.seqNo}
className={cn(
"relative flex-1 overflow-hidden rounded-sm",
isCurrent ? "h-2.5" : "h-1.5",
)}
style={{ backgroundColor: `${bg}30` }}
title={`${step.processName}: ${step.status}${pct !== undefined ? ` (${pct}%)` : ""}`}
>
<div
className="absolute inset-y-0 left-0 rounded-sm transition-all duration-300"
style={{
backgroundColor: bg,
width: sem === "done" ? "100%" : pct !== undefined ? `${pct}%` : "0%",
}}
/>
</div>
);
})}
</div>
);
}
// --- 수량 칩 ---
function QtyChip({
label, value, color, showZero = true,
}: {
label: string; value: number; color: string; showZero?: boolean;
}) {
if (!showZero && value === 0) return null;
return (
<div className="flex items-baseline gap-0.5">
<span className="text-[9px] text-muted-foreground">{label}</span>
<span
className="text-[10px] font-semibold tabular-nums"
style={{ color }}
>
{value.toLocaleString()}
</span>
</div>
);
}
// ===== 13. footer-status =====
function FooterStatusCell({ cell, row }: CellRendererProps) {
const value = cell.footerStatusColumn ? row[cell.footerStatusColumn] : "";
@ -735,3 +931,514 @@ function FooterStatusCell({ cell, row }: CellRendererProps) {
</div>
);
}
// ===== 14. mes-process-card (MES 공정 전용 카드) =====
const MES_STATUS: Record<string, { label: string; color: string; bg: string }> = {
waiting: { label: "대기", color: "#94a3b8", bg: "#f8fafc" },
acceptable: { label: "접수가능", color: "#2563eb", bg: "#eff6ff" },
in_progress: { label: "진행중", color: "#d97706", bg: "#fffbeb" },
completed: { label: "완료", color: "#059669", bg: "#ecfdf5" },
};
function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: CellRendererProps) {
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
const rawStatus = String(row[VIRTUAL_SUB_STATUS] ?? row.status ?? "");
const isClone = !!row.__isAcceptClone;
const [flowModalOpen, setFlowModalOpen] = useState(false);
const instrQty = parseInt(String(row.qty ?? row.instruction_qty ?? "0"), 10) || 0;
const inputQty = parseInt(String(row.__process_input_qty ?? row.input_qty ?? "0"), 10) || 0;
const totalProd = parseInt(String(row.__process_total_production_qty ?? row.total_production_qty ?? "0"), 10) || 0;
const goodQty = parseInt(String(row.__process_good_qty ?? row.good_qty ?? "0"), 10) || 0;
const defectQty = parseInt(String(row.__process_defect_qty ?? row.defect_qty ?? "0"), 10) || 0;
const concessionQty = parseInt(String(row.__process_concession_qty ?? row.concession_qty ?? "0"), 10) || 0;
const isRework = String(row.__process_is_rework ?? row.is_rework ?? "N") === "Y";
const availableQty = parseInt(String(row.__availableQty ?? "0"), 10) || 0;
const prevGoodQty = parseInt(String(row.__prevGoodQty ?? String(instrQty)), 10) || 0;
const resultStatus = String(row.__process_result_status ?? "");
const currentStep = processFlow?.find((s) => s.isCurrent);
const currentIdx = processFlow?.findIndex((s) => s.isCurrent) ?? -1;
const isFirstProcess = currentIdx === 0;
const processId = currentStep?.processId;
const remainingQty = Math.max(0, inputQty - totalProd);
const progressPct = inputQty > 0 ? Math.min(100, Math.round((totalProd / inputQty) * 100)) : 0;
const yieldRate = totalProd > 0 ? Math.round((goodQty / totalProd) * 100) : 0;
const displayStatus = rawStatus;
const st = MES_STATUS[displayStatus] || MES_STATUS.waiting;
const processName = currentStep?.processName || String(row.__process_process_name ?? "");
const woNo = String(row.work_instruction_no ?? "");
const itemId = String(row.item_id ?? "");
const itemName = String(row.item_name ?? "");
// MES 워크플로우 상태 기반 버튼 결정
const acceptBtn = (cell.actionButtons || []).find((b) => b.showCondition?.type === "timeline-status");
const cancelBtn = (cell.actionButtons || []).find((b) => b.showCondition?.type === "owner-match");
let activeBtn: ActionButtonDef | undefined;
let showManualComplete = false;
const isFullyProduced = inputQty > 0 && totalProd >= inputQty;
if (isClone) {
activeBtn = acceptBtn;
} else if (rawStatus === "acceptable") {
activeBtn = acceptBtn;
} else if (rawStatus === "in_progress") {
if (isFullyProduced) {
if (availableQty > 0) activeBtn = acceptBtn;
} else if (totalProd > 0) {
showManualComplete = true;
} else {
activeBtn = cancelBtn;
}
}
return (
<>
<div
className="flex h-full w-full flex-col overflow-hidden"
style={{ borderLeft: `4px solid ${st.color}`, backgroundColor: st.bg }}
>
{/* ── 헤더 ── */}
<div className="flex items-start justify-between px-5 pt-5 pb-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-medium text-muted-foreground">{woNo}</span>
{processName && (
<span className="text-[14px] font-semibold" style={{ color: st.color }}>
{processName}
{processFlow && processFlow.length > 1 && ` (${currentIdx + 1}/${processFlow.length})`}
</span>
)}
</div>
<div className="mt-1">
<span className="text-[20px] font-bold leading-tight">{itemName || itemId || "-"}</span>
</div>
</div>
<div className="ml-3 flex shrink-0 items-center gap-2">
{isRework && (
<span className="rounded-md bg-amber-500 px-2.5 py-1 text-[12px] font-bold text-white">
</span>
)}
<span
className="rounded-full px-3.5 py-1.5 text-[14px] font-bold"
style={{ backgroundColor: st.color, color: "#fff" }}
>
{st.label}
</span>
</div>
</div>
{/* ── 수량 메트릭 (상태별) ── */}
<div className="flex-1 px-5 py-3">
{(isClone || rawStatus === "acceptable" || rawStatus === "waiting") && (
<MesAcceptableMetrics
instrQty={instrQty}
prevGoodQty={prevGoodQty}
availableQty={availableQty}
inputQty={inputQty}
isFirstProcess={isFirstProcess}
isClone={isClone}
isRework={isRework}
/>
)}
{rawStatus === "in_progress" && (
<MesInProgressMetrics
inputQty={inputQty}
totalProd={totalProd}
goodQty={goodQty}
defectQty={defectQty}
concessionQty={concessionQty}
remainingQty={remainingQty}
progressPct={progressPct}
availableQty={availableQty}
isBatchDone={false}
statusColor={st.color}
/>
)}
{rawStatus === "completed" && (
<MesCompletedMetrics
instrQty={instrQty}
goodQty={goodQty}
defectQty={defectQty}
concessionQty={concessionQty}
yieldRate={yieldRate}
/>
)}
</div>
{/* ── 공정 흐름 스트립 (클릭 시 모달) ── */}
{processFlow && processFlow.length > 0 && (
<div
className="cursor-pointer border-t px-5 py-3 transition-colors hover:bg-black/3"
style={{ borderColor: `${st.color}20` }}
onClick={(e) => { e.stopPropagation(); setFlowModalOpen(true); }}
title="클릭하여 공정 상세 보기"
>
<ProcessFlowStrip steps={processFlow} currentIdx={currentIdx} instrQty={instrQty} />
</div>
)}
{/* ── 부가정보 ── */}
{(row.end_date || row.equipment_id || row.work_team) && (
<div
className="border-t px-5 py-2"
style={{ borderColor: `${st.color}20` }}
>
<div className="flex items-center gap-4 text-[14px] text-muted-foreground">
{row.end_date && <span> <b className="text-foreground">{formatValue(row.end_date)}</b></span>}
{row.equipment_id && <span>{String(row.equipment_id)}</span>}
{row.work_team && <span>{String(row.work_team)}</span>}
</div>
</div>
)}
{/* ── 액션 버튼 ── */}
{(activeBtn || showManualComplete) && (
<div
className="mt-auto border-t px-5 py-3"
style={{ borderColor: `${st.color}20` }}
>
<div className="flex gap-3">
{activeBtn && (
<Button
variant={activeBtn.variant || "default"}
className="h-14 flex-1 rounded-xl text-[17px] font-bold"
onClick={(e) => {
e.stopPropagation();
const actions = activeBtn.clickActions?.length ? activeBtn.clickActions : [activeBtn.clickAction];
const firstAction = actions[0];
const config: Record<string, unknown> = { ...firstAction, __allActions: actions };
if (processId !== undefined) config.__processId = processId;
onActionButtonClick?.(activeBtn.label, row, config);
}}
>
{activeBtn.label}
</Button>
)}
{showManualComplete && (
<Button
variant="outline"
className="h-14 flex-1 rounded-xl text-[17px] font-bold"
onClick={(e) => {
e.stopPropagation();
onActionButtonClick?.("__manualComplete", row, { __processId: processId });
}}
>
</Button>
)}
</div>
</div>
)}
</div>
{/* ── 공정 상세 모달 ── */}
<Dialog open={flowModalOpen} onOpenChange={setFlowModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[560px]">
<DialogHeader>
<DialogTitle className="text-base">{woNo} </DialogTitle>
<DialogDescription className="text-xs">
{processFlow?.length ?? 0} {processFlow?.filter(s => (s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status]) === "done").length ?? 0}
</DialogDescription>
</DialogHeader>
<div className="space-y-0">
{processFlow?.map((step, idx) => {
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
const styles = getTimelineStyle(step);
const sInstr = instrQty;
const sInput = step.inputQty || 0;
const sProd = step.totalProductionQty || 0;
const sGood = step.goodQty || 0;
const sDefect = step.defectQty || 0;
const sYield = step.yieldRate || 0;
const sPct = sInput > 0 ? Math.round((sProd / sInput) * 100) : (sem === "done" ? 100 : 0);
const statusLabel = sem === "done" ? "완료" : sem === "active" ? "진행" : "대기";
return (
<div key={step.seqNo} className="flex items-center">
<div className="flex w-8 shrink-0 flex-col items-center">
{idx > 0 && <div className="h-3 w-px bg-border" />}
<div
className="flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold"
style={{ backgroundColor: styles.chipBg, color: styles.chipText }}
>
{step.seqNo}
</div>
{idx < (processFlow?.length ?? 0) - 1 && <div className="h-3 w-px bg-border" />}
</div>
<div className={cn(
"ml-2 flex flex-1 items-center justify-between rounded-md px-3 py-2",
step.isCurrent && "ring-1 ring-primary/30 bg-primary/5",
)}>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className={cn("text-sm", step.isCurrent ? "font-bold" : "font-medium")}>
{step.processName}
</span>
<span className="rounded px-1.5 py-0.5 text-[9px] font-medium"
style={{ backgroundColor: `${styles.chipBg}30`, color: styles.chipBg }}>
{statusLabel}
</span>
</div>
{(sInput > 0 || sem === "done") && (
<div className="mt-1 flex items-center gap-3 text-[10px] text-muted-foreground">
<span> <b className="text-foreground">{sGood.toLocaleString()}</b></span>
{sDefect > 0 && <span> <b className="text-destructive">{sDefect.toLocaleString()}</b></span>}
<span> <b style={{ color: sYield >= 95 ? "#059669" : sYield >= 80 ? "#d97706" : "#ef4444" }}>{sYield}%</b></span>
</div>
)}
</div>
<div className="ml-3 flex w-16 flex-col items-end">
<span className="text-[11px] font-bold tabular-nums">{sProd}/{sInput || sInstr}</span>
<div className="mt-0.5 h-1.5 w-full overflow-hidden rounded-full bg-secondary">
<div className="h-full rounded-full transition-all" style={{
width: `${sPct}%`,
backgroundColor: styles.chipBg,
}} />
</div>
</div>
</div>
</div>
);
})}
</div>
</DialogContent>
</Dialog>
</>
);
}
// ── 공정 흐름 스트립 (노드 기반: 지나온 + 이전 + 현재 + 다음 + 남은) ──
function ProcessFlowStrip({ steps, currentIdx, instrQty }: {
steps: TimelineProcessStep[]; currentIdx: number; instrQty: number;
}) {
const safeIdx = currentIdx >= 0 && currentIdx < steps.length ? currentIdx : -1;
const prevStep = safeIdx > 0 ? steps[safeIdx - 1] : null;
const currStep = safeIdx >= 0 ? steps[safeIdx] : null;
const nextStep = safeIdx >= 0 && safeIdx < steps.length - 1 ? steps[safeIdx + 1] : null;
const hiddenBefore = safeIdx > 1 ? safeIdx - 1 : 0;
const hiddenAfter = safeIdx >= 0 && safeIdx < steps.length - 2 ? steps.length - safeIdx - 2 : 0;
const allBeforeDone = hiddenBefore > 0 && safeIdx > 1 && steps.slice(0, safeIdx - 1).every(s => {
const sem = s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status] || "pending";
return sem === "done";
});
const renderNode = (step: TimelineProcessStep, isCurrent: boolean) => {
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
return (
<div className="flex flex-col items-center gap-1">
<div className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] border-2 text-[14px] font-bold",
isCurrent
? "border-primary bg-primary text-primary-foreground shadow-sm shadow-primary/20"
: sem === "done"
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-border bg-muted text-muted-foreground",
)}>
{sem === "done" && !isCurrent ? <Check className="h-4 w-4" /> : step.seqNo}
</div>
<span className={cn(
"max-w-[56px] truncate text-center text-[11px] font-medium",
isCurrent ? "font-bold text-primary" : "text-muted-foreground",
)}>
{step.processName}
</span>
</div>
);
};
const connDone = "mt-[18px] h-[3px] w-5 shrink-0 bg-emerald-400";
const connPending = "mt-[18px] h-[3px] w-5 shrink-0 bg-border";
return (
<div className="flex items-start">
{hiddenBefore > 0 && (
<>
<div className="flex flex-col items-center gap-1">
<div className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] border-2 text-[13px] font-bold tabular-nums",
allBeforeDone
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-border bg-muted text-muted-foreground",
)}>
+{hiddenBefore}
</div>
</div>
<div className={allBeforeDone ? connDone : connPending} />
</>
)}
{prevStep && (
<>
{renderNode(prevStep, false)}
<div className={connDone} />
</>
)}
{currStep && renderNode(currStep, true)}
{nextStep && (
<>
<div className={connPending} />
{renderNode(nextStep, false)}
</>
)}
{hiddenAfter > 0 && (
<>
<div className={connPending} />
<div className="flex flex-col items-center gap-1">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] border-2 border-amber-200 bg-amber-50 text-[13px] font-bold tabular-nums text-amber-600">
+{hiddenAfter}
</div>
</div>
</>
)}
</div>
);
}
// ── 접수가능 메트릭 ──
function MesAcceptableMetrics({ instrQty, prevGoodQty, availableQty, inputQty, isFirstProcess, isClone, isRework }: {
instrQty: number; prevGoodQty: number; availableQty: number; inputQty: number; isFirstProcess: boolean; isClone: boolean; isRework?: boolean;
}) {
if (isRework) {
return (
<div className="space-y-3">
<div className="flex items-center gap-3 text-[14px]">
<span className="font-medium text-amber-600"> </span>
</div>
<div className="flex items-center justify-center gap-2.5 rounded-lg py-3.5" style={{ backgroundColor: "rgba(245,158,11,0.08)" }}>
<span className="text-[16px] font-medium text-muted-foreground"> </span>
<span className="text-[36px] font-extrabold tabular-nums leading-none tracking-tight text-amber-600">{inputQty.toLocaleString()}</span>
<span className="text-[16px] font-medium text-muted-foreground">EA</span>
</div>
</div>
);
}
const displayAvail = isClone ? availableQty : (availableQty || prevGoodQty);
return (
<div className="space-y-3">
<div className="flex items-center gap-4 text-[14px]">
<span className="text-muted-foreground"> <b className="text-foreground">{instrQty.toLocaleString()}</b></span>
{!isFirstProcess && (
<span className="text-muted-foreground"> <b className="text-emerald-600">{prevGoodQty.toLocaleString()}</b></span>
)}
{inputQty > 0 && (
<span className="text-muted-foreground"> <b className="text-foreground">{inputQty.toLocaleString()}</b></span>
)}
</div>
<div className="flex items-center justify-center gap-2.5 rounded-lg py-3.5" style={{ backgroundColor: "rgba(37,99,235,0.06)" }}>
<span className="text-[16px] font-medium text-muted-foreground"></span>
<span className="text-[36px] font-extrabold tabular-nums leading-none tracking-tight text-primary">{displayAvail.toLocaleString()}</span>
<span className="text-[16px] font-medium text-muted-foreground">EA</span>
</div>
</div>
);
}
// ── 진행중 메트릭 ──
function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, concessionQty, remainingQty, progressPct, availableQty, isBatchDone, statusColor }: {
inputQty: number; totalProd: number; goodQty: number; defectQty: number; concessionQty: number; remainingQty: number; progressPct: number; availableQty: number; isBatchDone: boolean; statusColor: string;
}) {
return (
<div className="space-y-3">
<div className="flex items-center gap-4 text-[14px]">
<span className="text-muted-foreground"> <b className="text-foreground">{inputQty.toLocaleString()}</b></span>
{availableQty > 0 && (
<span className="text-muted-foreground"> <b className="text-violet-600">{availableQty.toLocaleString()}</b></span>
)}
</div>
<div className="flex items-center justify-center gap-2.5 rounded-lg py-3.5" style={{ backgroundColor: `${statusColor}0F` }}>
<span className="text-[16px] font-medium text-muted-foreground"></span>
<span className="text-[36px] font-extrabold tabular-nums leading-none tracking-tight" style={{ color: statusColor }}>
{totalProd.toLocaleString()}
</span>
<span className="text-[20px] font-normal text-muted-foreground">/ {inputQty.toLocaleString()}</span>
<span className="text-[16px] font-medium text-muted-foreground">EA</span>
</div>
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-50 px-3 py-1.5 text-[14px] font-semibold text-emerald-600">
<span className="font-medium opacity-70"></span> {goodQty.toLocaleString()}
</span>
{defectQty > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-red-50 px-3 py-1.5 text-[14px] font-semibold text-red-600">
<span className="font-medium opacity-70"></span> {defectQty.toLocaleString()}
</span>
)}
{concessionQty > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-violet-50 px-3 py-1.5 text-[14px] font-semibold text-violet-600">
<span className="font-medium opacity-70"></span> {concessionQty.toLocaleString()}
</span>
)}
<span className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[14px] font-semibold",
remainingQty > 0 ? "bg-amber-50 text-amber-600" : "bg-emerald-50 text-emerald-600",
)}>
<span className="font-medium opacity-70"></span> {remainingQty.toLocaleString()}
</span>
</div>
</div>
);
}
// ── 완료 메트릭 ──
function MesCompletedMetrics({ instrQty, goodQty, defectQty, concessionQty, yieldRate }: {
instrQty: number; goodQty: number; defectQty: number; concessionQty: number; yieldRate: number;
}) {
return (
<div className="space-y-3">
<div className="flex items-center gap-4 text-[14px]">
<span className="text-muted-foreground"> <b className="text-foreground">{instrQty.toLocaleString()}</b></span>
<span
className="ml-auto rounded-full px-3.5 py-1 text-[14px] font-bold"
style={{
backgroundColor: yieldRate >= 95 ? "#f0fdf4" : yieldRate >= 80 ? "#fffbeb" : "#fef2f2",
color: yieldRate >= 95 ? "#059669" : yieldRate >= 80 ? "#d97706" : "#ef4444",
}}
>
{yieldRate}%
</span>
</div>
<div className="flex items-center justify-center gap-2.5 rounded-lg py-3.5" style={{ backgroundColor: "rgba(5,150,105,0.06)" }}>
<span className="text-[16px] font-medium text-muted-foreground"></span>
<span className="text-[36px] font-extrabold tabular-nums leading-none tracking-tight text-emerald-600">{goodQty.toLocaleString()}</span>
<span className="text-[16px] font-medium text-muted-foreground">EA</span>
</div>
{(defectQty > 0 || concessionQty > 0) && (
<div className="flex flex-wrap gap-2">
{defectQty > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-red-50 px-3 py-1.5 text-[14px] font-semibold text-red-600">
<span className="font-medium opacity-70"></span> {defectQty.toLocaleString()}
</span>
)}
{concessionQty > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-violet-50 px-3 py-1.5 text-[14px] font-semibold text-violet-600">
<span className="font-medium opacity-70"></span> {concessionQty.toLocaleString()}
</span>
)}
</div>
)}
</div>
);
}
// ── 메트릭 박스 ──
function MesMetricBox({ label, value, color, dimZero = false }: {
label: string; value: number; color: string; dimZero?: boolean;
}) {
const isDim = dimZero && value === 0;
return (
<div className={cn("flex flex-col items-center rounded px-1 py-0.5", isDim && "opacity-40")}
style={{ backgroundColor: `${color}08` }}>
<span className="text-[8px] text-muted-foreground">{label}</span>
<span className="text-[11px] font-bold tabular-nums" style={{ color }}>{value.toLocaleString()}</span>
</div>
);
}

View File

@ -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: "버튼에서 데이터+매핑 수집 요청 수신" },
],
},

View File

@ -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,
};
}

View File

@ -229,6 +229,8 @@ export function NumberInputModal({
<DialogOverlay />
<DialogPrimitive.Content
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] w-full max-w-[95vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden border shadow-lg duration-200 sm:max-w-[360px] sm:rounded-lg"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<VisuallyHidden><DialogTitle> </DialogTitle></VisuallyHidden>
{/* 헤더 */}

View File

@ -14,7 +14,7 @@ import { useRouter } from "next/navigation";
import {
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight,
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
Trash2,
Trash2, Search,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
@ -770,6 +770,13 @@ export function PopCardListComponent({
.
</p>
</div>
) : !isCartListMode && config?.requireFilter && externalFilters.size === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-6">
<Search className="h-8 w-8 text-muted-foreground/50" />
<p className="text-center text-sm text-muted-foreground">
{config.requireFilterMessage || "필터를 먼저 선택해주세요."}
</p>
</div>
) : loading ? (
<div className="flex flex-1 items-center justify-center rounded-md border bg-muted/30 p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />

View File

@ -9,8 +9,11 @@
*/
import React, { useState, useEffect, useMemo } from "react";
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react";
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check, ChevronsUpDown } from "lucide-react";
import { useCollapsibleSections } from "@/hooks/pop/useCollapsibleSections";
import { useAuth } from "@/hooks/useAuth";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import type { GridMode } from "@/components/pop/designer/types/pop-layout";
import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout";
import { Button } from "@/components/ui/button";
@ -431,6 +434,32 @@ function BasicSettingsTab({
</CollapsibleSection>
)}
{/* 필터 필수 설정 (장바구니 모드 아닐 때만) */}
{!isCartListMode && dataSource.tableName && (
<CollapsibleSection sectionKey="basic-require-filter" title="필터 필수" sections={sections}>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-[10px]"> </Label>
<Switch
checked={!!config.requireFilter}
onCheckedChange={(checked) => onUpdate({ requireFilter: checked })}
/>
</div>
{config.requireFilter && (
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Input
value={config.requireFilterMessage || ""}
onChange={(e) => onUpdate({ requireFilterMessage: e.target.value })}
placeholder="필터를 먼저 선택해주세요."
className="h-7 text-[10px]"
/>
</div>
)}
</div>
</CollapsibleSection>
)}
{/* 저장 매핑 (장바구니 모드일 때만) */}
{isCartListMode && (
<CollapsibleSection
@ -842,28 +871,29 @@ function CartListModeSection({
onUpdate: (config: CartListModeConfig) => void;
}) {
const mode: CartListModeConfig = cartListMode || { enabled: false };
const [screens, setScreens] = useState<{ id: number; name: string }[]>([]);
const [screens, setScreens] = useState<{ id: number; name: string; code: string }[]>([]);
const [sourceCardLists, setSourceCardLists] = useState<SourceCardListInfo[]>([]);
const [loadingComponents, setLoadingComponents] = useState(false);
const [screenOpen, setScreenOpen] = useState(false);
const { companyCode } = useAuth();
// 화면 목록 로드
useEffect(() => {
screenApi
.getScreens({ size: 500 })
.getScreens({ size: 500, companyCode: companyCode || undefined })
.then((res) => {
if (res?.data) {
setScreens(
res.data.map((s) => ({
id: s.screenId,
name: s.screenName || `화면 ${s.screenId}`,
code: s.screenCode || "",
}))
);
}
})
.catch(() => {});
}, []);
}, [companyCode]);
// 원본 화면 선택 시 -> 해당 화면의 pop-card-list 컴포넌트 목록 로드
useEffect(() => {
if (!mode.sourceScreenId) {
setSourceCardLists([]);
@ -889,22 +919,7 @@ function CartListModeSection({
.finally(() => setLoadingComponents(false));
}, [mode.sourceScreenId]);
const handleScreenChange = (val: string) => {
const screenId = val === "__none__" ? undefined : Number(val);
onUpdate({ ...mode, sourceScreenId: screenId });
};
const handleComponentSelect = (val: string) => {
if (val === "__none__") {
onUpdate({ ...mode, sourceComponentId: undefined });
return;
}
const compId = val.startsWith("__comp_") ? val.replace("__comp_", "") : val;
const found = sourceCardLists.find((c) => c.componentId === compId);
if (found) {
onUpdate({ ...mode, sourceComponentId: found.componentId });
}
};
const selectedScreen = screens.find((s) => s.id === mode.sourceScreenId);
return (
<div className="space-y-3">
@ -923,28 +938,69 @@ function CartListModeSection({
{mode.enabled && (
<>
{/* 원본 화면 선택 */}
{/* 원본 화면 선택 (검색 가능 Combobox) */}
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
<Select
value={mode.sourceScreenId ? String(mode.sourceScreenId) : "__none__"}
onValueChange={handleScreenChange}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="화면 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{screens.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Popover open={screenOpen} onOpenChange={setScreenOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={screenOpen}
className="mt-1 h-7 w-full justify-between text-xs"
>
{selectedScreen
? `${selectedScreen.name} (${selectedScreen.id})`
: "화면 검색..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="화면 이름 또는 ID 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
{screens.map((s) => (
<CommandItem
key={s.id}
value={`${s.name} ${s.id} ${s.code}`}
onSelect={() => {
onUpdate({
...mode,
sourceScreenId: mode.sourceScreenId === s.id ? undefined : s.id,
sourceComponentId: mode.sourceScreenId === s.id ? undefined : mode.sourceComponentId,
});
setScreenOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
mode.sourceScreenId === s.id ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span>{s.name}</span>
<span className="text-[9px] text-muted-foreground">ID: {s.id}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 원본 컴포넌트 선택 (원본 화면에서 자동 로드) */}
{/* 원본 컴포넌트 선택 */}
{mode.sourceScreenId && (
<div>
<Label className="text-[10px] text-muted-foreground"> </Label>
@ -959,7 +1015,14 @@ function CartListModeSection({
) : (
<Select
value={mode.sourceComponentId ? `__comp_${mode.sourceComponentId}` : "__none__"}
onValueChange={handleComponentSelect}
onValueChange={(val) => {
if (val === "__none__") {
onUpdate({ ...mode, sourceComponentId: undefined });
} else {
const compId = val.replace("__comp_", "");
onUpdate({ ...mode, sourceComponentId: compId });
}
}}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="카드 목록 선택" />

View File

@ -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>
{/* 설정 배지 */}

View File

@ -50,8 +50,8 @@ const defaultConfig: PopCardListConfig = {
// 레지스트리 등록
PopComponentRegistry.registerComponent({
id: "pop-card-list",
name: "카드 목록",
description: "테이블 데이터를 카드 형태로 표시 (헤더 + 이미지 + 필드 목록)",
name: "장바구니 목록",
description: "장바구니 담기/확정 카드 목록 (입고, 출고, 수주 등)",
category: "display",
icon: "LayoutGrid",
component: PopCardListComponent,

View File

@ -356,7 +356,10 @@ function SaveTabContent({
};
const syncAndUpdateSaveMappings = useCallback(
(updater?: (prev: PopFieldSaveMapping[]) => PopFieldSaveMapping[]) => {
(
updater?: (prev: PopFieldSaveMapping[]) => PopFieldSaveMapping[],
extraPartial?: Partial<PopFieldConfig>,
) => {
const fieldIds = new Set(allFields.map(({ field }) => field.id));
const prev = saveMappings.filter((m) => fieldIds.has(m.fieldId));
const next = updater ? updater(prev) : prev;
@ -381,6 +384,7 @@ function SaveTabContent({
tableName: saveTableName,
fieldMappings: merged,
},
...extraPartial,
});
}
},
@ -395,22 +399,27 @@ function SaveTabContent({
const updateSaveMapping = useCallback(
(fieldId: string, partial: Partial<PopFieldSaveMapping>) => {
syncAndUpdateSaveMappings((prev) =>
prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m))
);
let extraPartial: Partial<PopFieldConfig> | undefined;
if (partial.targetColumn !== undefined) {
const newFieldName = partial.targetColumn || "";
const sections = cfg.sections.map((s) => ({
...s,
fields: (s.fields ?? []).map((f) =>
f.id === fieldId ? { ...f, fieldName: newFieldName } : f
),
}));
onUpdateConfig({ sections });
extraPartial = {
sections: cfg.sections.map((s) => ({
...s,
fields: (s.fields ?? []).map((f) =>
f.id === fieldId ? { ...f, fieldName: newFieldName } : f
),
})),
};
}
syncAndUpdateSaveMappings(
(prev) =>
prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m)),
extraPartial,
);
},
[syncAndUpdateSaveMappings, cfg, onUpdateConfig]
[syncAndUpdateSaveMappings, cfg.sections]
);
// --- 숨은 필드 매핑 로직 ---
@ -2086,23 +2095,24 @@ function JsonKeySelect({
onOpen?: () => void;
}) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (nextOpen) onOpen?.();
if (nextOpen) {
onOpen?.();
setInputValue("");
}
};
if (keys.length === 0 && !value) {
return (
<Input
placeholder="키"
value={value}
onChange={(e) => onValueChange(e.target.value)}
onFocus={() => onOpen?.()}
className="h-7 w-24 text-xs"
/>
);
}
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && inputValue.trim()) {
e.preventDefault();
onValueChange(inputValue.trim());
setInputValue("");
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
@ -2117,33 +2127,51 @@ function JsonKeySelect({
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-0" align="start">
<Command>
<CommandInput placeholder="키 검색..." className="text-xs" />
<Command shouldFilter={keys.length > 0}>
<CommandInput
placeholder={keys.length > 0 ? "키 검색..." : "키 직접 입력..."}
className="text-xs"
value={inputValue}
onValueChange={setInputValue}
onKeyDown={handleInputKeyDown}
/>
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
{keys.length === 0 ? "데이터를 불러오는 중..." : "일치하는 키가 없습니다."}
</CommandEmpty>
<CommandGroup>
{keys.map((k) => (
<CommandItem
key={k}
value={k}
onSelect={(v) => {
onValueChange(v === value ? "" : v);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
value === k ? "opacity-100" : "opacity-0"
)}
/>
{k}
</CommandItem>
))}
</CommandGroup>
{keys.length === 0 ? (
<div className="px-3 py-2 text-center text-xs text-muted-foreground">
{inputValue.trim()
? "Enter로 입력 확정"
: "테이블에 데이터가 없습니다. 키를 직접 입력하세요."}
</div>
) : (
<>
<CommandEmpty className="py-2 text-center text-xs">
{inputValue.trim()
? "Enter로 직접 입력 확정"
: "일치하는 키가 없습니다."}
</CommandEmpty>
<CommandGroup>
{keys.map((k) => (
<CommandItem
key={k}
value={k}
onSelect={(v) => {
onValueChange(v === value ? "" : v);
setOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
value === k ? "opacity-100" : "opacity-0"
)}
/>
{k}
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>

View File

@ -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>
);
}

View File

@ -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

View File

@ -28,6 +28,7 @@ import {
import { format, startOfWeek, endOfWeek, startOfMonth, endOfMonth } from "date-fns";
import { ko } from "date-fns/locale";
import { usePopEvent } from "@/hooks/pop";
import { useAuth } from "@/hooks/useAuth";
import { dataApi } from "@/lib/api/data";
import type {
PopSearchConfig,
@ -67,9 +68,11 @@ export function PopSearchComponent({
}: PopSearchComponentProps) {
const config = { ...DEFAULT_CONFIG, ...(rawConfig || {}) };
const { publish, subscribe, setSharedData } = usePopEvent(screenId || "");
const { user } = useAuth();
const [value, setValue] = useState<unknown>(config.defaultValue ?? "");
const [modalDisplayText, setModalDisplayText] = useState("");
const [simpleModalOpen, setSimpleModalOpen] = useState(false);
const initialValueAppliedRef = useRef(false);
const normalizedType = normalizeInputType(config.inputType as string);
const isModalType = normalizedType === "modal";
@ -107,6 +110,21 @@ export function PopSearchComponent({
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns]
);
// 초기값 고정 세팅: 사용자 프로필에서 자동으로 값 설정
useEffect(() => {
if (initialValueAppliedRef.current) return;
if (!config.initialValueSource || config.initialValueSource.type !== "user_profile") return;
if (!user) return;
const col = config.initialValueSource.column;
const profileValue = (user as Record<string, unknown>)[col];
if (profileValue != null && profileValue !== "") {
initialValueAppliedRef.current = true;
const timer = setTimeout(() => emitFilterChanged(profileValue), 100);
return () => clearTimeout(timer);
}
}, [user, config.initialValueSource, emitFilterChanged]);
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
@ -238,12 +256,6 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa
return <ToggleSearchInput value={Boolean(value)} onChange={onChange} />;
case "modal":
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} onClear={onModalClear} />;
case "status-chip":
return (
<div className="flex h-full items-center px-2 text-[10px] text-muted-foreground">
pop-status-bar
</div>
);
default:
return <PlaceholderInput inputType={config.inputType} />;
}
@ -1014,8 +1026,11 @@ function IconView({
return (
<div
key={i}
role="button"
tabIndex={0}
className="flex w-20 cursor-pointer flex-col items-center gap-1.5 rounded-lg p-2 transition-colors hover:bg-accent"
onClick={() => onSelect(row)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelect(row); }}
>
<div className={cn("flex h-14 w-14 items-center justify-center rounded-xl text-xl font-bold text-white", color)}>
{firstChar}

View File

@ -209,6 +209,39 @@ function StepBasicSettings({ cfg, update }: StepProps) {
</div>
)}
{/* 초기값 고정 세팅 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.initialValueSource?.column || "__none__"}
onValueChange={(v) => {
if (v === "__none__") {
update({ initialValueSource: undefined });
} else {
update({ initialValueSource: { type: "user_profile", column: v } });
}
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="사용 안 함" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs"> </SelectItem>
<SelectItem value="userId" className="text-xs"> ID</SelectItem>
<SelectItem value="userName" className="text-xs"> </SelectItem>
<SelectItem value="deptCode" className="text-xs"> </SelectItem>
<SelectItem value="deptName" className="text-xs"></SelectItem>
<SelectItem value="positionCode" className="text-xs"> </SelectItem>
<SelectItem value="positionName" className="text-xs"></SelectItem>
</SelectContent>
</Select>
{cfg.initialValueSource && (
<p className="text-[9px] text-muted-foreground">
{cfg.initialValueSource.column}
</p>
)}
</div>
</div>
);
}
@ -231,15 +264,6 @@ function StepDetailSettings({ cfg, update, allComponents, connections, component
return <DatePresetDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "modal":
return <ModalDetailSettings cfg={cfg} update={update} />;
case "status-chip":
return (
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-[10px] text-muted-foreground">
pop-status-bar .
&quot; &quot; .
</p>
</div>
);
case "toggle":
return (
<div className="rounded-lg bg-muted/50 p-3">

View File

@ -1,25 +1,20 @@
// ===== pop-search 전용 타입 =====
// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나.
/** 검색 필드 입력 타입 (10종) */
/** 검색 필드 입력 타입 */
export type SearchInputType =
| "text"
| "number"
| "date"
| "date-preset"
| "select"
| "multi-select"
| "combo"
| "modal"
| "toggle"
| "status-chip";
| "toggle";
/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */
export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid";
/** 레거시 타입 -> modal로 정규화 */
/** 레거시 입력 타입 정규화 (DB 호환) */
export function normalizeInputType(t: string): SearchInputType {
if (t === "modal-table" || t === "modal-card" || t === "modal-icon-grid") return "modal";
if (t === "status-chip" || t === "multi-select" || t === "combo") return "text";
return t as SearchInputType;
}
@ -38,15 +33,6 @@ export interface SelectOption {
label: string;
}
/** 셀렉트 옵션 데이터 소스 (DB에서 동적 로딩) */
export interface SelectDataSource {
tableName: string;
valueColumn: string;
labelColumn: string;
sortColumn?: string;
sortDirection?: "asc" | "desc";
}
/** 모달 보여주기 방식: 테이블 or 아이콘 */
export type ModalDisplayStyle = "table" | "icon";
@ -79,22 +65,9 @@ export interface ModalSelectConfig {
distinct?: boolean;
}
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
export type StatusChipStyle = "tab" | "pill";
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
export interface StatusChipConfig {
showCount?: boolean;
countColumn?: string;
allowAll?: boolean;
allLabel?: string;
chipStyle?: StatusChipStyle;
useSubCount?: boolean;
}
/** pop-search 전체 설정 */
export interface PopSearchConfig {
inputType: SearchInputType | LegacySearchInputType;
inputType: SearchInputType | string;
fieldName: string;
placeholder?: string;
defaultValue?: unknown;
@ -103,9 +76,8 @@ export interface PopSearchConfig {
debounceMs?: number;
triggerOnEnter?: boolean;
// select/multi-select 전용
// select 전용
options?: SelectOption[];
optionsDataSource?: SelectDataSource;
// date 전용
dateSelectionMode?: DateSelectionMode;
@ -117,9 +89,6 @@ export interface PopSearchConfig {
// modal 전용
modalConfig?: ModalSelectConfig;
// status-chip 전용
statusChipConfig?: StatusChipConfig;
// 라벨
labelText?: string;
labelVisible?: boolean;
@ -129,6 +98,12 @@ export interface PopSearchConfig {
// 필터 대상 컬럼 복수 선택 (fieldName은 대표 컬럼, filterColumns는 전체 대상)
filterColumns?: string[];
// 초기값 고정 세팅 (사용자 프로필에서 자동으로 값 설정)
initialValueSource?: {
type: "user_profile";
column: string;
};
}
/** 기본 설정값 (레지스트리 + 컴포넌트 공유) */
@ -157,17 +132,8 @@ export const SEARCH_INPUT_TYPE_LABELS: Record<SearchInputType, string> = {
date: "날짜",
"date-preset": "날짜 프리셋",
select: "단일 선택",
"multi-select": "다중 선택",
combo: "자동완성",
modal: "모달",
toggle: "토글",
"status-chip": "상태 칩 (대시보드)",
};
/** 상태 칩 스타일 라벨 (설정 패널용) */
export const STATUS_CHIP_STYLE_LABELS: Record<StatusChipStyle, string> = {
tab: "탭 (큰 숫자)",
pill: "알약 (작은 뱃지)",
};
/** 모달 보여주기 방식 라벨 */

View File

@ -25,6 +25,7 @@ export function PopStatusBarComponent({
const [selectedValue, setSelectedValue] = useState<string>("");
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
const [autoSubStatusColumn, setAutoSubStatusColumn] = useState<string | null>(null);
const [originalCount, setOriginalCount] = useState<number | null>(null);
// all_rows 이벤트 구독
useEffect(() => {
@ -47,13 +48,16 @@ export function PopStatusBarComponent({
const envelope = inner as {
rows?: unknown;
subStatusColumn?: string | null;
originalCount?: number;
};
if (Array.isArray(envelope.rows))
setAllRows(envelope.rows as Record<string, unknown>[]);
setAutoSubStatusColumn(envelope.subStatusColumn ?? null);
setOriginalCount(envelope.originalCount ?? null);
} else if (Array.isArray(inner)) {
setAllRows(inner as Record<string, unknown>[]);
setAutoSubStatusColumn(null);
setOriginalCount(null);
}
}
);
@ -130,7 +134,7 @@ export function PopStatusBarComponent({
return map;
}, [allRows, effectiveCountColumn, showCount]);
const totalCount = allRows.length;
const totalCount = originalCount ?? allRows.length;
const chipItems = useMemo(() => {
const items: { value: string; label: string; count: number }[] = [];

View File

@ -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} />

View File

@ -1,72 +1,327 @@
"use client";
import React, { useState } from "react";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import type { PopWorkDetailConfig } from "../types";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, Trash2, ChevronUp, ChevronDown } from "lucide-react";
import type { PopWorkDetailConfig, WorkDetailInfoBarField, ResultSectionConfig, ResultSectionType } from "../types";
interface PopWorkDetailConfigPanelProps {
config?: PopWorkDetailConfig;
onChange?: (config: PopWorkDetailConfig) => void;
}
const SECTION_TYPE_META: Record<ResultSectionType, { label: string }> = {
"total-qty": { label: "생산수량" },
"good-defect": { label: "양품/불량" },
"defect-types": { label: "불량 유형 상세" },
"note": { label: "비고" },
"box-packing": { label: "박스 포장" },
"label-print": { label: "라벨 출력" },
"photo": { label: "사진" },
"document": { label: "문서" },
"material-input": { label: "자재 투입" },
"barcode-scan": { label: "바코드 스캔" },
"plc-data": { label: "PLC 데이터" },
};
const ALL_SECTION_TYPES = Object.keys(SECTION_TYPE_META) as ResultSectionType[];
const DEFAULT_PHASE_LABELS: Record<string, string> = {
PRE: "작업 전",
IN: "작업 중",
POST: "작업 후",
};
const DEFAULT_INFO_BAR = {
enabled: true,
fields: [] as WorkDetailInfoBarField[],
};
const DEFAULT_STEP_CONTROL = {
requireStartBeforeInput: false,
autoAdvance: true,
};
const DEFAULT_NAVIGATION = {
showPrevNext: true,
showCompleteButton: true,
};
export function PopWorkDetailConfigPanel({
config,
onChange,
}: PopWorkDetailConfigPanelProps) {
const cfg: PopWorkDetailConfig = {
showTimer: config?.showTimer ?? true,
showQuantityInput: config?.showQuantityInput ?? true,
showQuantityInput: config?.showQuantityInput ?? false,
displayMode: config?.displayMode ?? "list",
phaseLabels: config?.phaseLabels ?? { ...DEFAULT_PHASE_LABELS },
infoBar: config?.infoBar ?? { ...DEFAULT_INFO_BAR },
stepControl: config?.stepControl ?? { ...DEFAULT_STEP_CONTROL },
navigation: config?.navigation ?? { ...DEFAULT_NAVIGATION },
resultSections: config?.resultSections ?? [],
};
const update = (partial: Partial<PopWorkDetailConfig>) => {
onChange?.({ ...cfg, ...partial });
};
const [newFieldLabel, setNewFieldLabel] = useState("");
const [newFieldColumn, setNewFieldColumn] = useState("");
const addInfoBarField = () => {
if (!newFieldLabel || !newFieldColumn) return;
const fields = [...(cfg.infoBar.fields ?? []), { label: newFieldLabel, column: newFieldColumn }];
update({ infoBar: { ...cfg.infoBar, fields } });
setNewFieldLabel("");
setNewFieldColumn("");
};
const removeInfoBarField = (idx: number) => {
const fields = (cfg.infoBar.fields ?? []).filter((_, i) => i !== idx);
update({ infoBar: { ...cfg.infoBar, fields } });
};
// --- 실적 입력 섹션 관리 ---
const sections = cfg.resultSections ?? [];
const usedTypes = new Set(sections.map((s) => s.type));
const availableTypes = ALL_SECTION_TYPES.filter((t) => !usedTypes.has(t));
const updateSections = (next: ResultSectionConfig[]) => {
update({ resultSections: next });
};
const addSection = (type: ResultSectionType) => {
updateSections([
...sections,
{ id: type, type, enabled: true, showCondition: { type: "always" } },
]);
};
const removeSection = (idx: number) => {
updateSections(sections.filter((_, i) => i !== idx));
};
const toggleSection = (idx: number, enabled: boolean) => {
const next = [...sections];
next[idx] = { ...next[idx], enabled };
updateSections(next);
};
const moveSection = (idx: number, dir: -1 | 1) => {
const target = idx + dir;
if (target < 0 || target >= sections.length) return;
const next = [...sections];
[next[idx], next[target]] = [next[target], next[idx]];
updateSections(next);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={cfg.showTimer}
onCheckedChange={(v) => update({ showTimer: v })}
/>
</div>
<div className="space-y-5">
{/* 기본 설정 */}
<Section title="기본 설정">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Select value={cfg.displayMode} onValueChange={(v) => update({ displayMode: v as "list" | "step" })}>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="list"></SelectItem>
<SelectItem value="step"></SelectItem>
</SelectContent>
</Select>
</div>
<ToggleRow label="타이머 표시" checked={cfg.showTimer} onChange={(v) => update({ showTimer: v })} />
</Section>
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={cfg.showQuantityInput}
onCheckedChange={(v) => update({ showQuantityInput: v })}
/>
</div>
{/* 실적 입력 섹션 */}
<Section title="실적 입력 섹션">
{sections.length === 0 ? (
<p className="text-xs text-muted-foreground py-1"> </p>
) : (
<div className="space-y-1">
{sections.map((s, i) => (
<div
key={s.id}
className="flex items-center gap-1 rounded-md border px-2 py-1"
>
<div className="flex flex-col">
<button
type="button"
className="h-3.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
disabled={i === 0}
onClick={() => moveSection(i, -1)}
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
className="h-3.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
disabled={i === sections.length - 1}
onClick={() => moveSection(i, 1)}
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
<span className="flex-1 truncate text-xs font-medium">
{SECTION_TYPE_META[s.type]?.label ?? s.type}
</span>
<Switch
checked={s.enabled}
onCheckedChange={(v) => toggleSection(i, v)}
className="scale-75"
/>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 shrink-0"
onClick={() => removeSection(i)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
{availableTypes.length > 0 && <SectionAdder types={availableTypes} onAdd={addSection} />}
</Section>
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
{/* 정보 바 */}
<Section title="작업지시 정보 바">
<ToggleRow
label="정보 바 표시"
checked={cfg.infoBar.enabled}
onChange={(v) => update({ infoBar: { ...cfg.infoBar, enabled: v } })}
/>
{cfg.infoBar.enabled && (
<div className="space-y-2 pt-1">
{(cfg.infoBar.fields ?? []).map((f, i) => (
<div key={i} className="flex items-center gap-1">
<span className="w-16 truncate text-xs text-muted-foreground">{f.label}</span>
<span className="flex-1 truncate text-xs font-mono">{f.column}</span>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => removeInfoBarField(i)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<div className="flex items-center gap-1">
<Input className="h-7 text-xs" placeholder="라벨" value={newFieldLabel} onChange={(e) => setNewFieldLabel(e.target.value)} />
<Input className="h-7 text-xs" placeholder="컬럼명" value={newFieldColumn} onChange={(e) => setNewFieldColumn(e.target.value)} />
<Button size="icon" variant="outline" className="h-7 w-7 shrink-0" onClick={addInfoBarField}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</Section>
{/* 단계 제어 */}
<Section title="단계 제어">
<ToggleRow
label="시작 전 입력 잠금"
checked={cfg.stepControl.requireStartBeforeInput}
onChange={(v) => update({ stepControl: { ...cfg.stepControl, requireStartBeforeInput: v } })}
/>
<ToggleRow
label="완료 시 자동 다음 이동"
checked={cfg.stepControl.autoAdvance}
onChange={(v) => update({ stepControl: { ...cfg.stepControl, autoAdvance: v } })}
/>
</Section>
{/* 네비게이션 */}
<Section title="네비게이션">
<ToggleRow
label="이전/다음 버튼"
checked={cfg.navigation.showPrevNext}
onChange={(v) => update({ navigation: { ...cfg.navigation, showPrevNext: v } })}
/>
<ToggleRow
label="공정 완료 버튼"
checked={cfg.navigation.showCompleteButton}
onChange={(v) => update({ navigation: { ...cfg.navigation, showCompleteButton: v } })}
/>
</Section>
{/* 단계 라벨 */}
<Section title="단계 라벨">
{(["PRE", "IN", "POST"] as const).map((phase) => (
<div key={phase} className="flex items-center gap-2">
<span className="w-12 text-xs font-medium text-muted-foreground">
{phase}
</span>
<span className="w-12 text-xs font-medium text-muted-foreground">{phase}</span>
<Input
className="h-8 text-xs"
className="h-7 text-xs"
value={cfg.phaseLabels[phase] ?? DEFAULT_PHASE_LABELS[phase]}
onChange={(e) =>
update({
phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value },
})
}
onChange={(e) => update({ phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value } })}
/>
</div>
))}
</div>
</Section>
</div>
);
}
function SectionAdder({
types,
onAdd,
}: {
types: ResultSectionType[];
onAdd: (type: ResultSectionType) => void;
}) {
const [selected, setSelected] = useState<string>("");
const handleAdd = () => {
if (!selected) return;
onAdd(selected as ResultSectionType);
setSelected("");
};
return (
<div className="flex items-center gap-1 pt-1">
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger className="h-7 flex-1 text-xs">
<SelectValue placeholder="섹션 선택" />
</SelectTrigger>
<SelectContent>
{types.map((t) => (
<SelectItem key={t} value={t} className="text-xs">
{SECTION_TYPE_META[t]?.label ?? t}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
className="h-7 shrink-0 gap-1 px-2 text-xs"
disabled={!selected}
onClick={handleAdd}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="space-y-2">
<div className="text-xs font-semibold text-muted-foreground">{title}</div>
{children}
</div>
);
}
function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<div className="flex items-center justify-between">
<Label className="text-xs">{label}</Label>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
);
}

View File

@ -8,8 +8,32 @@ import type { PopWorkDetailConfig } from "../types";
const defaultConfig: PopWorkDetailConfig = {
showTimer: true,
showQuantityInput: true,
showQuantityInput: false,
displayMode: "list",
phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" },
infoBar: {
enabled: true,
fields: [
{ label: "작업지시", column: "wo_no" },
{ label: "품목", column: "item_name" },
{ label: "공정", column: "__process_name" },
{ label: "지시수량", column: "qty" },
],
},
stepControl: {
requireStartBeforeInput: false,
autoAdvance: true,
},
navigation: {
showPrevNext: true,
showCompleteButton: true,
},
resultSections: [
{ id: "total-qty", type: "total-qty", enabled: true, showCondition: { type: "always" } },
{ id: "good-defect", type: "good-defect", enabled: true, showCondition: { type: "always" } },
{ id: "defect-types", type: "defect-types", enabled: true, showCondition: { type: "always" } },
{ id: "note", type: "note", enabled: true, showCondition: { type: "always" } },
],
};
PopComponentRegistry.registerComponent({

View File

@ -721,6 +721,9 @@ export interface PopCardListConfig {
cartListMode?: CartListModeConfig;
saveMapping?: CardListSaveMapping;
requireFilter?: boolean;
requireFilterMessage?: string;
}
// =============================================
@ -736,12 +739,13 @@ export type CardCellType =
| "badge"
| "button"
| "number-input"
| "cart-button"
| "package-summary"
| "status-badge"
| "timeline"
| "action-buttons"
| "footer-status";
| "footer-status"
| "process-qty-summary"
| "mes-process-card";
// timeline 셀에서 사용하는 하위 단계 데이터
export interface TimelineProcessStep {
@ -752,6 +756,12 @@ export interface TimelineProcessStep {
isCurrent: boolean;
processId?: string | number; // 공정 테이블 레코드 PK (접수 등 UPDATE 대상 특정용)
rawData?: Record<string, unknown>; // 하위 테이블 원본 행 (하위 필터 매칭용)
// 수량 필드 (process-flow-summary 셀용)
inputQty?: number; // 접수량
totalProductionQty?: number; // 총생산량
goodQty?: number; // 양품
defectQty?: number; // 불량
yieldRate?: number; // 수율 (양품/총생산*100)
}
// timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정
@ -814,12 +824,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 }>;
@ -846,6 +850,9 @@ export interface CardCellDefinitionV2 {
footerStatusColumn?: string;
footerStatusMap?: Array<{ value: string; label: string; color: string }>;
showTopBorder?: boolean;
// process-qty-summary 타입 전용 - 공정별 수량 흐름 요약
qtyDisplayMode?: "current" | "flow"; // current: 현재 공정만, flow: 전체 공정 흐름
}
export interface ActionButtonUpdate {
@ -948,7 +955,7 @@ export interface CardGridConfigV2 {
// ----- V2 카드 선택 동작 -----
export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open";
export type V2CardClickAction = "none" | "publish" | "navigate" | "modal-open" | "built-in-work-detail";
export interface V2CardClickModalConfig {
screenId: string;
@ -986,13 +993,15 @@ export interface PopCardListV2Config {
cardClickModalConfig?: V2CardClickModalConfig;
/** 연결된 필터 값이 전달되기 전까지 데이터 비표시 */
hideUntilFiltered?: boolean;
hideUntilFilteredMessage?: string;
responsiveDisplay?: CardResponsiveConfig;
inputField?: CardInputFieldConfig;
packageConfig?: CardPackageConfig;
cartAction?: CardCartActionConfig;
cartListMode?: CartListModeConfig;
saveMapping?: CardListSaveMapping;
ownerSortColumn?: string;
ownerFilterMode?: "priority" | "only";
workDetailConfig?: PopWorkDetailConfig;
showStatusTabs?: boolean;
}
/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */
@ -1006,8 +1015,55 @@ export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const;
// pop-work-detail 전용 타입
// =============================================
export interface WorkDetailInfoBarField {
label: string;
column: string;
}
export interface WorkDetailInfoBarConfig {
enabled: boolean;
fields: WorkDetailInfoBarField[];
}
export interface WorkDetailStepControl {
requireStartBeforeInput: boolean;
autoAdvance: boolean;
}
export interface WorkDetailNavigationConfig {
showPrevNext: boolean;
showCompleteButton: boolean;
}
export type ResultSectionType =
| "total-qty"
| "good-defect"
| "defect-types"
| "note"
| "box-packing"
| "label-print"
| "photo"
| "document"
| "material-input"
| "barcode-scan"
| "plc-data";
export interface ResultSectionConfig {
id: string;
type: ResultSectionType;
enabled: boolean;
showCondition?: { type: "always" | "last-process" };
}
export interface PopWorkDetailConfig {
showTimer: boolean;
/** @deprecated result-input 타입으로 대체 */
showQuantityInput: boolean;
/** 표시 모드: list(기존 리스트), step(한 항목씩 진행) */
displayMode: "list" | "step";
phaseLabels: Record<string, string>;
infoBar: WorkDetailInfoBarConfig;
stepControl: WorkDetailStepControl;
navigation: WorkDetailNavigationConfig;
resultSections?: ResultSectionConfig[];
}

View File

@ -266,6 +266,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -307,6 +308,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -340,6 +342,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@ -3055,6 +3058,7 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0",
@ -3708,6 +3712,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.6"
},
@ -3802,6 +3807,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@ -4115,6 +4121,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@ -6615,6 +6622,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -6625,6 +6633,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -6667,6 +6676,7 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@ -6749,6 +6759,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@ -7381,6 +7392,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -8531,7 +8543,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/d3": {
"version": "7.9.0",
@ -8853,6 +8866,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -9612,6 +9626,7 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -9700,6 +9715,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@ -9801,6 +9817,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -10972,6 +10989,7 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@ -11752,7 +11770,8 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/levn": {
"version": "0.4.1",
@ -13091,6 +13110,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -13384,6 +13404,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@ -13413,6 +13434,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@ -13461,6 +13483,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@ -13664,6 +13687,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -13733,6 +13757,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -13783,6 +13808,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -13815,7 +13841,8 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-leaflet": {
"version": "5.0.0",
@ -14123,6 +14150,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -14145,7 +14173,8 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/recharts/node_modules/redux-thunk": {
"version": "3.1.0",
@ -15175,7 +15204,8 @@
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@ -15263,6 +15293,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -15611,6 +15642,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"