feat: BLOCK DETAIL Phase 3 - pop-work-detail 컴포넌트 + 모달 캔버스 시스템
세부진행화면(4502)의 프론트엔드 구현: pop-work-detail 컴포넌트 신규 생성과 디자이너 모달 캔버스 편집을 통해, 카드 클릭 시 공정별 체크리스트/검사/실적 상세 작업 화면을 내부 모달로 표시할 수 있게 한다. [신규] pop-work-detail 컴포넌트 (4파일) - PopWorkDetailComponent: parentRow → 현재 공정 추출 → process_work_result 조회, 좌측 사이드바(PRE/IN/POST 3단계 작업항목 그룹) + 우측 체크리스트(5종: check/inspect/ input/procedure/material) + 타이머 제어(start/pause/resume) + 수량 등록 + 공정 완료 - PopWorkDetailConfig: showTimer/showQuantityInput/phaseLabels 설정 패널 - PopWorkDetailPreview: 디자이너 프리뷰 - index.tsx: PopComponentRegistry 등록 (category: display, touchOptimized) [모달 캔버스 시스템] PopDesigner.tsx 대규모 리팩토링 - handleMoveComponent/handleResizeComponent/handleRequestResize: layout 직접 참조 → setLayout(prev => ...) 함수형 업데이트로 전환 + activeCanvasId 분기: main이면 기존 로직, modal-*이면 modals 배열 내 해당 모달 수정 - PopCardListV2Config: 모달 캔버스 생성/열기 버튼 (usePopDesignerContext 연동) - PopCardListV2Component: modal-* screenId → setSharedData + __pop_modal_open__ 이벤트 - PopViewerWithModals: parentRow prop + fullscreen 모달 지원 + flex 레이아웃 [기타] - ComponentPalette: pop-work-detail 팔레트 항목 + ClipboardCheck 아이콘 - pop-layout.ts: PopComponentType에 pop-work-detail 추가, 기본 크기 38x26 - PopRenderer: COMPONENT_TYPE_LABELS에 pop-work-detail 추가 - types.ts: PopWorkDetailConfig 인터페이스 - PopCanvas.tsx: activeLayout.components 참조 수정 (모달 캔버스 호환) DB 변경 0건. 백엔드 변경 0건.
This commit is contained in:
parent
ed0f3393f6
commit
3bd0eff82e
|
|
@ -403,7 +403,7 @@ export default function PopCanvas({
|
|||
// 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기
|
||||
// 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
|
||||
const currentEffectivePos = effectivePositions.get(dragItem.componentId);
|
||||
const componentData = layout.components[dragItem.componentId];
|
||||
const componentData = activeLayout.components[dragItem.componentId];
|
||||
|
||||
if (!currentEffectivePos && !componentData) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -389,97 +389,156 @@ export default function PopDesigner({
|
|||
|
||||
const handleMoveComponent = useCallback(
|
||||
(componentId: string, newPosition: PopGridPosition) => {
|
||||
const component = layout.components[componentId];
|
||||
if (!component) return;
|
||||
|
||||
// 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정
|
||||
if (currentMode === "tablet_landscape") {
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: {
|
||||
...layout.components,
|
||||
[componentId]: {
|
||||
...component,
|
||||
position: newPosition,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
} else {
|
||||
// 다른 모드인 경우: 오버라이드에 저장
|
||||
// 숨김 상태였던 컴포넌트를 이동하면 숨김 해제도 함께 처리
|
||||
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
|
||||
const isHidden = currentHidden.includes(componentId);
|
||||
const newHidden = isHidden
|
||||
? currentHidden.filter(id => id !== componentId)
|
||||
: currentHidden;
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
overrides: {
|
||||
...layout.overrides,
|
||||
[currentMode]: {
|
||||
...layout.overrides?.[currentMode],
|
||||
positions: {
|
||||
...layout.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
setLayout((prev) => {
|
||||
if (activeCanvasId === "main") {
|
||||
const component = prev.components[componentId];
|
||||
if (!component) return prev;
|
||||
|
||||
if (currentMode === "tablet_landscape") {
|
||||
const newLayout = {
|
||||
...prev,
|
||||
components: {
|
||||
...prev.components,
|
||||
[componentId]: { ...component, position: newPosition },
|
||||
},
|
||||
// 숨김 배열 업데이트 (빈 배열이면 undefined로)
|
||||
hidden: newHidden.length > 0 ? newHidden : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
} else {
|
||||
const currentHidden = prev.overrides?.[currentMode]?.hidden || [];
|
||||
const newHidden = currentHidden.filter(id => id !== componentId);
|
||||
const newLayout = {
|
||||
...prev,
|
||||
overrides: {
|
||||
...prev.overrides,
|
||||
[currentMode]: {
|
||||
...prev.overrides?.[currentMode],
|
||||
positions: {
|
||||
...prev.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
hidden: newHidden.length > 0 ? newHidden : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
}
|
||||
} else {
|
||||
// 모달 캔버스
|
||||
const newLayout = {
|
||||
...prev,
|
||||
modals: (prev.modals || []).map(m => {
|
||||
if (m.id !== activeCanvasId) return m;
|
||||
const component = m.components[componentId];
|
||||
if (!component) return m;
|
||||
|
||||
if (currentMode === "tablet_landscape") {
|
||||
return {
|
||||
...m,
|
||||
components: {
|
||||
...m.components,
|
||||
[componentId]: { ...component, position: newPosition },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const currentHidden = m.overrides?.[currentMode]?.hidden || [];
|
||||
const newHidden = currentHidden.filter(id => id !== componentId);
|
||||
return {
|
||||
...m,
|
||||
overrides: {
|
||||
...m.overrides,
|
||||
[currentMode]: {
|
||||
...m.overrides?.[currentMode],
|
||||
positions: {
|
||||
...m.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
hidden: newHidden.length > 0 ? newHidden : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
}
|
||||
});
|
||||
setHasChanges(true);
|
||||
},
|
||||
[layout, saveToHistory, currentMode]
|
||||
[saveToHistory, currentMode, activeCanvasId]
|
||||
);
|
||||
|
||||
const handleResizeComponent = useCallback(
|
||||
(componentId: string, newPosition: PopGridPosition) => {
|
||||
const component = layout.components[componentId];
|
||||
if (!component) return;
|
||||
|
||||
// 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정
|
||||
if (currentMode === "tablet_landscape") {
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: {
|
||||
...layout.components,
|
||||
[componentId]: {
|
||||
...component,
|
||||
position: newPosition,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
// 리사이즈는 드래그 중 계속 호출되므로 히스토리는 마우스업 시에만 저장
|
||||
// 현재는 간단히 매번 저장 (최적화 가능)
|
||||
setHasChanges(true);
|
||||
} else {
|
||||
// 다른 모드인 경우: 오버라이드에 저장
|
||||
const newLayout = {
|
||||
...layout,
|
||||
overrides: {
|
||||
...layout.overrides,
|
||||
[currentMode]: {
|
||||
...layout.overrides?.[currentMode],
|
||||
positions: {
|
||||
...layout.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
setLayout((prev) => {
|
||||
if (activeCanvasId === "main") {
|
||||
const component = prev.components[componentId];
|
||||
if (!component) return prev;
|
||||
|
||||
if (currentMode === "tablet_landscape") {
|
||||
return {
|
||||
...prev,
|
||||
components: {
|
||||
...prev.components,
|
||||
[componentId]: { ...component, position: newPosition },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
overrides: {
|
||||
...prev.overrides,
|
||||
[currentMode]: {
|
||||
...prev.overrides?.[currentMode],
|
||||
positions: {
|
||||
...prev.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 모달 캔버스
|
||||
return {
|
||||
...prev,
|
||||
modals: (prev.modals || []).map(m => {
|
||||
if (m.id !== activeCanvasId) return m;
|
||||
const component = m.components[componentId];
|
||||
if (!component) return m;
|
||||
|
||||
if (currentMode === "tablet_landscape") {
|
||||
return {
|
||||
...m,
|
||||
components: {
|
||||
...m.components,
|
||||
[componentId]: { ...component, position: newPosition },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...m,
|
||||
overrides: {
|
||||
...m.overrides,
|
||||
[currentMode]: {
|
||||
...m.overrides?.[currentMode],
|
||||
positions: {
|
||||
...m.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
});
|
||||
setHasChanges(true);
|
||||
},
|
||||
[layout, currentMode]
|
||||
[currentMode, activeCanvasId]
|
||||
);
|
||||
|
||||
const handleResizeEnd = useCallback(
|
||||
|
|
@ -493,51 +552,87 @@ export default function PopDesigner({
|
|||
// 컴포넌트가 자신의 rowSpan/colSpan을 동적으로 변경 요청 (CardList 확장 등)
|
||||
const handleRequestResize = useCallback(
|
||||
(componentId: string, newRowSpan: number, newColSpan?: number) => {
|
||||
const component = layout.components[componentId];
|
||||
if (!component) return;
|
||||
setLayout((prev) => {
|
||||
const buildPosition = (comp: PopComponentDefinition) => ({
|
||||
...comp.position,
|
||||
rowSpan: newRowSpan,
|
||||
...(newColSpan !== undefined ? { colSpan: newColSpan } : {}),
|
||||
});
|
||||
|
||||
const newPosition = {
|
||||
...component.position,
|
||||
rowSpan: newRowSpan,
|
||||
...(newColSpan !== undefined ? { colSpan: newColSpan } : {}),
|
||||
};
|
||||
|
||||
// 기본 모드(tablet_landscape)인 경우: 원본 position 직접 수정
|
||||
if (currentMode === "tablet_landscape") {
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: {
|
||||
...layout.components,
|
||||
[componentId]: {
|
||||
...component,
|
||||
position: newPosition,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
} else {
|
||||
// 다른 모드인 경우: 오버라이드에 저장
|
||||
const newLayout = {
|
||||
...layout,
|
||||
overrides: {
|
||||
...layout.overrides,
|
||||
[currentMode]: {
|
||||
...layout.overrides?.[currentMode],
|
||||
positions: {
|
||||
...layout.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
if (activeCanvasId === "main") {
|
||||
const component = prev.components[componentId];
|
||||
if (!component) return prev;
|
||||
const newPosition = buildPosition(component);
|
||||
|
||||
if (currentMode === "tablet_landscape") {
|
||||
const newLayout = {
|
||||
...prev,
|
||||
components: {
|
||||
...prev.components,
|
||||
[componentId]: { ...component, position: newPosition },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
} else {
|
||||
const newLayout = {
|
||||
...prev,
|
||||
overrides: {
|
||||
...prev.overrides,
|
||||
[currentMode]: {
|
||||
...prev.overrides?.[currentMode],
|
||||
positions: {
|
||||
...prev.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
}
|
||||
} else {
|
||||
// 모달 캔버스
|
||||
const newLayout = {
|
||||
...prev,
|
||||
modals: (prev.modals || []).map(m => {
|
||||
if (m.id !== activeCanvasId) return m;
|
||||
const component = m.components[componentId];
|
||||
if (!component) return m;
|
||||
const newPosition = buildPosition(component);
|
||||
|
||||
if (currentMode === "tablet_landscape") {
|
||||
return {
|
||||
...m,
|
||||
components: {
|
||||
...m.components,
|
||||
[componentId]: { ...component, position: newPosition },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...m,
|
||||
overrides: {
|
||||
...m.overrides,
|
||||
[currentMode]: {
|
||||
...m.overrides?.[currentMode],
|
||||
positions: {
|
||||
...m.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
};
|
||||
saveToHistory(newLayout);
|
||||
return newLayout;
|
||||
}
|
||||
});
|
||||
setHasChanges(true);
|
||||
},
|
||||
[layout, currentMode, saveToHistory]
|
||||
[currentMode, saveToHistory, activeCanvasId]
|
||||
);
|
||||
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useDrag } from "react-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PopComponentType } from "../types/pop-layout";
|
||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2 } from "lucide-react";
|
||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2, ClipboardCheck } from "lucide-react";
|
||||
import { DND_ITEM_TYPES } from "../constants";
|
||||
|
||||
// 컴포넌트 정의
|
||||
|
|
@ -93,6 +93,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||
icon: UserCircle,
|
||||
description: "사용자 프로필 / PC 전환 / 로그아웃",
|
||||
},
|
||||
{
|
||||
type: "pop-work-detail",
|
||||
label: "작업 상세",
|
||||
icon: ClipboardCheck,
|
||||
description: "공정별 체크리스트/검사/실적 상세 작업 화면",
|
||||
},
|
||||
];
|
||||
|
||||
// 드래그 가능한 컴포넌트 아이템
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
|||
"pop-field": "입력",
|
||||
"pop-scanner": "스캐너",
|
||||
"pop-profile": "프로필",
|
||||
"pop-work-detail": "작업 상세",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
/**
|
||||
* POP 컴포넌트 타입
|
||||
*/
|
||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile";
|
||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile" | "pop-work-detail";
|
||||
|
||||
/**
|
||||
* 데이터 흐름 정의
|
||||
|
|
@ -377,6 +377,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
|
|||
"pop-field": { colSpan: 19, rowSpan: 6 },
|
||||
"pop-scanner": { colSpan: 2, rowSpan: 2 },
|
||||
"pop-profile": { colSpan: 2, rowSpan: 2 },
|
||||
"pop-work-detail": { colSpan: 38, rowSpan: 26 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -42,12 +42,15 @@ interface PopViewerWithModalsProps {
|
|||
overrideGap?: number;
|
||||
/** Padding 오버라이드 */
|
||||
overridePadding?: number;
|
||||
/** 부모 화면에서 선택된 행 데이터 (모달 내부 컴포넌트가 sharedData로 조회) */
|
||||
parentRow?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 열린 모달 상태 */
|
||||
interface OpenModal {
|
||||
definition: PopModalDefinition;
|
||||
returnTo?: string;
|
||||
fullscreen?: boolean;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
@ -61,10 +64,17 @@ export default function PopViewerWithModals({
|
|||
currentMode,
|
||||
overrideGap,
|
||||
overridePadding,
|
||||
parentRow,
|
||||
}: PopViewerWithModalsProps) {
|
||||
const router = useRouter();
|
||||
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
|
||||
const { subscribe, publish } = usePopEvent(screenId);
|
||||
const { subscribe, publish, setSharedData } = usePopEvent(screenId);
|
||||
|
||||
useEffect(() => {
|
||||
if (parentRow) {
|
||||
setSharedData("parentRow", parentRow);
|
||||
}
|
||||
}, [parentRow, setSharedData]);
|
||||
|
||||
// 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환
|
||||
const stableConnections = useMemo(
|
||||
|
|
@ -96,6 +106,7 @@ export default function PopViewerWithModals({
|
|||
title?: string;
|
||||
mode?: string;
|
||||
returnTo?: string;
|
||||
fullscreen?: boolean;
|
||||
};
|
||||
|
||||
if (data?.modalId) {
|
||||
|
|
@ -104,6 +115,7 @@ export default function PopViewerWithModals({
|
|||
setModalStack(prev => [...prev, {
|
||||
definition: modalDef,
|
||||
returnTo: data.returnTo,
|
||||
fullscreen: data.fullscreen,
|
||||
}]);
|
||||
}
|
||||
}
|
||||
|
|
@ -173,7 +185,7 @@ export default function PopViewerWithModals({
|
|||
|
||||
{/* 모달 스택 렌더링 */}
|
||||
{modalStack.map((modal, index) => {
|
||||
const { definition } = modal;
|
||||
const { definition, fullscreen } = modal;
|
||||
const isTopModal = index === modalStack.length - 1;
|
||||
const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false;
|
||||
const closeOnEsc = definition.frameConfig?.closeOnEsc !== false;
|
||||
|
|
@ -185,10 +197,15 @@ export default function PopViewerWithModals({
|
|||
overrides: definition.overrides,
|
||||
};
|
||||
|
||||
const detectedMode = currentMode || detectGridMode(viewportWidth);
|
||||
const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth);
|
||||
const isFull = modalWidth >= viewportWidth;
|
||||
const rendererWidth = isFull ? viewportWidth : modalWidth - 32;
|
||||
const isFull = fullscreen || (() => {
|
||||
const detectedMode = currentMode || detectGridMode(viewportWidth);
|
||||
const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth);
|
||||
return modalWidth >= viewportWidth;
|
||||
})();
|
||||
const rendererWidth = isFull
|
||||
? viewportWidth
|
||||
: resolveModalWidth(definition.sizeConfig, currentMode || detectGridMode(viewportWidth), viewportWidth) - 32;
|
||||
const modalWidth = isFull ? viewportWidth : resolveModalWidth(definition.sizeConfig, currentMode || detectGridMode(viewportWidth), viewportWidth);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
|
@ -200,7 +217,7 @@ export default function PopViewerWithModals({
|
|||
>
|
||||
<DialogContent
|
||||
className={isFull
|
||||
? "h-dvh max-h-dvh w-screen max-w-[100vw] overflow-auto rounded-none border-none p-0"
|
||||
? "flex h-dvh max-h-dvh w-screen max-w-[100vw] flex-col gap-0 overflow-hidden rounded-none border-none p-0"
|
||||
: "max-h-[90vh] overflow-auto p-0"
|
||||
}
|
||||
style={isFull ? undefined : {
|
||||
|
|
@ -208,14 +225,13 @@ export default function PopViewerWithModals({
|
|||
width: `${modalWidth}px`,
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
// 최상위 모달이 아니면 overlay 클릭 무시 (하위 모달이 먼저 닫히는 것 방지)
|
||||
if (!isTopModal || !closeOnOverlay) e.preventDefault();
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
if (!isTopModal || !closeOnEsc) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader className={isFull ? "px-4 pt-3 pb-2" : "px-4 pt-4 pb-2"}>
|
||||
<DialogHeader className={isFull ? "shrink-0 border-b px-4 py-2" : "px-4 pt-4 pb-2"}>
|
||||
<DialogTitle className="text-base">
|
||||
{definition.title}
|
||||
</DialogTitle>
|
||||
|
|
|
|||
|
|
@ -26,3 +26,4 @@ import "./pop-status-bar";
|
|||
import "./pop-field";
|
||||
import "./pop-scanner";
|
||||
import "./pop-profile";
|
||||
import "./pop-work-detail";
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export function PopCardListV2Component({
|
|||
currentColSpan,
|
||||
onRequestResize,
|
||||
}: PopCardListV2ComponentProps) {
|
||||
const { subscribe, publish } = usePopEvent(screenId || "default");
|
||||
const { subscribe, publish, setSharedData } = usePopEvent(screenId || "default");
|
||||
const router = useRouter();
|
||||
const { userId: currentUserId } = useAuth();
|
||||
|
||||
|
|
@ -250,6 +250,13 @@ export function PopCardListV2Component({
|
|||
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)) {
|
||||
|
|
@ -268,7 +275,7 @@ export function PopCardListV2Component({
|
|||
} catch {
|
||||
toast.error("POP 화면을 불러오는데 실패했습니다.");
|
||||
}
|
||||
}, []);
|
||||
}, [publish, setSharedData]);
|
||||
|
||||
const handleCardSelect = useCallback((row: RowData) => {
|
||||
|
||||
|
|
@ -1176,6 +1183,7 @@ export function PopCardListV2Component({
|
|||
viewportWidth={typeof window !== "undefined" ? window.innerWidth : 1024}
|
||||
screenId={popModalScreenId}
|
||||
currentMode={detectGridMode(typeof window !== "undefined" ? window.innerWidth : 1024)}
|
||||
parentRow={popModalRow ?? undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useState, useEffect, useRef, useCallback, useMemo, Fragment } from "rea
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -2940,6 +2941,7 @@ function TabActions({
|
|||
onUpdate: (partial: Partial<PopCardListV2Config>) => void;
|
||||
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: "" };
|
||||
|
|
@ -3013,15 +3015,52 @@ function TabActions({
|
|||
</div>
|
||||
{clickAction === "modal-open" && (
|
||||
<div className="mt-2 space-y-1.5 rounded border bg-muted/20 p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">POP 화면 ID</span>
|
||||
<Input
|
||||
value={modalConfig.screenId || ""}
|
||||
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })}
|
||||
placeholder="화면 ID (예: 4481)"
|
||||
className="h-7 flex-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
{/* 모달 캔버스 (디자이너 모드) */}
|
||||
{designerCtx && (
|
||||
<div>
|
||||
{modalConfig.screenId?.startsWith("modal-") ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-[10px]"
|
||||
onClick={() => designerCtx.navigateToCanvas(modalConfig.screenId)}
|
||||
>
|
||||
모달 캔버스 열기
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-[10px]"
|
||||
onClick={() => {
|
||||
const selectedId = designerCtx.selectedComponentId;
|
||||
if (!selectedId) return;
|
||||
const modalId = designerCtx.createModalCanvas(
|
||||
selectedId,
|
||||
modalConfig.modalTitle || "카드 상세"
|
||||
);
|
||||
onUpdate({
|
||||
cardClickModalConfig: { ...modalConfig, screenId: modalId },
|
||||
});
|
||||
}}
|
||||
>
|
||||
모달 캔버스 생성
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 뷰어 모드 또는 직접 입력 폴백 */}
|
||||
{!designerCtx && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">모달 ID</span>
|
||||
<Input
|
||||
value={modalConfig.screenId || ""}
|
||||
onChange={(e) => onUpdate({ cardClickModalConfig: { ...modalConfig, screenId: e.target.value } })}
|
||||
placeholder="모달 ID"
|
||||
className="h-7 flex-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="w-16 shrink-0 text-[9px] text-muted-foreground">모달 제목</span>
|
||||
<Input
|
||||
|
|
|
|||
|
|
@ -0,0 +1,832 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import type { PopWorkDetailConfig } from "../types";
|
||||
import type { TimelineProcessStep } from "../types";
|
||||
|
||||
// ========================================
|
||||
// 타입
|
||||
// ========================================
|
||||
|
||||
type RowData = Record<string, unknown>;
|
||||
|
||||
interface WorkResultRow {
|
||||
id: string;
|
||||
work_order_process_id: string;
|
||||
source_work_item_id: string;
|
||||
source_detail_id: string;
|
||||
work_phase: string;
|
||||
item_title: string;
|
||||
item_sort_order: string;
|
||||
detail_type: string;
|
||||
detail_label: string;
|
||||
detail_sort_order: string;
|
||||
spec_value: string | null;
|
||||
lower_limit: string | null;
|
||||
upper_limit: string | null;
|
||||
input_type: string | null;
|
||||
result_value: string | null;
|
||||
status: string;
|
||||
is_passed: string | null;
|
||||
recorded_by: string | null;
|
||||
recorded_at: string | null;
|
||||
}
|
||||
|
||||
interface WorkGroup {
|
||||
phase: string;
|
||||
title: string;
|
||||
itemId: string;
|
||||
sortOrder: number;
|
||||
total: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
type WorkPhase = "PRE" | "IN" | "POST";
|
||||
const PHASE_ORDER: Record<string, number> = { PRE: 1, IN: 2, POST: 3 };
|
||||
|
||||
interface ProcessTimerData {
|
||||
started_at: string | null;
|
||||
paused_at: string | null;
|
||||
total_paused_time: string | null;
|
||||
status: string;
|
||||
good_qty: string | null;
|
||||
defect_qty: string | null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
// ========================================
|
||||
|
||||
interface PopWorkDetailComponentProps {
|
||||
config?: PopWorkDetailConfig;
|
||||
screenId?: string;
|
||||
componentId?: string;
|
||||
currentRowSpan?: number;
|
||||
currentColSpan?: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 메인 컴포넌트
|
||||
// ========================================
|
||||
|
||||
export function PopWorkDetailComponent({
|
||||
config,
|
||||
screenId,
|
||||
componentId,
|
||||
}: PopWorkDetailComponentProps) {
|
||||
const { getSharedData } = usePopEvent(screenId || "default");
|
||||
const { user } = useAuth();
|
||||
|
||||
const cfg: PopWorkDetailConfig = {
|
||||
showTimer: config?.showTimer ?? true,
|
||||
showQuantityInput: config?.showQuantityInput ?? true,
|
||||
phaseLabels: config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" },
|
||||
};
|
||||
|
||||
// parentRow에서 현재 공정 정보 추출
|
||||
const parentRow = getSharedData<RowData>("parentRow");
|
||||
const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined;
|
||||
const currentProcess = processFlow?.find((p) => p.isCurrent);
|
||||
const workOrderProcessId = currentProcess?.processId
|
||||
? String(currentProcess.processId)
|
||||
: undefined;
|
||||
const processName = currentProcess?.processName ?? "공정 상세";
|
||||
|
||||
// ========================================
|
||||
// 상태
|
||||
// ========================================
|
||||
|
||||
const [allResults, setAllResults] = useState<WorkResultRow[]>([]);
|
||||
const [processData, setProcessData] = useState<ProcessTimerData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const [tick, setTick] = useState(Date.now());
|
||||
const [savingIds, setSavingIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 수량 입력 로컬 상태
|
||||
const [goodQty, setGoodQty] = useState("");
|
||||
const [defectQty, setDefectQty] = useState("");
|
||||
|
||||
// ========================================
|
||||
// D-FE1: 데이터 로드
|
||||
// ========================================
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!workOrderProcessId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const [resultRes, processRes] = await Promise.all([
|
||||
dataApi.getTableData("process_work_result", {
|
||||
size: 500,
|
||||
filters: { work_order_process_id: workOrderProcessId },
|
||||
}),
|
||||
dataApi.getTableData("work_order_process", {
|
||||
size: 1,
|
||||
filters: { id: workOrderProcessId },
|
||||
}),
|
||||
]);
|
||||
|
||||
setAllResults((resultRes.data ?? []) as unknown as WorkResultRow[]);
|
||||
|
||||
const proc = (processRes.data?.[0] ?? null) as ProcessTimerData | null;
|
||||
setProcessData(proc);
|
||||
if (proc) {
|
||||
setGoodQty(proc.good_qty ?? "");
|
||||
setDefectQty(proc.defect_qty ?? "");
|
||||
}
|
||||
} catch {
|
||||
toast.error("데이터를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workOrderProcessId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// ========================================
|
||||
// D-FE2: 좌측 사이드바 - 작업항목 그룹핑
|
||||
// ========================================
|
||||
|
||||
const groups = useMemo<WorkGroup[]>(() => {
|
||||
const map = new Map<string, WorkGroup>();
|
||||
for (const row of allResults) {
|
||||
const key = row.source_work_item_id;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
phase: row.work_phase,
|
||||
title: row.item_title,
|
||||
itemId: key,
|
||||
sortOrder: parseInt(row.item_sort_order || "0", 10),
|
||||
total: 0,
|
||||
completed: 0,
|
||||
});
|
||||
}
|
||||
const g = map.get(key)!;
|
||||
g.total++;
|
||||
if (row.status === "completed") g.completed++;
|
||||
}
|
||||
return Array.from(map.values()).sort(
|
||||
(a, b) =>
|
||||
(PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) ||
|
||||
a.sortOrder - b.sortOrder
|
||||
);
|
||||
}, [allResults]);
|
||||
|
||||
// phase별로 그룹핑
|
||||
const groupsByPhase = useMemo(() => {
|
||||
const result: Record<string, WorkGroup[]> = {};
|
||||
for (const g of groups) {
|
||||
if (!result[g.phase]) result[g.phase] = [];
|
||||
result[g.phase].push(g);
|
||||
}
|
||||
return result;
|
||||
}, [groups]);
|
||||
|
||||
// 첫 그룹 자동 선택
|
||||
useEffect(() => {
|
||||
if (groups.length > 0 && !selectedGroupId) {
|
||||
setSelectedGroupId(groups[0].itemId);
|
||||
}
|
||||
}, [groups, selectedGroupId]);
|
||||
|
||||
// ========================================
|
||||
// D-FE3: 우측 체크리스트
|
||||
// ========================================
|
||||
|
||||
const currentItems = useMemo(
|
||||
() =>
|
||||
allResults
|
||||
.filter((r) => r.source_work_item_id === selectedGroupId)
|
||||
.sort((a, b) => parseInt(a.detail_sort_order || "0", 10) - parseInt(b.detail_sort_order || "0", 10)),
|
||||
[allResults, selectedGroupId]
|
||||
);
|
||||
|
||||
const saveResultValue = useCallback(
|
||||
async (
|
||||
rowId: string,
|
||||
resultValue: string,
|
||||
isPassed: string | null,
|
||||
newStatus: string
|
||||
) => {
|
||||
setSavingIds((prev) => new Set(prev).add(rowId));
|
||||
try {
|
||||
await apiClient.post("/pop/execute-action", {
|
||||
tasks: [
|
||||
{ type: "data-update", targetTable: "process_work_result", targetColumn: "result_value", value: resultValue, items: [{ id: rowId }] },
|
||||
{ type: "data-update", targetTable: "process_work_result", targetColumn: "status", value: newStatus, items: [{ id: rowId }] },
|
||||
...(isPassed !== null
|
||||
? [{ type: "data-update", targetTable: "process_work_result", targetColumn: "is_passed", value: isPassed, items: [{ id: rowId }] }]
|
||||
: []),
|
||||
{ type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_by", value: user?.userId ?? "", items: [{ id: rowId }] },
|
||||
{ type: "data-update", targetTable: "process_work_result", targetColumn: "recorded_at", value: new Date().toISOString(), items: [{ id: rowId }] },
|
||||
],
|
||||
data: { items: [{ id: rowId }], fieldValues: {} },
|
||||
});
|
||||
|
||||
setAllResults((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === rowId
|
||||
? {
|
||||
...r,
|
||||
result_value: resultValue,
|
||||
status: newStatus,
|
||||
is_passed: isPassed,
|
||||
recorded_by: user?.userId ?? null,
|
||||
recorded_at: new Date().toISOString(),
|
||||
}
|
||||
: r
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
toast.error("저장에 실패했습니다.");
|
||||
} finally {
|
||||
setSavingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(rowId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[user?.userId]
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// D-FE4: 타이머
|
||||
// ========================================
|
||||
|
||||
useEffect(() => {
|
||||
if (!cfg.showTimer || !processData?.started_at) return;
|
||||
const id = setInterval(() => setTick(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [cfg.showTimer, processData?.started_at]);
|
||||
|
||||
const elapsedMs = useMemo(() => {
|
||||
if (!processData?.started_at) return 0;
|
||||
const now = tick;
|
||||
const totalMs = now - new Date(processData.started_at).getTime();
|
||||
const pausedSec = parseInt(processData.total_paused_time || "0", 10);
|
||||
const currentPauseMs = processData.paused_at
|
||||
? now - new Date(processData.paused_at).getTime()
|
||||
: 0;
|
||||
return Math.max(0, totalMs - pausedSec * 1000 - currentPauseMs);
|
||||
}, [processData?.started_at, processData?.paused_at, processData?.total_paused_time, tick]);
|
||||
|
||||
const formattedTime = useMemo(() => {
|
||||
const totalSec = Math.floor(elapsedMs / 1000);
|
||||
const h = String(Math.floor(totalSec / 3600)).padStart(2, "0");
|
||||
const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, "0");
|
||||
const s = String(totalSec % 60).padStart(2, "0");
|
||||
return `${h}:${m}:${s}`;
|
||||
}, [elapsedMs]);
|
||||
|
||||
const isPaused = !!processData?.paused_at;
|
||||
const isStarted = !!processData?.started_at;
|
||||
|
||||
const handleTimerAction = useCallback(
|
||||
async (action: "start" | "pause" | "resume") => {
|
||||
if (!workOrderProcessId) return;
|
||||
try {
|
||||
await apiClient.post("/api/pop/production/timer", {
|
||||
workOrderProcessId,
|
||||
action,
|
||||
});
|
||||
// 타이머 상태 새로고침
|
||||
const res = await dataApi.getTableData("work_order_process", {
|
||||
size: 1,
|
||||
filters: { id: workOrderProcessId },
|
||||
});
|
||||
const proc = (res.data?.[0] ?? null) as ProcessTimerData | null;
|
||||
if (proc) setProcessData(proc);
|
||||
} catch {
|
||||
toast.error("타이머 제어에 실패했습니다.");
|
||||
}
|
||||
},
|
||||
[workOrderProcessId]
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// D-FE5: 수량 등록 + 완료
|
||||
// ========================================
|
||||
|
||||
const handleQuantityRegister = useCallback(async () => {
|
||||
if (!workOrderProcessId) return;
|
||||
try {
|
||||
await apiClient.post("/pop/execute-action", {
|
||||
tasks: [
|
||||
{ type: "data-update", targetTable: "work_order_process", targetColumn: "good_qty", value: goodQty || "0", items: [{ id: workOrderProcessId }] },
|
||||
{ type: "data-update", targetTable: "work_order_process", targetColumn: "defect_qty", value: defectQty || "0", items: [{ id: workOrderProcessId }] },
|
||||
],
|
||||
data: { items: [{ id: workOrderProcessId }], fieldValues: {} },
|
||||
});
|
||||
toast.success("수량이 등록되었습니다.");
|
||||
} catch {
|
||||
toast.error("수량 등록에 실패했습니다.");
|
||||
}
|
||||
}, [workOrderProcessId, goodQty, defectQty]);
|
||||
|
||||
const handleProcessComplete = useCallback(async () => {
|
||||
if (!workOrderProcessId) return;
|
||||
try {
|
||||
await apiClient.post("/pop/execute-action", {
|
||||
tasks: [
|
||||
{ type: "data-update", targetTable: "work_order_process", targetColumn: "status", value: "completed", items: [{ id: workOrderProcessId }] },
|
||||
{ type: "data-update", targetTable: "work_order_process", targetColumn: "completed_at", value: new Date().toISOString(), items: [{ id: workOrderProcessId }] },
|
||||
],
|
||||
data: { items: [{ id: workOrderProcessId }], fieldValues: {} },
|
||||
});
|
||||
toast.success("공정이 완료되었습니다.");
|
||||
setProcessData((prev) =>
|
||||
prev ? { ...prev, status: "completed" } : prev
|
||||
);
|
||||
} catch {
|
||||
toast.error("공정 완료 처리에 실패했습니다.");
|
||||
}
|
||||
}, [workOrderProcessId]);
|
||||
|
||||
// ========================================
|
||||
// 안전 장치
|
||||
// ========================================
|
||||
|
||||
if (!parentRow) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<AlertCircle className="mr-2 h-4 w-4" />
|
||||
카드를 선택해주세요
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!workOrderProcessId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<AlertCircle className="mr-2 h-4 w-4" />
|
||||
공정 정보를 찾을 수 없습니다
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (allResults.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
<AlertCircle className="mr-2 h-4 w-4" />
|
||||
작업기준이 등록되지 않았습니다
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isProcessCompleted = processData?.status === "completed";
|
||||
|
||||
// ========================================
|
||||
// 렌더링
|
||||
// ========================================
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<h3 className="text-sm font-semibold">{processName}</h3>
|
||||
{cfg.showTimer && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Timer className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-mono text-sm font-medium tabular-nums">
|
||||
{formattedTime}
|
||||
</span>
|
||||
{!isProcessCompleted && (
|
||||
<>
|
||||
{!isStarted && (
|
||||
<Button size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => handleTimerAction("start")}>
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
시작
|
||||
</Button>
|
||||
)}
|
||||
{isStarted && !isPaused && (
|
||||
<Button size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => handleTimerAction("pause")}>
|
||||
<Pause className="mr-1 h-3 w-3" />
|
||||
정지
|
||||
</Button>
|
||||
)}
|
||||
{isStarted && isPaused && (
|
||||
<Button size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => handleTimerAction("resume")}>
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
재개
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 본문: 좌측 사이드바 + 우측 체크리스트 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 좌측 사이드바 */}
|
||||
<div className="w-40 shrink-0 overflow-y-auto border-r bg-muted/30">
|
||||
{(["PRE", "IN", "POST"] as WorkPhase[]).map((phase) => {
|
||||
const phaseGroups = groupsByPhase[phase];
|
||||
if (!phaseGroups || phaseGroups.length === 0) return null;
|
||||
return (
|
||||
<div key={phase}>
|
||||
<div className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase text-muted-foreground">
|
||||
{cfg.phaseLabels[phase] ?? phase}
|
||||
</div>
|
||||
{phaseGroups.map((g) => (
|
||||
<button
|
||||
key={g.itemId}
|
||||
className={cn(
|
||||
"flex w-full flex-col px-3 py-1.5 text-left transition-colors",
|
||||
selectedGroupId === g.itemId
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-muted/60"
|
||||
)}
|
||||
onClick={() => setSelectedGroupId(g.itemId)}
|
||||
>
|
||||
<span className="text-xs font-medium leading-tight">
|
||||
{g.title}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{g.completed}/{g.total} 완료
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 우측 체크리스트 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{selectedGroupId && (
|
||||
<div className="space-y-2">
|
||||
{currentItems.map((item) => (
|
||||
<ChecklistItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
saving={savingIds.has(item.id)}
|
||||
disabled={isProcessCompleted}
|
||||
onSave={saveResultValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단: 수량 입력 + 완료 */}
|
||||
{cfg.showQuantityInput && (
|
||||
<div className="flex items-center gap-2 border-t px-3 py-2">
|
||||
<Package className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">양품</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-7 w-20 text-xs"
|
||||
value={goodQty}
|
||||
onChange={(e) => setGoodQty(e.target.value)}
|
||||
disabled={isProcessCompleted}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">불량</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-7 w-20 text-xs"
|
||||
value={defectQty}
|
||||
onChange={(e) => setDefectQty(e.target.value)}
|
||||
disabled={isProcessCompleted}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleQuantityRegister}
|
||||
disabled={isProcessCompleted}
|
||||
>
|
||||
수량 등록
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
{!isProcessCompleted && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7 text-xs"
|
||||
onClick={handleProcessComplete}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
공정 완료
|
||||
</Button>
|
||||
)}
|
||||
{isProcessCompleted && (
|
||||
<Badge variant="outline" className="text-xs text-green-600">
|
||||
완료됨
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 체크리스트 개별 항목
|
||||
// ========================================
|
||||
|
||||
interface ChecklistItemProps {
|
||||
item: WorkResultRow;
|
||||
saving: boolean;
|
||||
disabled: boolean;
|
||||
onSave: (
|
||||
rowId: string,
|
||||
resultValue: string,
|
||||
isPassed: string | null,
|
||||
newStatus: string
|
||||
) => void;
|
||||
}
|
||||
|
||||
function ChecklistItem({ item, saving, disabled, onSave }: ChecklistItemProps) {
|
||||
const isSaving = saving;
|
||||
const isDisabled = disabled || isSaving;
|
||||
|
||||
switch (item.detail_type) {
|
||||
case "check":
|
||||
return <CheckItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||||
case "inspect":
|
||||
return <InspectItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||||
case "input":
|
||||
return <InputItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||||
case "procedure":
|
||||
return <ProcedureItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||||
case "material":
|
||||
return <MaterialItem item={item} disabled={isDisabled} saving={isSaving} onSave={onSave} />;
|
||||
default:
|
||||
return (
|
||||
<div className="rounded border p-2 text-xs text-muted-foreground">
|
||||
알 수 없는 유형: {item.detail_type}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== check: 체크박스 =====
|
||||
|
||||
function CheckItem({
|
||||
item,
|
||||
disabled,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
item: WorkResultRow;
|
||||
disabled: boolean;
|
||||
saving: boolean;
|
||||
onSave: ChecklistItemProps["onSave"];
|
||||
}) {
|
||||
const checked = item.result_value === "Y";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded border px-3 py-2",
|
||||
item.status === "completed" && "bg-green-50 border-green-200"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(v) => {
|
||||
const val = v ? "Y" : "N";
|
||||
onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending");
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 text-xs">{item.detail_label}</span>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{item.status === "completed" && !saving && (
|
||||
<Badge variant="outline" className="text-[10px] text-green-600">
|
||||
완료
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== inspect: 측정값 입력 (범위 판정) =====
|
||||
|
||||
function InspectItem({
|
||||
item,
|
||||
disabled,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
item: WorkResultRow;
|
||||
disabled: boolean;
|
||||
saving: boolean;
|
||||
onSave: ChecklistItemProps["onSave"];
|
||||
}) {
|
||||
const [inputVal, setInputVal] = useState(item.result_value ?? "");
|
||||
const lower = parseFloat(item.lower_limit ?? "");
|
||||
const upper = parseFloat(item.upper_limit ?? "");
|
||||
const hasRange = !isNaN(lower) && !isNaN(upper);
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!inputVal || disabled) return;
|
||||
const numVal = parseFloat(inputVal);
|
||||
let passed: string | null = null;
|
||||
if (hasRange) {
|
||||
passed = numVal >= lower && numVal <= upper ? "Y" : "N";
|
||||
}
|
||||
onSave(item.id, inputVal, passed, "completed");
|
||||
};
|
||||
|
||||
const isPassed = item.is_passed;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border px-3 py-2",
|
||||
isPassed === "Y" && "bg-green-50 border-green-200",
|
||||
isPassed === "N" && "bg-red-50 border-red-200"
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-medium">{item.detail_label}</span>
|
||||
{hasRange && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
기준: {item.lower_limit} ~ {item.upper_limit}
|
||||
{item.spec_value ? ` (표준: ${item.spec_value})` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
className="h-7 w-28 text-xs"
|
||||
value={inputVal}
|
||||
onChange={(e) => setInputVal(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
placeholder="측정값 입력"
|
||||
/>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{isPassed === "Y" && !saving && (
|
||||
<Badge variant="outline" className="text-[10px] text-green-600">합격</Badge>
|
||||
)}
|
||||
{isPassed === "N" && !saving && (
|
||||
<Badge variant="outline" className="text-[10px] text-red-600">불합격</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== input: 자유 입력 =====
|
||||
|
||||
function InputItem({
|
||||
item,
|
||||
disabled,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
item: WorkResultRow;
|
||||
disabled: boolean;
|
||||
saving: boolean;
|
||||
onSave: ChecklistItemProps["onSave"];
|
||||
}) {
|
||||
const [inputVal, setInputVal] = useState(item.result_value ?? "");
|
||||
const inputType = item.input_type === "number" ? "number" : "text";
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!inputVal || disabled) return;
|
||||
onSave(item.id, inputVal, null, "completed");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border px-3 py-2",
|
||||
item.status === "completed" && "bg-green-50 border-green-200"
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 text-xs font-medium">{item.detail_label}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type={inputType}
|
||||
className="h-7 flex-1 text-xs"
|
||||
value={inputVal}
|
||||
onChange={(e) => setInputVal(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
placeholder="값 입력"
|
||||
/>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== procedure: 절차 확인 (읽기 전용 + 체크) =====
|
||||
|
||||
function ProcedureItem({
|
||||
item,
|
||||
disabled,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
item: WorkResultRow;
|
||||
disabled: boolean;
|
||||
saving: boolean;
|
||||
onSave: ChecklistItemProps["onSave"];
|
||||
}) {
|
||||
const checked = item.result_value === "Y";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border px-3 py-2",
|
||||
item.status === "completed" && "bg-green-50 border-green-200"
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 text-xs text-muted-foreground">
|
||||
{item.spec_value || item.detail_label}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(v) => {
|
||||
onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending");
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">확인</span>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== material: 자재/LOT 입력 =====
|
||||
|
||||
function MaterialItem({
|
||||
item,
|
||||
disabled,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
item: WorkResultRow;
|
||||
disabled: boolean;
|
||||
saving: boolean;
|
||||
onSave: ChecklistItemProps["onSave"];
|
||||
}) {
|
||||
const [inputVal, setInputVal] = useState(item.result_value ?? "");
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!inputVal || disabled) return;
|
||||
onSave(item.id, inputVal, null, "completed");
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border px-3 py-2",
|
||||
item.status === "completed" && "bg-green-50 border-green-200"
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 text-xs font-medium">{item.detail_label}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="h-7 flex-1 text-xs"
|
||||
value={inputVal}
|
||||
onChange={(e) => setInputVal(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
placeholder="LOT 번호 입력"
|
||||
/>
|
||||
{saving && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { PopWorkDetailConfig } from "../types";
|
||||
|
||||
interface PopWorkDetailConfigPanelProps {
|
||||
config?: PopWorkDetailConfig;
|
||||
onChange?: (config: PopWorkDetailConfig) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_PHASE_LABELS: Record<string, string> = {
|
||||
PRE: "작업 전",
|
||||
IN: "작업 중",
|
||||
POST: "작업 후",
|
||||
};
|
||||
|
||||
export function PopWorkDetailConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
}: PopWorkDetailConfigPanelProps) {
|
||||
const cfg: PopWorkDetailConfig = {
|
||||
showTimer: config?.showTimer ?? true,
|
||||
showQuantityInput: config?.showQuantityInput ?? true,
|
||||
phaseLabels: config?.phaseLabels ?? { ...DEFAULT_PHASE_LABELS },
|
||||
};
|
||||
|
||||
const update = (partial: Partial<PopWorkDetailConfig>) => {
|
||||
onChange?.({ ...cfg, ...partial });
|
||||
};
|
||||
|
||||
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="flex items-center justify-between">
|
||||
<Label className="text-xs sm:text-sm">수량 입력 표시</Label>
|
||||
<Switch
|
||||
checked={cfg.showQuantityInput}
|
||||
onCheckedChange={(v) => update({ showQuantityInput: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">단계 라벨</Label>
|
||||
{(["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>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={cfg.phaseLabels[phase] ?? DEFAULT_PHASE_LABELS[phase]}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { ClipboardCheck } from "lucide-react";
|
||||
import type { PopWorkDetailConfig } from "../types";
|
||||
|
||||
interface PopWorkDetailPreviewProps {
|
||||
config?: PopWorkDetailConfig;
|
||||
}
|
||||
|
||||
export function PopWorkDetailPreviewComponent({ config }: PopWorkDetailPreviewProps) {
|
||||
const labels = config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" };
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
||||
<ClipboardCheck className="h-6 w-6 text-muted-foreground" />
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
작업 상세
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{Object.values(labels).map((l) => (
|
||||
<span
|
||||
key={l}
|
||||
className="rounded bg-muted/60 px-1.5 py-0.5 text-[8px] text-muted-foreground"
|
||||
>
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { PopComponentRegistry } from "../../PopComponentRegistry";
|
||||
import { PopWorkDetailComponent } from "./PopWorkDetailComponent";
|
||||
import { PopWorkDetailConfigPanel } from "./PopWorkDetailConfig";
|
||||
import { PopWorkDetailPreviewComponent } from "./PopWorkDetailPreview";
|
||||
import type { PopWorkDetailConfig } from "../types";
|
||||
|
||||
const defaultConfig: PopWorkDetailConfig = {
|
||||
showTimer: true,
|
||||
showQuantityInput: true,
|
||||
phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" },
|
||||
};
|
||||
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-work-detail",
|
||||
name: "작업 상세",
|
||||
description: "공정별 체크리스트/검사/실적 상세 작업 화면",
|
||||
category: "display",
|
||||
icon: "ClipboardCheck",
|
||||
component: PopWorkDetailComponent,
|
||||
configPanel: PopWorkDetailConfigPanel,
|
||||
preview: PopWorkDetailPreviewComponent,
|
||||
defaultProps: defaultConfig,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{
|
||||
key: "process_completed",
|
||||
label: "공정 완료",
|
||||
type: "event",
|
||||
category: "event",
|
||||
description: "공정 작업 전체 완료 이벤트",
|
||||
},
|
||||
],
|
||||
receivable: [],
|
||||
},
|
||||
touchOptimized: true,
|
||||
supportedDevices: ["mobile", "tablet"],
|
||||
});
|
||||
|
|
@ -1000,3 +1000,14 @@ export const VIRTUAL_SUB_STATUS = "__subStatus__" as const;
|
|||
export const VIRTUAL_SUB_SEMANTIC = "__subSemantic__" as const;
|
||||
export const VIRTUAL_SUB_PROCESS = "__subProcessName__" as const;
|
||||
export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const;
|
||||
|
||||
|
||||
// =============================================
|
||||
// pop-work-detail 전용 타입
|
||||
// =============================================
|
||||
|
||||
export interface PopWorkDetailConfig {
|
||||
showTimer: boolean;
|
||||
showQuantityInput: boolean;
|
||||
phaseLabels: Record<string, string>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue