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/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 40eada6e..baa1f02c 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -2720,28 +2720,48 @@ export class NodeFlowExecutionService { const trueData: any[] = []; const falseData: any[] = []; - inputData.forEach((item: any) => { - const results = conditions.map((condition: any) => { + // 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기) + for (const item of inputData) { + const results: boolean[] = []; + + for (const condition of conditions) { const fieldValue = item[condition.field]; - let compareValue = condition.value; - if (condition.valueType === "field") { - compareValue = item[condition.value]; + // EXISTS 계열 연산자 처리 + if ( + condition.operator === "EXISTS_IN" || + condition.operator === "NOT_EXISTS_IN" + ) { + const existsResult = await this.evaluateExistsCondition( + fieldValue, + condition.operator, + condition.lookupTable, + condition.lookupField, + context.buttonContext?.companyCode + ); + results.push(existsResult); logger.info( - `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - logger.info( - `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + // 일반 연산자 처리 + let compareValue = condition.value; + if (condition.valueType === "field") { + compareValue = item[condition.value]; + logger.info( + `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + ); + } else { + logger.info( + `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + ); + } + + results.push( + this.evaluateCondition(fieldValue, condition.operator, compareValue) ); } - - return this.evaluateCondition( - fieldValue, - condition.operator, - compareValue - ); - }); + } const result = logic === "OR" @@ -2753,7 +2773,7 @@ export class NodeFlowExecutionService { } else { falseData.push(item); } - }); + } logger.info( `🔍 조건 필터링 결과: TRUE ${trueData.length}건 / FALSE ${falseData.length}건 (${logic} 로직)` @@ -2768,27 +2788,46 @@ export class NodeFlowExecutionService { } // 단일 객체인 경우 - const results = conditions.map((condition: any) => { + const results: boolean[] = []; + + for (const condition of conditions) { const fieldValue = inputData[condition.field]; - let compareValue = condition.value; - if (condition.valueType === "field") { - compareValue = inputData[condition.value]; + // EXISTS 계열 연산자 처리 + if ( + condition.operator === "EXISTS_IN" || + condition.operator === "NOT_EXISTS_IN" + ) { + const existsResult = await this.evaluateExistsCondition( + fieldValue, + condition.operator, + condition.lookupTable, + condition.lookupField, + context.buttonContext?.companyCode + ); + results.push(existsResult); logger.info( - `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + `🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}` ); } else { - logger.info( - `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + // 일반 연산자 처리 + let compareValue = condition.value; + if (condition.valueType === "field") { + compareValue = inputData[condition.value]; + logger.info( + `🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})` + ); + } else { + logger.info( + `📊 고정값 비교: ${condition.field} (${fieldValue}) vs ${compareValue}` + ); + } + + results.push( + this.evaluateCondition(fieldValue, condition.operator, compareValue) ); } - - return this.evaluateCondition( - fieldValue, - condition.operator, - compareValue - ); - }); + } const result = logic === "OR" @@ -2797,7 +2836,7 @@ export class NodeFlowExecutionService { logger.info(`🔍 조건 평가 결과: ${result} (${logic} 로직)`); - // ⚠️ 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요 + // 조건 노드는 TRUE/FALSE 브랜치를 위한 특별한 처리 필요 // 조건 결과를 저장하고, 원본 데이터는 항상 반환 // 다음 노드에서 sourceHandle을 기반으로 필터링됨 return { @@ -2808,6 +2847,68 @@ export class NodeFlowExecutionService { }; } + /** + * EXISTS_IN / NOT_EXISTS_IN 조건 평가 + * 다른 테이블에 값이 존재하는지 확인 + */ + private static async evaluateExistsCondition( + fieldValue: any, + operator: string, + lookupTable: string, + lookupField: string, + companyCode?: string + ): Promise { + if (!lookupTable || !lookupField) { + logger.warn("⚠️ EXISTS 조건: lookupTable 또는 lookupField가 없습니다"); + return false; + } + + if (fieldValue === null || fieldValue === undefined || fieldValue === "") { + logger.info( + `⚠️ EXISTS 조건: 필드값이 비어있어 ${operator === "NOT_EXISTS_IN" ? "TRUE" : "FALSE"} 반환` + ); + // 값이 비어있으면: EXISTS_IN은 false, NOT_EXISTS_IN은 true + return operator === "NOT_EXISTS_IN"; + } + + try { + // 멀티테넌시: company_code 필터 적용 여부 확인 + // company_mng 테이블은 제외 + const hasCompanyCode = lookupTable !== "company_mng" && companyCode; + + let sql: string; + let params: any[]; + + if (hasCompanyCode) { + sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1 AND company_code = $2) as exists_result`; + params = [fieldValue, companyCode]; + } else { + sql = `SELECT EXISTS(SELECT 1 FROM "${lookupTable}" WHERE "${lookupField}" = $1) as exists_result`; + params = [fieldValue]; + } + + logger.info(`🔍 EXISTS 쿼리: ${sql}, params: ${JSON.stringify(params)}`); + + const result = await query(sql, params); + const existsInTable = result[0]?.exists_result === true; + + logger.info( + `🔍 EXISTS 결과: ${fieldValue}이(가) ${lookupTable}.${lookupField}에 ${existsInTable ? "존재함" : "존재하지 않음"}` + ); + + // EXISTS_IN: 존재하면 true + // NOT_EXISTS_IN: 존재하지 않으면 true + if (operator === "EXISTS_IN") { + return existsInTable; + } else { + return !existsInTable; + } + } catch (error: any) { + logger.error(`❌ EXISTS 조건 평가 실패: ${error.message}`); + return false; + } + } + /** * WHERE 절 생성 */ 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/dataflow/node-editor/nodes/ConditionNode.tsx b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx index 5418fcab..4cf5e32d 100644 --- a/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx +++ b/frontend/components/dataflow/node-editor/nodes/ConditionNode.tsx @@ -22,6 +22,13 @@ const OPERATOR_LABELS: Record = { NOT_IN: "NOT IN", IS_NULL: "NULL", IS_NOT_NULL: "NOT NULL", + EXISTS_IN: "EXISTS IN", + NOT_EXISTS_IN: "NOT EXISTS IN", +}; + +// EXISTS 계열 연산자인지 확인 +const isExistsOperator = (operator: string): boolean => { + return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN"; }; export const ConditionNode = memo(({ data, selected }: NodeProps) => { @@ -54,15 +61,31 @@ export const ConditionNode = memo(({ data, selected }: NodeProps 0 && (
{data.logic}
)} -
+
{condition.field} - + {OPERATOR_LABELS[condition.operator] || condition.operator} - {condition.value !== null && condition.value !== undefined && ( - - {typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)} + {/* EXISTS 연산자인 경우 테이블.필드 표시 */} + {isExistsOperator(condition.operator) ? ( + + {(condition as any).lookupTableLabel || (condition as any).lookupTable || "..."} + {(condition as any).lookupField && `.${(condition as any).lookupFieldLabel || (condition as any).lookupField}`} + ) : ( + // 일반 연산자인 경우 값 표시 + condition.value !== null && + condition.value !== undefined && ( + + {typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)} + + ) )}
diff --git a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx index 87f7f771..a2d060d4 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/ConditionProperties.tsx @@ -4,14 +4,18 @@ * 조건 분기 노드 속성 편집 */ -import { useEffect, useState } from "react"; -import { Plus, Trash2 } from "lucide-react"; +import { useEffect, useState, useCallback } from "react"; +import { Plus, Trash2, Database, Search, Check, ChevronsUpDown } from "lucide-react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; -import type { ConditionNodeData } from "@/types/node-editor"; +import type { ConditionNodeData, ConditionOperator } from "@/types/node-editor"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { cn } from "@/lib/utils"; // 필드 정의 interface FieldDefinition { @@ -20,6 +24,19 @@ interface FieldDefinition { type?: string; } +// 테이블 정보 +interface TableInfo { + tableName: string; + tableLabel: string; +} + +// 테이블 컬럼 정보 +interface ColumnInfo { + columnName: string; + columnLabel: string; + dataType: string; +} + interface ConditionPropertiesProps { nodeId: string; data: ConditionNodeData; @@ -38,8 +55,194 @@ const OPERATORS = [ { value: "NOT_IN", label: "NOT IN" }, { value: "IS_NULL", label: "NULL" }, { value: "IS_NOT_NULL", label: "NOT NULL" }, + { value: "EXISTS_IN", label: "다른 테이블에 존재함" }, + { value: "NOT_EXISTS_IN", label: "다른 테이블에 존재하지 않음" }, ] as const; +// EXISTS 계열 연산자인지 확인 +const isExistsOperator = (operator: string): boolean => { + return operator === "EXISTS_IN" || operator === "NOT_EXISTS_IN"; +}; + +// 테이블 선택용 검색 가능한 Combobox +function TableCombobox({ + tables, + value, + onSelect, + placeholder = "테이블 검색...", +}: { + tables: TableInfo[]; + value: string; + onSelect: (value: string) => void; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + + const selectedTable = tables.find((t) => t.tableName === value); + + return ( + + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((table) => ( + { + onSelect(table.tableName); + setOpen(false); + }} + className="text-xs" + > + +
+ {table.tableLabel} + {table.tableName} +
+
+ ))} +
+
+
+
+
+ ); +} + +// 컬럼 선택용 검색 가능한 Combobox +function ColumnCombobox({ + columns, + value, + onSelect, + placeholder = "컬럼 검색...", +}: { + columns: ColumnInfo[]; + value: string; + onSelect: (value: string) => void; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + + const selectedColumn = columns.find((c) => c.columnName === value); + + return ( + + + + + + + + + 컬럼을 찾을 수 없습니다. + + {columns.map((col) => ( + { + onSelect(col.columnName); + setOpen(false); + }} + className="text-xs" + > + + {col.columnLabel} + ({col.columnName}) + + ))} + + + + + + ); +} + +// 컬럼 선택 섹션 (자동 로드 포함) +function ColumnSelectSection({ + lookupTable, + lookupField, + tableColumnsCache, + loadingColumns, + loadTableColumns, + onSelect, +}: { + lookupTable: string; + lookupField: string; + tableColumnsCache: Record; + loadingColumns: Record; + loadTableColumns: (tableName: string) => Promise; + onSelect: (value: string) => void; +}) { + // 캐시에 없고 로딩 중이 아니면 자동으로 로드 + useEffect(() => { + if (lookupTable && !tableColumnsCache[lookupTable] && !loadingColumns[lookupTable]) { + loadTableColumns(lookupTable); + } + }, [lookupTable, tableColumnsCache, loadingColumns, loadTableColumns]); + + const isLoading = loadingColumns[lookupTable]; + const columns = tableColumnsCache[lookupTable]; + + return ( +
+ + {isLoading ? ( +
+ 컬럼 목록 로딩 중... +
+ ) : columns && columns.length > 0 ? ( + + ) : ( +
+ 컬럼 목록을 로드할 수 없습니다 +
+ )} +
+ ); +} + export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) { const { updateNode, nodes, edges } = useFlowEditorStore(); @@ -48,6 +251,12 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND"); const [availableFields, setAvailableFields] = useState([]); + // EXISTS 연산자용 상태 + const [allTables, setAllTables] = useState([]); + const [tableColumnsCache, setTableColumnsCache] = useState>({}); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState>({}); + // 데이터 변경 시 로컬 상태 업데이트 useEffect(() => { setDisplayName(data.displayName || "조건 분기"); @@ -55,6 +264,100 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) setLogic(data.logic || "AND"); }, [data]); + // 전체 테이블 목록 로드 (EXISTS 연산자용) + useEffect(() => { + const loadAllTables = async () => { + // 이미 EXISTS 연산자가 있거나 로드된 적이 있으면 스킵 + if (allTables.length > 0) return; + + // EXISTS 연산자가 하나라도 있으면 테이블 목록 로드 + const hasExistsOperator = conditions.some((c) => isExistsOperator(c.operator)); + if (!hasExistsOperator) return; + + setLoadingTables(true); + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables( + response.data.map((t: any) => ({ + tableName: t.tableName, + tableLabel: t.tableLabel || t.tableName, + })) + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }; + + loadAllTables(); + }, [conditions, allTables.length]); + + // 테이블 컬럼 로드 함수 + const loadTableColumns = useCallback( + async (tableName: string): Promise => { + // 캐시에 있으면 반환 + if (tableColumnsCache[tableName]) { + return tableColumnsCache[tableName]; + } + + // 이미 로딩 중이면 스킵 + if (loadingColumns[tableName]) { + return []; + } + + // 로딩 상태 설정 + setLoadingColumns((prev) => ({ ...prev, [tableName]: true })); + + try { + // getColumnList 반환: { success, data: { columns, total, ... } } + const response = await tableManagementApi.getColumnList(tableName); + if (response.success && response.data && response.data.columns) { + const columns = response.data.columns.map((c: any) => ({ + columnName: c.columnName, + columnLabel: c.columnLabel || c.columnName, + dataType: c.dataType, + })); + setTableColumnsCache((prev) => ({ ...prev, [tableName]: columns })); + console.log(`✅ 테이블 ${tableName} 컬럼 로드 완료:`, columns.length, "개"); + return columns; + } else { + console.warn(`⚠️ 테이블 ${tableName} 컬럼 조회 실패:`, response); + } + } catch (error) { + console.error(`❌ 테이블 ${tableName} 컬럼 로드 실패:`, error); + } finally { + setLoadingColumns((prev) => ({ ...prev, [tableName]: false })); + } + return []; + }, + [tableColumnsCache, loadingColumns] + ); + + // EXISTS 연산자 선택 시 테이블 목록 강제 로드 + const ensureTablesLoaded = useCallback(async () => { + if (allTables.length > 0) return; + + setLoadingTables(true); + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables( + response.data.map((t: any) => ({ + tableName: t.tableName, + tableLabel: t.tableLabel || t.tableName, + })) + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }, [allTables.length]); + // 🔥 연결된 소스 노드의 필드를 재귀적으로 수집 useEffect(() => { const getAllSourceFields = (currentNodeId: string, visited: Set = new Set()): FieldDefinition[] => { @@ -170,15 +473,18 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) }, [nodeId, nodes, edges]); const handleAddCondition = () => { - setConditions([ - ...conditions, - { - field: "", - operator: "EQUALS", - value: "", - valueType: "static", // "static" (고정값) 또는 "field" (필드 참조) - }, - ]); + const newCondition = { + field: "", + operator: "EQUALS" as ConditionOperator, + value: "", + valueType: "static" as "static" | "field", + // EXISTS 연산자용 필드는 초기값 없음 + lookupTable: undefined, + lookupTableLabel: undefined, + lookupField: undefined, + lookupFieldLabel: undefined, + }; + setConditions([...conditions, newCondition]); }; const handleRemoveCondition = (index: number) => { @@ -196,9 +502,50 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) }); }; - const handleConditionChange = (index: number, field: string, value: any) => { + const handleConditionChange = async (index: number, field: string, value: any) => { const newConditions = [...conditions]; newConditions[index] = { ...newConditions[index], [field]: value }; + + // EXISTS 연산자로 변경 시 테이블 목록 로드 및 기존 value/valueType 초기화 + if (field === "operator" && isExistsOperator(value)) { + await ensureTablesLoaded(); + // EXISTS 연산자에서는 value, valueType이 필요 없으므로 초기화 + newConditions[index].value = ""; + newConditions[index].valueType = undefined; + } + + // EXISTS 연산자에서 다른 연산자로 변경 시 lookup 필드들 초기화 + if (field === "operator" && !isExistsOperator(value)) { + newConditions[index].lookupTable = undefined; + newConditions[index].lookupTableLabel = undefined; + newConditions[index].lookupField = undefined; + newConditions[index].lookupFieldLabel = undefined; + } + + // lookupTable 변경 시 컬럼 목록 로드 및 라벨 설정 + if (field === "lookupTable" && value) { + const tableInfo = allTables.find((t) => t.tableName === value); + if (tableInfo) { + newConditions[index].lookupTableLabel = tableInfo.tableLabel; + } + // 테이블 변경 시 필드 초기화 + newConditions[index].lookupField = undefined; + newConditions[index].lookupFieldLabel = undefined; + // 컬럼 목록 미리 로드 + await loadTableColumns(value); + } + + // lookupField 변경 시 라벨 설정 + if (field === "lookupField" && value) { + const tableName = newConditions[index].lookupTable; + if (tableName && tableColumnsCache[tableName]) { + const columnInfo = tableColumnsCache[tableName].find((c) => c.columnName === value); + if (columnInfo) { + newConditions[index].lookupFieldLabel = columnInfo.columnLabel; + } + } + } + setConditions(newConditions); updateNode(nodeId, { conditions: newConditions, @@ -329,64 +676,114 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
- {condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && ( + {/* EXISTS 연산자인 경우: 테이블/필드 선택 UI (검색 가능한 Combobox) */} + {isExistsOperator(condition.operator) && ( <>
- - + + {loadingTables ? ( +
+ 테이블 목록 로딩 중... +
+ ) : allTables.length > 0 ? ( + handleConditionChange(index, "lookupTable", value)} + placeholder="테이블 검색..." + /> + ) : ( +
+ 테이블 목록을 로드할 수 없습니다 +
+ )}
-
- - {(condition as any).valueType === "field" ? ( - // 필드 참조: 드롭다운으로 선택 - availableFields.length > 0 ? ( - - ) : ( -
- 소스 노드를 연결하세요 -
- ) - ) : ( - // 고정값: 직접 입력 - handleConditionChange(index, "value", e.target.value)} - placeholder="비교할 값" - className="mt-1 h-8 text-xs" - /> - )} + {(condition as any).lookupTable && ( + handleConditionChange(index, "lookupField", value)} + /> + )} + +
+ {condition.operator === "EXISTS_IN" + ? `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하면 TRUE` + : `소스의 "${condition.field || "..."}" 값이 "${(condition as any).lookupTableLabel || "..."}" 테이블의 "${(condition as any).lookupFieldLabel || "..."}" 컬럼에 존재하지 않으면 TRUE`}
)} + + {/* 일반 연산자인 경우: 기존 비교값 UI */} + {condition.operator !== "IS_NULL" && + condition.operator !== "IS_NOT_NULL" && + !isExistsOperator(condition.operator) && ( + <> +
+ + +
+ +
+ + {(condition as any).valueType === "field" ? ( + // 필드 참조: 드롭다운으로 선택 + availableFields.length > 0 ? ( + + ) : ( +
+ 소스 노드를 연결하세요 +
+ ) + ) : ( + // 고정값: 직접 입력 + handleConditionChange(index, "value", e.target.value)} + placeholder="비교할 값" + className="mt-1 h-8 text-xs" + /> + )} +
+ + )}
))} @@ -402,20 +799,28 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {/* 안내 */}
- 🔌 소스 노드 연결: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다. + 소스 노드 연결: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
- 🔄 비교 값 타입:
고정값: 직접 입력한 값과 비교 (예: age > 30) -
필드 참조: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량) + 비교 값 타입:
+ - 고정값: 직접 입력한 값과 비교 (예: age > 30) +
- 필드 참조: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량) +
+
+ 테이블 존재 여부 검사:
+ - 다른 테이블에 존재함: 값이 다른 테이블에 있으면 TRUE +
- 다른 테이블에 존재하지 않음: 값이 다른 테이블에 없으면 TRUE +
+ (예: 품명이 품목정보 테이블에 없으면 자동 등록)
- 💡 AND: 모든 조건이 참이어야 TRUE 출력 + AND: 모든 조건이 참이어야 TRUE 출력
- 💡 OR: 하나라도 참이면 TRUE 출력 + OR: 하나라도 참이면 TRUE 출력
- ⚡ TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다. + TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
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 ( +
+

작업 내용 입력

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