1833 lines
76 KiB
TypeScript
1833 lines
76 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* pop-card-list-v2 런타임 컴포넌트 (MES 공정흐름)
|
|
*
|
|
* 데이터 로딩/필터링/페이징 로직 + CSS Grid + 셀 타입별 렌더러(cell-renderers.tsx).
|
|
*/
|
|
|
|
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
|
import {
|
|
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Check, X, Search,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { cn } from "@/lib/utils";
|
|
import type {
|
|
PopCardListV2Config,
|
|
CardGridConfigV2,
|
|
CardCellDefinitionV2,
|
|
CardInputFieldConfig,
|
|
CardPackageConfig,
|
|
CardPresetSpec,
|
|
PackageEntry,
|
|
CollectDataRequest,
|
|
CollectedDataResponse,
|
|
TimelineProcessStep,
|
|
TimelineDataSource,
|
|
ActionButtonUpdate,
|
|
ActionButtonClickAction,
|
|
QuantityInputConfig,
|
|
StatusValueMapping,
|
|
SelectModeConfig,
|
|
SelectModeButtonConfig,
|
|
} from "../types";
|
|
import {
|
|
CARD_PRESET_SPECS, DEFAULT_CARD_IMAGE,
|
|
VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC, VIRTUAL_SUB_PROCESS, VIRTUAL_SUB_SEQ,
|
|
} from "../types";
|
|
import { dataApi } from "@/lib/api/data";
|
|
import { screenApi } from "@/lib/api/screen";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { NumberInputModal } from "../pop-card-list/NumberInputModal";
|
|
import { renderCellV2 } from "./cell-renderers";
|
|
import type { PopLayoutData } from "@/components/pop/designer/types/pop-layout";
|
|
import { isPopLayout, detectGridMode } from "@/components/pop/designer/types/pop-layout";
|
|
import dynamic from "next/dynamic";
|
|
const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopViewerWithModals"), { ssr: false });
|
|
const LazyPopWorkDetail = dynamic(
|
|
() => import("../pop-work-detail/PopWorkDetailComponent").then((m) => ({ default: m.PopWorkDetailComponent })),
|
|
{ ssr: false },
|
|
);
|
|
|
|
type RowData = Record<string, unknown>;
|
|
|
|
const MES_STATUS_TABS = [
|
|
{ value: "", label: "전체" },
|
|
{ value: "waiting", label: "대기", color: "#94a3b8", bg: "#f8fafc" },
|
|
{ value: "acceptable", label: "접수가능", color: "#2563eb", bg: "#eff6ff" },
|
|
{ value: "in_progress", label: "진행중", color: "#d97706", bg: "#fffbeb" },
|
|
{ value: "completed", label: "완료", color: "#059669", bg: "#ecfdf5" },
|
|
] as const;
|
|
|
|
function calculateMaxQty(
|
|
row: RowData,
|
|
processId: string | number | undefined,
|
|
cfg?: QuantityInputConfig,
|
|
): number {
|
|
if (!cfg) return 999999;
|
|
const maxVal = cfg.maxColumn ? Number(row[cfg.maxColumn]) || 999999 : 999999;
|
|
if (!cfg.currentColumn) return maxVal;
|
|
|
|
const processFlow = row.__processFlow__ as Array<{
|
|
isCurrent: boolean;
|
|
processId?: string | number;
|
|
rawData?: Record<string, unknown>;
|
|
}> | undefined;
|
|
|
|
const currentProcess = processId
|
|
? processFlow?.find((p) => String(p.processId) === String(processId))
|
|
: processFlow?.find((p) => p.isCurrent);
|
|
|
|
if (currentProcess?.rawData) {
|
|
const currentVal = Number(currentProcess.rawData[cfg.currentColumn]) || 0;
|
|
return Math.max(0, maxVal - currentVal);
|
|
}
|
|
return maxVal;
|
|
}
|
|
|
|
// 레거시 statusValues(고정 4키 객체) → statusMappings(동적 배열) 자동 변환
|
|
function resolveStatusMappings(src: TimelineDataSource): StatusValueMapping[] {
|
|
if (src.statusMappings && src.statusMappings.length > 0) return src.statusMappings;
|
|
|
|
// 레거시 호환: 기존 statusValues 객체가 있으면 변환
|
|
const sv = (src as Record<string, unknown>).statusValues as Record<string, string> | undefined;
|
|
return [
|
|
{ dbValue: sv?.waiting || "waiting", label: "대기", semantic: "pending" as const },
|
|
{ dbValue: sv?.accepted || "accepted", label: "접수", semantic: "active" as const },
|
|
{ dbValue: sv?.inProgress || "in_progress", label: "진행중", semantic: "active" as const },
|
|
{ dbValue: sv?.completed || "completed", label: "완료", semantic: "done" as const },
|
|
];
|
|
}
|
|
|
|
interface PopCardListV2ComponentProps {
|
|
config?: PopCardListV2Config;
|
|
className?: string;
|
|
screenId?: string;
|
|
componentId?: string;
|
|
currentRowSpan?: number;
|
|
currentColSpan?: number;
|
|
onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void;
|
|
}
|
|
|
|
export function PopCardListV2Component({
|
|
config,
|
|
className,
|
|
screenId,
|
|
componentId,
|
|
currentRowSpan,
|
|
currentColSpan,
|
|
onRequestResize,
|
|
}: PopCardListV2ComponentProps) {
|
|
const { subscribe, publish, setSharedData } = usePopEvent(screenId || "default");
|
|
const { userId: currentUserId } = useAuth();
|
|
|
|
const isHorizontalMode = (config?.scrollDirection || "vertical") === "horizontal";
|
|
const maxGridColumns = config?.gridColumns || 2;
|
|
const configGridRows = config?.gridRows || 3;
|
|
const dataSource = config?.dataSource;
|
|
const cardGrid = config?.cardGrid;
|
|
|
|
const [rows, setRows] = useState<RowData[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// 외부 필터
|
|
const [externalFilters, setExternalFilters] = useState<
|
|
Map<string, {
|
|
fieldName: string;
|
|
value: unknown;
|
|
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean };
|
|
_source?: string;
|
|
}>
|
|
>(new Map());
|
|
|
|
useEffect(() => {
|
|
if (!componentId) return;
|
|
const unsub = subscribe(
|
|
`__comp_input__${componentId}__filter_condition`,
|
|
(payload: unknown) => {
|
|
const data = payload as {
|
|
value?: { fieldName?: string; value?: unknown; _source?: string };
|
|
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean };
|
|
_connectionId?: string;
|
|
};
|
|
const connId = data?._connectionId || "default";
|
|
setExternalFilters((prev) => {
|
|
const next = new Map(prev);
|
|
if (data?.value?.value) {
|
|
next.set(connId, {
|
|
fieldName: data.value.fieldName || "",
|
|
value: data.value.value,
|
|
filterConfig: data.filterConfig,
|
|
_source: data.value._source,
|
|
});
|
|
} else {
|
|
next.delete(connId);
|
|
}
|
|
return next;
|
|
});
|
|
},
|
|
);
|
|
return unsub;
|
|
}, [componentId, subscribe]);
|
|
|
|
// ===== 선택 모드 =====
|
|
const [selectMode, setSelectMode] = useState(false);
|
|
const [selectModeStatus, setSelectModeStatus] = useState<string>("");
|
|
const [selectModeConfig, setSelectModeConfig] = useState<SelectModeConfig | null>(null);
|
|
const [selectedRowIds, setSelectedRowIds] = useState<Set<string>>(new Set());
|
|
const [selectProcessing, setSelectProcessing] = useState(false);
|
|
|
|
// ===== 내장 작업 상세 모달 =====
|
|
const [workDetailOpen, setWorkDetailOpen] = useState(false);
|
|
const [workDetailRow, setWorkDetailRow] = useState<RowData | null>(null);
|
|
|
|
// ===== 모달 열기 (POP 화면) =====
|
|
const [popModalOpen, setPopModalOpen] = useState(false);
|
|
const [popModalLayout, setPopModalLayout] = useState<PopLayoutData | null>(null);
|
|
const [popModalScreenId, setPopModalScreenId] = useState<string>("");
|
|
const [popModalRow, setPopModalRow] = useState<RowData | null>(null);
|
|
|
|
const openPopModal = useCallback(async (screenIdStr: string, row: RowData) => {
|
|
// 내부 모달 캔버스 (디자이너에서 생성한 modal-*)인 경우 이벤트 발행
|
|
if (screenIdStr.startsWith("modal-")) {
|
|
setSharedData("parentRow", row);
|
|
publish("__pop_modal_open__", { modalId: screenIdStr, fullscreen: true });
|
|
return;
|
|
}
|
|
// 외부 POP 화면 ID인 경우 기존 fetch 방식
|
|
try {
|
|
const sid = parseInt(screenIdStr, 10);
|
|
if (isNaN(sid)) {
|
|
toast.error("올바른 화면 ID가 아닙니다.");
|
|
return;
|
|
}
|
|
const popLayout = await screenApi.getLayoutPop(sid);
|
|
if (popLayout && isPopLayout(popLayout)) {
|
|
setPopModalLayout(popLayout);
|
|
setPopModalScreenId(String(sid));
|
|
setPopModalRow(row);
|
|
setPopModalOpen(true);
|
|
} else {
|
|
toast.error("해당 POP 화면을 찾을 수 없습니다.");
|
|
}
|
|
} catch {
|
|
toast.error("POP 화면을 불러오는데 실패했습니다.");
|
|
}
|
|
}, [publish, setSharedData]);
|
|
|
|
const handleCardSelect = useCallback((row: RowData) => {
|
|
if (row.__isAcceptClone) return;
|
|
|
|
if (config?.cardClickAction === "built-in-work-detail") {
|
|
const subStatus = row[VIRTUAL_SUB_STATUS] as string | undefined;
|
|
if (subStatus && subStatus !== "in_progress") return;
|
|
setWorkDetailRow(row);
|
|
setWorkDetailOpen(true);
|
|
return;
|
|
}
|
|
|
|
if (config?.cardClickAction === "modal-open" && config?.cardClickModalConfig?.screenId) {
|
|
const mc = config.cardClickModalConfig;
|
|
|
|
// 작업상세는 "진행(in_progress)" 탭 카드만 열 수 있음
|
|
const subStatus = row[VIRTUAL_SUB_STATUS] as string | undefined;
|
|
if (subStatus && subStatus !== "in_progress") return;
|
|
|
|
if (mc.condition && mc.condition.type !== "always") {
|
|
if (mc.condition.type === "timeline-status") {
|
|
const condVal = mc.condition.value;
|
|
const curStatus = subStatus || (() => {
|
|
const pf = row.__processFlow__ as { isCurrent: boolean; status?: string }[] | undefined;
|
|
return pf?.find((s) => s.isCurrent)?.status;
|
|
})();
|
|
if (Array.isArray(condVal)) {
|
|
if (!curStatus || !condVal.includes(curStatus)) return;
|
|
} else {
|
|
if (curStatus !== condVal) return;
|
|
}
|
|
} else if (mc.condition.type === "column-value") {
|
|
if (String(row[mc.condition.column || ""] ?? "") !== mc.condition.value) return;
|
|
}
|
|
}
|
|
openPopModal(mc.screenId, row);
|
|
return;
|
|
}
|
|
if (!componentId) return;
|
|
publish(`__comp_output__${componentId}__selected_row`, row);
|
|
}, [componentId, publish, config, openPopModal]);
|
|
|
|
const enterSelectMode = useCallback((whenStatus: string, buttonConfig: Record<string, unknown>) => {
|
|
const smConfig = buttonConfig.selectModeConfig as SelectModeConfig | undefined;
|
|
if (!smConfig) return;
|
|
setSelectMode(true);
|
|
setSelectModeStatus(smConfig.filterStatus || whenStatus);
|
|
setSelectModeConfig(smConfig);
|
|
setSelectedRowIds(new Set());
|
|
}, []);
|
|
|
|
const exitSelectMode = useCallback(() => {
|
|
setSelectMode(false);
|
|
setSelectModeStatus("");
|
|
setSelectModeConfig(null);
|
|
setSelectedRowIds(new Set());
|
|
}, []);
|
|
|
|
const toggleRowSelection = useCallback((row: RowData) => {
|
|
const rowId = String(row.id ?? row.pk ?? "");
|
|
if (!rowId) return;
|
|
setSelectedRowIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(rowId)) next.delete(rowId); else next.add(rowId);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const isRowSelectable = useCallback((row: RowData) => {
|
|
if (!selectMode) return false;
|
|
const subStatus = row[VIRTUAL_SUB_STATUS];
|
|
if (subStatus !== undefined) return String(subStatus) === selectModeStatus;
|
|
return true;
|
|
}, [selectMode, selectModeStatus]);
|
|
|
|
// 내장 상태 탭
|
|
const [selectedStatusTab, setSelectedStatusTab] = useState("");
|
|
|
|
// 확장/페이지네이션
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [originalRowSpan, setOriginalRowSpan] = useState<number | null>(null);
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [containerWidth, setContainerWidth] = useState(0);
|
|
const [containerHeight, setContainerHeight] = useState(0);
|
|
const baseContainerHeight = useRef(0);
|
|
|
|
useEffect(() => {
|
|
if (!containerRef.current) return;
|
|
const observer = new ResizeObserver((entries) => {
|
|
const entry = entries[0];
|
|
if (!entry) return;
|
|
const { width, height } = entry.contentRect;
|
|
if (width > 0) setContainerWidth(width);
|
|
if (height > 0) setContainerHeight(height);
|
|
});
|
|
observer.observe(containerRef.current);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
const cardSizeKey = config?.cardSize || "large";
|
|
const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large;
|
|
|
|
const maxAllowedColumns = useMemo(() => {
|
|
if (!currentColSpan) return maxGridColumns;
|
|
if (currentColSpan >= 8) return maxGridColumns;
|
|
return 1;
|
|
}, [currentColSpan, maxGridColumns]);
|
|
|
|
const minCardWidth = Math.round(spec.height * 1.6);
|
|
const autoColumns = containerWidth > 0
|
|
? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap)))
|
|
: maxGridColumns;
|
|
const gridColumns = Math.max(1, Math.min(autoColumns, maxGridColumns, maxAllowedColumns));
|
|
const gridRows = configGridRows;
|
|
|
|
// 셀 설정에서 timelineSource 탐색 (timeline/status-badge/action-buttons 중 하나에 설정됨)
|
|
const timelineSource = useMemo<TimelineDataSource | undefined>(() => {
|
|
const cells = cardGrid?.cells || [];
|
|
for (const c of cells) {
|
|
if ((c.type === "timeline" || c.type === "status-badge" || c.type === "action-buttons" || c.type === "mes-process-card") && c.timelineSource?.processTable) {
|
|
return c.timelineSource;
|
|
}
|
|
}
|
|
return undefined;
|
|
}, [cardGrid?.cells]);
|
|
|
|
// 접수가능 잔여가 있는 카드를 복제하여 "접수가능" 탭에도 노출
|
|
// 분할 구조: 분할 행이 있는 경우 원본(마스터) 행이 이미 acceptable로 포함되어 있으므로 추가 복제 불필요
|
|
// 분할 행이 없고 in_progress인 경우에만 기존 복제 로직 적용 (하위 호환)
|
|
const duplicateAcceptableCards = useCallback((sourceRows: RowData[]): RowData[] => {
|
|
const result: RowData[] = [];
|
|
for (const row of sourceRows) {
|
|
result.push(row);
|
|
if (row.__isAcceptClone || row.__splitProcessId) continue;
|
|
|
|
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
|
|
if (!processFlow || processFlow.length === 0) continue;
|
|
|
|
const currentStep = processFlow.find((s) => s.isCurrent);
|
|
if (!currentStep) continue;
|
|
if (currentStep.status !== "in_progress") continue;
|
|
|
|
const availableQty = Number(currentStep.rawData?.__availableQty ?? 0);
|
|
if (availableQty <= 0) continue;
|
|
|
|
const clonedFlow = processFlow.map((s) => ({
|
|
...s,
|
|
isCurrent: s.seqNo === currentStep.seqNo,
|
|
status: s.seqNo === currentStep.seqNo ? "acceptable" : s.status,
|
|
semantic: s.seqNo === currentStep.seqNo ? ("active" as const) : s.semantic,
|
|
}));
|
|
|
|
const clonedProcessFields: Record<string, unknown> = {};
|
|
if (currentStep.rawData) {
|
|
for (const [key, val] of Object.entries(currentStep.rawData)) {
|
|
clonedProcessFields[`__process_${key}`] = val;
|
|
}
|
|
}
|
|
|
|
result.push({
|
|
...row,
|
|
__processFlow__: clonedFlow,
|
|
__isAcceptClone: true,
|
|
__cloneSourceId: String(row.id ?? ""),
|
|
[VIRTUAL_SUB_STATUS]: "acceptable",
|
|
[VIRTUAL_SUB_SEMANTIC]: "active",
|
|
[VIRTUAL_SUB_PROCESS]: currentStep.processName,
|
|
[VIRTUAL_SUB_SEQ]: currentStep.seqNo,
|
|
...clonedProcessFields,
|
|
});
|
|
}
|
|
return result;
|
|
}, []);
|
|
|
|
// 하위 필터 + 카드 복제 적용 (공통 함수)
|
|
const applySubFilterAndDuplicate = useCallback((sourceRows: RowData[], subFilters: Array<{
|
|
fieldName: string;
|
|
value: unknown;
|
|
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean };
|
|
}>) => {
|
|
const afterSubFilter = subFilters.length === 0
|
|
? sourceRows
|
|
: sourceRows
|
|
.map((row) => {
|
|
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
|
|
if (!processFlow || processFlow.length === 0) return null;
|
|
|
|
const matchingSteps = processFlow.filter((step) =>
|
|
subFilters.every((filter) => {
|
|
const searchValue = String(filter.value).toLowerCase();
|
|
if (!searchValue) return true;
|
|
const fc = filter.filterConfig;
|
|
const col = fc?.targetColumn || filter.fieldName || "";
|
|
if (!col) return true;
|
|
const cellValue = String(step.rawData?.[col] ?? "").toLowerCase();
|
|
const mode = fc?.filterMode || "contains";
|
|
switch (mode) {
|
|
case "equals": return cellValue === searchValue;
|
|
case "starts_with": return cellValue.startsWith(searchValue);
|
|
default: return cellValue.includes(searchValue);
|
|
}
|
|
}),
|
|
);
|
|
|
|
if (matchingSteps.length === 0) return null;
|
|
|
|
const matched = matchingSteps[0];
|
|
const updatedFlow = processFlow.map((s) => ({
|
|
...s,
|
|
isCurrent: s.seqNo === matched.seqNo,
|
|
}));
|
|
|
|
// 분할 카드는 이미 개별 분할 데이터를 가지고 있으므로 마스터 집계값으로 덮어쓰지 않음
|
|
if (row.__splitProcessId) {
|
|
return {
|
|
...row,
|
|
__processFlow__: updatedFlow,
|
|
[VIRTUAL_SUB_STATUS]: String(row[VIRTUAL_SUB_STATUS] ?? row.__process_status ?? matched.status),
|
|
[VIRTUAL_SUB_SEMANTIC]: String(row[VIRTUAL_SUB_SEMANTIC] ?? (matched.semantic || "pending")),
|
|
[VIRTUAL_SUB_PROCESS]: String(row[VIRTUAL_SUB_PROCESS] ?? matched.processName),
|
|
[VIRTUAL_SUB_SEQ]: row[VIRTUAL_SUB_SEQ] ?? matched.seqNo,
|
|
} as RowData;
|
|
}
|
|
|
|
// 비분할 카드: 서브 필터로 공정이 바뀌면 __process_* 필드 재주입
|
|
const processFields: Record<string, unknown> = {};
|
|
if (matched.rawData) {
|
|
for (const [key, val] of Object.entries(matched.rawData)) {
|
|
processFields[`__process_${key}`] = val;
|
|
}
|
|
processFields.__availableQty = matched.rawData.__availableQty ?? 0;
|
|
processFields.__prevGoodQty = matched.rawData.__prevGoodQty ?? 0;
|
|
}
|
|
return {
|
|
...row,
|
|
__processFlow__: updatedFlow,
|
|
[VIRTUAL_SUB_STATUS]: matched.status,
|
|
[VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending",
|
|
[VIRTUAL_SUB_PROCESS]: matched.processName,
|
|
[VIRTUAL_SUB_SEQ]: matched.seqNo,
|
|
...processFields,
|
|
} as RowData;
|
|
})
|
|
.filter((row): row is RowData => row !== null);
|
|
|
|
// 카드 복제: in_progress + 잔여 접수가능량 > 0 → 접수가능 탭에도 노출
|
|
return duplicateAcceptableCards(afterSubFilter);
|
|
}, [duplicateAcceptableCards]);
|
|
|
|
// 메인 필터 적용 (공통 함수)
|
|
const applyMainFilters = useCallback((
|
|
sourceRows: RowData[],
|
|
mainFilters: Array<{ fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean } }>,
|
|
hasSubFilters: boolean,
|
|
) => {
|
|
if (mainFilters.length === 0) return sourceRows;
|
|
|
|
const subCol = hasSubFilters ? VIRTUAL_SUB_STATUS : null;
|
|
const statusCol = timelineSource?.statusColumn || "status";
|
|
|
|
return sourceRows.filter((row) =>
|
|
mainFilters.every((filter) => {
|
|
const searchValue = String(filter.value).toLowerCase();
|
|
if (!searchValue) return true;
|
|
const fc = filter.filterConfig;
|
|
const columns: string[] =
|
|
fc?.targetColumns?.length ? fc.targetColumns
|
|
: fc?.targetColumn ? [fc.targetColumn]
|
|
: filter.fieldName ? [filter.fieldName] : [];
|
|
if (columns.length === 0) return true;
|
|
const mode = fc?.filterMode || "contains";
|
|
|
|
const effectiveColumns = subCol
|
|
? columns.map((col) => col === statusCol || col === "status" ? subCol : col)
|
|
: columns;
|
|
|
|
return effectiveColumns.some((col) => {
|
|
const cellValue = String(row[col] ?? "").toLowerCase();
|
|
switch (mode) {
|
|
case "equals": return cellValue === searchValue;
|
|
case "starts_with": return cellValue.startsWith(searchValue);
|
|
default: return cellValue.includes(searchValue);
|
|
}
|
|
});
|
|
}),
|
|
);
|
|
}, [timelineSource]);
|
|
|
|
// processFlow rawData 키셋 (하위 테이블 컬럼 자동 판단용)
|
|
const subTableKeys = useMemo(() => {
|
|
for (const row of rows) {
|
|
const pf = row.__processFlow__ as TimelineProcessStep[] | undefined;
|
|
if (pf?.[0]?.rawData) return new Set(Object.keys(pf[0].rawData));
|
|
}
|
|
return new Set<string>();
|
|
}, [rows]);
|
|
|
|
// 필터 컬럼이 하위 테이블에 속하는지 자동 판단
|
|
const isSubTableColumn = useCallback((filter: { fieldName: string; filterConfig?: { targetColumn: string; isSubTable?: boolean } }) => {
|
|
if (filter.filterConfig?.isSubTable) return true;
|
|
const col = filter.filterConfig?.targetColumn || filter.fieldName;
|
|
return col ? subTableKeys.has(col) : false;
|
|
}, [subTableKeys]);
|
|
|
|
// showStatusTabs일 때 외부 status-bar 필터 무시 (내장 탭으로 대체)
|
|
const effectiveExternalFilters = useMemo(() => {
|
|
if (!config?.showStatusTabs) return externalFilters;
|
|
const filtered = new Map(
|
|
[...externalFilters.entries()].filter(([, f]) => f._source !== "status-bar")
|
|
);
|
|
return filtered;
|
|
}, [externalFilters, config?.showStatusTabs]);
|
|
|
|
// 외부 필터 (자동 분류: 컬럼이 processFlow에 있으면 subFilter)
|
|
const filteredRows = useMemo(() => {
|
|
if (effectiveExternalFilters.size === 0) return duplicateAcceptableCards(rows);
|
|
|
|
const allFilters = [...effectiveExternalFilters.values()];
|
|
const mainFilters = allFilters.filter((f) => !isSubTableColumn(f));
|
|
const subFilters = allFilters.filter((f) => isSubTableColumn(f));
|
|
|
|
const afterDuplicate = applySubFilterAndDuplicate(rows, subFilters);
|
|
return applyMainFilters(afterDuplicate, mainFilters, subFilters.length > 0);
|
|
}, [rows, effectiveExternalFilters, duplicateAcceptableCards, applySubFilterAndDuplicate, applyMainFilters, isSubTableColumn]);
|
|
|
|
// 하위 필터 활성 여부
|
|
const hasActiveSubFilter = useMemo(() => {
|
|
if (effectiveExternalFilters.size === 0) return false;
|
|
return [...effectiveExternalFilters.values()].some((f) => isSubTableColumn(f));
|
|
}, [effectiveExternalFilters, isSubTableColumn]);
|
|
|
|
// 내장 상태 탭: 카운트 계산 (filteredRows 기준, 상태 필터 적용 전)
|
|
const hasProcessFlow = useMemo(
|
|
() => rows.some((r) => r[VIRTUAL_SUB_STATUS] !== undefined),
|
|
[rows],
|
|
);
|
|
const statusCounts = useMemo(() => {
|
|
if (!config?.showStatusTabs || !hasProcessFlow) return null;
|
|
const map = new Map<string, number>();
|
|
let originalTotal = 0;
|
|
for (const row of filteredRows) {
|
|
const v = String(row[VIRTUAL_SUB_STATUS] ?? "");
|
|
if (v) map.set(v, (map.get(v) || 0) + 1);
|
|
if (!row.__isAcceptClone) originalTotal++;
|
|
}
|
|
return { counts: map, total: originalTotal };
|
|
}, [filteredRows, config?.showStatusTabs, hasProcessFlow]);
|
|
|
|
// 내장 상태 탭: 필터 적용
|
|
const statusFilteredRows = useMemo(() => {
|
|
if (!config?.showStatusTabs || !selectedStatusTab) return filteredRows;
|
|
return filteredRows.filter((row) =>
|
|
String(row[VIRTUAL_SUB_STATUS] ?? "") === selectedStatusTab
|
|
);
|
|
}, [filteredRows, selectedStatusTab, config?.showStatusTabs]);
|
|
|
|
// 선택 모드 일괄 처리
|
|
const handleSelectModeAction = useCallback(async (btnConfig: SelectModeButtonConfig) => {
|
|
if (btnConfig.clickMode === "cancel-select") {
|
|
exitSelectMode();
|
|
return;
|
|
}
|
|
|
|
if (btnConfig.clickMode === "status-change" && btnConfig.updates && btnConfig.targetTable) {
|
|
if (selectedRowIds.size === 0) {
|
|
toast.error("선택된 항목이 없습니다.");
|
|
return;
|
|
}
|
|
if (btnConfig.confirmMessage && !window.confirm(btnConfig.confirmMessage)) return;
|
|
|
|
setSelectProcessing(true);
|
|
try {
|
|
const selectedRows = filteredRows.filter((r) => {
|
|
const rowId = String(r.id ?? r.pk ?? "");
|
|
return selectedRowIds.has(rowId);
|
|
});
|
|
|
|
let successCount = 0;
|
|
for (const row of selectedRows) {
|
|
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
|
|
const currentProcess = processFlow?.find((s) => s.isCurrent);
|
|
const targetId = currentProcess?.processId ?? row.id ?? row.pk;
|
|
if (!targetId) continue;
|
|
|
|
const tasks = btnConfig.updates.map((u, idx) => ({
|
|
id: `sel-update-${idx}`,
|
|
type: "data-update" as const,
|
|
targetTable: btnConfig.targetTable!,
|
|
targetColumn: u.column,
|
|
operationType: (u.operationType || "assign") as "assign" | "add" | "subtract",
|
|
valueSource: "fixed" as const,
|
|
fixedValue: u.valueType === "static" ? (u.value ?? "") :
|
|
u.valueType === "currentUser" ? "__CURRENT_USER__" :
|
|
u.valueType === "currentTime" ? "__CURRENT_TIME__" :
|
|
(u.value ?? ""),
|
|
lookupMode: "manual" as const,
|
|
manualItemField: "id",
|
|
manualPkColumn: "id",
|
|
}));
|
|
|
|
const result = await apiClient.post("/pop/execute-action", {
|
|
tasks,
|
|
data: { items: [{ ...row, id: targetId }], fieldValues: {} },
|
|
mappings: {},
|
|
});
|
|
if (result.data?.success) successCount++;
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
toast.success(`${successCount}건 처리 완료`);
|
|
exitSelectMode();
|
|
fetchDataRef.current();
|
|
} else {
|
|
toast.error("처리에 실패했습니다.");
|
|
}
|
|
} catch (err: unknown) {
|
|
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
|
|
} finally {
|
|
setSelectProcessing(false);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (btnConfig.clickMode === "modal-open" && btnConfig.modalScreenId) {
|
|
const selectedRows = filteredRows.filter((r) => {
|
|
const rowId = String(r.id ?? r.pk ?? "");
|
|
return selectedRowIds.has(rowId);
|
|
});
|
|
openPopModal(btnConfig.modalScreenId, selectedRows[0] || {});
|
|
return;
|
|
}
|
|
}, [selectedRowIds, filteredRows, exitSelectMode]);
|
|
|
|
// status-bar 필터를 제외한 rows (외부 status-bar 카운트 집계용, 하위 호환)
|
|
const rowsForStatusCount = useMemo(() => {
|
|
if (config?.showStatusTabs) return filteredRows;
|
|
const hasStatusBarFilter = [...externalFilters.values()].some((f) => f._source === "status-bar");
|
|
if (!hasStatusBarFilter) return filteredRows;
|
|
|
|
const nonStatusFilters = new Map(
|
|
[...externalFilters.entries()].filter(([, f]) => f._source !== "status-bar")
|
|
);
|
|
if (nonStatusFilters.size === 0) return duplicateAcceptableCards(rows);
|
|
|
|
const allFilters = [...nonStatusFilters.values()];
|
|
const mainFilters = allFilters.filter((f) => !isSubTableColumn(f));
|
|
const subFilters = allFilters.filter((f) => isSubTableColumn(f));
|
|
|
|
const afterDuplicate = applySubFilterAndDuplicate(rows, subFilters);
|
|
return applyMainFilters(afterDuplicate, mainFilters, subFilters.length > 0);
|
|
}, [rows, filteredRows, externalFilters, config?.showStatusTabs, duplicateAcceptableCards, applySubFilterAndDuplicate, applyMainFilters, isSubTableColumn]);
|
|
|
|
// 카운트 집계용 rows 발행 (status-bar 필터 제외)
|
|
// originalCount: 복제 카드를 제외한 원본 카드 수
|
|
useEffect(() => {
|
|
if (!componentId || loading) return;
|
|
const originalCount = rowsForStatusCount.filter((r) => !r.__isAcceptClone).length;
|
|
publish(`__comp_output__${componentId}__all_rows`, {
|
|
rows: rowsForStatusCount,
|
|
subStatusColumn: hasActiveSubFilter ? VIRTUAL_SUB_STATUS : null,
|
|
originalCount,
|
|
});
|
|
}, [componentId, rowsForStatusCount, loading, publish, hasActiveSubFilter]);
|
|
|
|
const overflowCfg = config?.overflow;
|
|
const baseVisibleCount = overflowCfg?.visibleCount ?? gridColumns * gridRows;
|
|
const visibleCardCount = useMemo(() => Math.max(1, baseVisibleCount), [baseVisibleCount]);
|
|
const hasMoreCards = statusFilteredRows.length > visibleCardCount;
|
|
const expandedCardsPerPage = useMemo(() => {
|
|
if (overflowCfg?.mode === "pagination" && overflowCfg.pageSize) return overflowCfg.pageSize;
|
|
if (overflowCfg?.mode === "loadMore" && overflowCfg.loadMoreCount) return overflowCfg.loadMoreCount + visibleCardCount;
|
|
return Math.max(1, visibleCardCount * 2 + gridColumns);
|
|
}, [visibleCardCount, gridColumns, overflowCfg]);
|
|
|
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
|
|
|
const ownerSortColumn = config?.ownerSortColumn;
|
|
const ownerFilterMode = config?.ownerFilterMode || "priority";
|
|
|
|
const displayCards = useMemo(() => {
|
|
let source = statusFilteredRows;
|
|
|
|
if (ownerSortColumn && currentUserId) {
|
|
const mine: RowData[] = [];
|
|
const others: RowData[] = [];
|
|
for (const row of source) {
|
|
if (String(row[ownerSortColumn] ?? "") === currentUserId) {
|
|
mine.push(row);
|
|
} else {
|
|
others.push(row);
|
|
}
|
|
}
|
|
source = ownerFilterMode === "only" ? mine : [...mine, ...others];
|
|
}
|
|
|
|
if (!isExpanded) return source.slice(0, visibleCardCount);
|
|
const start = (currentPage - 1) * expandedCardsPerPage;
|
|
return source.slice(start, start + expandedCardsPerPage);
|
|
}, [statusFilteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage, ownerSortColumn, ownerFilterMode, currentUserId]);
|
|
|
|
const totalPages = isExpanded ? Math.ceil(statusFilteredRows.length / expandedCardsPerPage) : 1;
|
|
const needsPagination = isExpanded && totalPages > 1;
|
|
|
|
const toggleExpand = () => {
|
|
if (isExpanded) {
|
|
if (!isHorizontalMode && originalRowSpan !== null && componentId && onRequestResize) {
|
|
onRequestResize(componentId, originalRowSpan);
|
|
}
|
|
setCurrentPage(1);
|
|
setOriginalRowSpan(null);
|
|
baseContainerHeight.current = 0;
|
|
setIsExpanded(false);
|
|
} else {
|
|
baseContainerHeight.current = containerHeight;
|
|
if (!isHorizontalMode && componentId && onRequestResize && currentRowSpan !== undefined) {
|
|
setOriginalRowSpan(currentRowSpan);
|
|
onRequestResize(componentId, currentRowSpan * 2);
|
|
}
|
|
setIsExpanded(true);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (scrollAreaRef.current && isExpanded) {
|
|
scrollAreaRef.current.scrollTop = 0;
|
|
scrollAreaRef.current.scrollLeft = 0;
|
|
}
|
|
}, [currentPage, isExpanded]);
|
|
|
|
// 데이터 조회
|
|
const dataSourceKey = useMemo(() => JSON.stringify(dataSource || null), [dataSource]);
|
|
// 하위 데이터 조회 + __processFlow__ 가상 컬럼 주입
|
|
const injectProcessFlow = useCallback(async (
|
|
fetchedRows: RowData[],
|
|
src: TimelineDataSource,
|
|
): Promise<RowData[]> => {
|
|
if (fetchedRows.length === 0) return fetchedRows;
|
|
const rowIds = fetchedRows.map((r) => String(r.id)).filter(Boolean);
|
|
if (rowIds.length === 0) return fetchedRows;
|
|
|
|
// statusMappings 동적 배열 → dbValue-to-내부키 맵 구축
|
|
// 레거시 statusValues 객체도 자동 변환
|
|
const mappings = resolveStatusMappings(src);
|
|
const dbToInternal = new Map<string, string>();
|
|
const dbToSemantic = new Map<string, string>();
|
|
for (const m of mappings) {
|
|
dbToInternal.set(m.dbValue, m.dbValue);
|
|
dbToSemantic.set(m.dbValue, m.semantic);
|
|
}
|
|
|
|
const processResult = await dataApi.getTableData(src.processTable, {
|
|
page: 1,
|
|
size: 1000,
|
|
sortBy: src.seqColumn || "seq_no",
|
|
sortOrder: "asc",
|
|
});
|
|
const allProcesses = processResult.data || [];
|
|
|
|
// isDerived 매핑: DB에 없는 자동 판별 상태
|
|
// 같은 시맨틱의 DB 원본 상태를 자동으로 찾아 변환 조건 구축
|
|
const derivedRules: { sourceStatus: string; targetDbValue: string; targetSemantic: string }[] = [];
|
|
for (const dm of mappings.filter((m) => m.isDerived)) {
|
|
const source = mappings.find((m) => !m.isDerived && m.semantic === dm.semantic);
|
|
if (source) {
|
|
derivedRules.push({ sourceStatus: source.dbValue, targetDbValue: dm.dbValue, targetSemantic: dm.semantic });
|
|
}
|
|
}
|
|
|
|
// 분할 행(parent_process_id != null)은 별도 관리, 원본(마스터)만 타임라인에 사용
|
|
const processMap = new Map<string, TimelineProcessStep[]>();
|
|
const splitRowsMap = new Map<string, Array<Record<string, unknown>>>();
|
|
|
|
for (const p of allProcesses) {
|
|
const fkValue = String(p[src.foreignKey] || "");
|
|
if (!fkValue || !rowIds.includes(fkValue)) continue;
|
|
|
|
const parentProcessId = p.parent_process_id as string | null;
|
|
if (parentProcessId) {
|
|
// 분할 행: splitRowsMap에 별도 저장
|
|
if (!splitRowsMap.has(fkValue)) splitRowsMap.set(fkValue, []);
|
|
splitRowsMap.get(fkValue)!.push(p as Record<string, unknown>);
|
|
continue;
|
|
}
|
|
|
|
// 원본(마스터) 행만 타임라인에 추가
|
|
if (!processMap.has(fkValue)) processMap.set(fkValue, []);
|
|
|
|
const rawStatus = String(p[src.statusColumn] || "");
|
|
const normalizedStatus = dbToInternal.get(rawStatus) || rawStatus;
|
|
const semantic = dbToSemantic.get(rawStatus) || "pending";
|
|
|
|
processMap.get(fkValue)!.push({
|
|
seqNo: parseInt(String(p[src.seqColumn] || "0"), 10),
|
|
processName: String(p[src.nameColumn] || ""),
|
|
status: normalizedStatus,
|
|
semantic: semantic as "pending" | "active" | "done",
|
|
isCurrent: semantic === "active",
|
|
processId: p.id as string | number | undefined,
|
|
rawData: p as Record<string, unknown>,
|
|
});
|
|
}
|
|
|
|
// 원본 행의 수량을 분할 행들의 SUM으로 덮어씀
|
|
for (const [fkValue, steps] of processMap) {
|
|
const splits = splitRowsMap.get(fkValue) || [];
|
|
for (const step of steps) {
|
|
const seqSplits = splits.filter(
|
|
(s) => parseInt(String(s[src.seqColumn] || "0"), 10) === step.seqNo
|
|
);
|
|
if (seqSplits.length > 0) {
|
|
const sumInput = seqSplits.reduce((acc, s) => acc + (parseInt(String(s.input_qty ?? "0"), 10) || 0), 0);
|
|
const sumGood = seqSplits.reduce((acc, s) => acc + (parseInt(String(s.good_qty ?? "0"), 10) || 0), 0);
|
|
const sumDefect = seqSplits.reduce((acc, s) => acc + (parseInt(String(s.defect_qty ?? "0"), 10) || 0), 0);
|
|
const sumTotal = seqSplits.reduce((acc, s) => acc + (parseInt(String(s.total_production_qty ?? "0"), 10) || 0), 0);
|
|
if (step.rawData) {
|
|
step.rawData.input_qty = String(sumInput);
|
|
step.rawData.good_qty = String(sumGood);
|
|
step.rawData.defect_qty = String(sumDefect);
|
|
step.rawData.total_production_qty = String(sumTotal);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 파생 상태 자동 변환: 이전 공정이 완료된 경우 변환
|
|
if (derivedRules.length > 0) {
|
|
for (const [, steps] of processMap) {
|
|
steps.sort((a, b) => a.seqNo - b.seqNo);
|
|
for (let i = 0; i < steps.length; i++) {
|
|
const step = steps[i];
|
|
const prevStep = i > 0 ? steps[i - 1] : null;
|
|
for (const rule of derivedRules) {
|
|
if (step.status !== rule.sourceStatus) continue;
|
|
const prevIsDone = prevStep ? prevStep.semantic === "done" : true;
|
|
if (prevIsDone) {
|
|
step.status = rule.targetDbValue;
|
|
step.semantic = rule.targetSemantic as "pending" | "active" | "done";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// isCurrent 결정: "기준" 체크된 상태와 일치하는 공정을 강조
|
|
// 기준 상태가 없으면 기존 로직 (active → 첫 pending) 폴백
|
|
const pivotDbValues = mappings.filter((m) => m.isDerived).map((m) => m.dbValue);
|
|
for (const [fkValue, steps] of processMap) {
|
|
steps.sort((a, b) => a.seqNo - b.seqNo);
|
|
steps.forEach((s) => { s.isCurrent = false; });
|
|
|
|
// 활성 분할(in_progress 등)이 있는 step을 최우선으로 선택
|
|
const splits = splitRowsMap.get(fkValue) || [];
|
|
const stepWithActiveSplits = steps.find((s) => {
|
|
const seqSplits = splits.filter(
|
|
(sp) => parseInt(String(sp[src.seqColumn] || "0"), 10) === s.seqNo
|
|
&& String(sp[src.statusColumn] || "") !== "completed"
|
|
);
|
|
return seqSplits.length > 0;
|
|
});
|
|
if (stepWithActiveSplits) {
|
|
stepWithActiveSplits.isCurrent = true;
|
|
continue;
|
|
}
|
|
|
|
if (pivotDbValues.length > 0) {
|
|
const pivotStep = steps.find((s) => pivotDbValues.includes(s.status));
|
|
if (pivotStep) {
|
|
pivotStep.isCurrent = true;
|
|
continue;
|
|
}
|
|
}
|
|
// 폴백: active가 있으면 첫 active, 없으면 첫 pending
|
|
const firstActive = steps.find((s) => s.semantic === "active");
|
|
if (firstActive) { firstActive.isCurrent = true; continue; }
|
|
const firstPending = steps.find((s) => s.semantic === "pending");
|
|
if (firstPending) { firstPending.isCurrent = true; }
|
|
}
|
|
|
|
// 각 공정에 접수가능 잔여량(__availableQty) 주입
|
|
for (const [rowId, steps] of processMap) {
|
|
steps.sort((a, b) => a.seqNo - b.seqNo);
|
|
const parentRow = fetchedRows.find((r) => String(r.id) === rowId);
|
|
const instrQty = parseInt(String(parentRow?.qty ?? "0"), 10) || 0;
|
|
|
|
for (let i = 0; i < steps.length; i++) {
|
|
const step = steps[i];
|
|
const prevStep = i > 0 ? steps[i - 1] : null;
|
|
const prevGoodQty = prevStep
|
|
? parseInt(String(prevStep.rawData?.good_qty ?? "0"), 10) || 0
|
|
: instrQty;
|
|
const myInputQty = parseInt(String(step.rawData?.input_qty ?? "0"), 10) || 0;
|
|
const availableQty = Math.max(0, prevGoodQty - myInputQty);
|
|
if (step.rawData) {
|
|
step.rawData.__availableQty = availableQty;
|
|
step.rawData.__prevGoodQty = prevGoodQty;
|
|
}
|
|
// TimelineProcessStep에 수량 필드 직접 주입 (process-qty-summary 셀용)
|
|
step.inputQty = myInputQty;
|
|
step.totalProductionQty = parseInt(String(step.rawData?.total_production_qty ?? "0"), 10) || 0;
|
|
step.goodQty = parseInt(String(step.rawData?.good_qty ?? "0"), 10) || 0;
|
|
step.defectQty = parseInt(String(step.rawData?.defect_qty ?? "0"), 10) || 0;
|
|
step.yieldRate = step.totalProductionQty > 0
|
|
? Math.round((step.goodQty / step.totalProductionQty) * 100)
|
|
: 0;
|
|
}
|
|
}
|
|
|
|
const result: RowData[] = [];
|
|
for (const row of fetchedRows) {
|
|
const steps = processMap.get(String(row.id)) || [];
|
|
const splits = splitRowsMap.get(String(row.id)) || [];
|
|
const current = steps.find((s) => s.isCurrent);
|
|
const processFields: Record<string, unknown> = {};
|
|
if (current?.rawData) {
|
|
for (const [key, val] of Object.entries(current.rawData)) {
|
|
processFields[`__process_${key}`] = val;
|
|
}
|
|
}
|
|
if (current?.rawData) {
|
|
processFields.__availableQty = current.rawData.__availableQty ?? 0;
|
|
processFields.__prevGoodQty = current.rawData.__prevGoodQty ?? 0;
|
|
}
|
|
|
|
// 현재 공정의 분할 행 찾기
|
|
const currentSeqSplits = current
|
|
? splits.filter((s) => parseInt(String(s[src.seqColumn] || "0"), 10) === current.seqNo)
|
|
: [];
|
|
|
|
if (currentSeqSplits.length === 0) {
|
|
// 분할 행 없음 -> 기존처럼 원본 카드 1개
|
|
result.push({ ...row, __processFlow__: steps, ...processFields });
|
|
} else {
|
|
// 분할 행 있음 -> 각 분할 행마다 카드 생성
|
|
for (const splitRow of currentSeqSplits) {
|
|
const splitProcessFields: Record<string, unknown> = {};
|
|
for (const [key, val] of Object.entries(splitRow)) {
|
|
splitProcessFields[`__process_${key}`] = val;
|
|
}
|
|
// 분할 행의 status/수량으로 타임라인 현재 단계를 오버라이드
|
|
const splitStatus = String(splitRow[src.statusColumn] || "in_progress");
|
|
const splitSemantic = (dbToSemantic.get(splitStatus) || "active") as "pending" | "active" | "done";
|
|
const splitSteps = steps.map((s) => {
|
|
if (s.seqNo === current!.seqNo) {
|
|
return {
|
|
...s,
|
|
status: splitStatus,
|
|
semantic: splitSemantic,
|
|
processId: splitRow.id as string | number | undefined,
|
|
rawData: { ...s.rawData, ...splitRow, __availableQty: s.rawData?.__availableQty, __prevGoodQty: s.rawData?.__prevGoodQty },
|
|
};
|
|
}
|
|
return s;
|
|
});
|
|
result.push({
|
|
...row,
|
|
__processFlow__: splitSteps,
|
|
...splitProcessFields,
|
|
__splitProcessId: splitRow.id,
|
|
__process_id: splitRow.id,
|
|
__process_status: splitStatus,
|
|
__process_accepted_by: splitRow.accepted_by,
|
|
__process_input_qty: splitRow.input_qty,
|
|
__process_total_production_qty: splitRow.total_production_qty,
|
|
__process_good_qty: splitRow.good_qty,
|
|
__process_defect_qty: splitRow.defect_qty,
|
|
__process_concession_qty: splitRow.concession_qty,
|
|
__process_is_rework: splitRow.is_rework,
|
|
__process_rework_source_id: splitRow.rework_source_id,
|
|
__process_result_status: splitRow.result_status,
|
|
__availableQty: current?.rawData?.__availableQty ?? 0,
|
|
__prevGoodQty: current?.rawData?.__prevGoodQty ?? 0,
|
|
[VIRTUAL_SUB_STATUS]: splitStatus,
|
|
[VIRTUAL_SUB_SEMANTIC]: splitSemantic,
|
|
[VIRTUAL_SUB_PROCESS]: current!.processName,
|
|
[VIRTUAL_SUB_SEQ]: current!.seqNo,
|
|
});
|
|
}
|
|
// 접수가능 잔여가 있으면 원본(마스터) 카드도 추가 (접수가능 탭용)
|
|
const masterAvail = Number(current?.rawData?.__availableQty ?? 0);
|
|
if (masterAvail > 0) {
|
|
const acceptCloneFlow = steps.map((s) => ({
|
|
...s,
|
|
isCurrent: s.seqNo === current!.seqNo,
|
|
status: s.seqNo === current!.seqNo ? "acceptable" : s.status,
|
|
semantic: s.seqNo === current!.seqNo ? ("active" as const) : s.semantic,
|
|
}));
|
|
result.push({
|
|
...row,
|
|
__processFlow__: acceptCloneFlow,
|
|
...processFields,
|
|
__isAcceptClone: true,
|
|
__cloneSourceId: String(row.id ?? ""),
|
|
[VIRTUAL_SUB_STATUS]: "acceptable",
|
|
[VIRTUAL_SUB_SEMANTIC]: "active",
|
|
[VIRTUAL_SUB_PROCESS]: current!.processName,
|
|
[VIRTUAL_SUB_SEQ]: current!.seqNo,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}, []);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
if (!dataSource?.tableName) { setLoading(false); setRows([]); return; }
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const filters: Record<string, unknown> = {};
|
|
if (dataSource.filters?.length) {
|
|
dataSource.filters.forEach((f) => {
|
|
if (f.column && f.value && (!f.operator || f.operator === "=")) filters[f.column] = f.value;
|
|
});
|
|
}
|
|
const sortArray = Array.isArray(dataSource.sort)
|
|
? dataSource.sort
|
|
: dataSource.sort && typeof dataSource.sort === "object"
|
|
? [dataSource.sort as { column: string; direction: "asc" | "desc" }]
|
|
: [];
|
|
const primarySort = sortArray[0];
|
|
const size = dataSource.limit?.mode === "limited" && dataSource.limit?.count ? dataSource.limit.count : 100;
|
|
|
|
const result = await dataApi.getTableData(dataSource.tableName, {
|
|
page: 1,
|
|
size,
|
|
sortBy: primarySort?.column || undefined,
|
|
sortOrder: primarySort?.direction,
|
|
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
|
});
|
|
|
|
let fetchedRows = result.data || [];
|
|
const clientFilters = (dataSource.filters || []).filter(
|
|
(f) => f.column && f.value && f.operator && f.operator !== "=",
|
|
);
|
|
if (clientFilters.length > 0) {
|
|
fetchedRows = fetchedRows.filter((row) =>
|
|
clientFilters.every((f) => {
|
|
const cellVal = row[f.column];
|
|
const filterVal = f.value;
|
|
switch (f.operator) {
|
|
case "!=": return String(cellVal ?? "") !== filterVal;
|
|
case ">": return Number(cellVal) > Number(filterVal);
|
|
case ">=": return Number(cellVal) >= Number(filterVal);
|
|
case "<": return Number(cellVal) < Number(filterVal);
|
|
case "<=": return Number(cellVal) <= Number(filterVal);
|
|
case "like": return String(cellVal ?? "").toLowerCase().includes(filterVal.toLowerCase());
|
|
default: return true;
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
// timelineSource 설정이 있으면 공정 데이터 조회하여 __processFlow__ 주입
|
|
if (timelineSource) {
|
|
try {
|
|
fetchedRows = await injectProcessFlow(fetchedRows, timelineSource);
|
|
} catch {
|
|
// 공정 데이터 조회 실패 시 무시 (메인 데이터는 정상 표시)
|
|
}
|
|
}
|
|
|
|
setRows(fetchedRows);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "데이터 조회 실패");
|
|
setRows([]);
|
|
} finally { setLoading(false); }
|
|
}, [dataSource, timelineSource, injectProcessFlow]);
|
|
|
|
const fetchDataRef = useRef(fetchData);
|
|
fetchDataRef.current = fetchData;
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [dataSourceKey, fetchData]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// 데이터 수집
|
|
useEffect(() => {
|
|
if (!componentId) return;
|
|
const unsub = subscribe(
|
|
`__comp_input__${componentId}__collect_data`,
|
|
(payload: unknown) => {
|
|
const request = (payload as Record<string, unknown>)?.value as CollectDataRequest | undefined;
|
|
const sm = config?.saveMapping;
|
|
const mapping = sm?.targetTable && sm.mappings.length > 0
|
|
? { targetTable: sm.targetTable, columnMapping: Object.fromEntries(sm.mappings.filter((m) => m.sourceField && m.targetColumn).map((m) => [m.sourceField, m.targetColumn])) }
|
|
: null;
|
|
const response: CollectedDataResponse = {
|
|
requestId: request?.requestId ?? "",
|
|
componentId: componentId,
|
|
componentType: "pop-card-list-v2",
|
|
data: { items: rows } as any,
|
|
mapping,
|
|
};
|
|
publish(`__comp_output__${componentId}__collected_data`, response);
|
|
},
|
|
);
|
|
return unsub;
|
|
}, [componentId, subscribe, publish, rows, config]);
|
|
|
|
// 공정 완료 이벤트 수신 시 목록 갱신
|
|
useEffect(() => {
|
|
const unsub = subscribe("process_completed", () => {
|
|
fetchDataRef.current();
|
|
});
|
|
return unsub;
|
|
}, [subscribe]);
|
|
|
|
// 카드 영역 스타일
|
|
const cardGap = config?.cardGap ?? spec.gap;
|
|
const cardMinHeight = spec.height;
|
|
const cardAreaStyle: React.CSSProperties = {
|
|
gap: `${cardGap}px`,
|
|
...(isHorizontalMode
|
|
? {
|
|
gridTemplateRows: `repeat(${gridRows}, minmax(${cardMinHeight}px, auto))`,
|
|
gridAutoFlow: "column",
|
|
gridAutoColumns: `${Math.round(cardMinHeight * 1.6)}px`,
|
|
}
|
|
: {
|
|
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
|
gridAutoRows: `minmax(${cardMinHeight}px, auto)`,
|
|
}),
|
|
};
|
|
|
|
const scrollClassName = isHorizontalMode
|
|
? "overflow-x-auto overflow-y-hidden"
|
|
: isExpanded
|
|
? "overflow-y-auto overflow-x-hidden"
|
|
: "overflow-hidden";
|
|
|
|
return (
|
|
<div ref={containerRef} className={`flex h-full w-full flex-col ${className || ""}`}>
|
|
{!dataSource?.tableName ? (
|
|
<div className="flex flex-1 items-center justify-center p-4">
|
|
<p className="text-sm text-muted-foreground">데이터 소스를 설정해주세요.</p>
|
|
</div>
|
|
) : config?.hideUntilFiltered && effectiveExternalFilters.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.hideUntilFilteredMessage || "필터를 먼저 선택해주세요."}
|
|
</p>
|
|
</div>
|
|
) : loading ? (
|
|
<div className="flex flex-1 items-center justify-center p-4">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : error ? (
|
|
<div className="flex flex-1 items-center justify-center rounded-md bg-destructive/10 p-4">
|
|
<p className="text-sm text-destructive">{error}</p>
|
|
</div>
|
|
) : rows.length === 0 ? (
|
|
<div className="flex flex-1 items-center justify-center p-4">
|
|
<p className="text-sm text-muted-foreground">데이터가 없습니다.</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 선택 모드 상단 바 */}
|
|
{selectMode && (
|
|
<div className="flex shrink-0 items-center justify-between border-b bg-primary/5 px-3 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
|
<span className="text-xs font-bold">{selectedRowIds.size}</span>
|
|
</div>
|
|
<span className="text-sm font-medium">
|
|
{selectedRowIds.size > 0 ? `${selectedRowIds.size}개 선택됨` : "카드를 선택하세요"}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={exitSelectMode}
|
|
className="h-7 px-2 text-xs"
|
|
>
|
|
<X className="mr-1 h-3.5 w-3.5" />
|
|
취소
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 내장 MES 상태 탭 */}
|
|
{config?.showStatusTabs && statusCounts && hasProcessFlow && !selectMode && (
|
|
<div className="flex shrink-0 items-center gap-2 border-b bg-background px-4 py-2">
|
|
{MES_STATUS_TABS.map((tab) => {
|
|
const isActive = selectedStatusTab === tab.value;
|
|
const count = tab.value === ""
|
|
? statusCounts.total
|
|
: (statusCounts.counts.get(tab.value) || 0);
|
|
return (
|
|
<button
|
|
key={tab.value}
|
|
type="button"
|
|
onClick={() => { setSelectedStatusTab(tab.value); setCurrentPage(1); }}
|
|
className={cn(
|
|
"flex items-center gap-1.5 rounded-full px-4 py-2 text-sm font-semibold transition-colors",
|
|
isActive
|
|
? "bg-primary text-primary-foreground shadow-sm"
|
|
: "bg-muted text-muted-foreground hover:bg-accent"
|
|
)}
|
|
>
|
|
{tab.label}
|
|
<span className={cn(
|
|
"min-w-[20px] rounded-full px-1.5 py-0.5 text-center text-xs font-bold leading-none",
|
|
isActive
|
|
? "bg-primary-foreground/20 text-primary-foreground"
|
|
: "bg-background text-foreground"
|
|
)}>
|
|
{count}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={scrollAreaRef}
|
|
className={`min-h-0 flex-1 grid ${scrollClassName}`}
|
|
style={{ ...cardAreaStyle, alignContent: "start", justifyContent: isHorizontalMode ? "start" : "center" }}
|
|
>
|
|
{displayCards.map((row, index) => {
|
|
const locked = !!ownerSortColumn
|
|
&& !!String(row[ownerSortColumn] ?? "")
|
|
&& String(row[ownerSortColumn] ?? "") !== (currentUserId ?? "");
|
|
const cardKey = row.__isAcceptClone
|
|
? `card-clone-${row.__cloneSourceId}-${index}`
|
|
: row.__splitProcessId
|
|
? `card-split-${row.__splitProcessId}`
|
|
: `card-${row.id ?? index}`;
|
|
return (
|
|
<CardV2
|
|
key={cardKey}
|
|
row={row}
|
|
cardGrid={cardGrid}
|
|
spec={spec}
|
|
config={config}
|
|
onSelect={handleCardSelect}
|
|
publish={publish}
|
|
parentComponentId={componentId}
|
|
onRefresh={fetchData}
|
|
selectMode={selectMode}
|
|
isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))}
|
|
isSelectable={isRowSelectable(row)}
|
|
onToggleRowSelect={() => toggleRowSelection(row)}
|
|
onEnterSelectMode={enterSelectMode}
|
|
onOpenPopModal={openPopModal}
|
|
currentUserId={currentUserId}
|
|
isLockedByOther={locked}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 선택 모드 하단 액션 바 */}
|
|
{selectMode && selectModeConfig && (
|
|
<div className="shrink-0 border-t bg-background px-3 py-2.5 shadow-[0_-2px_8px_rgba(0,0,0,0.08)]">
|
|
<div className="flex items-center justify-end gap-2">
|
|
{selectModeConfig.buttons.map((btn, idx) => (
|
|
<Button
|
|
key={idx}
|
|
variant={btn.variant || (btn.clickMode === "cancel-select" ? "outline" : "default")}
|
|
size="sm"
|
|
className="h-10 min-w-[80px] px-4 text-sm font-medium"
|
|
disabled={selectProcessing || (btn.clickMode !== "cancel-select" && selectedRowIds.size === 0)}
|
|
onClick={() => handleSelectModeAction(btn)}
|
|
>
|
|
{selectProcessing && btn.clickMode !== "cancel-select" && (
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
)}
|
|
{btn.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 더보기/페이지네이션 */}
|
|
{!selectMode && hasMoreCards && (
|
|
<div className="shrink-0 border-t bg-background px-3 py-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={toggleExpand} className="h-9 px-4 text-sm font-medium">
|
|
{isExpanded ? (<>접기 <ChevronUp className="ml-1 h-4 w-4" /></>) : (<>더보기 <ChevronDown className="ml-1 h-4 w-4" /></>)}
|
|
</Button>
|
|
<span className="text-xs text-muted-foreground">
|
|
{filteredRows.length}건{externalFilters.size > 0 && filteredRows.length !== rows.length ? ` / ${rows.length}건` : ""}
|
|
</span>
|
|
</div>
|
|
{isExpanded && needsPagination && (
|
|
<div className="flex items-center gap-1.5">
|
|
<Button variant="default" size="sm" onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1} className="h-9 w-9 p-0">
|
|
<ChevronLeft className="h-5 w-5" />
|
|
</Button>
|
|
<span className="min-w-[48px] text-center text-sm font-medium">{currentPage} / {totalPages}</span>
|
|
<Button variant="default" size="sm" onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages} className="h-9 w-9 p-0">
|
|
<ChevronRight className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* 내장 작업 상세 모달 (풀스크린) */}
|
|
<Dialog open={workDetailOpen} onOpenChange={(open) => {
|
|
setWorkDetailOpen(open);
|
|
if (!open) setWorkDetailRow(null);
|
|
}}>
|
|
<DialogContent className="flex h-dvh w-screen max-w-none flex-col gap-0 rounded-none border-none p-0 [&>button]:z-50">
|
|
<DialogHeader className="flex shrink-0 flex-row items-center justify-between border-b px-4 py-2">
|
|
<DialogTitle className="text-base">작업 상세</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-auto">
|
|
{workDetailRow && (
|
|
<LazyPopWorkDetail
|
|
parentRow={workDetailRow}
|
|
config={config?.workDetailConfig}
|
|
screenId={screenId}
|
|
/>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* POP 화면 모달 (풀스크린) */}
|
|
<Dialog open={popModalOpen} onOpenChange={(open) => {
|
|
setPopModalOpen(open);
|
|
if (!open) {
|
|
setPopModalLayout(null);
|
|
setPopModalRow(null);
|
|
}
|
|
}}>
|
|
<DialogContent className="flex h-dvh w-screen max-w-none flex-col gap-0 rounded-none border-none p-0 [&>button]:z-50">
|
|
<DialogHeader className="flex shrink-0 flex-row items-center justify-between border-b px-4 py-2">
|
|
<DialogTitle className="text-base">{config?.cardClickModalConfig?.modalTitle || "상세 작업"}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-auto">
|
|
{popModalLayout && (
|
|
<PopViewerWithModals
|
|
layout={popModalLayout}
|
|
viewportWidth={typeof window !== "undefined" ? window.innerWidth : 1024}
|
|
screenId={popModalScreenId}
|
|
currentMode={detectGridMode(typeof window !== "undefined" ? window.innerWidth : 1024)}
|
|
parentRow={popModalRow ?? undefined}
|
|
/>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== 카드 V2 =====
|
|
|
|
interface CardV2Props {
|
|
row: RowData;
|
|
cardGrid?: CardGridConfigV2;
|
|
spec: CardPresetSpec;
|
|
config?: PopCardListV2Config;
|
|
onSelect?: (row: RowData) => void;
|
|
publish: (eventName: string, payload?: unknown) => void;
|
|
parentComponentId?: string;
|
|
onRefresh?: () => void;
|
|
selectMode?: boolean;
|
|
isSelectModeSelected?: boolean;
|
|
isSelectable?: boolean;
|
|
onToggleRowSelect?: () => void;
|
|
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
|
|
onOpenPopModal?: (screenId: string, row: RowData) => void;
|
|
currentUserId?: string;
|
|
isLockedByOther?: boolean;
|
|
}
|
|
|
|
function CardV2({
|
|
row, cardGrid, spec, config, onSelect, publish,
|
|
parentComponentId, onRefresh,
|
|
selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode,
|
|
onOpenPopModal, currentUserId, isLockedByOther,
|
|
}: CardV2Props) {
|
|
const inputField = config?.inputField;
|
|
const packageConfig = config?.packageConfig;
|
|
|
|
const [inputValue, setInputValue] = useState<number>(0);
|
|
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
|
|
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
const [qtyModalState, setQtyModalState] = useState<{
|
|
open: boolean;
|
|
row: RowData;
|
|
processId?: string | number;
|
|
action: ActionButtonClickAction;
|
|
dynamicMax?: number;
|
|
} | null>(null);
|
|
|
|
// 수량 모달이 열려 있을 때 카드 클릭 차단 (모달 닫힘 직후 이벤트 전파 방지)
|
|
const qtyModalClosedAtRef = useRef<number>(0);
|
|
const closeQtyModal = useCallback(() => {
|
|
qtyModalClosedAtRef.current = Date.now();
|
|
setQtyModalState(null);
|
|
}, []);
|
|
|
|
const handleQtyConfirm = useCallback(async (value: number) => {
|
|
if (!qtyModalState) return;
|
|
const { row: actionRow, processId: qtyProcessId, action } = qtyModalState;
|
|
if (!action.targetTable || !action.updates) { closeQtyModal(); return; }
|
|
|
|
const rowId = qtyProcessId ?? actionRow.id ?? actionRow.pk;
|
|
if (!rowId) { toast.error("대상 레코드 ID를 찾을 수 없습니다."); return; }
|
|
|
|
// MES 전용: work_order_process 접수는 accept-process API 사용
|
|
const isAcceptAction = action.targetTable === "work_order_process"
|
|
&& action.updates.some((u) => u.column === "input_qty");
|
|
|
|
if (isAcceptAction) {
|
|
let wopId = qtyProcessId;
|
|
if (!wopId) {
|
|
const pf = actionRow.__processFlow__ as Array<{ isCurrent: boolean; processId?: string | number }> | undefined;
|
|
const cur = pf?.find((p) => p.isCurrent);
|
|
wopId = cur?.processId;
|
|
}
|
|
if (!wopId) {
|
|
toast.error("공정 ID를 찾을 수 없습니다.");
|
|
closeQtyModal();
|
|
return;
|
|
}
|
|
try {
|
|
const result = await apiClient.post("/pop/production/accept-process", {
|
|
work_order_process_id: wopId,
|
|
accept_qty: value,
|
|
});
|
|
if (result.data?.success) {
|
|
toast.success(result.data.message || "접수 완료");
|
|
onRefresh?.();
|
|
} else {
|
|
toast.error(result.data?.message || "접수 실패");
|
|
}
|
|
} catch (err: unknown) {
|
|
const errMsg = (err as any)?.response?.data?.message;
|
|
toast.error(errMsg || "접수 중 오류 발생");
|
|
onRefresh?.();
|
|
}
|
|
closeQtyModal();
|
|
return;
|
|
}
|
|
|
|
// 일반 quantity-input (기존 로직)
|
|
const lookupValue = action.joinConfig
|
|
? String(actionRow[action.joinConfig.sourceColumn] ?? rowId)
|
|
: rowId;
|
|
const lookupColumn = action.joinConfig?.targetColumn || "id";
|
|
|
|
const tasks = action.updates.map((u, idx) => ({
|
|
id: `qty-update-${idx}`,
|
|
type: "data-update" as const,
|
|
targetTable: action.targetTable!,
|
|
targetColumn: u.column,
|
|
operationType: (u.operationType || "assign") as "assign" | "add" | "subtract",
|
|
valueSource: "fixed" as const,
|
|
fixedValue: u.valueType === "userInput" ? String(value) :
|
|
u.valueType === "static" ? (u.value ?? "") :
|
|
u.valueType === "currentUser" ? "__CURRENT_USER__" :
|
|
u.valueType === "currentTime" ? "__CURRENT_TIME__" :
|
|
u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") :
|
|
(u.value ?? ""),
|
|
lookupMode: "manual" as const,
|
|
manualItemField: lookupColumn,
|
|
manualPkColumn: lookupColumn,
|
|
...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}),
|
|
}));
|
|
|
|
const targetRow = action.joinConfig
|
|
? { ...actionRow, [lookupColumn]: lookupValue }
|
|
: qtyProcessId ? { ...actionRow, id: qtyProcessId } : actionRow;
|
|
|
|
try {
|
|
const result = await apiClient.post("/pop/execute-action", {
|
|
tasks,
|
|
data: { items: [targetRow], fieldValues: {} },
|
|
mappings: {},
|
|
});
|
|
if (result.data?.success) {
|
|
toast.success(result.data.message || "처리 완료");
|
|
onRefresh?.();
|
|
} else {
|
|
toast.error(result.data?.message || "처리 실패");
|
|
}
|
|
} catch (err: unknown) {
|
|
if ((err as any)?.response?.status === 409) {
|
|
toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다.");
|
|
onRefresh?.();
|
|
} else {
|
|
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
|
|
}
|
|
}
|
|
closeQtyModal();
|
|
}, [qtyModalState, onRefresh, closeQtyModal]);
|
|
|
|
// 제한 컬럼 자동 초기화
|
|
const limitCol = inputField?.limitColumn || inputField?.maxColumn;
|
|
const effectiveMax = useMemo(() => {
|
|
if (limitCol) { const v = Number(row[limitCol]); if (!isNaN(v) && v > 0) return v; }
|
|
return 999999;
|
|
}, [limitCol, row]);
|
|
|
|
useEffect(() => {
|
|
if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) {
|
|
setInputValue(effectiveMax);
|
|
}
|
|
}, [effectiveMax, inputField?.enabled, limitCol]);
|
|
|
|
const handleInputClick = (e: React.MouseEvent) => { e.stopPropagation(); setIsModalOpen(true); };
|
|
const handleInputConfirm = (value: number, unit?: string, entries?: PackageEntry[]) => {
|
|
setInputValue(value);
|
|
setPackageUnit(unit);
|
|
setPackageEntries(entries || []);
|
|
};
|
|
|
|
const borderClass = selectMode
|
|
? isSelectModeSelected
|
|
? "border-primary border-2 bg-primary/5"
|
|
: isSelectable
|
|
? "hover:border-2 hover:border-primary/50"
|
|
: "opacity-40 pointer-events-none"
|
|
: "hover:border-2 hover:border-blue-500";
|
|
|
|
// mes-process-card 전용 카드일 때 래퍼 스타일 변경
|
|
const isMesCard = cardGrid?.cells.some((c) => c.type === "mes-process-card");
|
|
|
|
if (!cardGrid || cardGrid.cells.length === 0) {
|
|
return (
|
|
<div className={`flex items-center justify-center rounded-lg border p-4 ${borderClass}`} style={{ minHeight: `${spec.height}px` }}>
|
|
<span className="text-xs text-muted-foreground">카드 레이아웃을 설정하세요</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const gridStyle: React.CSSProperties = {
|
|
display: "grid",
|
|
gridTemplateColumns: cardGrid.colWidths.length > 0
|
|
? cardGrid.colWidths.map((w) => `minmax(30px, ${w || "1fr"})`).join(" ")
|
|
: "1fr",
|
|
gridTemplateRows: cardGrid.rowHeights?.length
|
|
? cardGrid.rowHeights.map((h) => {
|
|
if (!h) return "minmax(24px, auto)";
|
|
if (h.endsWith("px")) return `minmax(${h}, auto)`;
|
|
const px = Math.round(parseFloat(h) * 24) || 24;
|
|
return `minmax(${px}px, auto)`;
|
|
}).join(" ")
|
|
: `repeat(${cardGrid.rows || 1}, minmax(24px, auto))`,
|
|
gap: `${cardGrid.gap || 0}px`,
|
|
};
|
|
|
|
const justifyMap = { left: "flex-start", center: "center", right: "flex-end" } as const;
|
|
const alignItemsMap = { top: "flex-start", middle: "center", bottom: "flex-end" } as const;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative flex flex-col rounded-lg border shadow-sm transition-all duration-150",
|
|
isMesCard ? "overflow-hidden" : "bg-card",
|
|
isLockedByOther
|
|
? "cursor-not-allowed opacity-50"
|
|
: "cursor-pointer hover:shadow-md",
|
|
borderClass,
|
|
)}
|
|
style={{ minHeight: isMesCard ? undefined : `${spec.height}px` }}
|
|
onClick={() => {
|
|
if (isLockedByOther) return;
|
|
if (qtyModalState?.open) return;
|
|
if (Date.now() - qtyModalClosedAtRef.current < 500) return;
|
|
if (selectMode && isSelectable) { onToggleRowSelect?.(); return; }
|
|
if (!selectMode) onSelect?.(row);
|
|
}}
|
|
role="button"
|
|
tabIndex={isLockedByOther ? -1 : 0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
if (isLockedByOther) return;
|
|
if (qtyModalState?.open) return;
|
|
if (selectMode && isSelectable) { onToggleRowSelect?.(); return; }
|
|
if (!selectMode) onSelect?.(row);
|
|
}
|
|
}}
|
|
>
|
|
{/* 선택 모드: 체크 인디케이터 */}
|
|
{selectMode && isSelectable && (
|
|
<div className="absolute left-1.5 top-1.5 z-10">
|
|
<div
|
|
className={cn(
|
|
"flex h-6 w-6 items-center justify-center rounded-full border-2 transition-colors duration-150",
|
|
isSelectModeSelected
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
: "border-muted-foreground/40 bg-background"
|
|
)}
|
|
onClick={(e) => { e.stopPropagation(); onToggleRowSelect?.(); }}
|
|
>
|
|
{isSelectModeSelected && <Check className="h-3.5 w-3.5" />}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* CSS Grid 기반 셀 렌더링 */}
|
|
<div className={cn("flex-1 overflow-hidden", !isMesCard && "p-1")} style={gridStyle}>
|
|
{cardGrid.cells.map((cell) => (
|
|
<div
|
|
key={cell.id}
|
|
className={cn("overflow-hidden", !isMesCard && "p-1")}
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
justifyContent: alignItemsMap[cell.verticalAlign || "top"],
|
|
alignItems: justifyMap[cell.align || "left"],
|
|
gridColumn: `${cell.col} / span ${cell.colSpan || 1}`,
|
|
gridRow: `${cell.row} / span ${cell.rowSpan || 1}`,
|
|
border: cardGrid.showCellBorder ? "1px solid hsl(var(--border))" : "none",
|
|
}}
|
|
>
|
|
{renderCellV2({
|
|
cell,
|
|
row,
|
|
inputValue,
|
|
onInputClick: handleInputClick,
|
|
onEnterSelectMode,
|
|
onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => {
|
|
const cfg = buttonConfig as Record<string, unknown> | undefined;
|
|
const processId = cfg?.__processId as string | number | undefined;
|
|
|
|
// 수동 완료 처리
|
|
if (taskPreset === "__manualComplete" && processId) {
|
|
if (!window.confirm("이 공정을 수동으로 완료 처리하시겠습니까? 현재 생산량으로 확정됩니다.")) return;
|
|
try {
|
|
const result = await apiClient.post("/pop/production/confirm-result", {
|
|
work_order_process_id: processId,
|
|
});
|
|
if (result.data?.success) {
|
|
toast.success("공정이 완료 처리되었습니다.");
|
|
onRefresh?.();
|
|
} else {
|
|
toast.error(result.data?.message || "완료 처리에 실패했습니다.");
|
|
}
|
|
} catch (err: unknown) {
|
|
const errMsg = (err as any)?.response?.data?.message;
|
|
toast.error(errMsg || "완료 처리 중 오류가 발생했습니다.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 접수 취소 처리 (__cancelAccept 또는 "접수취소" 라벨 버튼)
|
|
if ((taskPreset === "__cancelAccept" || taskPreset === "접수취소") && processId) {
|
|
if (!window.confirm("접수를 취소하시겠습니까? 실적이 없는 경우에만 가능합니다.")) return;
|
|
try {
|
|
const result = await apiClient.post("/pop/production/cancel-accept", {
|
|
work_order_process_id: processId,
|
|
});
|
|
if (result.data?.success) {
|
|
toast.success(result.data.message || "접수가 취소되었습니다.");
|
|
onRefresh?.();
|
|
} else {
|
|
toast.error(result.data?.message || "접수 취소에 실패했습니다.");
|
|
}
|
|
} catch (err: unknown) {
|
|
const errMsg = (err as any)?.response?.data?.message;
|
|
toast.error(errMsg || "접수 취소 중 오류가 발생했습니다.");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const allActions = (cfg?.__allActions as ActionButtonClickAction[] | undefined) || [];
|
|
|
|
// 단일 액션 폴백 (기존 구조 호환)
|
|
const actionsToRun = allActions.length > 0
|
|
? allActions
|
|
: cfg?.type
|
|
? [cfg as unknown as ActionButtonClickAction]
|
|
: [];
|
|
|
|
if (actionsToRun.length === 0) {
|
|
if (parentComponentId) {
|
|
publish(`__comp_output__${parentComponentId}__action`, { taskPreset, row: actionRow });
|
|
}
|
|
return;
|
|
}
|
|
|
|
for (const action of actionsToRun) {
|
|
if (action.type === "quantity-input" && action.targetTable && action.updates) {
|
|
if (action.confirmMessage && !window.confirm(action.confirmMessage)) return;
|
|
|
|
// MES 전용: accept-process API 기반 접수 상한 조회
|
|
const isAcceptAction = action.targetTable === "work_order_process"
|
|
&& action.updates.some((u) => u.column === "input_qty");
|
|
let dynamicMax: number | undefined;
|
|
let resolvedProcessId = processId;
|
|
if (!resolvedProcessId) {
|
|
const pf = actionRow.__processFlow__ as Array<{ isCurrent: boolean; processId?: string | number }> | undefined;
|
|
resolvedProcessId = pf?.find((p) => p.isCurrent)?.processId;
|
|
}
|
|
if (isAcceptAction && resolvedProcessId) {
|
|
try {
|
|
const aqRes = await apiClient.get("/pop/production/available-qty", {
|
|
params: { work_order_process_id: resolvedProcessId },
|
|
});
|
|
if (aqRes.data?.success) {
|
|
dynamicMax = aqRes.data.data.availableQty;
|
|
}
|
|
} catch { /* fallback to static */ }
|
|
}
|
|
|
|
setQtyModalState({ open: true, row: actionRow, processId: resolvedProcessId ?? processId, action, dynamicMax });
|
|
return;
|
|
} else if (action.type === "immediate" && action.updates && action.updates.length > 0 && action.targetTable) {
|
|
if (action.confirmMessage) {
|
|
if (!window.confirm(action.confirmMessage)) return;
|
|
}
|
|
try {
|
|
const rowId = processId ?? actionRow.id ?? actionRow.pk;
|
|
if (!rowId) { toast.error("대상 레코드의 ID를 찾을 수 없습니다."); return; }
|
|
const lookupValue = action.joinConfig
|
|
? String(actionRow[action.joinConfig.sourceColumn] ?? rowId)
|
|
: rowId;
|
|
const lookupColumn = action.joinConfig?.targetColumn || "id";
|
|
const tasks = action.updates.map((u, idx) => ({
|
|
id: `btn-update-${idx}`,
|
|
type: "data-update" as const,
|
|
targetTable: action.targetTable!,
|
|
targetColumn: u.column,
|
|
operationType: (u.operationType || "assign") as "assign" | "add" | "subtract",
|
|
valueSource: "fixed" as const,
|
|
fixedValue: u.valueType === "static" ? (u.value ?? "") :
|
|
u.valueType === "currentUser" ? "__CURRENT_USER__" :
|
|
u.valueType === "currentTime" ? "__CURRENT_TIME__" :
|
|
u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") :
|
|
(u.value ?? ""),
|
|
lookupMode: "manual" as const,
|
|
manualItemField: lookupColumn,
|
|
manualPkColumn: lookupColumn,
|
|
...(idx === 0 && action.preCondition ? { preCondition: action.preCondition } : {}),
|
|
}));
|
|
const targetRow = action.joinConfig
|
|
? { ...actionRow, [lookupColumn]: lookupValue }
|
|
: processId ? { ...actionRow, id: processId } : actionRow;
|
|
const result = await apiClient.post("/pop/execute-action", {
|
|
tasks,
|
|
data: { items: [targetRow], fieldValues: {} },
|
|
mappings: {},
|
|
});
|
|
if (result.data?.success) {
|
|
toast.success(result.data.message || "처리 완료");
|
|
onRefresh?.();
|
|
} else {
|
|
toast.error(result.data?.message || "처리 실패");
|
|
return;
|
|
}
|
|
} catch (err: unknown) {
|
|
if ((err as any)?.response?.status === 409) {
|
|
toast.error((err as any).response?.data?.message || "이미 다른 사용자가 처리한 작업입니다.");
|
|
onRefresh?.();
|
|
} else {
|
|
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
|
|
}
|
|
return;
|
|
}
|
|
} else if (action.type === "modal-open" && action.modalScreenId) {
|
|
onOpenPopModal?.(action.modalScreenId, actionRow);
|
|
}
|
|
}
|
|
},
|
|
packageEntries,
|
|
inputUnit: inputField?.unit,
|
|
currentUserId,
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{inputField?.enabled && (
|
|
<NumberInputModal
|
|
open={isModalOpen}
|
|
onOpenChange={setIsModalOpen}
|
|
unit={inputField.unit || "EA"}
|
|
initialValue={inputValue}
|
|
initialPackageUnit={packageUnit}
|
|
maxValue={effectiveMax}
|
|
packageConfig={packageConfig}
|
|
showPackageUnit={inputField.showPackageUnit}
|
|
onConfirm={handleInputConfirm}
|
|
/>
|
|
)}
|
|
|
|
{qtyModalState?.open && (
|
|
<NumberInputModal
|
|
open={true}
|
|
onOpenChange={(open) => { if (!open) closeQtyModal(); }}
|
|
unit={qtyModalState.action.quantityInput?.unit || "EA"}
|
|
maxValue={qtyModalState.dynamicMax ?? calculateMaxQty(qtyModalState.row, qtyModalState.processId, qtyModalState.action.quantityInput)}
|
|
showPackageUnit={qtyModalState.action.quantityInput?.enablePackage ?? false}
|
|
onConfirm={(value) => handleQtyConfirm(value)}
|
|
/>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
}
|