From 8ee10e411e2cdef802b3c1d154112e98abd01732 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 16 Mar 2026 12:09:37 +0900 Subject: [PATCH 01/15] =?UTF-8?q?fix:=20POP=20=EB=B7=B0=EC=96=B4=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=A0=9C=EA=B1=B0=20+=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B4=EB=84=88-=EB=B7=B0=EC=96=B4=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=20=EC=B9=B8=20=EC=88=98=20=EB=B6=88=EC=9D=BC?= =?UTF-8?q?=EC=B9=98=20=EC=88=98=EC=A0=95=20POP=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=83=81=EB=8B=A8=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=B0=94(POP=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C/PC=20=EB=AA=A8=EB=93=9C=20=EC=A0=84=ED=99=98)?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0,=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B4=EB=84=88=EC=99=80=20=EB=B7=B0=EC=96=B4?= =?UTF-8?q?=EC=9D=98=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=B9=B8=20=EC=88=98?= =?UTF-8?q?=EA=B0=80=20=EB=8B=AC=EB=9D=BC=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=B0=EC=B9=98=EA=B0=80=20=EC=96=B4=EA=B8=8B?= =?UTF-8?q?=EB=82=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4.=20[=ED=97=A4=EB=8D=94=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0]=20-=20"POP=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?|=20=ED=99=94=EB=A9=B4=EC=9D=B4=EB=A6=84=20|=20PC=20=EB=AA=A8?= =?UTF-8?q?=EB=93=9C"=20=EB=B0=94=20=EC=82=AD=EC=A0=9C=20(pop-profile=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A1=9C=20=EB=8C=80?= =?UTF-8?q?=EC=B2=B4)=20-=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20import=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20(LayoutGrid,=20Monitor,=20GAP=5FPRESETS,?= =?UTF-8?q?=20GRID=5FBREAKPOINTS)=20[=EA=B7=B8=EB=A6=AC=EB=93=9C=20?= =?UTF-8?q?=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=88=98=EC=A0=95]=20-=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C:=20=EB=94=94=EC=9E=90=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=ED=83=9C=EB=B8=94=EB=A6=BF=20=EA=B0=80=EB=A1=9C=3D1024px(38?= =?UTF-8?q?=EC=B9=B8),=20=EB=B7=B0=EC=96=B4=20window.innerWidth=3D1366px(5?= =?UTF-8?q?2=EC=B9=B8)=20-=20=EC=88=98=EC=A0=95:=20=EB=B7=B0=EC=96=B4?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=AA=A8=EB=93=9C=EB=B3=84=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=EB=84=88=EB=B9=84=20=EA=B3=A0=EC=A0=95=20(tablet?= =?UTF-8?q?=5Flandscape=3D1024,=20tablet=5Fportrait=3D820,=20=20=20mobile?= =?UTF-8?q?=5Flandscape=3D600,=20mobile=5Fportrait=3D375)=20-=20rawWidth?= =?UTF-8?q?=EB=8A=94=20=EB=AA=A8=EB=93=9C=20=EA=B0=90=EC=A7=80=20=EC=9A=A9?= =?UTF-8?q?=EB=8F=84=EB=A1=9C=EB=A7=8C=20=EC=82=AC=EC=9A=A9,=20viewportWid?= =?UTF-8?q?th=EB=8A=94=20=EB=94=94=EC=9E=90=EC=9D=B4=EB=84=88=EC=99=80=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=ED=95=9C=20=EA=B8=B0=EC=A4=80=20=EB=84=88?= =?UTF-8?q?=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(pop)/pop/screens/[screenId]/page.tsx | 57 ++++++++----------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index c7933033..dee674f1 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useState } from "react"; import { useParams, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; -import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw, LayoutGrid, Monitor } from "lucide-react"; +import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { useRouter } from "next/navigation"; @@ -21,8 +21,6 @@ import { GridMode, isPopLayout, createEmptyLayout, - GAP_PRESETS, - GRID_BREAKPOINTS, BLOCK_GAP, BLOCK_PADDING, detectGridMode, @@ -86,26 +84,32 @@ function PopScreenViewPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px) - const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로 - - // 모드 결정: - // - 프리뷰 모드: 수동 선택한 device/orientation 사용 - // - 일반 모드: 화면 너비 기준으로 자동 결정 (GRID_BREAKPOINTS와 일치) - const currentModeKey = isPreviewMode - ? getModeKey(deviceType, isLandscape) - : detectGridMode(viewportWidth); + // 실제 브라우저 너비 (모드 감지용) + const [rawWidth, setRawWidth] = useState(1024); useEffect(() => { - const updateViewportWidth = () => { - setViewportWidth(Math.min(window.innerWidth, 1366)); - }; - - updateViewportWidth(); - window.addEventListener("resize", updateViewportWidth); - return () => window.removeEventListener("resize", updateViewportWidth); + const updateWidth = () => setRawWidth(window.innerWidth); + updateWidth(); + window.addEventListener("resize", updateWidth); + return () => window.removeEventListener("resize", updateWidth); }, []); + // 모드 결정 + const currentModeKey = isPreviewMode + ? getModeKey(deviceType, isLandscape) + : detectGridMode(rawWidth); + + // 디자이너와 동일한 기준 너비 사용 (모드별 고정 너비) + const MODE_REFERENCE_WIDTH: Record = { + mobile_portrait: 375, + mobile_landscape: 600, + tablet_portrait: 820, + tablet_landscape: 1024, + }; + const viewportWidth = isPreviewMode + ? DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"].width + : MODE_REFERENCE_WIDTH[currentModeKey]; + // 화면 및 POP 레이아웃 로드 useEffect(() => { const loadScreen = async () => { @@ -288,20 +292,7 @@ function PopScreenViewPage() { )} - {/* 일반 모드 네비게이션 바 */} - {!isPreviewMode && ( -
- - {screen.screenName} - -
- )} + {/* 일반 모드 네비게이션 바 제거 (프로필 컴포넌트에서 PC 모드 전환 가능) */} {/* POP 화면 컨텐츠 */}
From 230d35b03aaeb83c7198b5dcbb164c65e17c6e2c Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 16 Mar 2026 16:24:27 +0900 Subject: [PATCH 02/15] =?UTF-8?q?fix:=20PopFieldConfig=20JsonKeySelect=20-?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=86=EC=9D=84=20=EB=95=8C?= =?UTF-8?q?=EB=8F=84=20Combobox=20UI=20=EC=9C=A0=EC=A7=80=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EC=97=90=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EA=B0=80=200=EA=B1=B4=EC=9D=BC=20=EB=95=8C=20JsonKeySelect?= =?UTF-8?q?=EA=B0=80=20plain=20Input=EC=9C=BC=EB=A1=9C=20=ED=8F=B4?= =?UTF-8?q?=EB=B0=B1=EB=90=98=EC=96=B4=20=EC=84=A4=EA=B3=84=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=EC=97=90=EC=84=9C=20Select=20=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=EA=B0=80=20=ED=91=9C=EC=8B=9C=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.=20[JsonKeySelect=20=EA=B0=9C=EC=84=A0]=20-?= =?UTF-8?q?=20=ED=95=AD=EC=83=81=20Combobox(Popover=20+=20Command)=20UI?= =?UTF-8?q?=EB=A1=9C=20=EB=A0=8C=EB=8D=94=EB=A7=81=20-=20keys=20=EC=9E=88?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C:=20=EA=B8=B0=EC=A1=B4=EA=B3=BC=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=ED=95=9C=20=EC=9E=90=EB=8F=99=EC=99=84?= =?UTF-8?q?=EC=84=B1=20=EB=AA=A9=EB=A1=9D=20+=20=EA=B2=80=EC=83=89=20-=20k?= =?UTF-8?q?eys=20=EC=97=86=EC=9D=84=20=EB=95=8C:=20"=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EC=97=90=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EA=B0=80=20?= =?UTF-8?q?=EC=97=86=EC=8A=B5=EB=8B=88=EB=8B=A4"=20=EC=95=88=EB=82=B4=20+?= =?UTF-8?q?=20Enter=EB=A1=9C=20=EC=A7=81=EC=A0=91=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=ED=99=95=EC=A0=95=20-=20=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=97=86=EC=9D=84=20=EB=95=8C=EB=8F=84=20Enter?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=90=EC=9C=A0=20=EC=9E=85=EB=A0=A5=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=20[updateSaveMapping=20=EA=B2=BD=ED=95=A9=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=88=98=EC=A0=95]=20-=20onUpdateConfig=20?= =?UTF-8?q?=EB=91=90=20=EB=B2=88=20=EC=97=B0=EC=86=8D=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=20=EC=8B=9C=20React=20batching=EC=9C=BC=EB=A1=9C=20=EC=B2=AB?= =?UTF-8?q?=20=EB=B2=88=EC=A7=B8=20=ED=98=B8=EC=B6=9C=EC=9D=B4=20=20=20?= =?UTF-8?q?=EB=8D=AE=EC=96=B4=EC=93=B0=EC=97=AC=EC=A7=80=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95=20-=20syncAndUpdateSave?= =?UTF-8?q?Mappings=EC=97=90=20extraPartial=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=20=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=88=EC=9D=98=20onUpdateConfig=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EB=A1=9C=20=EB=B3=91=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pop-field/PopFieldConfig.tsx | 128 +++++++++++------- 1 file changed, 78 insertions(+), 50 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx index 8e0f8ad1..965bccee 100644 --- a/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-field/PopFieldConfig.tsx @@ -356,7 +356,10 @@ function SaveTabContent({ }; const syncAndUpdateSaveMappings = useCallback( - (updater?: (prev: PopFieldSaveMapping[]) => PopFieldSaveMapping[]) => { + ( + updater?: (prev: PopFieldSaveMapping[]) => PopFieldSaveMapping[], + extraPartial?: Partial, + ) => { const fieldIds = new Set(allFields.map(({ field }) => field.id)); const prev = saveMappings.filter((m) => fieldIds.has(m.fieldId)); const next = updater ? updater(prev) : prev; @@ -381,6 +384,7 @@ function SaveTabContent({ tableName: saveTableName, fieldMappings: merged, }, + ...extraPartial, }); } }, @@ -395,22 +399,27 @@ function SaveTabContent({ const updateSaveMapping = useCallback( (fieldId: string, partial: Partial) => { - syncAndUpdateSaveMappings((prev) => - prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m)) - ); + let extraPartial: Partial | undefined; if (partial.targetColumn !== undefined) { const newFieldName = partial.targetColumn || ""; - const sections = cfg.sections.map((s) => ({ - ...s, - fields: (s.fields ?? []).map((f) => - f.id === fieldId ? { ...f, fieldName: newFieldName } : f - ), - })); - onUpdateConfig({ sections }); + extraPartial = { + sections: cfg.sections.map((s) => ({ + ...s, + fields: (s.fields ?? []).map((f) => + f.id === fieldId ? { ...f, fieldName: newFieldName } : f + ), + })), + }; } + + syncAndUpdateSaveMappings( + (prev) => + prev.map((m) => (m.fieldId === fieldId ? { ...m, ...partial } : m)), + extraPartial, + ); }, - [syncAndUpdateSaveMappings, cfg, onUpdateConfig] + [syncAndUpdateSaveMappings, cfg.sections] ); // --- 숨은 필드 매핑 로직 --- @@ -2086,23 +2095,24 @@ function JsonKeySelect({ onOpen?: () => void; }) { const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(""); const handleOpenChange = (nextOpen: boolean) => { setOpen(nextOpen); - if (nextOpen) onOpen?.(); + if (nextOpen) { + onOpen?.(); + setInputValue(""); + } }; - if (keys.length === 0 && !value) { - return ( - onValueChange(e.target.value)} - onFocus={() => onOpen?.()} - className="h-7 w-24 text-xs" - /> - ); - } + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && inputValue.trim()) { + e.preventDefault(); + onValueChange(inputValue.trim()); + setInputValue(""); + setOpen(false); + } + }; return ( @@ -2117,33 +2127,51 @@ function JsonKeySelect({ - - + 0}> + 0 ? "키 검색..." : "키 직접 입력..."} + className="text-xs" + value={inputValue} + onValueChange={setInputValue} + onKeyDown={handleInputKeyDown} + /> - - {keys.length === 0 ? "데이터를 불러오는 중..." : "일치하는 키가 없습니다."} - - - {keys.map((k) => ( - { - onValueChange(v === value ? "" : v); - setOpen(false); - }} - className="text-xs" - > - - {k} - - ))} - + {keys.length === 0 ? ( +
+ {inputValue.trim() + ? "Enter로 입력 확정" + : "테이블에 데이터가 없습니다. 키를 직접 입력하세요."} +
+ ) : ( + <> + + {inputValue.trim() + ? "Enter로 직접 입력 확정" + : "일치하는 키가 없습니다."} + + + {keys.map((k) => ( + { + onValueChange(v === value ? "" : v); + setOpen(false); + }} + className="text-xs" + > + + {k} + + ))} + + + )}
From 06c52b422f9a3fcdb96ce7443422989f9b7fdfdb Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 17 Mar 2026 09:32:59 +0900 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20BLOCK=20DETAIL=20Phase=204=20+=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94=20-=20=EA=B7=B8=EB=A3=B9=EB=B3=84?= =?UTF-8?q?=20=ED=83=80=EC=9D=B4=EB=A8=B8,=20=ED=84=B0=EC=B9=98=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20UI,=20DB=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20pop-work-detail=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=EB=B3=84=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=EA=B3=BC=20=ED=84=B0=EC=B9=98=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94=20UI=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=98?= =?UTF-8?q?=EA=B3=A0,=20=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=EA=B0=80=20DB=EC=97=90=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EB=B2=84=EA=B7=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=88=98=EC=A0=95=ED=95=98=EC=97=AC=20=EC=95=88?= =?UTF-8?q?=EC=A0=95=ED=99=94=EB=A5=BC=20=EC=99=84=EB=A3=8C=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20[=EA=B7=B8=EB=A3=B9=EB=B3=84=20=ED=83=80=EC=9D=B4?= =?UTF-8?q?=EB=A8=B8]=20-=20group-timer=20API=20=EC=8B=A0=EA=B7=9C:=20star?= =?UTF-8?q?t/pause/resume/complete=20=EC=95=A1=EC=85=98=20(popProductionCo?= =?UTF-8?q?ntroller)=20-=20process=5Fwork=5Fresult=EC=97=90=20group=5Fstar?= =?UTF-8?q?ted=5Fat/paused=5Fat/total=5Fpaused=5Ftime/completed=5Fat=20?= =?UTF-8?q?=ED=99=9C=EC=9A=A9=20-=20GroupTimerHeader=20UI:=20=EC=88=9C?= =?UTF-8?q?=EC=88=98=20=EC=9E=91=EC=97=85=EC=8B=9C=EA=B0=84=20+=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=BC=EC=8B=9C=EA=B0=84=20=EC=9D=B4=EC=A4=91=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20-=20=EC=B2=AB=20=EA=B7=B8=EB=A3=B9=20"?= =?UTF-8?q?=EC=8B=9C=EC=9E=91"=20=EC=8B=9C=20work=5Forder=5Fprocess.starte?= =?UTF-8?q?d=5Fat=20=EC=9E=90=EB=8F=99=20=EA=B8=B0=EB=A1=9D=20(=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=EC=9E=91=20=EC=9E=90=EB=8F=99=20=EA=B0=90?= =?UTF-8?q?=EC=A7=80)=20-=20=EA=B3=B5=EC=A0=95=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=8B=9C=20actual=5Fwork=5Ftime=EC=9D=84=20=EA=B7=B8=EB=A3=B9?= =?UTF-8?q?=20=ED=83=80=EC=9D=B4=EB=A8=B8=20=ED=95=A9=EC=82=B0=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20[=ED=84=B0=EC=B9=98=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94=20UI]=20-=2012=EA=B0=9C=20=EC=98=81=EC=97=AD=20?= =?UTF-8?q?=EC=A0=84=EB=A9=B4=20=EC=8A=A4=EC=BC=80=EC=9D=BC=EC=97=85:=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20h-11~h-12,=20=EC=9E=85=EB=A0=A5=20h-11,=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20h-6=20w-6=20-=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20w-[180px],=20InfoBar=20t?= =?UTF-8?q?ext-sm,=20=EC=B5=9C=EC=86=8C=20=ED=84=B0=EC=B9=98=20=EC=98=81?= =?UTF-8?q?=EC=97=AD=2040~44px=20=ED=99=95=EB=B3=B4=20-=20=EC=82=B0?= =?UTF-8?q?=EC=97=85=20=ED=98=84=EC=9E=A5=20=ED=83=9C=EB=B8=94=EB=A6=BF=20?= =?UTF-8?q?=ED=84=B0=EC=B9=98=20=EC=82=AC=EC=9A=A9=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94=20[DB=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95]=20-=20saveResultValue/handleQuantityRegiste?= =?UTF-8?q?r:=20execute-action=20task=20=ED=98=95=EC=8B=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=20=20(fixedValue=20+=20lookupMode:"manual"=20+=20m?= =?UTF-8?q?anualItemField/manualPkColumn:"id")=20-=20=EC=9B=90=EC=9D=B8:?= =?UTF-8?q?=20=EB=B0=B1=EC=97=94=EB=93=9C=EA=B0=80=20=5F=5Fcart=5Frow=5Fke?= =?UTF-8?q?y=EB=A5=BC=20=EC=B0=BE=EB=8A=94=EB=8D=B0=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=90=EC=84=9C=20id=EB=A7=8C=20=EC=A0=84=EC=86=A1?= =?UTF-8?q?=ED=95=98=EC=97=AC=20lookup=20=EC=8B=A4=ED=8C=A8=20[=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B4=EB=84=88=20=EC=84=A4=EC=A0=95=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5]=20-=20displayMode:=20list/step=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80=20-=20PopWorkDetailC?= =?UTF-8?q?onfig:=20=ED=91=9C=EC=8B=9C=20=EB=AA=A8=EB=93=9C=20Select=20?= =?UTF-8?q?=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4=20-=20types.ts:=20PopWorkD?= =?UTF-8?q?etailConfig=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20d?= =?UTF-8?q?isplayMode=20=EC=B6=94=EA=B0=80=20-=20PopCardListV2Component:?= =?UTF-8?q?=20parentRow.=5F=5FprocessFlow=5F=5F=20=EC=A0=84=EB=8B=AC=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/popProductionController.ts | 180 ++- .../src/routes/popProductionRoutes.ts | 2 + .../PopCardListV2Component.tsx | 8 + .../PopWorkDetailComponent.tsx | 1410 +++++++++++++---- .../pop-work-detail/PopWorkDetailConfig.tsx | 164 +- .../pop-components/pop-work-detail/index.tsx | 18 + frontend/lib/registry/pop-components/types.ts | 26 + 7 files changed, 1441 insertions(+), 367 deletions(-) diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index d575b07a..59cef801 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -206,10 +206,11 @@ export const controlTimer = async ( }); } - if (!["start", "pause", "resume"].includes(action)) { + if (!["start", "pause", "resume", "complete"].includes(action)) { return res.status(400).json({ success: false, - message: "action은 start, pause, resume 중 하나여야 합니다.", + message: + "action은 start, pause, resume, complete 중 하나여야 합니다.", }); } @@ -262,6 +263,47 @@ export const controlTimer = async ( [work_order_process_id, companyCode] ); break; + + case "complete": { + const { good_qty, defect_qty } = req.body; + + const groupSumResult = await pool.query( + `SELECT COALESCE(SUM( + CASE WHEN group_started_at IS NOT NULL AND group_completed_at IS NOT NULL THEN + EXTRACT(EPOCH FROM group_completed_at::timestamp - group_started_at::timestamp)::int + - COALESCE(group_total_paused_time::int, 0) + ELSE 0 END + ), 0)::text AS total_work_seconds + FROM process_work_result + WHERE work_order_process_id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + const calculatedWorkTime = groupSumResult.rows[0]?.total_work_seconds || "0"; + + result = await pool.query( + `UPDATE work_order_process + SET status = 'completed', + completed_at = NOW()::text, + completed_by = $3, + actual_work_time = $4, + good_qty = COALESCE($5, good_qty), + defect_qty = COALESCE($6, defect_qty), + paused_at = NULL, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + AND status != 'completed' + RETURNING id, status, completed_at, completed_by, actual_work_time, good_qty, defect_qty`, + [ + work_order_process_id, + companyCode, + userId, + calculatedWorkTime, + good_qty || null, + defect_qty || null, + ] + ); + break; + } } if (!result || result.rowCount === 0) { @@ -289,3 +331,137 @@ export const controlTimer = async ( }); } }; + +/** + * 그룹(작업항목)별 타이머 제어 + * 좌측 사이드바의 각 작업 그룹마다 독립적인 시작/정지/재개/완료 타이머 + */ +export const controlGroupTimer = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const { work_order_process_id, source_work_item_id, action } = req.body; + + if (!work_order_process_id || !source_work_item_id || !action) { + return res.status(400).json({ + success: false, + message: + "work_order_process_id, source_work_item_id, action은 필수입니다.", + }); + } + + if (!["start", "pause", "resume", "complete"].includes(action)) { + return res.status(400).json({ + success: false, + message: + "action은 start, pause, resume, complete 중 하나여야 합니다.", + }); + } + + logger.info("[pop/production] group-timer 요청", { + companyCode, + work_order_process_id, + source_work_item_id, + action, + }); + + const whereClause = `work_order_process_id = $1 AND source_work_item_id = $2 AND company_code = $3`; + const baseParams = [work_order_process_id, source_work_item_id, companyCode]; + + let result; + + switch (action) { + case "start": + result = await pool.query( + `UPDATE process_work_result + SET group_started_at = CASE WHEN group_started_at IS NULL THEN NOW()::text ELSE group_started_at END, + updated_date = NOW() + WHERE ${whereClause} + RETURNING id, group_started_at`, + baseParams + ); + await pool.query( + `UPDATE work_order_process + SET started_at = NOW()::text, updated_date = NOW() + WHERE id = $1 AND company_code = $2 AND started_at IS NULL`, + [work_order_process_id, companyCode] + ); + break; + + case "pause": + result = await pool.query( + `UPDATE process_work_result + SET group_paused_at = NOW()::text, + updated_date = NOW() + WHERE ${whereClause} AND group_paused_at IS NULL + RETURNING id, group_paused_at`, + baseParams + ); + break; + + case "resume": + result = await pool.query( + `UPDATE process_work_result + SET group_total_paused_time = ( + COALESCE(group_total_paused_time::int, 0) + + EXTRACT(EPOCH FROM NOW() - group_paused_at::timestamp)::int + )::text, + group_paused_at = NULL, + updated_date = NOW() + WHERE ${whereClause} AND group_paused_at IS NOT NULL + RETURNING id, group_total_paused_time`, + baseParams + ); + break; + + case "complete": { + result = await pool.query( + `UPDATE process_work_result + SET group_completed_at = NOW()::text, + group_total_paused_time = CASE + WHEN group_paused_at IS NOT NULL THEN ( + COALESCE(group_total_paused_time::int, 0) + + EXTRACT(EPOCH FROM NOW() - group_paused_at::timestamp)::int + )::text + ELSE group_total_paused_time + END, + group_paused_at = NULL, + updated_date = NOW() + WHERE ${whereClause} + RETURNING id, group_started_at, group_completed_at, group_total_paused_time`, + baseParams + ); + break; + } + } + + if (!result || result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "대상 그룹을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.", + }); + } + + logger.info("[pop/production] group-timer 완료", { + action, + source_work_item_id, + affectedRows: result.rowCount, + }); + + return res.json({ + success: true, + data: result.rows[0], + affectedRows: result.rowCount, + }); + } catch (error: any) { + logger.error("[pop/production] group-timer 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "그룹 타이머 처리 중 오류가 발생했습니다.", + }); + } +}; diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts index f20d470d..c50f061a 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -3,6 +3,7 @@ import { authenticateToken } from "../middleware/authMiddleware"; import { createWorkProcesses, controlTimer, + controlGroupTimer, } from "../controllers/popProductionController"; const router = Router(); @@ -11,5 +12,6 @@ router.use(authenticateToken); router.post("/create-work-processes", createWorkProcesses); router.post("/timer", controlTimer); +router.post("/group-timer", controlGroupTimer); export default router; diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index 8c3c6447..bc930e95 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -974,6 +974,14 @@ export function PopCardListV2Component({ publish(`__comp_output__${componentId}__selected_items`, selectedItems); }, [selectedKeys, filteredRows, componentId, isCartListMode, publish]); + // 공정 완료 이벤트 수신 시 목록 갱신 + useEffect(() => { + const unsub = subscribe("process_completed", () => { + fetchDataRef.current(); + }); + return unsub; + }, [subscribe]); + // 카드 영역 스타일 const cardGap = effectiveConfig?.cardGap ?? spec.gap; const cardMinHeight = spec.height; diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx index 963d2148..5aa894cf 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { useEffect, useState, useMemo, useCallback } from "react"; +import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package, + ChevronLeft, ChevronRight, Check, X, CircleDot, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -32,7 +33,8 @@ interface WorkResultRow { item_title: string; item_sort_order: string; detail_type: string; - detail_label: string; + detail_label: string | null; + detail_content: string; detail_sort_order: string; spec_value: string | null; lower_limit: string | null; @@ -41,8 +43,23 @@ interface WorkResultRow { result_value: string | null; status: string; is_passed: string | null; + is_required: string | null; recorded_by: string | null; recorded_at: string | null; + started_at: string | null; + group_started_at: string | null; + group_paused_at: string | null; + group_total_paused_time: string | null; + group_completed_at: string | null; + inspection_method: string | null; + unit: string | null; +} + +interface GroupTimerState { + startedAt: string | null; + pausedAt: string | null; + totalPausedTime: number; + completedAt: string | null; } interface WorkGroup { @@ -52,6 +69,8 @@ interface WorkGroup { sortOrder: number; total: number; completed: number; + stepStatus: "pending" | "active" | "completed"; + timer: GroupTimerState; } type WorkPhase = "PRE" | "IN" | "POST"; @@ -61,11 +80,31 @@ interface ProcessTimerData { started_at: string | null; paused_at: string | null; total_paused_time: string | null; + completed_at: string | null; + completed_by: string | null; + actual_work_time: string | null; status: string; good_qty: string | null; defect_qty: string | null; } +const DEFAULT_INFO_FIELDS = [ + { label: "작업지시", column: "wo_no" }, + { label: "품목", column: "item_name" }, + { label: "공정", column: "__process_name" }, + { label: "지시수량", column: "qty" }, +]; + +const DEFAULT_CFG: PopWorkDetailConfig = { + showTimer: true, + showQuantityInput: true, + displayMode: "list", + phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, + infoBar: { enabled: true, fields: [] }, + stepControl: { requireStartBeforeInput: false, autoAdvance: true }, + navigation: { showPrevNext: true, showCompleteButton: true }, +}; + // ======================================== // Props // ======================================== @@ -85,18 +124,20 @@ interface PopWorkDetailComponentProps { export function PopWorkDetailComponent({ config, screenId, - componentId, }: PopWorkDetailComponentProps) { - const { getSharedData } = usePopEvent(screenId || "default"); + const { getSharedData, publish } = usePopEvent(screenId || "default"); const { user } = useAuth(); const cfg: PopWorkDetailConfig = { - showTimer: config?.showTimer ?? true, - showQuantityInput: config?.showQuantityInput ?? true, - phaseLabels: config?.phaseLabels ?? { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, + ...DEFAULT_CFG, + ...config, + displayMode: config?.displayMode ?? DEFAULT_CFG.displayMode, + infoBar: { ...DEFAULT_CFG.infoBar, ...config?.infoBar }, + stepControl: { ...DEFAULT_CFG.stepControl, ...config?.stepControl }, + navigation: { ...DEFAULT_CFG.navigation, ...config?.navigation }, + phaseLabels: { ...DEFAULT_CFG.phaseLabels, ...config?.phaseLabels }, }; - // parentRow에서 현재 공정 정보 추출 const parentRow = getSharedData("parentRow"); const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined; const currentProcess = processFlow?.find((p) => p.isCurrent); @@ -115,13 +156,17 @@ export function PopWorkDetailComponent({ const [selectedGroupId, setSelectedGroupId] = useState(null); const [tick, setTick] = useState(Date.now()); const [savingIds, setSavingIds] = useState>(new Set()); + const [activeStepIds, setActiveStepIds] = useState>(new Set()); - // 수량 입력 로컬 상태 const [goodQty, setGoodQty] = useState(""); const [defectQty, setDefectQty] = useState(""); + const [currentItemIdx, setCurrentItemIdx] = useState(0); + const [showQuantityPanel, setShowQuantityPanel] = useState(false); + + const contentRef = useRef(null); // ======================================== - // D-FE1: 데이터 로드 + // 데이터 로드 // ======================================== const fetchData = useCallback(async () => { @@ -129,10 +174,8 @@ export function PopWorkDetailComponent({ setLoading(false); return; } - try { setLoading(true); - const [resultRes, processRes] = await Promise.all([ dataApi.getTableData("process_work_result", { size: 500, @@ -143,9 +186,7 @@ export function PopWorkDetailComponent({ filters: { id: workOrderProcessId }, }), ]); - setAllResults((resultRes.data ?? []) as unknown as WorkResultRow[]); - const proc = (processRes.data?.[0] ?? null) as ProcessTimerData | null; setProcessData(proc); if (proc) { @@ -164,7 +205,7 @@ export function PopWorkDetailComponent({ }, [fetchData]); // ======================================== - // D-FE2: 좌측 사이드바 - 작업항목 그룹핑 + // 좌측 사이드바 - 작업항목 그룹핑 // ======================================== const groups = useMemo(() => { @@ -179,20 +220,34 @@ export function PopWorkDetailComponent({ sortOrder: parseInt(row.item_sort_order || "0", 10), total: 0, completed: 0, + stepStatus: "pending", + timer: { + startedAt: row.group_started_at ?? null, + pausedAt: row.group_paused_at ?? null, + totalPausedTime: parseInt(row.group_total_paused_time || "0", 10), + completedAt: row.group_completed_at ?? null, + }, }); } const g = map.get(key)!; g.total++; if (row.status === "completed") g.completed++; } - return Array.from(map.values()).sort( + const arr = Array.from(map.values()).sort( (a, b) => (PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) || a.sortOrder - b.sortOrder ); - }, [allResults]); + for (const g of arr) { + if (g.completed >= g.total && g.total > 0) { + g.stepStatus = "completed"; + } else if (activeStepIds.has(g.itemId) || g.timer.startedAt) { + g.stepStatus = "active"; + } + } + return arr; + }, [allResults, activeStepIds]); - // phase별로 그룹핑 const groupsByPhase = useMemo(() => { const result: Record = {}; for (const g of groups) { @@ -202,25 +257,93 @@ export function PopWorkDetailComponent({ return result; }, [groups]); - // 첫 그룹 자동 선택 useEffect(() => { if (groups.length > 0 && !selectedGroupId) { setSelectedGroupId(groups[0].itemId); } }, [groups, selectedGroupId]); + // 현재 선택 인덱스 + const selectedIndex = useMemo( + () => groups.findIndex((g) => g.itemId === selectedGroupId), + [groups, selectedGroupId] + ); + // ======================================== - // D-FE3: 우측 체크리스트 + // 네비게이션 + // ======================================== + + const navigateStep = useCallback( + (delta: number) => { + const nextIdx = selectedIndex + delta; + if (nextIdx >= 0 && nextIdx < groups.length) { + setSelectedGroupId(groups[nextIdx].itemId); + contentRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + } + }, + [selectedIndex, groups] + ); + + // ======================================== + // 우측 체크리스트 // ======================================== 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)), + .sort( + (a, b) => + parseInt(a.detail_sort_order || "0", 10) - + parseInt(b.detail_sort_order || "0", 10) + ), [allResults, selectedGroupId] ); + // 스텝 모드: 전체 항목을 flat 리스트로 정렬 + const flatItems = useMemo(() => { + const sorted = [...allResults].sort( + (a, b) => + (PHASE_ORDER[a.work_phase] ?? 9) - (PHASE_ORDER[b.work_phase] ?? 9) || + parseInt(a.item_sort_order || "0", 10) - parseInt(b.item_sort_order || "0", 10) || + parseInt(a.detail_sort_order || "0", 10) - parseInt(b.detail_sort_order || "0", 10) + ); + return sorted; + }, [allResults]); + + // 스텝 모드: 모든 체크리스트 항목 완료 여부 + const allItemsCompleted = useMemo( + () => flatItems.length > 0 && flatItems.every((r) => r.status === "completed"), + [flatItems] + ); + + // 그룹 선택 변경 시 스텝 인덱스 리셋 + useEffect(() => { + setCurrentItemIdx(0); + }, [selectedGroupId]); + + // 스텝 모드 자동 다음 이동 + const stepAutoAdvance = useCallback(() => { + if (cfg.displayMode !== "step") return; + const nextPendingIdx = currentItems.findIndex( + (r, i) => i > currentItemIdx && r.status !== "completed" + ); + if (nextPendingIdx >= 0) { + setCurrentItemIdx(nextPendingIdx); + } else if (currentItems.every((r) => r.status === "completed")) { + // 현재 그룹 완료 → 다음 그룹 + const nextGroupIdx = groups.findIndex( + (g, i) => i > selectedIndex && g.stepStatus !== "completed" + ); + if (nextGroupIdx >= 0) { + setSelectedGroupId(groups[nextGroupIdx].itemId); + toast.success(`${groups[selectedIndex]?.title} 완료`); + } else { + setShowQuantityPanel(true); + } + } + }, [cfg.displayMode, currentItems, currentItemIdx, groups, selectedIndex, selectedGroupId]); + const saveResultValue = useCallback( async ( rowId: string, @@ -230,16 +353,33 @@ export function PopWorkDetailComponent({ ) => { setSavingIds((prev) => new Set(prev).add(rowId)); try { + const existingRow = allResults.find((r) => r.id === rowId); + const isFirstTouch = existingRow && !existingRow.started_at; + const now = new Date().toISOString(); + + const mkTask = (col: string, val: string) => ({ + type: "data-update" as const, + targetTable: "process_work_result", + targetColumn: col, + operationType: "assign" as const, + valueSource: "fixed" as const, + fixedValue: val, + lookupMode: "manual" as const, + manualItemField: "id", + manualPkColumn: "id", + }); + + const tasks = [ + mkTask("result_value", resultValue), + mkTask("status", newStatus), + ...(isPassed !== null ? [mkTask("is_passed", isPassed)] : []), + mkTask("recorded_by", user?.userId ?? ""), + mkTask("recorded_at", now), + ...(isFirstTouch ? [mkTask("started_at", now)] : []), + ]; + 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 }] }, - ], + tasks, data: { items: [{ id: rowId }], fieldValues: {} }, }); @@ -252,11 +392,22 @@ export function PopWorkDetailComponent({ status: newStatus, is_passed: isPassed, recorded_by: user?.userId ?? null, - recorded_at: new Date().toISOString(), + recorded_at: now, + started_at: r.started_at ?? now, } : r ) ); + + if (cfg.stepControl.autoAdvance && newStatus === "completed") { + setTimeout(() => { + if (cfg.displayMode === "step") { + stepAutoAdvance(); + } else { + checkAutoAdvance(); + } + }, 300); + } } catch { toast.error("저장에 실패했습니다."); } finally { @@ -267,18 +418,57 @@ export function PopWorkDetailComponent({ }); } }, - [user?.userId] + // eslint-disable-next-line react-hooks/exhaustive-deps + [user?.userId, cfg.stepControl.autoAdvance, allResults] ); + const checkAutoAdvance = useCallback(() => { + if (!selectedGroupId) return; + const groupItems = allResults.filter( + (r) => r.source_work_item_id === selectedGroupId + ); + const requiredItems = groupItems.filter((r) => r.is_required === "Y"); + const allRequiredDone = requiredItems.length > 0 && requiredItems.every((r) => r.status === "completed"); + const allDone = groupItems.every((r) => r.status === "completed"); + + if (allRequiredDone || allDone) { + const idx = groups.findIndex((g) => g.itemId === selectedGroupId); + if (idx >= 0 && idx < groups.length - 1) { + const nextGroup = groups[idx + 1]; + setSelectedGroupId(nextGroup.itemId); + contentRef.current?.scrollTo({ top: 0, behavior: "smooth" }); + toast.success(`${groups[idx].title} 완료 → ${nextGroup.title}`); + } + } + }, [selectedGroupId, allResults, groups]); + // ======================================== - // D-FE4: 타이머 + // 단계 시작/활성화 + // ======================================== + + const handleStepStart = useCallback((itemId: string) => { + setActiveStepIds((prev) => new Set(prev).add(itemId)); + }, []); + + const isStepLocked = useMemo(() => { + if (!cfg.stepControl.requireStartBeforeInput) return false; + if (!selectedGroupId) return true; + return !activeStepIds.has(selectedGroupId); + }, [cfg.stepControl.requireStartBeforeInput, selectedGroupId, activeStepIds]); + + // ======================================== + // 프로세스 타이머 (전체 공정용) // ======================================== useEffect(() => { - if (!cfg.showTimer || !processData?.started_at) return; + if (!cfg.showTimer) return; + const hasActiveGroupTimer = groups.some( + (g) => g.timer.startedAt && !g.timer.completedAt + ); + if (!hasActiveGroupTimer && !processData?.started_at) return; const id = setInterval(() => setTick(Date.now()), 1000); return () => clearInterval(id); - }, [cfg.showTimer, processData?.started_at]); + }, [cfg.showTimer, processData?.started_at, groups]); const elapsedMs = useMemo(() => { if (!processData?.started_at) return 0; @@ -291,50 +481,121 @@ export function PopWorkDetailComponent({ 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]); + // formattedTime은 제거 - 그룹별 타이머로 대체됨 + // isPaused, isStarted는 프로세스 레벨 (사용하지 않으나 processData 참조용으로 유지) - const isPaused = !!processData?.paused_at; - const isStarted = !!processData?.started_at; + // ======================================== + // 그룹별 타이머 + // ======================================== - const handleTimerAction = useCallback( - async (action: "start" | "pause" | "resume") => { - if (!workOrderProcessId) return; + const selectedGroupTimer = useMemo(() => { + const g = groups.find((g) => g.itemId === selectedGroupId); + return g?.timer ?? { startedAt: null, pausedAt: null, totalPausedTime: 0, completedAt: null }; + }, [groups, selectedGroupId]); + + // 그룹 타이머: 순수 작업시간 (일시정지 제외) + const groupWorkMs = useMemo(() => { + if (!selectedGroupTimer.startedAt) return 0; + const end = selectedGroupTimer.completedAt + ? new Date(selectedGroupTimer.completedAt).getTime() + : tick; + const start = new Date(selectedGroupTimer.startedAt).getTime(); + const totalMs = end - start; + const pausedMs = selectedGroupTimer.totalPausedTime * 1000; + const currentPauseMs = selectedGroupTimer.pausedAt + ? (selectedGroupTimer.completedAt ? 0 : tick - new Date(selectedGroupTimer.pausedAt).getTime()) + : 0; + return Math.max(0, totalMs - pausedMs - currentPauseMs); + }, [selectedGroupTimer, tick]); + + // 그룹 타이머: 경과 시간 (일시정지 무시, 시작~끝) + const groupElapsedMs = useMemo(() => { + if (!selectedGroupTimer.startedAt) return 0; + const end = selectedGroupTimer.completedAt + ? new Date(selectedGroupTimer.completedAt).getTime() + : tick; + const start = new Date(selectedGroupTimer.startedAt).getTime(); + return Math.max(0, end - start); + }, [selectedGroupTimer, tick]); + + const groupTimerFormatted = useMemo(() => formatMsToTime(groupWorkMs), [groupWorkMs]); + const groupElapsedFormatted = useMemo(() => formatMsToTime(groupElapsedMs), [groupElapsedMs]); + + const isGroupStarted = !!selectedGroupTimer.startedAt; + const isGroupPaused = !!selectedGroupTimer.pausedAt; + const isGroupCompleted = !!selectedGroupTimer.completedAt; + + const handleGroupTimerAction = useCallback( + async (action: "start" | "pause" | "resume" | "complete") => { + if (!workOrderProcessId || !selectedGroupId) return; try { - await apiClient.post("/api/pop/production/timer", { - workOrderProcessId, + await apiClient.post("/pop/production/group-timer", { + work_order_process_id: workOrderProcessId, + source_work_item_id: selectedGroupId, action, }); - // 타이머 상태 새로고침 + await fetchData(); + } catch { + toast.error("그룹 타이머 제어에 실패했습니다."); + } + }, + [workOrderProcessId, selectedGroupId, fetchData] + ); + + const handleTimerAction = useCallback( + async (action: "start" | "pause" | "resume" | "complete") => { + if (!workOrderProcessId) return; + try { + const body: Record = { + work_order_process_id: workOrderProcessId, + action, + }; + if (action === "complete") { + body.good_qty = goodQty || "0"; + body.defect_qty = defectQty || "0"; + } + await apiClient.post("/pop/production/timer", body); 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); + if (proc) { + setProcessData(proc); + if (action === "complete") { + toast.success("공정이 완료되었습니다."); + publish("process_completed", { workOrderProcessId, goodQty, defectQty }); + } + } } catch { toast.error("타이머 제어에 실패했습니다."); } }, - [workOrderProcessId] + [workOrderProcessId, goodQty, defectQty, publish] ); // ======================================== - // D-FE5: 수량 등록 + 완료 + // 수량 등록 + 완료 // ======================================== const handleQuantityRegister = useCallback(async () => { if (!workOrderProcessId) return; try { + const mkWopTask = (col: string, val: string) => ({ + type: "data-update" as const, + targetTable: "work_order_process", + targetColumn: col, + operationType: "assign" as const, + valueSource: "fixed" as const, + fixedValue: val, + lookupMode: "manual" as const, + manualItemField: "id", + manualPkColumn: "id", + }); 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 }] }, + mkWopTask("good_qty", goodQty || "0"), + mkWopTask("defect_qty", defectQty || "0"), ], data: { items: [{ id: workOrderProcessId }], fieldValues: {} }, }); @@ -345,23 +606,8 @@ export function PopWorkDetailComponent({ }, [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]); + await handleTimerAction("complete"); + }, [handleTimerAction]); // ======================================== // 안전 장치 @@ -403,6 +649,7 @@ export function PopWorkDetailComponent({ } const isProcessCompleted = processData?.status === "completed"; + const selectedGroup = groups.find((g) => g.itemId === selectedGroupId); // ======================================== // 렌더링 @@ -410,70 +657,58 @@ export function PopWorkDetailComponent({ return (
- {/* 헤더 */} -
-

{processName}

- {cfg.showTimer && ( -
- - - {formattedTime} - - {!isProcessCompleted && ( - <> - {!isStarted && ( - - )} - {isStarted && !isPaused && ( - - )} - {isStarted && isPaused && ( - - )} - - )} -
- )} -
+ {/* 작업지시 정보 바 */} + {cfg.infoBar.enabled && ( + 0 ? cfg.infoBar.fields : DEFAULT_INFO_FIELDS} + parentRow={parentRow} + processName={processName} + /> + )} - {/* 본문: 좌측 사이드바 + 우측 체크리스트 */} + {/* 전체 공정 진행 요약은 제거 - 타이머는 그룹 헤더로 이동 */} + + {/* 본문: 좌측 사이드바 + 우측 콘텐츠 */}
{/* 좌측 사이드바 */} -
+
{(["PRE", "IN", "POST"] as WorkPhase[]).map((phase) => { const phaseGroups = groupsByPhase[phase]; if (!phaseGroups || phaseGroups.length === 0) return null; return (
-
+
{cfg.phaseLabels[phase] ?? phase}
{phaseGroups.map((g) => ( ))}
@@ -481,73 +716,398 @@ export function PopWorkDetailComponent({ })}
- {/* 우측 체크리스트 */} -
- {selectedGroupId && ( -
- {currentItems.map((item) => ( - + {cfg.displayMode === "step" ? ( + /* ======== 스텝 모드 ======== */ + <> + {showQuantityPanel || allItemsCompleted ? ( + /* 수량 등록 + 공정 완료 화면 */ +
+ +

모든 작업 항목이 완료되었습니다

+ + {cfg.showQuantityInput && !isProcessCompleted && ( +
+

실적 수량 등록

+
+
+ 양품 + setGoodQty(e.target.value)} placeholder="0" /> +
+
+ 불량 + setDefectQty(e.target.value)} placeholder="0" /> +
+ {(parseInt(goodQty, 10) || 0) + (parseInt(defectQty, 10) || 0) > 0 && ( +

+ 합계: {(parseInt(goodQty, 10) || 0) + (parseInt(defectQty, 10) || 0)} +

+ )} +
+
+ )} + + {!isProcessCompleted && cfg.navigation.showCompleteButton && ( + + )} + + {isProcessCompleted && ( + + 공정이 완료되었습니다 + + )} +
+ ) : ( + /* 단계별 항목 표시 */ + <> + {/* 그룹 헤더 + 타이머 + 진행률 */} + {selectedGroup && ( + <> + +
+
+ {currentItemIdx + 1} / {currentItems.length} +
+
+
0 ? ((selectedGroup.completed / selectedGroup.total) * 100) : 0}%`, + }} + /> +
+
+ + )} + + {/* 현재 항목 1개 표시 */} +
+ {currentItems[currentItemIdx] && ( +
+ {currentItems[currentItemIdx].started_at && ( +
+ + {currentItems[currentItemIdx].recorded_at + ? formatDuration(currentItems[currentItemIdx].started_at!, currentItems[currentItemIdx].recorded_at!) + : "진행 중..."} +
+ )} + +
+ )} +
+ + {/* 스텝 네비게이션 */} +
+ + + + {selectedGroup?.title} ({currentItemIdx + 1}/{currentItems.length}) + + + +
+ + )} + + ) : ( + /* ======== 리스트 모드 (기존) ======== */ + <> + {/* 그룹 헤더 + 타이머 */} + {selectedGroup && ( + - ))} -
+ )} + + {/* 체크리스트 콘텐츠 */} +
+ {selectedGroupId && ( +
+ {currentItems.map((item) => ( + + ))} +
+ )} +
+ + {/* 하단 네비게이션 + 수량/완료 */} +
+ {cfg.showQuantityInput && ( +
+ +
+ 양품 + setGoodQty(e.target.value)} disabled={isProcessCompleted} /> +
+
+ 불량 + setDefectQty(e.target.value)} disabled={isProcessCompleted} /> +
+ +
+ )} + + {cfg.navigation.showPrevNext && ( +
+ + + + {selectedIndex + 1} / {groups.length} + + + {selectedIndex < groups.length - 1 ? ( + + ) : cfg.navigation.showCompleteButton && !isProcessCompleted ? ( + + ) : ( +
+ )} +
+ )} +
+ )}
+
+ ); +} - {/* 하단: 수량 입력 + 완료 */} - {cfg.showQuantityInput && ( -
- -
- 양품 - setGoodQty(e.target.value)} - disabled={isProcessCompleted} - /> +// ======================================== +// 유틸리티 +// ======================================== + +function formatSeconds(totalSec: number): string { + 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}`; +} + +function formatMsToTime(ms: number): string { + return formatSeconds(Math.floor(ms / 1000)); +} + +function formatDuration(startISO: string, endISO: string): string { + const diffMs = new Date(endISO).getTime() - new Date(startISO).getTime(); + if (diffMs <= 0) return "0초"; + const totalSec = Math.floor(diffMs / 1000); + if (totalSec < 60) return `${totalSec}초`; + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return sec > 0 ? `${min}분 ${sec}초` : `${min}분`; +} + +// ======================================== +// 작업지시 정보 바 +// ======================================== + +interface InfoBarProps { + fields: Array<{ label: string; column: string }>; + parentRow: RowData; + processName: string; +} + +function InfoBar({ fields, parentRow, processName }: InfoBarProps) { + return ( +
+ {fields.map((f) => { + const val = f.column === "__process_name" + ? processName + : parentRow[f.column]; + return ( +
+ {f.label} + {val != null ? String(val) : "-"}
-
- 불량 - setDefectQty(e.target.value)} - disabled={isProcessCompleted} - /> + ); + })} +
+ ); +} + +// ======================================== +// 그룹별 타이머 헤더 +// ======================================== + +interface GroupTimerHeaderProps { + group: WorkGroup; + cfg: PopWorkDetailConfig; + isProcessCompleted: boolean; + isGroupStarted: boolean; + isGroupPaused: boolean; + isGroupCompleted: boolean; + groupTimerFormatted: string; + groupElapsedFormatted: string; + onTimerAction: (action: "start" | "pause" | "resume" | "complete") => void; +} + +function GroupTimerHeader({ + group, + cfg, + isProcessCompleted, + isGroupStarted, + isGroupPaused, + isGroupCompleted, + groupTimerFormatted, + groupElapsedFormatted, + onTimerAction, +}: GroupTimerHeaderProps) { + return ( +
+ {/* 그룹 제목 + 진행 카운트 */} +
+
+ {group.title} + + {group.completed}/{group.total} + +
+ {isGroupCompleted && ( + 완료 + )} +
+ + {/* 그룹 타이머 */} + {cfg.showTimer && ( +
+
+
+ + {groupTimerFormatted} + 작업 +
+
+ {groupElapsedFormatted} + 경과 +
- -
- {!isProcessCompleted && ( - - )} - {isProcessCompleted && ( - - 완료됨 - + {!isProcessCompleted && !isGroupCompleted && ( +
+ {!isGroupStarted && ( + + )} + {isGroupStarted && !isGroupPaused && ( + <> + + + + )} + {isGroupStarted && isGroupPaused && ( + <> + + + + )} +
)}
)} @@ -556,98 +1116,102 @@ export function PopWorkDetailComponent({ } // ======================================== -// 체크리스트 개별 항목 +// 단계 상태 아이콘 +// ======================================== + +function StepStatusIcon({ status }: { status: "pending" | "active" | "completed" }) { + switch (status) { + case "completed": + return ; + case "active": + return ; + default: + return
; + } +} + +// ======================================== +// 체크리스트 개별 항목 (라우터) // ======================================== interface ChecklistItemProps { item: WorkResultRow; saving: boolean; disabled: boolean; - onSave: ( - rowId: string, - resultValue: string, - isPassed: string | null, - newStatus: string - ) => void; + 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; + const isDisabled = disabled || saving; switch (item.detail_type) { case "check": - return ; + return ; case "inspect": - return ; + return ; case "input": - return ; + return ; case "procedure": - return ; + return ; case "material": - return ; + return ; + case "result": + return ; default: return ( -
+
알 수 없는 유형: {item.detail_type}
); } } -// ===== check: 체크박스 ===== +// ======================================== +// check: 체크박스 +// ======================================== -function CheckItem({ - item, - disabled, - saving, - onSave, -}: { - item: WorkResultRow; - disabled: boolean; - saving: boolean; - onSave: ChecklistItemProps["onSave"]; -}) { +function CheckItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { const checked = item.result_value === "Y"; return ( -
+
{ const val = v ? "Y" : "N"; onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending"); }} /> - {item.detail_label} - {saving && } - {item.status === "completed" && !saving && ( - - 완료 - - )} + {item.detail_label || item.detail_content} + {item.is_required === "Y" && 필수} + {saving && } + {item.status === "completed" && !saving && 완료}
); } -// ===== inspect: 측정값 입력 (범위 판정) ===== +// ======================================== +// inspect: 라우터 (input_type으로 분기) +// ======================================== -function InspectItem({ - item, - disabled, - saving, - onSave, -}: { - item: WorkResultRow; - disabled: boolean; - saving: boolean; - onSave: ChecklistItemProps["onSave"]; -}) { +function InspectRouter(props: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { + const inputType = props.item.input_type ?? "numeric_range"; + switch (inputType) { + case "ox": + return ; + case "select": + return ; + case "text": + return ; + default: + return ; + } +} + +// ===== inspect: 수치(범위) ===== + +function InspectNumeric({ 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 ?? ""); @@ -663,60 +1227,183 @@ function InspectItem({ onSave(item.id, inputVal, passed, "completed"); }; - const isPassed = item.is_passed; - return ( -
-
- {item.detail_label} +
+
+
+ {item.detail_label || item.detail_content} + {item.is_required === "Y" && 필수} +
{hasRange && ( - - 기준: {item.lower_limit} ~ {item.upper_limit} + + {item.lower_limit} ~ {item.upper_limit} {item.unit || ""} {item.spec_value ? ` (표준: ${item.spec_value})` : ""} )}
-
- setInputVal(e.target.value)} - onBlur={handleBlur} - disabled={disabled} - placeholder="측정값 입력" - /> - {saving && } - {isPassed === "Y" && !saving && ( - 합격 - )} - {isPassed === "N" && !saving && ( - 불합격 - )} + {item.inspection_method && ( +
{item.inspection_method}
+ )} +
+ setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="측정값 입력" /> + {item.unit && {item.unit}} + {saving && } + {item.is_passed === "Y" && !saving && 합격} + {item.is_passed === "N" && !saving && 불합격}
); } -// ===== input: 자유 입력 ===== +// ===== inspect: O/X ===== -function InputItem({ - item, - disabled, - saving, - onSave, -}: { - item: WorkResultRow; - disabled: boolean; - saving: boolean; - onSave: ChecklistItemProps["onSave"]; -}) { +function InspectOX({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { + const currentValue = item.result_value ?? ""; + const criteriaOK = item.spec_value ?? "OK"; + + const handleSelect = (value: string) => { + if (disabled) return; + const passed = value === criteriaOK ? "Y" : "N"; + onSave(item.id, value, passed, "completed"); + }; + + return ( +
+
+ {item.detail_label || item.detail_content} + {item.is_required === "Y" && 필수} +
+
+ + + {saving && } + {item.is_passed === "Y" && !saving && 합격} + {item.is_passed === "N" && !saving && 불합격} +
+
+ ); +} + +// ===== inspect: 선택형 ===== + +function InspectSelect({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { + const currentValue = item.result_value ?? ""; + let options: string[] = []; + let passValues: string[] = []; + + try { + const parsed = JSON.parse(item.spec_value ?? "{}"); + options = parsed.options ?? []; + passValues = parsed.passValues ?? []; + } catch { + options = (item.spec_value ?? "").split(",").filter(Boolean); + } + + const handleSelect = (value: string) => { + if (disabled) return; + const passed = passValues.includes(value) ? "Y" : "N"; + onSave(item.id, value, passed, "completed"); + }; + + return ( +
+
+ {item.detail_label || item.detail_content} + {item.is_required === "Y" && 필수} +
+
+ {options.map((opt) => ( + + ))} + {saving && } + {item.is_passed === "Y" && !saving && 합격} + {item.is_passed === "N" && !saving && 불합격} +
+
+ ); +} + +// ===== inspect: 텍스트 입력 + 수동 판정 ===== + +function InspectText({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { + const [inputVal, setInputVal] = useState(item.result_value ?? ""); + const [judged, setJudged] = useState(item.is_passed); + + const handleJudge = (passed: string) => { + if (disabled) return; + setJudged(passed); + onSave(item.id, inputVal || "-", passed, "completed"); + }; + + const handleBlur = () => { + if (!inputVal || disabled) return; + if (judged) { + onSave(item.id, inputVal, judged, "completed"); + } + }; + + return ( +
+
+ {item.detail_label || item.detail_content} + {item.is_required === "Y" && 필수} +
+
+ setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="내용 입력" /> + + + {saving && } +
+
+ ); +} + +// ======================================== +// 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"; @@ -726,106 +1413,153 @@ function InputItem({ }; return ( -
-
{item.detail_label}
-
- setInputVal(e.target.value)} - onBlur={handleBlur} - disabled={disabled} - placeholder="값 입력" - /> - {saving && } +
+
{item.detail_label || item.detail_content}
+
+ setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="값 입력" /> + {saving && }
); } -// ===== procedure: 절차 확인 (읽기 전용 + 체크) ===== +// ======================================== +// procedure: 절차 확인 +// ======================================== -function ProcedureItem({ - item, - disabled, - saving, - onSave, -}: { - item: WorkResultRow; - disabled: boolean; - saving: boolean; - onSave: ChecklistItemProps["onSave"]; -}) { +function ProcedureItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { const checked = item.result_value === "Y"; return ( -
-
- {item.spec_value || item.detail_label} +
+
+
+ {item.detail_sort_order || "?"} +
+ {item.detail_content || item.detail_label} + {item.is_required === "Y" && 필수}
-
+
{ onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending"); }} /> - 확인 - {saving && } + 확인 + {saving && }
); } -// ===== material: 자재/LOT 입력 ===== +// ======================================== +// material: 자재/LOT 입력 (바코드 스캔 지원) +// ======================================== -function MaterialItem({ - item, - disabled, - saving, - onSave, -}: { - item: WorkResultRow; - disabled: boolean; - saving: boolean; - onSave: ChecklistItemProps["onSave"]; -}) { +function MaterialItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { const [inputVal, setInputVal] = useState(item.result_value ?? ""); + const inputRef = useRef(null); - const handleBlur = () => { + const handleSubmit = () => { if (!inputVal || disabled) return; onSave(item.id, inputVal, null, "completed"); }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSubmit(); + } + }; + return ( -
-
{item.detail_label}
-
+
+
+ + {item.detail_label || item.detail_content} + {item.is_required === "Y" && 필수} +
+
setInputVal(e.target.value)} - onBlur={handleBlur} + onKeyDown={handleKeyDown} disabled={disabled} - placeholder="LOT 번호 입력" + placeholder="LOT/바코드 스캔 또는 입력" /> - {saving && } + + {saving && } + {item.status === "completed" && !saving && 투입} +
+
+ ); +} + +// ======================================== +// result: 실적 입력 (양품/불량/불량유형) +// ======================================== + +function ResultInputItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) { + let savedData: { good?: string; defect?: string; defectType?: string; note?: string } = {}; + try { + savedData = JSON.parse(item.result_value ?? "{}"); + } catch { + savedData = {}; + } + + const [good, setGood] = useState(savedData.good ?? ""); + const [defect, setDefect] = useState(savedData.defect ?? ""); + const [defectType, setDefectType] = useState(savedData.defectType ?? ""); + const [note, setNote] = useState(savedData.note ?? ""); + + const handleSave = () => { + if (disabled) return; + const val = JSON.stringify({ good, defect, defectType, note }); + onSave(item.id, val, null, "completed"); + }; + + const total = (parseInt(good, 10) || 0) + (parseInt(defect, 10) || 0); + + return ( +
+
{item.detail_label || item.detail_content || "실적 입력"}
+ +
+
+ 양품 + setGood(e.target.value)} disabled={disabled} placeholder="0" /> +
+
+ 불량 + setDefect(e.target.value)} disabled={disabled} placeholder="0" /> +
+ 합계: {total} +
+ + {parseInt(defect, 10) > 0 && ( +
+ 불량유형 + setDefectType(e.target.value)} disabled={disabled} placeholder="스크래치, 치수불량 등" /> +
+ )} + +
+ 비고 + setNote(e.target.value)} disabled={disabled} placeholder="비고 (선택)" /> +
+ +
+ + {saving && } + {item.status === "completed" && !saving && 등록됨}
); diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx index 7b75cf78..14d7cdfa 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailConfig.tsx @@ -1,9 +1,13 @@ "use client"; +import React, { useState } from "react"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Input } from "@/components/ui/input"; -import type { PopWorkDetailConfig } from "../types"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Plus, Trash2 } from "lucide-react"; +import type { PopWorkDetailConfig, WorkDetailInfoBarField } from "../types"; interface PopWorkDetailConfigPanelProps { config?: PopWorkDetailConfig; @@ -16,6 +20,21 @@ const DEFAULT_PHASE_LABELS: Record = { POST: "작업 후", }; +const DEFAULT_INFO_BAR = { + enabled: true, + fields: [] as WorkDetailInfoBarField[], +}; + +const DEFAULT_STEP_CONTROL = { + requireStartBeforeInput: false, + autoAdvance: true, +}; + +const DEFAULT_NAVIGATION = { + showPrevNext: true, + showCompleteButton: true, +}; + export function PopWorkDetailConfigPanel({ config, onChange, @@ -23,50 +42,141 @@ export function PopWorkDetailConfigPanel({ const cfg: PopWorkDetailConfig = { showTimer: config?.showTimer ?? true, showQuantityInput: config?.showQuantityInput ?? true, + displayMode: config?.displayMode ?? "list", phaseLabels: config?.phaseLabels ?? { ...DEFAULT_PHASE_LABELS }, + infoBar: config?.infoBar ?? { ...DEFAULT_INFO_BAR }, + stepControl: config?.stepControl ?? { ...DEFAULT_STEP_CONTROL }, + navigation: config?.navigation ?? { ...DEFAULT_NAVIGATION }, }; const update = (partial: Partial) => { onChange?.({ ...cfg, ...partial }); }; + const [newFieldLabel, setNewFieldLabel] = useState(""); + const [newFieldColumn, setNewFieldColumn] = useState(""); + + const addInfoBarField = () => { + if (!newFieldLabel || !newFieldColumn) return; + const fields = [...(cfg.infoBar.fields ?? []), { label: newFieldLabel, column: newFieldColumn }]; + update({ infoBar: { ...cfg.infoBar, fields } }); + setNewFieldLabel(""); + setNewFieldColumn(""); + }; + + const removeInfoBarField = (idx: number) => { + const fields = (cfg.infoBar.fields ?? []).filter((_, i) => i !== idx); + update({ infoBar: { ...cfg.infoBar, fields } }); + }; + return ( -
-
- - update({ showTimer: v })} - /> -
+
+ {/* 기본 설정 */} +
+
+ + +
+ update({ showTimer: v })} /> + update({ showQuantityInput: v })} /> +
-
- - update({ showQuantityInput: v })} + {/* 정보 바 */} +
+ update({ infoBar: { ...cfg.infoBar, enabled: v } })} /> -
+ {cfg.infoBar.enabled && ( +
+ {(cfg.infoBar.fields ?? []).map((f, i) => ( +
+ {f.label} + {f.column} + +
+ ))} +
+ setNewFieldLabel(e.target.value)} /> + setNewFieldColumn(e.target.value)} /> + +
+
+ )} + -
- + {/* 단계 제어 */} +
+ update({ stepControl: { ...cfg.stepControl, requireStartBeforeInput: v } })} + /> + update({ stepControl: { ...cfg.stepControl, autoAdvance: v } })} + /> +
+ + {/* 네비게이션 */} +
+ update({ navigation: { ...cfg.navigation, showPrevNext: v } })} + /> + update({ navigation: { ...cfg.navigation, showCompleteButton: v } })} + /> +
+ + {/* 단계 라벨 */} +
{(["PRE", "IN", "POST"] as const).map((phase) => (
- - {phase} - + {phase} - update({ - phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value }, - }) - } + onChange={(e) => update({ phaseLabels: { ...cfg.phaseLabels, [phase]: e.target.value } })} />
))} -
+ +
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ); +} + +function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) { + return ( +
+ +
); } diff --git a/frontend/lib/registry/pop-components/pop-work-detail/index.tsx b/frontend/lib/registry/pop-components/pop-work-detail/index.tsx index 941db8d4..f453fd7a 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/index.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/index.tsx @@ -9,7 +9,25 @@ import type { PopWorkDetailConfig } from "../types"; const defaultConfig: PopWorkDetailConfig = { showTimer: true, showQuantityInput: true, + displayMode: "list", phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" }, + infoBar: { + enabled: true, + fields: [ + { label: "작업지시", column: "wo_no" }, + { label: "품목", column: "item_name" }, + { label: "공정", column: "__process_name" }, + { label: "지시수량", column: "qty" }, + ], + }, + stepControl: { + requireStartBeforeInput: false, + autoAdvance: true, + }, + navigation: { + showPrevNext: true, + showCompleteButton: true, + }, }; PopComponentRegistry.registerComponent({ diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index a32a53cd..94540d27 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -1006,8 +1006,34 @@ export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const; // pop-work-detail 전용 타입 // ============================================= +export interface WorkDetailInfoBarField { + label: string; + column: string; +} + +export interface WorkDetailInfoBarConfig { + enabled: boolean; + fields: WorkDetailInfoBarField[]; +} + +export interface WorkDetailStepControl { + requireStartBeforeInput: boolean; + autoAdvance: boolean; +} + +export interface WorkDetailNavigationConfig { + showPrevNext: boolean; + showCompleteButton: boolean; +} + export interface PopWorkDetailConfig { showTimer: boolean; + /** @deprecated result-input 타입으로 대체 */ showQuantityInput: boolean; + /** 표시 모드: list(기존 리스트), step(한 항목씩 진행) */ + displayMode: "list" | "step"; phaseLabels: Record; + infoBar: WorkDetailInfoBarConfig; + stepControl: WorkDetailStepControl; + navigation: WorkDetailNavigationConfig; } From 20fbe85c7440973c7332dcca81e7c36952cdb148 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 17 Mar 2026 21:36:43 +0900 Subject: [PATCH 04/15] =?UTF-8?q?feat:=20BLOCK=20MES-REWORK=20-=20mes-proc?= =?UTF-8?q?ess-card=20=EC=A0=84=EC=9A=A9=20=EC=B9=B4=EB=93=9C=20+=20batch?= =?UTF-8?q?=5Fdone=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20+=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=81=20=EA=B4=80=EB=A6=AC=20=EA=B0=95=ED=99=94=20?= =?UTF-8?q?MES=20=EC=B9=B4=EB=93=9C=EB=A5=BC=20CSS=20Grid=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EC=85=80=20=EB=B0=A9=EC=8B=9D=EC=97=90=EC=84=9C=20?= =?UTF-8?q?Flexbox=20=EA=B8=B0=EB=B0=98=20=EB=8B=A8=EC=9D=BC=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EC=B9=B4=EB=93=9C(mes-process-card)=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=ED=95=98=EA=B3=A0,=20batch=5Fdone=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A5=BC=20=EB=8F=84=EC=9E=85=ED=95=98?= =?UTF-8?q?=EC=97=AC=20=EB=B6=80=EB=B6=84=20=ED=99=95=EC=A0=95=20=ED=9B=84?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=EC=A0=91=EC=88=98=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=EB=A5=BC=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.=20[mes-process-card=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C]=20-=20CardCellType=20"mes-process-card"=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C:=20=EC=83=81=ED=83=9C=EB=B3=84=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=EB=B3=B4=EB=8D=94+=EB=B0=B0=EA=B2=BD,=20=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=20=ED=9D=90=EB=A6=84=20=EC=8A=A4=ED=8A=B8=EB=A6=BD,?= =?UTF-8?q?=20=ED=81=B4=EB=A6=AD=20=EB=AA=A8=EB=8B=AC=20-=20MesAcceptableM?= =?UTF-8?q?etrics=20/=20MesInProgressMetrics=20/=20MesCompletedMetrics=20?= =?UTF-8?q?=EC=84=9C=EB=B8=8C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20-?= =?UTF-8?q?=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20activeBtn=20=EA=B2=B0=EC=A0=95=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20(=EC=83=81=ED=83=9C+=EC=9E=94=EC=97=AC=EB=9F=89=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=EC=9C=BC=EB=A1=9C=20=EB=B2=84=ED=8A=BC=201=EA=B0=9C?= =?UTF-8?q?=EB=A7=8C=20=ED=91=9C=EC=8B=9C)=20-=20=ED=95=98=EB=93=9C?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EC=B7=A8=EC=86=8C=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20DB=20=EC=84=A4=EC=A0=95=20"=EC=A0=91?= =?UTF-8?q?=EC=88=98=EC=B7=A8=EC=86=8C"=20=EB=9D=BC=EB=B2=A8=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=EC=85=89=ED=8A=B8=EB=A1=9C=20=ED=86=B5=ED=95=A9=20[ba?= =?UTF-8?q?tch=5Fdone=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=9A=B0]=20-?= =?UTF-8?q?=20confirmResult=20API:=20=EB=B6=80=EB=B6=84=20=ED=99=95?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9C=20status=20=3D=20'batch=5Fdone'=20(?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=ED=83=AD=20=EC=88=A8=EA=B9=80=20+=20?= =?UTF-8?q?=EC=A0=91=EC=88=98=EA=B0=80=EB=8A=A5=20=ED=83=AD=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80)=20-=20acceptProcess=20API:=20batch=5Fdone=20->=20in?= =?UTF-8?q?=5Fprogress=20=EB=B3=B5=EA=B7=80=20(=EC=B6=94=EA=B0=80=EC=A0=91?= =?UTF-8?q?=EC=88=98)=20-=20=EC=B9=B4=EB=93=9C=20=EB=B3=B5=EC=A0=9C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=EC=97=90=20batch=5Fdone=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=20(=EC=9E=94=EC=97=AC=EB=9F=89=20=EC=9E=88=EC=9C=BC=EB=A9=B4?= =?UTF-8?q?=20=EC=A0=91=EC=88=98=EA=B0=80=EB=8A=A5=20=ED=83=AD=EC=97=90=20?= =?UTF-8?q?=ED=81=B4=EB=A1=A0=20=EC=B9=B4=EB=93=9C)=20-=20status=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=EC=97=90=20batch=5Fdone=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(semantic:=20active)=20[=EC=8B=A4=EC=A0=81=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=95=ED=99=94]=20-=20PopWorkDetailComponent:?= =?UTF-8?q?=20=EC=8B=A4=EC=A0=81=20=EC=9E=85=EB=A0=A5=20UI=20=EC=A0=84?= =?UTF-8?q?=EB=A9=B4=20=EA=B5=AC=ED=98=84=20(=EC=B0=A8=EC=88=98=EB=B3=84?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D,=20=EB=88=84=EC=A0=81=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=81,=20=EC=9D=B4=EB=A0=A5=20=ED=91=9C=EC=8B=9C)=20-=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EC=8B=A4=EC=A0=81=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=8B=9C=20process=5Fcompleted=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=B0=9C=ED=96=89=20(=EC=B9=B4=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=A6=89=EC=8B=9C=20=EA=B0=B1=EC=8B=A0)?= =?UTF-8?q?=20-=20=EC=A0=84=EB=9F=89=EC=A0=91=EC=88=98+=EC=A0=84=EB=9F=89?= =?UTF-8?q?=EC=83=9D=EC=82=B0=20=EC=8B=9C=20=EC=9E=90=EB=8F=99=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20(status=3Dcompleted,=20result=5Fstatus=3Dconfirmed)?= =?UTF-8?q?=20[=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95]=20-=20=EC=84=9C?= =?UTF-8?q?=EB=B8=8C=20=ED=95=84=ED=84=B0=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C?= =?UTF-8?q?=20=5F=5Fprocess=5F*=20=ED=95=84=EB=93=9C=20=EB=AF=B8=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20->=20processFields=20=EC=9E=AC=EC=A3=BC=EC=9E=85=20?= =?UTF-8?q?-=20cancelAccept=20SQL=20inconsistent=20types=20->=20boolean=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EB=B6=84=EB=A6=AC=20-?= =?UTF-8?q?=20=EC=A0=91=EC=88=98=EC=B7=A8=EC=86=8C=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EB=88=84=EB=9D=BD=20->=20taskPreset=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/popProductionController.ts | 830 +++++++++++++++++- .../src/routes/popProductionRoutes.ts | 14 + .../PopCardListV2Component.tsx | 354 +++++--- .../pop-card-list-v2/cell-renderers.tsx | 671 +++++++++++++- .../pop-card-list/NumberInputModal.tsx | 2 + .../pop-status-bar/PopStatusBarComponent.tsx | 6 +- .../PopWorkDetailComponent.tsx | 612 ++++++++++++- frontend/lib/registry/pop-components/types.ts | 31 +- 8 files changed, 2382 insertions(+), 138 deletions(-) diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index 59cef801..f2cb9b63 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -3,6 +3,14 @@ import { getPool } from "../database/db"; import logger from "../utils/logger"; import { AuthenticatedRequest } from "../middleware/authMiddleware"; +// 불량 상세 항목 타입 +interface DefectDetailItem { + defect_code: string; + defect_name: string; + qty: string; + disposition: string; +} + /** * D-BE1: 작업지시 공정 일괄 생성 * PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성. @@ -102,7 +110,7 @@ export const createWorkProcesses = async ( rd.is_fixed_order, rd.standard_time, plan_qty || null, - "waiting", + parseInt(rd.seq_no, 10) === 1 ? "acceptable" : "waiting", rd.id, userId, ] @@ -465,3 +473,823 @@ export const controlGroupTimer = async ( }); } }; + +/** + * 불량 유형 목록 조회 (defect_standard_mng) + */ +export const getDefectTypes = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + + let query: string; + let params: unknown[]; + + if (companyCode === "*") { + query = ` + SELECT id, defect_code, defect_name, defect_type, severity, company_code + FROM defect_standard_mng + WHERE is_active = 'Y' + ORDER BY defect_code`; + params = []; + } else { + query = ` + SELECT id, defect_code, defect_name, defect_type, severity, company_code + FROM defect_standard_mng + WHERE is_active = 'Y' AND company_code = $1 + ORDER BY defect_code`; + params = [companyCode]; + } + + const result = await pool.query(query, params); + + logger.info("[pop/production] defect-types 조회", { + companyCode, + count: result.rowCount, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("[pop/production] defect-types 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "불량 유형 조회 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 실적 저장 (누적 방식) + * 이번 차수 생산수량을 기존 누적치에 더한다. + * result_status는 'draft' 유지 (확정 전까지 계속 추가 등록 가능) + */ +export const saveResult = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const { + work_order_process_id, + production_qty, + good_qty, + defect_qty, + defect_detail, + result_note, + } = req.body; + + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } + + if (!production_qty || parseInt(production_qty, 10) <= 0) { + return res.status(400).json({ + success: false, + message: "생산수량을 입력해주세요.", + }); + } + + const statusCheck = await pool.query( + `SELECT status, result_status, total_production_qty, good_qty, defect_qty, input_qty + FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + + if (statusCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "공정을 찾을 수 없습니다.", + }); + } + + const prev = statusCheck.rows[0]; + + if (prev.result_status === "confirmed") { + return res.status(403).json({ + success: false, + message: "이미 확정된 실적입니다. 추가 등록이 불가능합니다.", + }); + } + + // 실적 누적이 접수량을 초과하지 않도록 검증 + const prevTotal = parseInt(prev.total_production_qty, 10) || 0; + const acceptedQty = parseInt(prev.input_qty, 10) || 0; + const requestedQty = parseInt(production_qty, 10) || 0; + if (acceptedQty > 0 && (prevTotal + requestedQty) > acceptedQty) { + return res.status(400).json({ + success: false, + message: `실적 누적(${prevTotal + requestedQty})이 접수량(${acceptedQty})을 초과합니다. 추가 접수 후 등록해주세요.`, + }); + } + + let defectDetailStr: string | null = null; + if (defect_detail && Array.isArray(defect_detail)) { + const validated = defect_detail.map((item: DefectDetailItem) => ({ + defect_code: item.defect_code || "", + defect_name: item.defect_name || "", + qty: item.qty || "0", + disposition: item.disposition || "scrap", + })); + defectDetailStr = JSON.stringify(validated); + } + + const addProduction = parseInt(production_qty, 10) || 0; + const addGood = parseInt(good_qty, 10) || 0; + const addDefect = parseInt(defect_qty, 10) || 0; + + const newTotal = (parseInt(prev.total_production_qty, 10) || 0) + addProduction; + const newGood = (parseInt(prev.good_qty, 10) || 0) + addGood; + const newDefect = (parseInt(prev.defect_qty, 10) || 0) + addDefect; + + // 기존 defect_detail에 이번 차수 상세를 병합 + let mergedDefectDetail: string | null = null; + if (defectDetailStr) { + let existingEntries: DefectDetailItem[] = []; + try { + existingEntries = prev.defect_detail ? JSON.parse(prev.defect_detail) : []; + } catch { /* 파싱 실패 시 빈 배열 */ } + const newEntries: DefectDetailItem[] = JSON.parse(defectDetailStr); + // 같은 불량코드+처리방법 조합은 수량 합산 + const merged = [...existingEntries]; + for (const ne of newEntries) { + const existing = merged.find( + (e) => e.defect_code === ne.defect_code && e.disposition === ne.disposition + ); + if (existing) { + existing.qty = String( + (parseInt(existing.qty, 10) || 0) + (parseInt(ne.qty, 10) || 0) + ); + } else { + merged.push(ne); + } + } + mergedDefectDetail = JSON.stringify(merged); + } + + const result = await pool.query( + `UPDATE work_order_process + SET total_production_qty = $3, + good_qty = $4, + defect_qty = $5, + defect_detail = COALESCE($6, defect_detail), + result_note = COALESCE($7, result_note), + result_status = 'draft', + status = CASE WHEN status IN ('acceptable', 'waiting') THEN 'in_progress' ELSE status END, + writer = $8, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + RETURNING id, total_production_qty, good_qty, defect_qty, defect_detail, result_note, result_status, status`, + [ + work_order_process_id, + companyCode, + String(newTotal), + String(newGood), + String(newDefect), + mergedDefectDetail, + result_note || null, + userId, + ] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "공정을 찾을 수 없거나 권한이 없습니다.", + }); + } + + // 다음 공정 상태를 acceptable로 전환 (input_qty는 접수 버튼에서만 변경) + const currentSeq = await pool.query( + `SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_input_qty, + wi.qty as instruction_qty + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + WHERE wop.id = $1 AND wop.company_code = $2`, + [work_order_process_id, companyCode] + ); + + if (addGood > 0 && currentSeq.rowCount > 0) { + const { seq_no, wo_id } = currentSeq.rows[0]; + const nextSeq = String(parseInt(seq_no, 10) + 1); + const nextUpdate = await pool.query( + `UPDATE work_order_process + SET status = CASE WHEN status = 'waiting' THEN 'acceptable' ELSE status END, + updated_date = NOW() + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 + RETURNING id, process_name, status`, + [wo_id, nextSeq, companyCode] + ); + if (nextUpdate.rowCount > 0) { + logger.info("[pop/production] 다음 공정 상태 전환", { + nextProcess: nextUpdate.rows[0], + }); + } + } + + // 자동 완료 체크: 접수가능 잔여 0 + 접수한 수량 전부 완료 시 자동 completed + if (currentSeq.rowCount > 0) { + const { seq_no, wo_id, current_input_qty, instruction_qty } = currentSeq.rows[0]; + const seqNum = parseInt(seq_no, 10); + const myInputQty = parseInt(current_input_qty, 10) || 0; + const instrQty = parseInt(instruction_qty, 10) || 0; + + // 앞공정 완료량 계산 + let prevGoodQty = instrQty; + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await pool.query( + `SELECT COALESCE(good_qty::int, 0) as good_qty + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + [wo_id, prevSeq, companyCode] + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = prevProcess.rows[0].good_qty; + } + } + + const remainingAcceptable = prevGoodQty - myInputQty; + const allProduced = newTotal >= myInputQty && myInputQty > 0; + + if (remainingAcceptable <= 0 && allProduced) { + await pool.query( + `UPDATE work_order_process + SET status = 'completed', + result_status = 'confirmed', + completed_at = NOW()::text, + completed_by = $3, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + AND status != 'completed'`, + [work_order_process_id, companyCode, userId] + ); + logger.info("[pop/production] 자동 완료 처리", { + work_order_process_id, + remainingAcceptable, + newTotal, + myInputQty, + }); + } + } + + logger.info("[pop/production] save-result 완료 (누적)", { + companyCode, + work_order_process_id, + added: { production_qty: addProduction, good_qty: addGood, defect_qty: addDefect }, + accumulated: { total: newTotal, good: newGood, defect: newDefect }, + }); + + // 자동 완료 후 최신 데이터 반환 (status가 변경되었을 수 있음) + const latestData = await pool.query( + `SELECT id, total_production_qty, good_qty, defect_qty, defect_detail, result_note, result_status, status, input_qty + FROM work_order_process WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + + return res.json({ + success: true, + data: latestData.rows[0] || result.rows[0], + }); + } catch (error: any) { + logger.error("[pop/production] save-result 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "실적 저장 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 실적 확정은 더 이상 단일 확정이 아님. + * 마지막 등록 확인 용도로 유지. 실적은 save-result에서 차수별로 쌓임. + */ +export const confirmResult = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const { work_order_process_id } = req.body; + + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } + + const statusCheck = await pool.query( + `SELECT status, result_status, total_production_qty FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + + if (statusCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "공정을 찾을 수 없습니다.", + }); + } + + const currentProcess = statusCheck.rows[0]; + + if (!currentProcess.total_production_qty || + parseInt(currentProcess.total_production_qty, 10) <= 0) { + return res.status(400).json({ + success: false, + message: "등록된 실적이 없습니다. 실적을 먼저 등록해주세요.", + }); + } + + // 잔여 접수가능량 계산하여 completed 여부 결정 + const seqCheck = await pool.query( + `SELECT wop.seq_no, wop.wo_id, wop.input_qty, + wi.qty as instruction_qty + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + WHERE wop.id = $1 AND wop.company_code = $2`, + [work_order_process_id, companyCode] + ); + + let shouldComplete = false; + if (seqCheck.rowCount > 0) { + const { seq_no, wo_id, input_qty: currentInputQty, instruction_qty } = seqCheck.rows[0]; + const seqNum = parseInt(seq_no, 10); + const myInputQty = parseInt(currentInputQty, 10) || 0; + const instrQty = parseInt(instruction_qty, 10) || 0; + + let prevGoodQty = instrQty; + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await pool.query( + `SELECT COALESCE(good_qty::int, 0) as good_qty + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + [wo_id, prevSeq, companyCode] + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = prevProcess.rows[0].good_qty; + } + } + + const remainingAcceptable = prevGoodQty - myInputQty; + const totalProduced = parseInt(currentProcess.total_production_qty, 10) || 0; + shouldComplete = remainingAcceptable <= 0 && totalProduced >= myInputQty && myInputQty > 0; + } + + // shouldComplete = true: 전량 완료 -> completed + // shouldComplete = false: 부분 확정 -> batch_done (진행 탭에서 숨김) + const newStatus = shouldComplete ? "completed" : "batch_done"; + + const result = await pool.query( + `UPDATE work_order_process + SET result_status = 'confirmed', + status = $4, + completed_at = CASE WHEN $4 = 'completed' THEN NOW()::text ELSE completed_at END, + completed_by = CASE WHEN $4 = 'completed' THEN $3 ELSE completed_by END, + writer = $3, + updated_date = NOW() + WHERE id = $1 AND company_code = $2 + RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty`, + [work_order_process_id, companyCode, userId, newStatus] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "공정을 찾을 수 없습니다.", + }); + } + + // completed로 전환된 경우에만 다음 공정 활성화 + if (shouldComplete && seqCheck.rowCount > 0) { + const { seq_no, wo_id } = seqCheck.rows[0]; + const nextSeq = String(parseInt(seq_no, 10) + 1); + await pool.query( + `UPDATE work_order_process + SET status = CASE WHEN status = 'waiting' THEN 'acceptable' ELSE status END, + updated_date = NOW() + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + [wo_id, nextSeq, companyCode] + ); + } + + logger.info("[pop/production] confirm-result 완료", { + companyCode, + work_order_process_id, + userId, + shouldComplete, + newStatus, + finalStatus: result.rows[0].status, + }); + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("[pop/production] confirm-result 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "실적 확정 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 실적 이력 조회 (work_order_process_log에서 차수별 추출) + * total_production_qty 변경 이력 = 각 차수의 등록 기록 + */ +export const getResultHistory = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const { work_order_process_id } = req.query; + + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } + + // 소유권 확인 + const ownerCheck = await pool.query( + `SELECT id FROM work_order_process WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + if (ownerCheck.rowCount === 0) { + return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); + } + + // 같은 changed_at 기준으로 그룹핑하여 차수별 이력 추출 + // total_production_qty가 증가한(new_value > old_value) 로그만 = 실적 등록 시점 + const historyResult = await pool.query( + `WITH grouped AS ( + SELECT + changed_at, + MAX(changed_by) as changed_by, + MAX(CASE WHEN changed_column = 'total_production_qty' THEN old_value END) as total_old, + MAX(CASE WHEN changed_column = 'total_production_qty' THEN new_value END) as total_new, + MAX(CASE WHEN changed_column = 'good_qty' THEN old_value END) as good_old, + MAX(CASE WHEN changed_column = 'good_qty' THEN new_value END) as good_new, + MAX(CASE WHEN changed_column = 'defect_qty' THEN old_value END) as defect_old, + MAX(CASE WHEN changed_column = 'defect_qty' THEN new_value END) as defect_new + FROM work_order_process_log + WHERE original_id = $1 + AND changed_column IN ('total_production_qty', 'good_qty', 'defect_qty') + AND new_value IS NOT NULL + GROUP BY changed_at + ) + SELECT * FROM grouped + WHERE total_new IS NOT NULL + AND (COALESCE(total_new::int, 0) - COALESCE(total_old::int, 0)) > 0 + ORDER BY changed_at ASC`, + [work_order_process_id] + ); + + const batches = historyResult.rows.map((row: any, idx: number) => { + const batchQty = (parseInt(row.total_new, 10) || 0) - (parseInt(row.total_old, 10) || 0); + const batchGood = (parseInt(row.good_new, 10) || 0) - (parseInt(row.good_old, 10) || 0); + const batchDefect = (parseInt(row.defect_new, 10) || 0) - (parseInt(row.defect_old, 10) || 0); + + return { + seq: idx + 1, + batch_qty: batchQty, + batch_good: batchGood, + batch_defect: batchDefect, + accumulated_total: parseInt(row.total_new, 10) || 0, + changed_at: row.changed_at, + changed_by: row.changed_by, + }; + }); + + logger.info("[pop/production] result-history 조회", { + work_order_process_id, + batchCount: batches.length, + }); + + return res.json({ + success: true, + data: batches, + }); + } catch (error: any) { + logger.error("[pop/production] result-history 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "이력 조회 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 앞공정 완료량 + 접수가능량 조회 + * GET /api/pop/production/available-qty?work_order_process_id=xxx + * 반환: { prevGoodQty, myInputQty, availableQty, instructionQty } + */ +export const getAvailableQty = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { work_order_process_id } = req.query; + + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id가 필요합니다.", + }); + } + + const current = await pool.query( + `SELECT wop.seq_no, wop.wo_id, wop.input_qty, + wi.qty as instruction_qty + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + WHERE wop.id = $1 AND wop.company_code = $2`, + [work_order_process_id, companyCode] + ); + + if (current.rowCount === 0) { + return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); + } + + const { seq_no, wo_id, input_qty, instruction_qty } = current.rows[0]; + const myInputQty = parseInt(input_qty, 10) || 0; + const instrQty = parseInt(instruction_qty, 10) || 0; + const seqNum = parseInt(seq_no, 10); + + let prevGoodQty = instrQty; // 첫 공정이면 지시수량이 상한 + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await pool.query( + `SELECT COALESCE(good_qty::int, 0) as good_qty + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + [wo_id, prevSeq, companyCode] + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = prevProcess.rows[0].good_qty; + } + } + + const availableQty = Math.max(0, prevGoodQty - myInputQty); + + logger.info("[pop/production] available-qty 조회", { + work_order_process_id, + prevGoodQty, + myInputQty, + availableQty, + instructionQty: instrQty, + }); + + return res.json({ + success: true, + data: { + prevGoodQty, + myInputQty, + availableQty, + instructionQty: instrQty, + }, + }); + } catch (error: any) { + logger.error("[pop/production] available-qty 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "접수가능량 조회 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 공정 접수 (수량 지정) + * POST /api/pop/production/accept-process + * body: { work_order_process_id, accept_qty } + * - 접수 상한 = 앞공정.good_qty - 내.input_qty (첫 공정은 지시수량 - input_qty) + * - 추가 접수 가능 (in_progress 상태에서도) + * - status: acceptable/waiting -> in_progress (또는 이미 in_progress면 유지) + */ +export const acceptProcess = async (req: AuthenticatedRequest, res: Response) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { work_order_process_id, accept_qty } = req.body; + + if (!work_order_process_id || !accept_qty) { + return res.status(400).json({ + success: false, + message: "work_order_process_id와 accept_qty가 필요합니다.", + }); + } + + const qty = parseInt(accept_qty, 10); + if (qty <= 0) { + return res.status(400).json({ success: false, message: "접수 수량은 1 이상이어야 합니다." }); + } + + const current = await pool.query( + `SELECT wop.seq_no, wop.wo_id, wop.input_qty, wop.status, wop.accepted_by, + wi.qty as instruction_qty + FROM work_order_process wop + JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code + WHERE wop.id = $1 AND wop.company_code = $2`, + [work_order_process_id, companyCode] + ); + + if (current.rowCount === 0) { + return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); + } + + const { seq_no, wo_id, input_qty, status, instruction_qty } = current.rows[0]; + + if (status === "completed") { + return res.status(400).json({ success: false, message: "이미 완료된 공정입니다." }); + } + if (status !== "acceptable" && status !== "in_progress") { + return res.status(400).json({ success: false, message: `현재 상태(${status})에서는 접수할 수 없습니다.` }); + } + + const myInputQty = parseInt(input_qty, 10) || 0; + const instrQty = parseInt(instruction_qty, 10) || 0; + const seqNum = parseInt(seq_no, 10); + + // 앞공정 완료량 계산 + let prevGoodQty = instrQty; + if (seqNum > 1) { + const prevSeq = String(seqNum - 1); + const prevProcess = await pool.query( + `SELECT COALESCE(good_qty::int, 0) as good_qty + FROM work_order_process + WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3`, + [wo_id, prevSeq, companyCode] + ); + if (prevProcess.rowCount > 0) { + prevGoodQty = prevProcess.rows[0].good_qty; + } + } + + const availableQty = prevGoodQty - myInputQty; + if (qty > availableQty) { + return res.status(400).json({ + success: false, + message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수: ${myInputQty})`, + }); + } + + const newInputQty = myInputQty + qty; + const result = await pool.query( + `UPDATE work_order_process + SET input_qty = $3, + status = CASE WHEN status IN ('acceptable', 'waiting', 'batch_done') THEN 'in_progress' ELSE status END, + result_status = CASE WHEN result_status = 'confirmed' THEN 'draft' ELSE result_status END, + accepted_by = $4, + started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END, + updated_date = NOW(), + writer = $4 + WHERE id = $1 AND company_code = $2 + RETURNING id, input_qty, status, process_name, result_status`, + [work_order_process_id, companyCode, String(newInputQty), userId] + ); + + logger.info("[pop/production] accept-process 완료", { + companyCode, + userId, + work_order_process_id, + addedQty: qty, + newInputQty, + prevGoodQty, + }); + + return res.json({ + success: true, + data: result.rows[0], + message: `${qty}개 접수 완료 (총 접수: ${newInputQty})`, + }); + } catch (error: any) { + logger.error("[pop/production] accept-process 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "접수 중 오류가 발생했습니다.", + }); + } +}; + +/** + * 접수 취소: input_qty를 0으로 리셋하고 status를 acceptable로 되돌림 + * 조건: 아직 실적(total_production_qty)이 없어야 함 + */ +export const cancelAccept = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { work_order_process_id } = req.body; + + if (!work_order_process_id) { + return res.status(400).json({ + success: false, + message: "work_order_process_id는 필수입니다.", + }); + } + + const current = await pool.query( + `SELECT id, status, input_qty, total_production_qty, result_status + FROM work_order_process + WHERE id = $1 AND company_code = $2`, + [work_order_process_id, companyCode] + ); + + if (current.rowCount === 0) { + return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." }); + } + + const proc = current.rows[0]; + + if (proc.status !== "in_progress") { + return res.status(400).json({ + success: false, + message: `현재 상태(${proc.status})에서는 접수 취소할 수 없습니다. 진행중 상태만 가능합니다.`, + }); + } + + const totalProduced = parseInt(proc.total_production_qty ?? "0", 10) || 0; + const currentInputQty = parseInt(proc.input_qty ?? "0", 10) || 0; + + // 미소진 접수분 = input_qty - total_production_qty + const unproducedQty = currentInputQty - totalProduced; + + if (unproducedQty <= 0) { + return res.status(400).json({ + success: false, + message: "취소할 미소진 접수분이 없습니다. 모든 접수량에 대해 실적이 등록되었습니다.", + }); + } + + // input_qty를 total_production_qty로 되돌림 (실적 있는 분량만 유지) + // 실적이 0이면 완전 초기화, 실적이 있으면 부분 취소 + const newInputQty = totalProduced; + const newStatus = totalProduced > 0 ? "in_progress" : "acceptable"; + + const isFullCancel = newInputQty === 0; + const result = await pool.query( + `UPDATE work_order_process + SET input_qty = $3, + status = $4, + accepted_by = CASE WHEN $6 THEN NULL ELSE accepted_by END, + started_at = CASE WHEN $6 THEN NULL ELSE started_at END, + updated_date = NOW(), + writer = $5 + WHERE id = $1 AND company_code = $2 + RETURNING id, input_qty, status, process_name`, + [work_order_process_id, companyCode, String(newInputQty), newStatus, userId, isFullCancel] + ); + + logger.info("[pop/production] cancel-accept 완료", { + companyCode, + userId, + work_order_process_id, + previousInputQty: currentInputQty, + newInputQty, + cancelledQty: unproducedQty, + }); + + return res.json({ + success: true, + data: result.rows[0], + message: `미소진 ${unproducedQty}개 접수가 취소되었습니다. (잔여 접수량: ${newInputQty})`, + }); + } catch (error: any) { + logger.error("[pop/production] cancel-accept 오류:", error); + return res.status(500).json({ + success: false, + message: error.message || "접수 취소 중 오류가 발생했습니다.", + }); + } +}; diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts index c50f061a..57417797 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -4,6 +4,13 @@ import { createWorkProcesses, controlTimer, controlGroupTimer, + getDefectTypes, + saveResult, + confirmResult, + getResultHistory, + getAvailableQty, + acceptProcess, + cancelAccept, } from "../controllers/popProductionController"; const router = Router(); @@ -13,5 +20,12 @@ router.use(authenticateToken); router.post("/create-work-processes", createWorkProcesses); router.post("/timer", controlTimer); router.post("/group-timer", controlGroupTimer); +router.get("/defect-types", getDefectTypes); +router.post("/save-result", saveResult); +router.post("/confirm-result", confirmResult); +router.get("/result-history", getResultHistory); +router.get("/available-qty", getAvailableQty); +router.post("/accept-process", acceptProcess); +router.post("/cancel-accept", cancelAccept); export default router; diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index bc930e95..2a3121f0 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -116,6 +116,7 @@ function resolveStatusMappings(src: TimelineDataSource): StatusValueMapping[] { { 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: "batch_done", label: "접수분완료", semantic: "active" as const }, { dbValue: sv?.completed || "completed", label: "완료", semantic: "done" as const }, ]; } @@ -278,6 +279,8 @@ export function PopCardListV2Component({ }, [publish, setSharedData]); const handleCardSelect = useCallback((row: RowData) => { + // 복제 카드(접수가능 가상)는 클릭 시 모달을 열지 않음 - 접수 버튼으로만 동작 + if (row.__isAcceptClone) return; if (effectiveConfig?.cardClickAction === "modal-open" && effectiveConfig?.cardClickModalConfig?.screenId) { const mc = effectiveConfig.cardClickModalConfig; @@ -285,7 +288,13 @@ export function PopCardListV2Component({ const processFlow = row.__processFlow__ as { isCurrent: boolean; status?: string }[] | undefined; const currentProcess = processFlow?.find((s) => s.isCurrent); if (mc.condition.type === "timeline-status") { - if (currentProcess?.status !== mc.condition.value) return; + const condVal = mc.condition.value; + const curStatus = currentProcess?.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; } @@ -373,25 +382,71 @@ export function PopCardListV2Component({ const timelineSource = useMemo(() => { const cells = cardGrid?.cells || []; for (const c of cells) { - if ((c.type === "timeline" || c.type === "status-badge" || c.type === "action-buttons") && c.timelineSource?.processTable) { + 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]); - // 외부 필터 (메인 테이블 + 하위 테이블 분기) - const filteredRows = useMemo(() => { - if (externalFilters.size === 0) return rows; + // in_progress + 잔여 접수가능량 > 0인 카드를 복제하여 "접수가능" 탭에도 노출 + const duplicateAcceptableCards = useCallback((sourceRows: RowData[]): RowData[] => { + const result: RowData[] = []; + for (const row of sourceRows) { + result.push(row); + // 이미 복제된 카드는 다시 복제하지 않음 + if (row.__isAcceptClone) continue; - const allFilters = [...externalFilters.values()]; - const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable); - const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable); + const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; + if (!processFlow || processFlow.length === 0) continue; - // 1단계: 하위 테이블 필터 → __subStatus__ 주입 + const currentStep = processFlow.find((s) => s.isCurrent); + if (!currentStep) continue; + // in_progress 또는 batch_done 공정이면서 잔여 접수가능량 > 0인 경우만 복제 + if (currentStep.status !== "in_progress" && currentStep.status !== "batch_done") continue; + + const availableQty = Number(currentStep.rawData?.__availableQty ?? 0); + if (availableQty <= 0) continue; + + // 복제 카드: isCurrent를 해당 공정의 acceptable 가상 상태로 변경 + 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 = {}; + 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 - ? rows - : rows + ? sourceRows + : sourceRows .map((row) => { const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; if (!processFlow || processFlow.length === 0) return null; @@ -416,11 +471,19 @@ export function PopCardListV2Component({ if (matchingSteps.length === 0) return null; const matched = matchingSteps[0]; - // 매칭된 공정을 타임라인에서 강조 const updatedFlow = processFlow.map((s) => ({ ...s, isCurrent: s.seqNo === matched.seqNo, })); + // 서브 필터로 공정이 바뀌면 __process_* 필드도 재주입 + const processFields: Record = {}; + 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, @@ -428,14 +491,27 @@ export function PopCardListV2Component({ [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); - // 2단계: 메인 테이블 필터 (__subStatus__ 주입된 데이터 기반) - if (mainFilters.length === 0) return afterSubFilter; + // 카드 복제: in_progress + 잔여 접수가능량 > 0 → 접수가능 탭에도 노출 + return duplicateAcceptableCards(afterSubFilter); + }, [duplicateAcceptableCards]); - return afterSubFilter.filter((row) => + // 메인 필터 적용 (공통 함수) + 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; @@ -447,9 +523,6 @@ export function PopCardListV2Component({ if (columns.length === 0) return true; const mode = fc?.filterMode || "contains"; - // 하위 필터 활성 시: 상태 컬럼(status 등)을 __subStatus__로 대체 - const subCol = subFilters.length > 0 ? VIRTUAL_SUB_STATUS : null; - const statusCol = timelineSource?.statusColumn || "status"; const effectiveColumns = subCol ? columns.map((col) => col === statusCol || col === "status" ? subCol : col) : columns; @@ -464,7 +537,19 @@ export function PopCardListV2Component({ }); }), ); - }, [rows, externalFilters, timelineSource]); + }, [timelineSource]); + + // 외부 필터 (메인 테이블 + 하위 테이블 분기) + const filteredRows = useMemo(() => { + if (externalFilters.size === 0) return duplicateAcceptableCards(rows); + + const allFilters = [...externalFilters.values()]; + const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable); + const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable); + + const afterDuplicate = applySubFilterAndDuplicate(rows, subFilters); + return applyMainFilters(afterDuplicate, mainFilters, subFilters.length > 0); + }, [rows, externalFilters, duplicateAcceptableCards, applySubFilterAndDuplicate, applyMainFilters]); // 하위 필터 활성 여부 const hasActiveSubFilter = useMemo(() => { @@ -550,96 +635,32 @@ export function PopCardListV2Component({ }, [selectedRowIds, filteredRows, exitSelectMode]); // status-bar 필터를 제외한 rows (카운트 집계용) - // status-bar에서 "접수가능" 등 선택해도 전체 카운트가 유지되어야 함 const rowsForStatusCount = useMemo(() => { const hasStatusBarFilter = [...externalFilters.values()].some((f) => f._source === "status-bar"); if (!hasStatusBarFilter) return filteredRows; - // status-bar 필터를 제외한 필터만 적용 const nonStatusFilters = new Map( [...externalFilters.entries()].filter(([, f]) => f._source !== "status-bar") ); - if (nonStatusFilters.size === 0) return rows; + if (nonStatusFilters.size === 0) return duplicateAcceptableCards(rows); const allFilters = [...nonStatusFilters.values()]; const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable); const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable); - const afterSubFilter = subFilters.length === 0 - ? rows - : rows - .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, - })); - 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, - }; - }) - .filter((row): row is RowData => row !== null); - - if (mainFilters.length === 0) return afterSubFilter; - - return afterSubFilter.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 subCol = subFilters.length > 0 ? VIRTUAL_SUB_STATUS : null; - const statusCol = timelineSource?.statusColumn || "status"; - 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); - } - }); - }), - ); - }, [rows, filteredRows, externalFilters, timelineSource]); + const afterDuplicate = applySubFilterAndDuplicate(rows, subFilters); + return applyMainFilters(afterDuplicate, mainFilters, subFilters.length > 0); + }, [rows, filteredRows, externalFilters, duplicateAcceptableCards, applySubFilterAndDuplicate, applyMainFilters]); // 카운트 집계용 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]); @@ -809,6 +830,35 @@ export function PopCardListV2Component({ 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; + } + } + return fetchedRows.map((row) => { const steps = processMap.get(String(row.id)) || []; const current = steps.find((s) => s.isCurrent); @@ -818,6 +868,11 @@ export function PopCardListV2Component({ processFields[`__process_${key}`] = val; } } + // row 레벨에 현재 공정의 접수가능/전공정양품 주입 (process-qty-summary 셀용) + if (current?.rawData) { + processFields.__availableQty = current.rawData.__availableQty ?? 0; + processFields.__prevGoodQty = current.rawData.__prevGoodQty ?? 0; + } return { ...row, __processFlow__: steps, ...processFields }; }); }, []); @@ -1086,9 +1141,12 @@ export function PopCardListV2Component({ const locked = !!ownerSortColumn && !!String(row[ownerSortColumn] ?? "") && String(row[ownerSortColumn] ?? "") !== (currentUserId ?? ""); + const cardKey = row.__isAcceptClone + ? `card-clone-${row.__cloneSourceId}-${index}` + : `card-${row.id ?? index}`; return ( (null); + // 수량 모달이 열려 있을 때 카드 클릭 차단 (모달 닫힘 직후 이벤트 전파 방지) + const qtyModalClosedAtRef = useRef(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; - setQtyModalState(null); - if (!action.targetTable || !action.updates) return; + 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; @@ -1309,7 +1411,8 @@ function CardV2({ toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); } } - }, [qtyModalState, onRefresh]); + closeQtyModal(); + }, [qtyModalState, onRefresh, closeQtyModal]); const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : ""; const isCarted = cart.isItemInCart(rowKey); @@ -1387,6 +1490,9 @@ function CardV2({ ? isSelected ? "border-primary border-2 hover:border-primary/80" : "hover:border-2 hover:border-blue-500" : isCarted ? "border-emerald-500 border-2 hover:border-emerald-600" : "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 (
@@ -1417,15 +1523,18 @@ function CardV2({ return (
{ if (isLockedByOther) return; + if (qtyModalState?.open) return; + if (Date.now() - qtyModalClosedAtRef.current < 500) return; if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } if (!selectMode) onSelect?.(row); }} @@ -1434,6 +1543,7 @@ function CardV2({ onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { if (isLockedByOther) return; + if (qtyModalState?.open) return; if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } if (!selectMode) onSelect?.(row); } @@ -1499,9 +1609,30 @@ function CardV2({ onEnterSelectMode, onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => { const cfg = buttonConfig as Record | undefined; - const allActions = (cfg?.__allActions as ActionButtonClickAction[] | undefined) || []; const processId = cfg?.__processId as string | number | undefined; + // 접수 취소 처리 (__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 @@ -1519,7 +1650,28 @@ function CardV2({ for (const action of actionsToRun) { if (action.type === "quantity-input" && action.targetTable && action.updates) { if (action.confirmMessage && !window.confirm(action.confirmMessage)) return; - setQtyModalState({ open: true, row: actionRow, processId, action }); + + // 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) { @@ -1603,9 +1755,9 @@ function CardV2({ {qtyModalState?.open && ( { if (!open) setQtyModalState(null); }} + onOpenChange={(open) => { if (!open) closeQtyModal(); }} unit={qtyModalState.action.quantityInput?.unit || "EA"} - maxValue={calculateMaxQty(qtyModalState.row, qtyModalState.processId, qtyModalState.action.quantityInput)} + maxValue={qtyModalState.dynamicMax ?? calculateMaxQty(qtyModalState.row, qtyModalState.processId, qtyModalState.action.quantityInput)} showPackageUnit={qtyModalState.action.quantityInput?.enablePackage ?? false} onConfirm={(value) => handleQtyConfirm(value)} /> diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx index 180dc219..e3d450bc 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx @@ -101,6 +101,10 @@ export function renderCellV2(props: CellRendererProps): React.ReactNode { return ; case "footer-status": return ; + case "process-qty-summary": + return ; + case "mes-process-card": + return ; default: return 알 수 없는 셀 타입; } @@ -327,6 +331,7 @@ const STATUS_COLORS: Record = { waiting: { bg: "#94a3b820", text: "#64748b" }, accepted: { bg: "#3b82f620", text: "#2563eb" }, in_progress: { bg: "#f59e0b20", text: "#d97706" }, + batch_done: { bg: "#8b5cf620", text: "#7c3aed" }, completed: { bg: "#10b98120", text: "#059669" }, }; @@ -349,17 +354,28 @@ function StatusBadgeCell({ cell, row }: CellRendererProps) { ); } - const defaultColors = STATUS_COLORS[strValue]; + // in_progress + 접수분 전부 생산 완료 → "접수분완료" 표시 + let displayValue = strValue; + if (strValue === "in_progress") { + const inputQty = parseInt(String(row.input_qty ?? "0"), 10) || 0; + const totalProd = parseInt(String(row.total_production_qty ?? "0"), 10) || 0; + if (inputQty > 0 && totalProd >= inputQty) { + displayValue = "batch_done"; + } + } + + const defaultColors = STATUS_COLORS[displayValue]; if (defaultColors) { const labelMap: Record = { - waiting: "대기", accepted: "접수", in_progress: "진행", completed: "완료", + waiting: "대기", accepted: "접수", in_progress: "진행중", + batch_done: "접수분완료", completed: "완료", }; return ( - {labelMap[strValue] || strValue} + {labelMap[displayValue] || displayValue} ); } @@ -601,7 +617,11 @@ function evaluateShowCondition(btn: ActionButtonDef, row: RowData, currentUserId if (cond.type === "timeline-status") { const subStatus = row[VIRTUAL_SUB_STATUS]; - matched = subStatus !== undefined && String(subStatus) === cond.value; + if (Array.isArray(cond.value)) { + matched = subStatus !== undefined && cond.value.includes(String(subStatus)); + } else { + matched = subStatus !== undefined && String(subStatus) === cond.value; + } } else if (cond.type === "column-value" && cond.column) { matched = String(row[cond.column] ?? "") === (cond.value ?? ""); } else if (cond.type === "owner-match" && cond.column) { @@ -621,10 +641,22 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, const currentProcessId = currentProcess?.processId; if (cell.actionButtons && cell.actionButtons.length > 0) { - const evaluated = cell.actionButtons.map((btn) => ({ - btn, - state: evaluateShowCondition(btn, row, currentUserId), - })); + const evaluated = cell.actionButtons.map((btn) => { + let state = evaluateShowCondition(btn, row, currentUserId); + // 접수가능 조건 버튼이 원본 카드의 in_progress에서 보이지 않도록 차단 + // (접수는 접수가능 탭의 클론 카드에서만 가능) + if (state === "visible" && !row.__isAcceptClone) { + const cond = btn.showCondition; + if (cond?.type === "timeline-status") { + const condValues = Array.isArray(cond.value) ? cond.value : [cond.value]; + const currentSubStatus = String(row[VIRTUAL_SUB_STATUS] ?? ""); + if (condValues.includes("acceptable") && currentSubStatus === "in_progress") { + state = "hidden"; + } + } + } + return { btn, state }; + }); const activeBtn = evaluated.find((e) => e.state === "visible"); const disabledBtn = activeBtn ? null : evaluated.find((e) => e.state === "disabled"); @@ -633,6 +665,14 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, const { btn, state } = pick; + // in_progress 상태 + 미소진 접수분 존재 시 접수취소 버튼 추가 + const subStatus = row[VIRTUAL_SUB_STATUS]; + const effectiveStatus = subStatus !== undefined ? String(subStatus) : ""; + const rowInputQty = parseInt(String(row.input_qty ?? "0"), 10) || 0; + const totalProduced = parseInt(String(row.total_production_qty ?? "0"), 10) || 0; + const hasUnproduced = rowInputQty > totalProduced; + const showCancelBtn = effectiveStatus === "in_progress" && hasUnproduced && currentProcessId; + return (
+ {showCancelBtn && ( + + )}
); } @@ -703,7 +759,205 @@ function ActionButtonsCell({ cell, row, onActionButtonClick, onEnterSelectMode, ); } -// ===== 12. footer-status ===== +// ===== 12. process-qty-summary ===== + +function ProcessQtySummaryCell({ cell, row }: CellRendererProps) { + const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; + const status = String(row[VIRTUAL_SUB_STATUS] ?? row.status ?? ""); + const isClone = !!row.__isAcceptClone; + + const instructionQty = parseInt(String(row.instruction_qty ?? "0"), 10) || 0; + const inputQty = parseInt(String(row.input_qty ?? "0"), 10) || 0; + const totalProd = parseInt(String(row.total_production_qty ?? "0"), 10) || 0; + const goodQty = parseInt(String(row.good_qty ?? "0"), 10) || 0; + const defectQty = parseInt(String(row.defect_qty ?? "0"), 10) || 0; + const availableQty = parseInt(String(row.__availableQty ?? "0"), 10) || 0; + const prevGoodQty = parseInt(String(row.__prevGoodQty ?? String(instructionQty)), 10) || 0; + + const currentStep = processFlow?.find((s) => s.isCurrent); + const currentIdx = processFlow?.findIndex((s) => s.isCurrent) ?? -1; + const isFirstProcess = currentIdx === 0; + const totalSteps = processFlow?.length ?? 0; + + const remainingQty = Math.max(0, inputQty - totalProd); + const progressPct = inputQty > 0 ? Math.min(100, Math.round((totalProd / inputQty) * 100)) : 0; + const yieldRate = totalProd > 0 ? Math.round((goodQty / totalProd) * 100) : 0; + + // 접수가능 탭 (클론 카드) - 접수 가능 수량 중심 + if (isClone || status === "acceptable" || status === "waiting") { + const showQty = isClone ? availableQty : (status === "acceptable" ? availableQty || prevGoodQty : 0); + return ( +
+ {/* 미니 공정 흐름 바 */} + {processFlow && processFlow.length > 1 && ( + + )} + {/* 핵심 수량 */} +
+
+ 지시 + {instructionQty.toLocaleString()} +
+ {!isFirstProcess && ( +
+ 전공정양품 + {prevGoodQty.toLocaleString()} +
+ )} +
+ 접수가능 + {(showQty || prevGoodQty).toLocaleString()} +
+
+
+ ); + } + + // 진행중 / 접수분완료 - 작업 현황 중심 + if (status === "in_progress") { + const isBatchDone = inputQty > 0 && totalProd >= inputQty; + return ( +
+ {/* 미니 공정 흐름 바 */} + {processFlow && processFlow.length > 1 && ( + + )} + {/* 프로그레스 바 */} +
+
+
+
+ + {progressPct}% + +
+ {/* 수량 상세 */} +
+ + + + 0 ? "#f59e0b" : "#10b981"} /> +
+ {/* 추가접수가능 수량 (있을 때만) */} + {availableQty > 0 && ( +
+ 추가접수가능 + {availableQty.toLocaleString()} +
+ )} +
+ ); + } + + // 완료 상태 - 최종 결과 요약 + if (status === "completed") { + return ( +
+ {/* 미니 공정 흐름 바 */} + {processFlow && processFlow.length > 1 && ( + + )} + {/* 완료 프로그레스 */} +
+
+
+
+ 완료 +
+ {/* 최종 수량 */} +
+ + + + {totalProd > 0 && ( +
+ 수율 + {yieldRate}% +
+ )} +
+
+ ); + } + + // fallback + return ( +
+ 지시수량 + {instructionQty.toLocaleString()} +
+ ); +} + +// --- 미니 공정 흐름 바 --- +function MiniProcessBar({ steps, currentIdx }: { steps: TimelineProcessStep[]; currentIdx: number }) { + return ( +
+ {steps.map((step, idx) => { + const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending"; + const isCurrent = idx === currentIdx; + let bg = "#e2e8f0"; // pending + if (sem === "done") bg = "#10b981"; + else if (sem === "active") bg = "#3b82f6"; + + const pct = step.totalProductionQty && step.inputQty && step.inputQty > 0 + ? Math.round((step.totalProductionQty / step.inputQty) * 100) + : undefined; + + return ( +
+
+
+ ); + })} +
+ ); +} + +// --- 수량 칩 --- +function QtyChip({ + label, value, color, showZero = true, +}: { + label: string; value: number; color: string; showZero?: boolean; +}) { + if (!showZero && value === 0) return null; + return ( +
+ {label} + + {value.toLocaleString()} + +
+ ); +} + +// ===== 13. footer-status ===== function FooterStatusCell({ cell, row }: CellRendererProps) { const value = cell.footerStatusColumn ? row[cell.footerStatusColumn] : ""; @@ -735,3 +989,402 @@ function FooterStatusCell({ cell, row }: CellRendererProps) {
); } + +// ===== 14. mes-process-card (MES 공정 전용 카드) ===== + +const MES_STATUS: Record = { + waiting: { label: "대기", color: "#94a3b8", bg: "#f8fafc" }, + acceptable: { label: "접수가능", color: "#2563eb", bg: "#eff6ff" }, + in_progress: { label: "진행중", color: "#d97706", bg: "#fffbeb" }, + batch_done: { label: "접수분완료", color: "#7c3aed", bg: "#f5f3ff" }, + completed: { label: "완료", color: "#059669", bg: "#ecfdf5" }, +}; + +function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: CellRendererProps) { + const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; + const rawStatus = String(row[VIRTUAL_SUB_STATUS] ?? row.status ?? ""); + const isClone = !!row.__isAcceptClone; + const [flowModalOpen, setFlowModalOpen] = useState(false); + + const instrQty = parseInt(String(row.qty ?? row.instruction_qty ?? "0"), 10) || 0; + const inputQty = parseInt(String(row.__process_input_qty ?? row.input_qty ?? "0"), 10) || 0; + const totalProd = parseInt(String(row.__process_total_production_qty ?? row.total_production_qty ?? "0"), 10) || 0; + const goodQty = parseInt(String(row.__process_good_qty ?? row.good_qty ?? "0"), 10) || 0; + const defectQty = parseInt(String(row.__process_defect_qty ?? row.defect_qty ?? "0"), 10) || 0; + const availableQty = parseInt(String(row.__availableQty ?? "0"), 10) || 0; + const prevGoodQty = parseInt(String(row.__prevGoodQty ?? String(instrQty)), 10) || 0; + const resultStatus = String(row.__process_result_status ?? ""); + + const currentStep = processFlow?.find((s) => s.isCurrent); + const currentIdx = processFlow?.findIndex((s) => s.isCurrent) ?? -1; + const isFirstProcess = currentIdx === 0; + const processId = currentStep?.processId; + + const remainingQty = Math.max(0, inputQty - totalProd); + const progressPct = inputQty > 0 ? Math.min(100, Math.round((totalProd / inputQty) * 100)) : 0; + const yieldRate = totalProd > 0 ? Math.round((goodQty / totalProd) * 100) : 0; + + const isBatchDone = rawStatus === "in_progress" && inputQty > 0 && totalProd >= inputQty; + let displayStatus = rawStatus; + if (isBatchDone) displayStatus = "batch_done"; + const st = MES_STATUS[displayStatus] || MES_STATUS.waiting; + + const processName = currentStep?.processName || String(row.__process_process_name ?? ""); + const woNo = String(row.work_instruction_no ?? ""); + const itemId = String(row.item_id ?? ""); + + // MES 워크플로우 상태 기반 버튼 결정 + const acceptBtn = (cell.actionButtons || []).find((b) => b.showCondition?.type === "timeline-status"); + const cancelBtn = (cell.actionButtons || []).find((b) => b.showCondition?.type === "owner-match"); + + let activeBtn: ActionButtonDef | undefined; + if (isClone) { + activeBtn = acceptBtn; + } else if (rawStatus === "acceptable") { + activeBtn = acceptBtn; + } else if (rawStatus === "batch_done") { + if (availableQty > 0) activeBtn = acceptBtn; + } else if (rawStatus === "in_progress") { + if (isBatchDone || resultStatus === "confirmed") { + if (availableQty > 0) activeBtn = acceptBtn; + } else { + activeBtn = cancelBtn; + } + } + return ( + <> +
+ {/* ── 헤더 ── */} +
+
+
+ {woNo} + {itemId && itemId !== "-" && ( + {itemId} + )} +
+ {processName && ( +
+ {processName} + {processFlow && processFlow.length > 1 && ( + + ({currentIdx + 1}/{processFlow.length}공정) + + )} +
+ )} +
+ + {st.label} + +
+ + {/* ── 수량 메트릭 (상태별) ── */} +
+ {(isClone || rawStatus === "acceptable" || rawStatus === "waiting") && ( + + )} + {(rawStatus === "in_progress" || rawStatus === "batch_done") && ( + + )} + {rawStatus === "completed" && ( + + )} +
+ + {/* ── 공정 흐름 스트립 (클릭 시 모달) ── */} + {processFlow && processFlow.length > 0 && ( +
{ e.stopPropagation(); setFlowModalOpen(true); }} + title="클릭하여 공정 상세 보기" + > + +
+ )} + + {/* ── 하단: 부가정보 + 액션 ── */} +
+
+ {row.end_date && 납기 {formatValue(row.end_date)}} + {row.equipment_id && {String(row.equipment_id)}} + {row.work_team && {String(row.work_team)}} +
+
+ {activeBtn && ( + + )} +
+
+
+ + {/* ── 공정 상세 모달 ── */} + + + + {woNo} 공정 현황 + + {processFlow?.length ?? 0}개 공정 중 {processFlow?.filter(s => (s.semantic || LEGACY_STATUS_TO_SEMANTIC[s.status]) === "done").length ?? 0}개 완료 + + +
+ {processFlow?.map((step, idx) => { + const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending"; + const styles = getTimelineStyle(step); + const sInstr = instrQty; + const sInput = step.inputQty || 0; + const sProd = step.totalProductionQty || 0; + const sGood = step.goodQty || 0; + const sDefect = step.defectQty || 0; + const sYield = step.yieldRate || 0; + const sPct = sInput > 0 ? Math.round((sProd / sInput) * 100) : (sem === "done" ? 100 : 0); + const statusLabel = sem === "done" ? "완료" : sem === "active" ? "진행" : "대기"; + + return ( +
+
+ {idx > 0 &&
} +
+ {step.seqNo} +
+ {idx < (processFlow?.length ?? 0) - 1 &&
} +
+
+
+
+ + {step.processName} + + + {statusLabel} + +
+ {(sInput > 0 || sem === "done") && ( +
+ 양품 {sGood.toLocaleString()} + {sDefect > 0 && 불량 {sDefect.toLocaleString()}} + 수율 = 95 ? "#059669" : sYield >= 80 ? "#d97706" : "#ef4444" }}>{sYield}% +
+ )} +
+
+ {sProd}/{sInput || sInstr} +
+
+
+
+
+
+ ); + })} +
+ +
+ + ); +} + +// ── 공정 흐름 스트립 (카드 내 표시) ── +function ProcessFlowStrip({ steps, currentIdx, instrQty }: { + steps: TimelineProcessStep[]; currentIdx: number; instrQty: number; +}) { + const maxShow = 5; + const showAll = steps.length <= maxShow; + const visible = showAll ? steps : steps.slice(0, maxShow); + const hiddenCount = steps.length - visible.length; + + return ( +
+ {visible.map((step, idx) => { + const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending"; + const isCurr = idx === currentIdx; + const sInput = step.inputQty || 0; + const sProd = step.totalProductionQty || 0; + const sGood = step.goodQty || 0; + const pct = sInput > 0 ? Math.round((sProd / sInput) * 100) : (sem === "done" ? 100 : 0); + + let barColor = "#e2e8f0"; + if (sem === "done") barColor = "#10b981"; + else if (sem === "active") barColor = "#3b82f6"; + + return ( + +
+ {isCurr && ( +
+ )} + + {step.processName} + +
+
+
+ + {sem === "pending" && sInput === 0 ? "-" : `${sGood}/${sInput || instrQty}`} + +
+ {idx < visible.length - 1 && ( +
+ )} + + ); + })} + {hiddenCount > 0 && ( +
+ +{hiddenCount} +
+ )} +
+ ); +} + +// ── 접수가능 메트릭 ── +function MesAcceptableMetrics({ instrQty, prevGoodQty, availableQty, inputQty, isFirstProcess, isClone }: { + instrQty: number; prevGoodQty: number; availableQty: number; inputQty: number; isFirstProcess: boolean; isClone: boolean; +}) { + const displayAvail = isClone ? availableQty : (availableQty || prevGoodQty); + return ( +
+
+ 지시 {instrQty.toLocaleString()} + {!isFirstProcess && ( + 전공정양품 {prevGoodQty.toLocaleString()} + )} + {inputQty > 0 && ( + 기접수 {inputQty.toLocaleString()} + )} +
+
+ 접수가능  + {displayAvail.toLocaleString()} +
+
+ ); +} + +// ── 진행중 메트릭 ── +function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, remainingQty, progressPct, availableQty, isBatchDone, statusColor }: { + inputQty: number; totalProd: number; goodQty: number; defectQty: number; remainingQty: number; progressPct: number; availableQty: number; isBatchDone: boolean; statusColor: string; +}) { + return ( +
+ {/* 메인 프로그레스 */} +
+ 접수 {inputQty.toLocaleString()} +
+
+
+ {progressPct}% +
+ {/* 수량 4칸 */} +
+ + + + 0 ? "#f59e0b" : "#10b981"} /> +
+ {availableQty > 0 && ( +
+ 추가접수가능 {availableQty.toLocaleString()} +
+ )} +
+ ); +} + +// ── 완료 메트릭 ── +function MesCompletedMetrics({ instrQty, goodQty, defectQty, yieldRate }: { + instrQty: number; goodQty: number; defectQty: number; yieldRate: number; +}) { + return ( +
+
+ 지시 {instrQty.toLocaleString()} + 최종양품 {goodQty.toLocaleString()} + 수율 = 95 ? "#059669" : yieldRate >= 80 ? "#d97706" : "#ef4444" }}>{yieldRate}% +
+ {defectQty > 0 && ( +
불량 {defectQty.toLocaleString()}
+ )} +
+ ); +} + +// ── 메트릭 박스 ── +function MesMetricBox({ label, value, color, dimZero = false }: { + label: string; value: number; color: string; dimZero?: boolean; +}) { + const isDim = dimZero && value === 0; + return ( +
+ {label} + {value.toLocaleString()} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx index 4136d144..a3015be3 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx @@ -229,6 +229,8 @@ export function NumberInputModal({ e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} > 수량 입력 {/* 헤더 */} diff --git a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx index 805fadcd..8cdead89 100644 --- a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx @@ -25,6 +25,7 @@ export function PopStatusBarComponent({ const [selectedValue, setSelectedValue] = useState(""); const [allRows, setAllRows] = useState[]>([]); const [autoSubStatusColumn, setAutoSubStatusColumn] = useState(null); + const [originalCount, setOriginalCount] = useState(null); // all_rows 이벤트 구독 useEffect(() => { @@ -47,13 +48,16 @@ export function PopStatusBarComponent({ const envelope = inner as { rows?: unknown; subStatusColumn?: string | null; + originalCount?: number; }; if (Array.isArray(envelope.rows)) setAllRows(envelope.rows as Record[]); setAutoSubStatusColumn(envelope.subStatusColumn ?? null); + setOriginalCount(envelope.originalCount ?? null); } else if (Array.isArray(inner)) { setAllRows(inner as Record[]); setAutoSubStatusColumn(null); + setOriginalCount(null); } } ); @@ -130,7 +134,7 @@ export function PopStatusBarComponent({ return map; }, [allRows, effectiveCountColumn, showCount]); - const totalCount = allRows.length; + const totalCount = originalCount ?? allRows.length; const chipItems = useMemo(() => { const items: { value: string; label: string; count: number }[] = []; diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx index 5aa894cf..da9dcf17 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx @@ -3,19 +3,21 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from "react"; import { Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package, - ChevronLeft, ChevronRight, Check, X, CircleDot, + ChevronLeft, ChevronRight, Check, X, CircleDot, ClipboardList, + Plus, Trash2, Save, FileCheck, } 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 { Textarea } from "@/components/ui/textarea"; 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 { PopWorkDetailConfig, ResultSectionConfig } from "../types"; import type { TimelineProcessStep } from "../types"; // ======================================== @@ -86,6 +88,26 @@ interface ProcessTimerData { status: string; good_qty: string | null; defect_qty: string | null; + total_production_qty: string | null; + defect_detail: string | null; + result_note: string | null; + result_status: string | null; + input_qty: string | null; +} + +interface DefectDetailEntry { + defect_code: string; + defect_name: string; + qty: string; + disposition: string; +} + +interface DefectTypeOption { + id: string; + defect_code: string; + defect_name: string; + defect_type: string; + severity: string; } const DEFAULT_INFO_FIELDS = [ @@ -163,6 +185,13 @@ export function PopWorkDetailComponent({ const [currentItemIdx, setCurrentItemIdx] = useState(0); const [showQuantityPanel, setShowQuantityPanel] = useState(false); + // 실적 입력 탭 상태 + const [resultTabActive, setResultTabActive] = useState(false); + const hasResultSections = !!(cfg.resultSections && cfg.resultSections.some((s) => s.enabled)); + + // 불량 유형 목록 (부모에서 1회 로드, ResultPanel에 전달) + const [cachedDefectTypes, setCachedDefectTypes] = useState([]); + const contentRef = useRef(null); // ======================================== @@ -204,6 +233,18 @@ export function PopWorkDetailComponent({ fetchData(); }, [fetchData]); + useEffect(() => { + const loadDefectTypes = async () => { + try { + const res = await apiClient.get("/pop/production/defect-types"); + if (res.data?.success) { + setCachedDefectTypes(res.data.data || []); + } + } catch { /* 실패 시 빈 배열 유지 */ } + }; + loadDefectTypes(); + }, []); + // ======================================== // 좌측 사이드바 - 작업항목 그룹핑 // ======================================== @@ -260,8 +301,10 @@ export function PopWorkDetailComponent({ useEffect(() => { if (groups.length > 0 && !selectedGroupId) { setSelectedGroupId(groups[0].itemId); + } else if (groups.length === 0 && hasResultSections && !resultTabActive) { + setResultTabActive(true); } - }, [groups, selectedGroupId]); + }, [groups, selectedGroupId, hasResultSections, resultTabActive]); // 현재 선택 인덱스 const selectedIndex = useMemo( @@ -446,10 +489,6 @@ export function PopWorkDetailComponent({ // 단계 시작/활성화 // ======================================== - const handleStepStart = useCallback((itemId: string) => { - setActiveStepIds((prev) => new Set(prev).add(itemId)); - }, []); - const isStepLocked = useMemo(() => { if (!cfg.stepControl.requireStartBeforeInput) return false; if (!selectedGroupId) return true; @@ -470,19 +509,7 @@ export function PopWorkDetailComponent({ return () => clearInterval(id); }, [cfg.showTimer, processData?.started_at, groups]); - 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]); - - // formattedTime은 제거 - 그룹별 타이머로 대체됨 - // isPaused, isStarted는 프로세스 레벨 (사용하지 않으나 processData 참조용으로 유지) + // 프로세스 레벨 타이머는 그룹별 타이머로 대체됨 // ======================================== // 그룹별 타이머 @@ -639,7 +666,9 @@ export function PopWorkDetailComponent({ ); } - if (allResults.length === 0) { + const isProcessCompleted = processData?.status === "completed"; + + if (allResults.length === 0 && !hasResultSections) { return (
@@ -647,8 +676,6 @@ export function PopWorkDetailComponent({
); } - - const isProcessCompleted = processData?.status === "completed"; const selectedGroup = groups.find((g) => g.itemId === selectedGroupId); // ======================================== @@ -691,6 +718,7 @@ export function PopWorkDetailComponent({ )} onClick={() => { setSelectedGroupId(g.itemId); + setResultTabActive(false); contentRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }} > @@ -714,10 +742,59 @@ export function PopWorkDetailComponent({
); })} + + {/* 실적 입력 탭 (resultSections가 설정된 경우만) */} + {cfg.resultSections && cfg.resultSections.some((s) => s.enabled) && ( + <> +
+ + + )}
{/* 우측 콘텐츠 */}
+ {/* 실적 입력 패널 (hidden으로 상태 유지, unmount 방지) */} + {hasResultSections && ( +
+ { + setProcessData((prev) => prev ? { ...prev, ...updated } : prev); + publish("process_completed", { workOrderProcessId, status: updated?.status }); + }} + /> +
+ )} + + {/* 체크리스트 영역 */} +
{cfg.displayMode === "step" ? ( /* ======== 스텝 모드 ======== */ <> @@ -727,7 +804,7 @@ export function PopWorkDetailComponent({

모든 작업 항목이 완료되었습니다

- {cfg.showQuantityInput && !isProcessCompleted && ( + {cfg.showQuantityInput && !isProcessCompleted && !hasResultSections && (

실적 수량 등록

@@ -906,7 +983,7 @@ export function PopWorkDetailComponent({ {/* 하단 네비게이션 + 수량/완료 */}
- {cfg.showQuantityInput && ( + {cfg.showQuantityInput && !hasResultSections && (
@@ -965,12 +1042,497 @@ export function PopWorkDetailComponent({
)} +
); } +// ======================================== +// 실적 입력 패널 (분할 실적 누적 방식) +// ======================================== + +const DISPOSITION_OPTIONS = [ + { value: "scrap", label: "폐기" }, + { value: "rework", label: "재작업" }, + { value: "downgrade", label: "등급하향" }, + { value: "return", label: "반품" }, + { value: "accept", label: "특채" }, +]; + +interface BatchHistoryItem { + seq: number; + batch_qty: number; + batch_good: number; + batch_defect: number; + accumulated_total: number; + changed_at: string; + changed_by: string | null; +} + +interface ResultPanelProps { + workOrderProcessId: string; + processData: ProcessTimerData | null; + sections: ResultSectionConfig[]; + isProcessCompleted: boolean; + defectTypes: DefectTypeOption[]; + onSaved: (updated: Partial) => void; +} + +function ResultPanel({ + workOrderProcessId, + processData, + sections, + isProcessCompleted, + defectTypes, + onSaved, +}: ResultPanelProps) { + // 이번 차수 입력값 (누적치가 아닌 이번에 생산한 수량) + const [batchQty, setBatchQty] = useState(""); + const [batchDefect, setBatchDefect] = useState(""); + const [resultNote, setResultNote] = useState(""); + const [defectEntries, setDefectEntries] = useState([]); + const [saving, setSaving] = useState(false); + const [confirming, setConfirming] = useState(false); + const [history, setHistory] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [availableInfo, setAvailableInfo] = useState<{ + prevGoodQty: number; + availableQty: number; + instructionQty: number; + } | null>(null); + + const isConfirmed = processData?.result_status === "confirmed"; + + const inputQty = parseInt(processData?.input_qty ?? "0", 10) || 0; + const accumulatedTotal = parseInt(processData?.total_production_qty ?? "0", 10) || 0; + const accumulatedGood = parseInt(processData?.good_qty ?? "0", 10) || 0; + const accumulatedDefect = parseInt(processData?.defect_qty ?? "0", 10) || 0; + const remainingQty = Math.max(0, inputQty - accumulatedTotal); + + const batchGood = useMemo(() => { + const production = parseInt(batchQty, 10) || 0; + const defect = parseInt(batchDefect, 10) || 0; + return Math.max(0, production - defect); + }, [batchQty, batchDefect]); + + const totalDefectFromEntries = useMemo( + () => defectEntries.reduce((sum, e) => sum + (parseInt(e.qty, 10) || 0), 0), + [defectEntries] + ); + + useEffect(() => { + if (totalDefectFromEntries > 0) { + setBatchDefect(String(totalDefectFromEntries)); + } + }, [totalDefectFromEntries]); + + const enabledSections = sections.filter((s) => s.enabled); + + // 접수가능량 로드 + const loadAvailableQty = useCallback(async () => { + try { + const res = await apiClient.get("/pop/production/available-qty", { + params: { work_order_process_id: workOrderProcessId }, + }); + if (res.data?.success) { + setAvailableInfo(res.data.data); + } + } catch { /* ignore */ } + }, [workOrderProcessId]); + + useEffect(() => { + loadAvailableQty(); + }, [loadAvailableQty]); + + // 이력 로드 + const loadHistory = useCallback(async () => { + setHistoryLoading(true); + try { + const res = await apiClient.get("/pop/production/result-history", { + params: { work_order_process_id: workOrderProcessId }, + }); + if (res.data?.success) { + setHistory(res.data.data || []); + } + } catch { /* 실패 시 빈 배열 유지 */ } + finally { setHistoryLoading(false); } + }, [workOrderProcessId]); + + useEffect(() => { + loadHistory(); + }, [loadHistory]); + + const addDefectEntry = () => { + setDefectEntries((prev) => [ + ...prev, + { defect_code: "", defect_name: "", qty: "", disposition: "scrap" }, + ]); + }; + + const removeDefectEntry = (idx: number) => { + setDefectEntries((prev) => prev.filter((_, i) => i !== idx)); + }; + + const updateDefectEntry = (idx: number, field: keyof DefectDetailEntry, value: string) => { + setDefectEntries((prev) => + prev.map((e, i) => { + if (i !== idx) return e; + const updated = { ...e, [field]: value }; + if (field === "defect_code") { + const found = defectTypes.find((dt) => dt.defect_code === value); + if (found) updated.defect_name = found.defect_name; + } + return updated; + }) + ); + }; + + const resetForm = () => { + setBatchQty(""); + setBatchDefect(""); + setResultNote(""); + setDefectEntries([]); + }; + + const handleSubmitBatch = async () => { + if (!batchQty || parseInt(batchQty, 10) <= 0) { + toast.error("생산수량을 입력해주세요."); + return; + } + const batchNum = parseInt(batchQty, 10); + if (inputQty > 0 && (accumulatedTotal + batchNum) > inputQty) { + toast.error(`생산수량(${batchNum})이 잔여량(${remainingQty})을 초과합니다.`); + return; + } + setSaving(true); + try { + const res = await apiClient.post("/pop/production/save-result", { + work_order_process_id: workOrderProcessId, + production_qty: batchQty, + good_qty: String(batchGood), + defect_qty: batchDefect || "0", + defect_detail: defectEntries.length > 0 ? defectEntries : null, + result_note: resultNote || null, + }); + if (res.data?.success) { + const savedData = res.data.data; + if (savedData?.status === "completed") { + toast.success("모든 수량이 완료되어 자동 확정되었습니다."); + } else { + toast.success(`${batchQty}개 실적이 등록되었습니다.`); + } + onSaved(savedData); + resetForm(); + loadHistory(); + loadAvailableQty(); + } else { + toast.error(res.data?.message || "실적 등록에 실패했습니다."); + } + } catch { + toast.error("실적 등록 중 오류가 발생했습니다."); + } finally { + setSaving(false); + } + }; + + const handleConfirm = async () => { + if (accumulatedTotal <= 0) { + toast.error("등록된 실적이 없습니다."); + return; + } + setConfirming(true); + try { + const res = await apiClient.post("/pop/production/confirm-result", { + work_order_process_id: workOrderProcessId, + }); + if (res.data?.success) { + toast.success("실적이 확정되었습니다."); + onSaved({ ...res.data.data, result_status: "confirmed" }); + } else { + toast.error(res.data?.message || "실적 확정에 실패했습니다."); + } + } catch { + toast.error("실적 확정 중 오류가 발생했습니다."); + } finally { + setConfirming(false); + } + }; + + return ( +
+
+ {/* 확정 상태 배너 */} + {isConfirmed && ( +
+ + 실적이 확정되었습니다 +
+ )} + + {/* 공정 현황: 접수량 / 작업완료 / 잔여 + 앞공정 완료량 */} +
+
공정 현황
+
+
+
{inputQty}
+
접수량
+
+
+
{accumulatedTotal}
+
작업완료
+
+
+
0 ? "text-amber-600" : "text-green-600"}`}> + {remainingQty} +
+
잔여
+
+ {availableInfo && availableInfo.availableQty > 0 && ( +
+
{availableInfo.availableQty}
+
추가접수가능
+
+ )} +
+ {inputQty > 0 && ( +
+
+
+ )} + {availableInfo && ( +
+ 앞공정 완료: {availableInfo.prevGoodQty} + 지시수량: {availableInfo.instructionQty} +
+ )} +
+ + {/* 누적 실적 현황 */} +
+
누적 실적
+
+
+
{accumulatedTotal}
+
총생산
+
+
+
{accumulatedGood}
+
양품
+
+
+
{accumulatedDefect}
+
불량
+
+
+
{history.length}
+
차수
+
+
+ {accumulatedTotal > 0 && ( +
+
0 ? (accumulatedGood / accumulatedTotal) * 100 : 0}%` }} + /> +
+ )} +
+ + {/* 이번 차수 실적 입력 */} + {!isConfirmed && ( +
+
이번 차수 실적
+ + {/* 생산수량 */} + {enabledSections.some((s) => s.type === "total-qty") && ( +
+ +
+ setBatchQty(e.target.value)} + placeholder="0" + /> + EA +
+
+ )} + + {/* 양품/불량 */} + {enabledSections.some((s) => s.type === "good-defect") && ( +
+ +
+
+ 양품 + 0 ? String(batchGood) : ""} + readOnly + placeholder="자동" + /> +
+
+ 불량 + setBatchDefect(e.target.value)} + placeholder="0" + /> +
+
+ {(parseInt(batchQty, 10) || 0) > 0 && ( +

+ 양품 {batchGood} = 생산 {batchQty} - 불량 {batchDefect || 0} +

+ )} +
+ )} + + {/* 불량 유형 상세 */} + {enabledSections.some((s) => s.type === "defect-types") && ( +
+
+ + +
+ {defectEntries.length === 0 ? ( +

등록된 불량 유형이 없습니다.

+ ) : ( +
+ {defectEntries.map((entry, idx) => ( +
+ + updateDefectEntry(idx, "qty", e.target.value)} + placeholder="수량" + /> + + +
+ ))} +
+ )} +
+ )} + + {/* 비고 */} + {enabledSections.some((s) => s.type === "note") && ( +
+ +