diff --git a/.playwright-mcp/pop-page-initial.png b/.playwright-mcp/pop-page-initial.png new file mode 100644 index 00000000..b14666b3 Binary files /dev/null and b/.playwright-mcp/pop-page-initial.png differ diff --git a/frontend/app/(pop)/layout.tsx b/frontend/app/(pop)/layout.tsx new file mode 100644 index 00000000..1c41d1c0 --- /dev/null +++ b/frontend/app/(pop)/layout.tsx @@ -0,0 +1,10 @@ +import "@/app/globals.css"; + +export const metadata = { + title: "POP - 생산실적관리", + description: "생산 현장 실적 관리 시스템", +}; + +export default function PopLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/frontend/app/(pop)/pop/page.tsx b/frontend/app/(pop)/pop/page.tsx new file mode 100644 index 00000000..3cf5de33 --- /dev/null +++ b/frontend/app/(pop)/pop/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { PopDashboard } from "@/components/pop/dashboard"; + +export default function PopPage() { + return ; +} diff --git a/frontend/app/(pop)/pop/work/page.tsx b/frontend/app/(pop)/pop/work/page.tsx new file mode 100644 index 00000000..15608959 --- /dev/null +++ b/frontend/app/(pop)/pop/work/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { PopApp } from "@/components/pop"; + +export default function PopWorkPage() { + return ; +} + diff --git a/frontend/app/(pop)/work/page.tsx b/frontend/app/(pop)/work/page.tsx new file mode 100644 index 00000000..15608959 --- /dev/null +++ b/frontend/app/(pop)/work/page.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { PopApp } from "@/components/pop"; + +export default function PopWorkPage() { + return ; +} + diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 06b7bd27..b332f5a0 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -388,4 +388,183 @@ select { border-spacing: 0 !important; } +/* ===== POP (Production Operation Panel) Styles ===== */ + +/* POP 전용 다크 테마 변수 */ +.pop-dark { + /* 배경 색상 */ + --pop-bg-deepest: 8 12 21; + --pop-bg-deep: 10 15 28; + --pop-bg-primary: 13 19 35; + --pop-bg-secondary: 18 26 47; + --pop-bg-tertiary: 25 35 60; + --pop-bg-elevated: 32 45 75; + + /* 네온 강조색 */ + --pop-neon-cyan: 0 212 255; + --pop-neon-cyan-bright: 0 240 255; + --pop-neon-cyan-dim: 0 150 190; + --pop-neon-pink: 255 0 102; + --pop-neon-purple: 138 43 226; + + /* 상태 색상 */ + --pop-success: 0 255 136; + --pop-success-dim: 0 180 100; + --pop-warning: 255 170 0; + --pop-warning-dim: 200 130 0; + --pop-danger: 255 51 51; + --pop-danger-dim: 200 40 40; + + /* 텍스트 색상 */ + --pop-text-primary: 255 255 255; + --pop-text-secondary: 180 195 220; + --pop-text-muted: 100 120 150; + + /* 테두리 색상 */ + --pop-border: 40 55 85; + --pop-border-light: 55 75 110; +} + +/* POP 전용 라이트 테마 변수 */ +.pop-light { + --pop-bg-deepest: 245 247 250; + --pop-bg-deep: 240 243 248; + --pop-bg-primary: 250 251 253; + --pop-bg-secondary: 255 255 255; + --pop-bg-tertiary: 245 247 250; + --pop-bg-elevated: 235 238 245; + + --pop-neon-cyan: 0 122 204; + --pop-neon-cyan-bright: 0 140 230; + --pop-neon-cyan-dim: 0 100 170; + --pop-neon-pink: 220 38 127; + --pop-neon-purple: 118 38 200; + + --pop-success: 22 163 74; + --pop-success-dim: 21 128 61; + --pop-warning: 245 158 11; + --pop-warning-dim: 217 119 6; + --pop-danger: 220 38 38; + --pop-danger-dim: 185 28 28; + + --pop-text-primary: 15 23 42; + --pop-text-secondary: 71 85 105; + --pop-text-muted: 148 163 184; + + --pop-border: 226 232 240; + --pop-border-light: 203 213 225; +} + +/* POP 배경 그리드 패턴 */ +.pop-bg-pattern::before { + content: ""; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: repeating-linear-gradient(90deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px), + repeating-linear-gradient(0deg, rgba(0, 212, 255, 0.03) 0px, transparent 1px, transparent 60px), + radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 212, 255, 0.08) 0%, transparent 60%); + pointer-events: none; + z-index: 0; +} + +.pop-light .pop-bg-pattern::before { + background: repeating-linear-gradient(90deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px), + repeating-linear-gradient(0deg, rgba(0, 122, 204, 0.02) 0px, transparent 1px, transparent 60px), + radial-gradient(ellipse 80% 50% at 50% 0%, rgba(0, 122, 204, 0.05) 0%, transparent 60%); +} + +/* POP 글로우 효과 */ +.pop-glow-cyan { + box-shadow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.3); +} + +.pop-glow-cyan-strong { + box-shadow: 0 0 10px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.5), 0 0 50px rgba(0, 212, 255, 0.3); +} + +.pop-glow-success { + box-shadow: 0 0 15px rgba(0, 255, 136, 0.5); +} + +.pop-glow-warning { + box-shadow: 0 0 15px rgba(255, 170, 0, 0.5); +} + +.pop-glow-danger { + box-shadow: 0 0 15px rgba(255, 51, 51, 0.5); +} + +/* POP 펄스 글로우 애니메이션 */ +@keyframes pop-pulse-glow { + 0%, + 100% { + box-shadow: 0 0 5px rgba(0, 212, 255, 0.5); + } + 50% { + box-shadow: 0 0 20px rgba(0, 212, 255, 0.8), 0 0 30px rgba(0, 212, 255, 0.4); + } +} + +.pop-animate-pulse-glow { + animation: pop-pulse-glow 2s ease-in-out infinite; +} + +/* POP 프로그레스 바 샤인 애니메이션 */ +@keyframes pop-progress-shine { + 0% { + opacity: 0; + transform: translateX(-20px); + } + 50% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translateX(20px); + } +} + +.pop-progress-shine::after { + content: ""; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 20px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3)); + animation: pop-progress-shine 1.5s ease-in-out infinite; +} + +/* POP 스크롤바 스타일 */ +.pop-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.pop-scrollbar::-webkit-scrollbar-track { + background: rgb(var(--pop-bg-secondary)); +} + +.pop-scrollbar::-webkit-scrollbar-thumb { + background: rgb(var(--pop-border-light)); + border-radius: 9999px; +} + +.pop-scrollbar::-webkit-scrollbar-thumb:hover { + background: rgb(var(--pop-neon-cyan-dim)); +} + +/* POP 스크롤바 숨기기 */ +.pop-hide-scrollbar::-webkit-scrollbar { + display: none; +} + +.pop-hide-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} + /* ===== End of Global Styles ===== */ diff --git a/frontend/components/pop/PopAcceptModal.tsx b/frontend/components/pop/PopAcceptModal.tsx new file mode 100644 index 00000000..06f1759e --- /dev/null +++ b/frontend/components/pop/PopAcceptModal.tsx @@ -0,0 +1,131 @@ +"use client"; + +import React from "react"; +import { X, Info } from "lucide-react"; +import { WorkOrder } from "./types"; + +interface PopAcceptModalProps { + isOpen: boolean; + workOrder: WorkOrder | null; + quantity: number; + onQuantityChange: (qty: number) => void; + onConfirm: (quantity: number) => void; + onClose: () => void; +} + +export function PopAcceptModal({ + isOpen, + workOrder, + quantity, + onQuantityChange, + onConfirm, + onClose, +}: PopAcceptModalProps) { + if (!isOpen || !workOrder) return null; + + const acceptedQty = workOrder.acceptedQuantity || 0; + const remainingQty = workOrder.orderQuantity - acceptedQty; + + const handleAdjust = (delta: number) => { + const newQty = Math.max(1, Math.min(quantity + delta, remainingQty)); + onQuantityChange(newQty); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const val = parseInt(e.target.value) || 0; + const newQty = Math.max(0, Math.min(val, remainingQty)); + onQuantityChange(newQty); + }; + + const handleConfirm = () => { + if (quantity > 0) { + onConfirm(quantity); + } + }; + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

작업 접수

+ +
+ +
+
+ {/* 작업지시 정보 */} +
+
{workOrder.id}
+
+ {workOrder.itemName} ({workOrder.spec}) +
+
+ 지시수량: {workOrder.orderQuantity} EA | 기 접수: {acceptedQty} EA +
+
+ + {/* 수량 입력 */} +
+ +
+ + + + + +
+
미접수 수량: {remainingQty} EA
+
+ + {/* 분할접수 안내 */} + {quantity < remainingQty && ( +
+ + + +
+
분할 접수
+
+ {quantity}EA 접수 후 {remainingQty - quantity}EA가 접수대기 상태로 남습니다. +
+
+
+ )} +
+
+ +
+ + +
+
+
+ ); +} + diff --git a/frontend/components/pop/PopApp.tsx b/frontend/components/pop/PopApp.tsx new file mode 100644 index 00000000..b1eb6551 --- /dev/null +++ b/frontend/components/pop/PopApp.tsx @@ -0,0 +1,462 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import "./styles.css"; + +import { + AppState, + ModalState, + PanelState, + StatusType, + ProductionType, + WorkOrder, + WorkStep, + Equipment, + Process, +} from "./types"; +import { WORK_ORDERS, EQUIPMENTS, PROCESSES, WORK_STEP_TEMPLATES, STATUS_TEXT } from "./data"; + +import { PopHeader } from "./PopHeader"; +import { PopStatusTabs } from "./PopStatusTabs"; +import { PopWorkCard } from "./PopWorkCard"; +import { PopBottomNav } from "./PopBottomNav"; +import { PopEquipmentModal } from "./PopEquipmentModal"; +import { PopProcessModal } from "./PopProcessModal"; +import { PopAcceptModal } from "./PopAcceptModal"; +import { PopSettingsModal } from "./PopSettingsModal"; +import { PopProductionPanel } from "./PopProductionPanel"; + +export function PopApp() { + // 앱 상태 + const [appState, setAppState] = useState({ + currentStatus: "waiting", + selectedEquipment: null, + selectedProcess: null, + selectedWorkOrder: null, + showMyWorkOnly: false, + currentWorkSteps: [], + currentStepIndex: 0, + currentProductionType: "work-order", + selectionMode: "single", + completionAction: "close", + acceptTargetWorkOrder: null, + acceptQuantity: 0, + theme: "dark", + }); + + // 모달 상태 + const [modalState, setModalState] = useState({ + equipment: false, + process: false, + accept: false, + settings: false, + }); + + // 패널 상태 + const [panelState, setPanelState] = useState({ + production: false, + }); + + // 현재 시간 (hydration 에러 방지를 위해 초기값 null) + const [currentDateTime, setCurrentDateTime] = useState(null); + const [isClient, setIsClient] = useState(false); + + // 작업지시 목록 (상태 변경을 위해 로컬 상태로 관리) + const [workOrders, setWorkOrders] = useState(WORK_ORDERS); + + // 클라이언트 마운트 확인 및 시계 업데이트 + useEffect(() => { + setIsClient(true); + setCurrentDateTime(new Date()); + + const timer = setInterval(() => { + setCurrentDateTime(new Date()); + }, 1000); + return () => clearInterval(timer); + }, []); + + // 로컬 스토리지에서 설정 로드 + useEffect(() => { + const savedSelectionMode = localStorage.getItem("selectionMode") as "single" | "multi" | null; + const savedCompletionAction = localStorage.getItem("completionAction") as "close" | "stay" | null; + const savedTheme = localStorage.getItem("popTheme") as "dark" | "light" | null; + + setAppState((prev) => ({ + ...prev, + selectionMode: savedSelectionMode || "single", + completionAction: savedCompletionAction || "close", + theme: savedTheme || "dark", + })); + }, []); + + // 상태별 카운트 계산 + const getStatusCounts = useCallback(() => { + const myProcessId = appState.selectedProcess?.id; + + let waitingCount = 0; + let pendingAcceptCount = 0; + let inProgressCount = 0; + let completedCount = 0; + + workOrders.forEach((wo) => { + if (!wo.processFlow) return; + + const myProcessIndex = myProcessId + ? wo.processFlow.findIndex((step) => step.id === myProcessId) + : -1; + + if (wo.status === "completed") { + completedCount++; + } else if (wo.status === "in-progress" && wo.accepted) { + inProgressCount++; + } else if (myProcessIndex >= 0) { + const currentProcessIndex = wo.currentProcessIndex || 0; + const myStep = wo.processFlow[myProcessIndex]; + + if (currentProcessIndex < myProcessIndex) { + waitingCount++; + } else if (currentProcessIndex === myProcessIndex && myStep.status !== "completed") { + pendingAcceptCount++; + } else if (myStep.status === "completed") { + completedCount++; + } + } else { + if (wo.status === "waiting") waitingCount++; + else if (wo.status === "in-progress") inProgressCount++; + } + }); + + return { waitingCount, pendingAcceptCount, inProgressCount, completedCount }; + }, [workOrders, appState.selectedProcess]); + + // 필터링된 작업 목록 + const getFilteredWorkOrders = useCallback(() => { + const myProcessId = appState.selectedProcess?.id; + let filtered: WorkOrder[] = []; + + workOrders.forEach((wo) => { + if (!wo.processFlow) return; + + const myProcessIndex = myProcessId + ? wo.processFlow.findIndex((step) => step.id === myProcessId) + : -1; + const currentProcessIndex = wo.currentProcessIndex || 0; + const myStep = myProcessIndex >= 0 ? wo.processFlow[myProcessIndex] : null; + + switch (appState.currentStatus) { + case "waiting": + if (myProcessIndex >= 0 && currentProcessIndex < myProcessIndex) { + filtered.push(wo); + } else if (!myProcessId && wo.status === "waiting") { + filtered.push(wo); + } + break; + + case "pending-accept": + if ( + myProcessIndex >= 0 && + currentProcessIndex === myProcessIndex && + myStep && + myStep.status !== "completed" && + !wo.accepted + ) { + filtered.push(wo); + } + break; + + case "in-progress": + if (wo.accepted && wo.status === "in-progress") { + filtered.push(wo); + } else if (!myProcessId && wo.status === "in-progress") { + filtered.push(wo); + } + break; + + case "completed": + if (wo.status === "completed") { + filtered.push(wo); + } else if (myStep && myStep.status === "completed") { + filtered.push(wo); + } + break; + } + }); + + // 내 작업만 보기 필터 + if (appState.showMyWorkOnly && myProcessId) { + filtered = filtered.filter((wo) => { + const mySteps = wo.processFlow.filter((step) => step.id === myProcessId); + if (mySteps.length === 0) return false; + return !mySteps.every((step) => step.status === "completed"); + }); + } + + return filtered; + }, [workOrders, appState.currentStatus, appState.selectedProcess, appState.showMyWorkOnly]); + + // 상태 탭 변경 + const handleStatusChange = (status: StatusType) => { + setAppState((prev) => ({ ...prev, currentStatus: status })); + }; + + // 생산 유형 변경 + const handleProductionTypeChange = (type: ProductionType) => { + setAppState((prev) => ({ ...prev, currentProductionType: type })); + }; + + // 내 작업만 보기 토글 + const handleMyWorkToggle = () => { + setAppState((prev) => ({ ...prev, showMyWorkOnly: !prev.showMyWorkOnly })); + }; + + // 테마 토글 + const handleThemeToggle = () => { + const newTheme = appState.theme === "dark" ? "light" : "dark"; + setAppState((prev) => ({ ...prev, theme: newTheme })); + localStorage.setItem("popTheme", newTheme); + }; + + // 모달 열기/닫기 + const openModal = (type: keyof ModalState) => { + setModalState((prev) => ({ ...prev, [type]: true })); + }; + + const closeModal = (type: keyof ModalState) => { + setModalState((prev) => ({ ...prev, [type]: false })); + }; + + // 설비 선택 + const handleEquipmentSelect = (equipment: Equipment) => { + setAppState((prev) => ({ + ...prev, + selectedEquipment: equipment, + // 공정이 1개면 자동 선택 + selectedProcess: + equipment.processIds.length === 1 + ? PROCESSES.find((p) => p.id === equipment.processIds[0]) || null + : null, + })); + }; + + // 공정 선택 + const handleProcessSelect = (process: Process) => { + setAppState((prev) => ({ ...prev, selectedProcess: process })); + }; + + // 작업 접수 모달 열기 + const handleOpenAcceptModal = (workOrder: WorkOrder) => { + const acceptedQty = workOrder.acceptedQuantity || 0; + const remainingQty = workOrder.orderQuantity - acceptedQty; + + setAppState((prev) => ({ + ...prev, + acceptTargetWorkOrder: workOrder, + acceptQuantity: remainingQty, + })); + openModal("accept"); + }; + + // 접수 확인 + const handleConfirmAccept = (quantity: number) => { + if (!appState.acceptTargetWorkOrder) return; + + setWorkOrders((prev) => + prev.map((wo) => { + if (wo.id === appState.acceptTargetWorkOrder!.id) { + const previousAccepted = wo.acceptedQuantity || 0; + const newAccepted = previousAccepted + quantity; + return { + ...wo, + acceptedQuantity: newAccepted, + remainingQuantity: wo.orderQuantity - newAccepted, + accepted: true, + status: "in-progress" as const, + isPartialAccept: newAccepted < wo.orderQuantity, + }; + } + return wo; + }) + ); + + closeModal("accept"); + setAppState((prev) => ({ + ...prev, + acceptTargetWorkOrder: null, + acceptQuantity: 0, + })); + }; + + // 접수 취소 + const handleCancelAccept = (workOrderId: string) => { + setWorkOrders((prev) => + prev.map((wo) => { + if (wo.id === workOrderId) { + return { + ...wo, + accepted: false, + acceptedQuantity: 0, + remainingQuantity: wo.orderQuantity, + isPartialAccept: false, + status: "waiting" as const, + }; + } + return wo; + }) + ); + }; + + // 생산진행 패널 열기 + const handleOpenProductionPanel = (workOrder: WorkOrder) => { + const template = WORK_STEP_TEMPLATES[workOrder.process] || WORK_STEP_TEMPLATES["default"]; + const workSteps: WorkStep[] = template.map((step) => ({ + ...step, + status: "pending" as const, + startTime: null, + endTime: null, + data: {}, + })); + + setAppState((prev) => ({ + ...prev, + selectedWorkOrder: workOrder, + currentWorkSteps: workSteps, + currentStepIndex: 0, + })); + setPanelState((prev) => ({ ...prev, production: true })); + }; + + // 생산진행 패널 닫기 + const handleCloseProductionPanel = () => { + setPanelState((prev) => ({ ...prev, production: false })); + setAppState((prev) => ({ + ...prev, + selectedWorkOrder: null, + currentWorkSteps: [], + currentStepIndex: 0, + })); + }; + + // 설정 저장 + const handleSaveSettings = (selectionMode: "single" | "multi", completionAction: "close" | "stay") => { + setAppState((prev) => ({ ...prev, selectionMode, completionAction })); + localStorage.setItem("selectionMode", selectionMode); + localStorage.setItem("completionAction", completionAction); + closeModal("settings"); + }; + + const statusCounts = getStatusCounts(); + const filteredWorkOrders = getFilteredWorkOrders(); + + return ( +
+
+ {/* 헤더 */} + openModal("equipment")} + onProcessClick={() => openModal("process")} + onMyWorkToggle={handleMyWorkToggle} + onSearchClick={() => { + /* 조회 */ + }} + onSettingsClick={() => openModal("settings")} + onThemeToggle={handleThemeToggle} + /> + + {/* 상태 탭 */} + + + {/* 메인 콘텐츠 */} +
+ {filteredWorkOrders.length === 0 ? ( +
+
작업이 없습니다
+
+ {appState.currentStatus === "waiting" && "대기 중인 작업이 없습니다"} + {appState.currentStatus === "pending-accept" && "접수 대기 작업이 없습니다"} + {appState.currentStatus === "in-progress" && "진행 중인 작업이 없습니다"} + {appState.currentStatus === "completed" && "완료된 작업이 없습니다"} +
+
+ ) : ( +
+ {filteredWorkOrders.map((workOrder) => ( + handleOpenAcceptModal(workOrder)} + onCancelAccept={() => handleCancelAccept(workOrder.id)} + onStartProduction={() => handleOpenProductionPanel(workOrder)} + onClick={() => handleOpenProductionPanel(workOrder)} + /> + ))} +
+ )} +
+ + {/* 하단 네비게이션 */} + +
+ + {/* 모달들 */} + closeModal("equipment")} + /> + + closeModal("process")} + /> + + setAppState((prev) => ({ ...prev, acceptQuantity: qty }))} + onConfirm={handleConfirmAccept} + onClose={() => closeModal("accept")} + /> + + closeModal("settings")} + /> + + {/* 생산진행 패널 */} + setAppState((prev) => ({ ...prev, currentStepIndex: index }))} + onStepsUpdate={(steps) => setAppState((prev) => ({ ...prev, currentWorkSteps: steps }))} + onClose={handleCloseProductionPanel} + /> +
+ ); +} + diff --git a/frontend/components/pop/PopBottomNav.tsx b/frontend/components/pop/PopBottomNav.tsx new file mode 100644 index 00000000..f3fb86ae --- /dev/null +++ b/frontend/components/pop/PopBottomNav.tsx @@ -0,0 +1,30 @@ +"use client"; + +import React from "react"; +import { Clock, ClipboardList } from "lucide-react"; + +export function PopBottomNav() { + const handleHistoryClick = () => { + console.log("작업이력 클릭"); + // TODO: 작업이력 페이지 이동 또는 모달 열기 + }; + + const handleRegisterClick = () => { + console.log("실적등록 클릭"); + // TODO: 실적등록 모달 열기 + }; + + return ( +
+ + +
+ ); +} + diff --git a/frontend/components/pop/PopEquipmentModal.tsx b/frontend/components/pop/PopEquipmentModal.tsx new file mode 100644 index 00000000..cfae902f --- /dev/null +++ b/frontend/components/pop/PopEquipmentModal.tsx @@ -0,0 +1,80 @@ +"use client"; + +import React from "react"; +import { X } from "lucide-react"; +import { Equipment } from "./types"; + +interface PopEquipmentModalProps { + isOpen: boolean; + equipments: Equipment[]; + selectedEquipment: Equipment | null; + onSelect: (equipment: Equipment) => void; + onClose: () => void; +} + +export function PopEquipmentModal({ + isOpen, + equipments, + selectedEquipment, + onSelect, + onClose, +}: PopEquipmentModalProps) { + const [tempSelected, setTempSelected] = React.useState(selectedEquipment); + + React.useEffect(() => { + setTempSelected(selectedEquipment); + }, [selectedEquipment, isOpen]); + + const handleConfirm = () => { + if (tempSelected) { + onSelect(tempSelected); + onClose(); + } + }; + + if (!isOpen) return null; + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

설비 선택

+ +
+ +
+
+ {equipments.map((equip) => ( +
setTempSelected(equip)} + > +
+
{equip.name}
+
{equip.processNames.join(", ")}
+
+ ))} +
+
+ +
+ + +
+
+
+ ); +} + diff --git a/frontend/components/pop/PopHeader.tsx b/frontend/components/pop/PopHeader.tsx new file mode 100644 index 00000000..b2266eef --- /dev/null +++ b/frontend/components/pop/PopHeader.tsx @@ -0,0 +1,123 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Moon, Sun } from "lucide-react"; +import { Equipment, Process, ProductionType } from "./types"; + +interface PopHeaderProps { + currentDateTime: Date; + productionType: ProductionType; + selectedEquipment: Equipment | null; + selectedProcess: Process | null; + showMyWorkOnly: boolean; + theme: "dark" | "light"; + onProductionTypeChange: (type: ProductionType) => void; + onEquipmentClick: () => void; + onProcessClick: () => void; + onMyWorkToggle: () => void; + onSearchClick: () => void; + onSettingsClick: () => void; + onThemeToggle: () => void; +} + +export function PopHeader({ + currentDateTime, + productionType, + selectedEquipment, + selectedProcess, + showMyWorkOnly, + theme, + onProductionTypeChange, + onEquipmentClick, + onProcessClick, + onMyWorkToggle, + onSearchClick, + onSettingsClick, + onThemeToggle, +}: PopHeaderProps) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const formatDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + + const formatTime = (date: Date) => { + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${hours}:${minutes}`; + }; + + return ( +
+ {/* 1행: 날짜/시간 + 테마 토글 + 작업지시/원자재 */} +
+
+ {mounted ? formatDate(currentDateTime) : "----.--.--"} + {mounted ? formatTime(currentDateTime) : "--:--"} +
+ + {/* 테마 토글 버튼 */} + + +
+ +
+ + +
+
+ + {/* 2행: 필터 버튼들 */} +
+ + + + +
+ + + +
+
+ ); +} + diff --git a/frontend/components/pop/PopProcessModal.tsx b/frontend/components/pop/PopProcessModal.tsx new file mode 100644 index 00000000..74f72c7e --- /dev/null +++ b/frontend/components/pop/PopProcessModal.tsx @@ -0,0 +1,92 @@ +"use client"; + +import React from "react"; +import { X } from "lucide-react"; +import { Equipment, Process } from "./types"; + +interface PopProcessModalProps { + isOpen: boolean; + selectedEquipment: Equipment | null; + selectedProcess: Process | null; + processes: Process[]; + onSelect: (process: Process) => void; + onClose: () => void; +} + +export function PopProcessModal({ + isOpen, + selectedEquipment, + selectedProcess, + processes, + onSelect, + onClose, +}: PopProcessModalProps) { + const [tempSelected, setTempSelected] = React.useState(selectedProcess); + + React.useEffect(() => { + setTempSelected(selectedProcess); + }, [selectedProcess, isOpen]); + + const handleConfirm = () => { + if (tempSelected) { + onSelect(tempSelected); + onClose(); + } + }; + + if (!isOpen || !selectedEquipment) return null; + + // 선택된 설비의 공정만 필터링 + const availableProcesses = selectedEquipment.processIds.map((processId, index) => { + const process = processes.find((p) => p.id === processId); + return { + id: processId, + name: selectedEquipment.processNames[index], + code: process?.code || "", + }; + }); + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

공정 선택

+ +
+ +
+
+ {availableProcesses.map((process) => ( +
setTempSelected(process as Process)} + > +
+
{process.name}
+
{process.code}
+
+ ))} +
+
+ +
+ + +
+
+
+ ); +} + diff --git a/frontend/components/pop/PopProductionPanel.tsx b/frontend/components/pop/PopProductionPanel.tsx new file mode 100644 index 00000000..6d61bd9b --- /dev/null +++ b/frontend/components/pop/PopProductionPanel.tsx @@ -0,0 +1,346 @@ +"use client"; + +import React from "react"; +import { X, Play, Square, ChevronRight } from "lucide-react"; +import { WorkOrder, WorkStep } from "./types"; + +interface PopProductionPanelProps { + isOpen: boolean; + workOrder: WorkOrder | null; + workSteps: WorkStep[]; + currentStepIndex: number; + currentDateTime: Date; + onStepChange: (index: number) => void; + onStepsUpdate: (steps: WorkStep[]) => void; + onClose: () => void; +} + +export function PopProductionPanel({ + isOpen, + workOrder, + workSteps, + currentStepIndex, + currentDateTime, + onStepChange, + onStepsUpdate, + onClose, +}: PopProductionPanelProps) { + if (!isOpen || !workOrder) return null; + + const currentStep = workSteps[currentStepIndex]; + + const formatDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + + const formatTime = (date: Date | null) => { + if (!date) return "--:--"; + const d = new Date(date); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; + }; + + const handleStartStep = () => { + const newSteps = [...workSteps]; + newSteps[currentStepIndex] = { + ...newSteps[currentStepIndex], + status: "in-progress", + startTime: new Date(), + }; + onStepsUpdate(newSteps); + }; + + const handleEndStep = () => { + const newSteps = [...workSteps]; + newSteps[currentStepIndex] = { + ...newSteps[currentStepIndex], + endTime: new Date(), + }; + onStepsUpdate(newSteps); + }; + + const handleSaveAndNext = () => { + const newSteps = [...workSteps]; + const step = newSteps[currentStepIndex]; + + // 시간 자동 설정 + if (!step.startTime) step.startTime = new Date(); + if (!step.endTime) step.endTime = new Date(); + step.status = "completed"; + + onStepsUpdate(newSteps); + + // 다음 단계로 이동 + if (currentStepIndex < workSteps.length - 1) { + onStepChange(currentStepIndex + 1); + } + }; + + const renderStepForm = () => { + if (!currentStep) return null; + + const isCompleted = currentStep.status === "completed"; + + if (currentStep.type === "work" || currentStep.type === "record") { + return ( +
+

작업 내용 입력

+
+
+ + +
+
+ + +
+
+
+ +