Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node
This commit is contained in:
commit
782ebb1b33
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -70,8 +70,8 @@ const COMPONENT_TYPE_LABELS: Record<string, string> = {
|
|||
"pop-text": "텍스트",
|
||||
"pop-icon": "아이콘",
|
||||
"pop-dashboard": "대시보드",
|
||||
"pop-card-list": "카드 목록",
|
||||
"pop-card-list-v2": "카드 목록 V2",
|
||||
"pop-card-list": "장바구니 목록",
|
||||
"pop-card-list-v2": "MES 공정흐름",
|
||||
"pop-field": "필드",
|
||||
"pop-button": "버튼",
|
||||
"pop-string-list": "리스트 목록",
|
||||
|
|
|
|||
|
|
@ -41,13 +41,13 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||
},
|
||||
{
|
||||
type: "pop-card-list",
|
||||
label: "카드 목록",
|
||||
label: "장바구니 목록",
|
||||
icon: LayoutGrid,
|
||||
description: "테이블 데이터를 카드 형태로 표시",
|
||||
},
|
||||
{
|
||||
type: "pop-card-list-v2",
|
||||
label: "카드 목록 V2",
|
||||
label: "MES 공정흐름",
|
||||
icon: LayoutGrid,
|
||||
description: "슬롯 기반 카드 (CSS Grid + 셀 타입별 렌더링)",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export function PopCardListV2PreviewComponent({ config }: PopCardListV2PreviewPr
|
|||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">카드 목록 V2</span>
|
||||
<span className="text-xs font-medium">MES 공정흐름</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-[9px] text-primary">
|
||||
|
|
|
|||
|
|
@ -9,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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ const defaultConfig: PopCardListV2Config = {
|
|||
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-card-list-v2",
|
||||
name: "카드 목록 V2",
|
||||
description: "슬롯 기반 카드 레이아웃 (CSS Grid + 셀 타입별 렌더링)",
|
||||
name: "MES 공정흐름",
|
||||
description: "MES 생산실적 카드 레이아웃 (공정 흐름 + 상태 관리)",
|
||||
category: "display",
|
||||
icon: "LayoutGrid",
|
||||
component: PopCardListV2Component,
|
||||
|
|
@ -44,15 +44,10 @@ PopComponentRegistry.registerComponent({
|
|||
sendable: [
|
||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
|
||||
{ key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" },
|
||||
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
|
||||
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
|
||||
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
|
||||
{ key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" },
|
||||
{ key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (항목 + 매핑)" },
|
||||
],
|
||||
receivable: [
|
||||
{ key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
|
||||
{ key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
|
||||
{ key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" },
|
||||
{ key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Conf
|
|||
// 3. 본문 필드들 (이미지 오른쪽)
|
||||
const fieldStartCol = old.cardTemplate?.image?.enabled ? 2 : 1;
|
||||
const fieldColSpan = old.cardTemplate?.image?.enabled ? 2 : 3;
|
||||
const hasRightActions = !!(old.inputField?.enabled || old.cartAction);
|
||||
const hasRightActions = !!old.inputField?.enabled;
|
||||
|
||||
(old.cardTemplate?.body?.fields || []).forEach((field, i) => {
|
||||
cells.push({
|
||||
|
|
@ -102,20 +102,7 @@ export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Conf
|
|||
limitColumn: old.inputField.limitColumn || old.inputField.maxColumn,
|
||||
});
|
||||
}
|
||||
if (old.cartAction) {
|
||||
cells.push({
|
||||
id: "cart",
|
||||
row: nextRow + Math.ceil(bodyRowSpan / 2),
|
||||
col: rightCol,
|
||||
rowSpan: Math.floor(bodyRowSpan / 2) || 1,
|
||||
colSpan: 1,
|
||||
type: "cart-button",
|
||||
cartLabel: old.cartAction.label,
|
||||
cartCancelLabel: old.cartAction.cancelLabel,
|
||||
cartIconType: old.cartAction.iconType,
|
||||
cartIconValue: old.cartAction.iconValue,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 5. 포장 요약 (마지막 행, full-width)
|
||||
if (old.packageConfig?.enabled) {
|
||||
|
|
@ -156,8 +143,6 @@ export function migrateCardListConfig(old: PopCardListConfig): PopCardListV2Conf
|
|||
responsiveDisplay: old.responsiveDisplay,
|
||||
inputField: old.inputField,
|
||||
packageConfig: old.packageConfig,
|
||||
cartAction: old.cartAction,
|
||||
cartListMode: old.cartListMode,
|
||||
saveMapping: old.saveMapping,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{/* 헤더 */}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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="카드 목록 선택" />
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function PopCardListPreviewComponent({
|
|||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span className="text-xs font-medium">카드 목록</span>
|
||||
<span className="text-xs font-medium">장바구니 목록</span>
|
||||
</div>
|
||||
|
||||
{/* 설정 배지 */}
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ const defaultConfig: PopCardListConfig = {
|
|||
// 레지스트리 등록
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-card-list",
|
||||
name: "카드 목록",
|
||||
description: "테이블 데이터를 카드 형태로 표시 (헤더 + 이미지 + 필드 목록)",
|
||||
name: "장바구니 목록",
|
||||
description: "장바구니 담기/확정 카드 목록 (입고, 출고, 수주 등)",
|
||||
category: "display",
|
||||
icon: "LayoutGrid",
|
||||
component: PopCardListComponent,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export interface PopIconConfig {
|
|||
labelColor?: string;
|
||||
labelFontSize?: number;
|
||||
backgroundColor?: string;
|
||||
iconColor?: string;
|
||||
gradient?: GradientConfig;
|
||||
borderRadiusPercent?: number;
|
||||
sizeMode: IconSizeMode;
|
||||
|
|
@ -337,12 +338,14 @@ export function PopIconComponent({
|
|||
setPendingNavigate(null);
|
||||
};
|
||||
|
||||
// 배경 스타일 (이미지 타입일 때는 배경 없음)
|
||||
// 배경 스타일: transparent 설정이 최우선
|
||||
const backgroundStyle: React.CSSProperties = iconType === "image"
|
||||
? { backgroundColor: "transparent" }
|
||||
: config?.gradient
|
||||
? buildGradientStyle(config.gradient)
|
||||
: { backgroundColor: config?.backgroundColor || "#e0e0e0" };
|
||||
: config?.backgroundColor === "transparent"
|
||||
? { backgroundColor: "transparent" }
|
||||
: config?.gradient
|
||||
? buildGradientStyle(config.gradient)
|
||||
: { backgroundColor: config?.backgroundColor || "hsl(var(--muted))" };
|
||||
|
||||
// 테두리 반경 (0% = 사각형, 100% = 원형)
|
||||
const radiusPercent = config?.borderRadiusPercent ?? 20;
|
||||
|
|
@ -352,6 +355,8 @@ export function PopIconComponent({
|
|||
const isLabelRight = config?.labelPosition === "right";
|
||||
const showLabel = config?.labelPosition !== "none" && (config?.label || label);
|
||||
|
||||
const effectiveIconColor = config?.iconColor || "#ffffff";
|
||||
|
||||
// 아이콘 렌더링
|
||||
const renderIcon = () => {
|
||||
// 빠른 선택
|
||||
|
|
@ -361,7 +366,7 @@ export function PopIconComponent({
|
|||
<DynamicLucideIcon
|
||||
name={config.quickSelectValue}
|
||||
size={iconSize * 0.5}
|
||||
className="text-white"
|
||||
style={{ color: effectiveIconColor }}
|
||||
/>
|
||||
);
|
||||
} else if (config?.quickSelectType === "emoji" && config?.quickSelectValue) {
|
||||
|
|
@ -398,36 +403,40 @@ export function PopIconComponent({
|
|||
return <span style={{ fontSize: iconSize * 0.5 }}>📦</span>;
|
||||
};
|
||||
|
||||
const hasLabel = showLabel && (config?.label || label);
|
||||
const labelFontSize = config?.labelFontSize || 12;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center cursor-pointer transition-transform hover:scale-105",
|
||||
"flex h-full w-full items-center justify-center cursor-pointer transition-transform hover:scale-105",
|
||||
isLabelRight ? "flex-row gap-2" : "flex-col"
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 아이콘 컨테이너 */}
|
||||
{/* 아이콘 컨테이너: 라벨이 있으면 라벨 공간만큼 축소 */}
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{
|
||||
...backgroundStyle,
|
||||
borderRadius,
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
minWidth: iconSize,
|
||||
minHeight: iconSize,
|
||||
maxWidth: "100%",
|
||||
maxHeight: hasLabel && !isLabelRight ? `calc(100% - ${labelFontSize + 6}px)` : "100%",
|
||||
aspectRatio: "1 / 1",
|
||||
}}
|
||||
>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
|
||||
{/* 라벨 */}
|
||||
{showLabel && (
|
||||
{hasLabel && (
|
||||
<span
|
||||
className={cn("truncate max-w-full", !isLabelRight && "mt-1")}
|
||||
className={cn("shrink-0 truncate max-w-full leading-tight", !isLabelRight && "mt-0.5")}
|
||||
style={{
|
||||
color: config?.labelColor || "hsl(var(--foreground))",
|
||||
fontSize: config?.labelFontSize || 12,
|
||||
fontSize: labelFontSize,
|
||||
}}
|
||||
>
|
||||
{config?.label || label}
|
||||
|
|
@ -453,8 +462,6 @@ export function PopIconComponent({
|
|||
<AlertDialogFooter>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmNavigate}
|
||||
className="text-white"
|
||||
style={{ backgroundColor: "#0984e3" }}
|
||||
>
|
||||
확인 후 이동
|
||||
</AlertDialogAction>
|
||||
|
|
@ -853,23 +860,69 @@ function LabelSettings({ config, onUpdate }: PopIconConfigPanelProps) {
|
|||
|
||||
// 스타일 설정
|
||||
function StyleSettings({ config, onUpdate }: PopIconConfigPanelProps) {
|
||||
const bgColor = config?.backgroundColor || "";
|
||||
const iconColor = config?.iconColor || "#ffffff";
|
||||
const isTransparent = bgColor === "transparent";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">
|
||||
모서리: {config?.borderRadiusPercent ?? 20}%
|
||||
</Label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={config?.borderRadiusPercent ?? 20}
|
||||
onChange={(e) => onUpdate({
|
||||
...config,
|
||||
borderRadiusPercent: Number(e.target.value)
|
||||
})}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{/* 배경색 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">배경색</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isTransparent}
|
||||
onChange={(e) => onUpdate({
|
||||
...config,
|
||||
backgroundColor: e.target.checked ? "transparent" : "",
|
||||
iconColor: e.target.checked && iconColor === "#ffffff" ? "hsl(var(--foreground))" : iconColor,
|
||||
})}
|
||||
className="h-3.5 w-3.5 rounded"
|
||||
/>
|
||||
투명
|
||||
</label>
|
||||
{!isTransparent && (
|
||||
<Input
|
||||
type="color"
|
||||
value={bgColor || "#d1d5db"}
|
||||
onChange={(e) => onUpdate({ ...config, backgroundColor: e.target.value })}
|
||||
className="h-8 w-12 cursor-pointer p-0.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 아이콘 색상 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">아이콘 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={iconColor.startsWith("hsl") ? "#000000" : iconColor}
|
||||
onChange={(e) => onUpdate({ ...config, iconColor: e.target.value })}
|
||||
className="h-8 w-12 cursor-pointer p-0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 모서리 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">
|
||||
모서리: {config?.borderRadiusPercent ?? 20}%
|
||||
</Label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={config?.borderRadiusPercent ?? 20}
|
||||
onChange={(e) => onUpdate({
|
||||
...config,
|
||||
borderRadiusPercent: Number(e.target.value)
|
||||
})}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ function PopProfileComponent({ config: rawConfig }: PopProfileComponentProps) {
|
|||
sizeInfo.container,
|
||||
sizeInfo.text,
|
||||
)}
|
||||
style={{ minWidth: sizeInfo.px, minHeight: sizeInfo.px }}
|
||||
style={{ width: sizeInfo.px, height: sizeInfo.px, maxWidth: "100%", maxHeight: "100%" }}
|
||||
>
|
||||
{user?.photo && user.photo.trim() !== "" && user.photo !== "null" ? (
|
||||
<img
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 컴포넌트로 분리되었습니다.
|
||||
새로운 "상태 바" 컴포넌트를 사용해주세요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
case "toggle":
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
|
|
|
|||
|
|
@ -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: "알약 (작은 뱃지)",
|
||||
};
|
||||
|
||||
/** 모달 보여주기 방식 라벨 */
|
||||
|
|
|
|||
|
|
@ -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 }[] = [];
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue