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/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 0b5ff573..4ba1e6c0 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -6,7 +6,10 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2, Copy, Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { toast } from "sonner"; import { useMultiLang } from "@/hooks/useMultiLang"; @@ -90,6 +93,13 @@ export default function TableManagementPage() { // ๐ŸŽฏ Entity ์กฐ์ธ ๊ด€๋ จ ์ƒํƒœ const [referenceTableColumns, setReferenceTableColumns] = useState>({}); + // ๐Ÿ†• Entity ํƒ€์ž… Combobox ์—ด๋ฆผ/๋‹ซํž˜ ์ƒํƒœ (์ปฌ๋Ÿผ๋ณ„ ๊ด€๋ฆฌ) + const [entityComboboxOpen, setEntityComboboxOpen] = useState>({}); + // DDL ๊ธฐ๋Šฅ ๊ด€๋ จ ์ƒํƒœ const [createTableModalOpen, setCreateTableModalOpen] = useState(false); const [addColumnModalOpen, setAddColumnModalOpen] = useState(false); @@ -1388,113 +1398,266 @@ export default function TableManagementPage() { {/* ์ž…๋ ฅ ํƒ€์ž…์ด 'entity'์ธ ๊ฒฝ์šฐ ์ฐธ์กฐ ํ…Œ์ด๋ธ” ์„ ํƒ */} {column.inputType === "entity" && ( <> - {/* ์ฐธ์กฐ ํ…Œ์ด๋ธ” */} -
+ {/* ์ฐธ์กฐ ํ…Œ์ด๋ธ” - ๊ฒ€์ƒ‰ ๊ฐ€๋Šฅํ•œ Combobox */} +
- + + + + + + + + + ํ…Œ์ด๋ธ”์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + + + {referenceTableOptions.map((option) => ( + { + handleDetailSettingsChange(column.columnName, "entity", option.value); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], table: false }, + })); + }} + className="text-xs" + > + +
+ {option.label} + {option.value !== "none" && ( + {option.value} + )} +
+
+ ))} +
+
+
+
+
- {/* ์กฐ์ธ ์ปฌ๋Ÿผ */} + {/* ์กฐ์ธ ์ปฌ๋Ÿผ - ๊ฒ€์ƒ‰ ๊ฐ€๋Šฅํ•œ Combobox */} {column.referenceTable && column.referenceTable !== "none" && ( -
+
- + ๋กœ๋”ฉ์ค‘... + + ) : column.referenceColumn && column.referenceColumn !== "none" ? ( + column.referenceColumn + ) : ( + "์ปฌ๋Ÿผ ์„ ํƒ..." + )} + + + + + + + + + ์ปฌ๋Ÿผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + + + { + handleDetailSettingsChange(column.columnName, "entity_reference_column", "none"); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], joinColumn: false }, + })); + }} + className="text-xs" + > + + -- ์„ ํƒ ์•ˆํ•จ -- + + {referenceTableColumns[column.referenceTable]?.map((refCol) => ( + { + handleDetailSettingsChange(column.columnName, "entity_reference_column", refCol.columnName); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], joinColumn: false }, + })); + }} + className="text-xs" + > + +
+ {refCol.columnName} + {refCol.columnLabel && ( + {refCol.columnLabel} + )} +
+
+ ))} +
+
+
+
+
)} - {/* ํ‘œ์‹œ ์ปฌ๋Ÿผ */} + {/* ํ‘œ์‹œ ์ปฌ๋Ÿผ - ๊ฒ€์ƒ‰ ๊ฐ€๋Šฅํ•œ Combobox */} {column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && column.referenceColumn !== "none" && ( -
+
- + ๋กœ๋”ฉ์ค‘... + + ) : column.displayColumn && column.displayColumn !== "none" ? ( + column.displayColumn + ) : ( + "์ปฌ๋Ÿผ ์„ ํƒ..." + )} + + + + + + + + + ์ปฌ๋Ÿผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. + + + { + handleDetailSettingsChange(column.columnName, "entity_display_column", "none"); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], displayColumn: false }, + })); + }} + className="text-xs" + > + + -- ์„ ํƒ ์•ˆํ•จ -- + + {referenceTableColumns[column.referenceTable]?.map((refCol) => ( + { + handleDetailSettingsChange(column.columnName, "entity_display_column", refCol.columnName); + setEntityComboboxOpen((prev) => ({ + ...prev, + [column.columnName]: { ...prev[column.columnName], displayColumn: false }, + })); + }} + className="text-xs" + > + +
+ {refCol.columnName} + {refCol.columnLabel && ( + {refCol.columnLabel} + )} +
+
+ ))} +
+
+
+
+
)} @@ -1505,8 +1668,8 @@ export default function TableManagementPage() { column.referenceColumn !== "none" && column.displayColumn && column.displayColumn !== "none" && ( -
- โœ“ +
+ ์„ค์ • ์™„๋ฃŒ
)} 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 ( +
+

์ž‘์—… ๋‚ด์šฉ ์ž…๋ ฅ

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