diff --git a/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx b/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx index 7fd1a67f..3bad27db 100644 --- a/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx +++ b/frontend/app/(main)/admin/screenMng/popSettingsMng/page.tsx @@ -9,7 +9,6 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; -import { Card, CardContent } from "@/components/ui/card"; import { Select, SelectContent, @@ -25,8 +24,13 @@ import { Trash2, Settings2, Loader2, - Monitor, - MousePointerClick, + ChevronRight, + ChevronDown, + PackageOpen, + Truck, + Factory, + Home, + Cpu, } from "lucide-react"; // ============================================================ @@ -81,681 +85,615 @@ const DEFAULT_SETTINGS: PopSettings = { }; // ============================================================ -// Setting Zone Interface & Zone Map +// Screen Groups & Items // ============================================================ -interface SettingZone { +interface ScreenItem { id: string; - label: string; - description: string; - settingPath: string; - type: "toggle" | "tags" | "select" | "text" | "number" | "array-object"; - top: string; - left: string; - width: string; - height: string; - selectOptions?: { value: string; label: string }[]; - arrayObjectFields?: { key: string; label: string; type: "string" | "number" | "select"; selectOptions?: { value: string; label: string }[] }[]; + name: string; + url: string; + settingsKey: string; } -const ZONE_MAP: Record = { - "/pop/home": [ +interface ScreenGroup { + id: string; + name: string; + icon: string; + screens: ScreenItem[]; +} + +const SCREEN_GROUPS: ScreenGroup[] = [ + { + id: "inbound", + name: "입고", + icon: "PackageOpen", + screens: [ + { id: "purchase-inbound", name: "구매입고", url: "/pop/inbound/purchase", settingsKey: "inbound" }, + { id: "return-inbound", name: "반품입고", url: "/pop/inbound", settingsKey: "inbound" }, + { id: "subcontract-inbound", name: "사급자재", url: "/pop/inbound", settingsKey: "inbound" }, + ], + }, + { + id: "outbound", + name: "출고", + icon: "Truck", + screens: [ + { id: "sales-outbound", name: "판매출고", url: "/pop/outbound/sales", settingsKey: "outbound" }, + { id: "subcontract-outbound", name: "외주출고", url: "/pop/outbound", settingsKey: "outbound" }, + ], + }, + { + id: "production", + name: "생산", + icon: "Factory", + screens: [ + { id: "process-execution", name: "공정실행", url: "/pop/production/process", settingsKey: "processExecution" }, + { id: "work-instruction", name: "작업지시", url: "/pop/production/work", settingsKey: "processExecution" }, + ], + }, + { + id: "home", + name: "홈", + icon: "Home", + screens: [ + { id: "home-screen", name: "홈 화면", url: "/pop/home", settingsKey: "home" }, + ], + }, + { + id: "plc", + name: "PLC", + icon: "Cpu", + screens: [ + { id: "plc-settings", name: "PLC 연동", url: "/pop/home", settingsKey: "plc" }, + ], + }, +]; + +const ICON_MAP: Record> = { + PackageOpen, + Truck, + Factory, + Home, + Cpu, +}; + +// ============================================================ +// Settings Schema +// ============================================================ +interface SettingField { + key: string; + label: string; + description: string; + type: "toggle" | "text" | "number" | "select" | "color" | "tags" | "array-object"; + defaultValue?: unknown; + options?: { value: string; label: string }[]; + fields?: { key: string; label: string; type: string }[]; +} + +const SETTINGS_SCHEMA: Record = { + inbound: [ + { key: "barcodeEnabled", label: "바코드 스캔", description: "바코드/QR 스캔 기능을 사용합니다", type: "toggle" }, + { key: "inspectionRequired", label: "검사 필수", description: "입고 시 검사 항목을 필수로 표시합니다", type: "toggle" }, + { key: "photoUpload", label: "사진 첨부", description: "입고 확정 시 사진 첨부를 허용합니다", type: "toggle" }, + { key: "packagingRecord", label: "포장 기록", description: "포장/적재 상세 기록을 사용합니다", type: "toggle" }, + { key: "defectSeparation", label: "불량 분리", description: "양품/불량 수량을 분리 입력합니다", type: "toggle" }, + ], + outbound: [ + { key: "barcodeEnabled", label: "바코드 스캔", description: "바코드/QR 스캔 기능을 사용합니다", type: "toggle" }, + { key: "photoUpload", label: "사진 첨부", description: "출고 시 사진 첨부를 허용합니다", type: "toggle" }, + ], + processExecution: [ + { key: "materialInput", label: "자재 투입", description: "BOM 기반 자재 투입 탭을 표시합니다", type: "toggle" }, + { key: "bomFlexible", label: "BOM 유동 투입", description: "기준과 다른 수량 투입을 허용합니다", type: "toggle" }, + { key: "photoUpload", label: "사진 첨부", description: "실적 입력 시 사진 첨부를 허용합니다", type: "toggle" }, + { key: "groupPhotoEnabled", label: "그룹별 사진", description: "체크리스트 그룹마다 사진을 첨부합니다", type: "toggle" }, + { key: "plcEnabled", label: "PLC 연동", description: "설비 PLC 데이터를 자동 연동합니다", type: "toggle" }, + { key: "reworkTargetSelection", label: "재작업 공정 지정", description: "불량 처리 시 특정 공정을 선택할 수 있습니다", type: "toggle" }, + { key: "dateFilter", label: "날짜 필터", description: "작업지시 목록에 날짜 필터를 표시합니다", type: "toggle" }, { - id: "kpiCarousel", - label: "KPI 캐러셀", - description: "오늘의 현황 캐러셀을 표시합니다", - settingPath: "screens.home.kpiCarousel", - type: "toggle", - top: "12%", - left: "3%", - width: "94%", - height: "32%", + key: "lastProcessInventory", label: "마지막 공정 입고", description: "마지막 공정 완료 시 재고 입고 방식", type: "select", options: [ + { value: "auto", label: "자동 입고" }, + { value: "manual", label: "수동 선택" }, + { value: "button", label: "버튼 활성화" }, + ], }, + { key: "defaultWarehouse", label: "기본 창고 기억", description: "선택한 창고를 다음에도 자동 선택합니다", type: "toggle" }, { - id: "recentActivity", - label: "최근 활동", - description: "최근 입출고 활동을 표시합니다", - settingPath: "screens.home.recentActivity", - type: "toggle", - top: "72%", - left: "3%", - width: "94%", - height: "25%", + key: "inspectionAutoJudge", label: "검사 자동 판정", description: "수치 검사 시 상/하한 초과 처리 방식", type: "select", options: [ + { value: "off", label: "사용 안 함" }, + { value: "warn", label: "경고만 표시" }, + { value: "fail", label: "자동 불량" }, + ], }, + { key: "standardTimeDisplay", label: "표준시간 비교", description: "표준시간 대비 실제시간을 표시합니다", type: "toggle" }, + { key: "progressDisplay", label: "진행률 표시", description: "작업지시 전체 진행률을 표시합니다", type: "toggle" }, + { key: "packagingOptions", label: "포장 옵션", description: "포장 단위 선택지를 관리합니다", type: "tags" }, + { key: "defectTypes", label: "불량 유형", description: "불량 유형 선택지를 관리합니다", type: "tags" }, + ], + home: [ + { key: "kpiCarousel", label: "KPI 캐러셀", description: "오늘의 현황 캐러셀을 표시합니다", type: "toggle" }, + { key: "recentActivity", label: "최근 활동", description: "최근 입출고 활동을 표시합니다", type: "toggle" }, + { key: "bannerEnabled", label: "공지 배너", description: "상단에 공지 배너를 표시합니다", type: "toggle" }, + { key: "bannerText", label: "배너 텍스트", description: "공지 배너에 표시할 텍스트", type: "text" }, + { key: "iconThemeColor", label: "아이콘 테마색", description: "메뉴 아이콘의 테마 색상", type: "color" }, + { key: "iconCustomImages", label: "아이콘 커스텀", description: "메뉴 아이콘 이미지를 커스터마이즈합니다", type: "toggle" }, { - id: "home_banner", - label: "공지 배너", - description: "상단에 공지 배너를 표시합니다", - settingPath: "screens.home.bannerEnabled", - type: "toggle", - top: "2%", - left: "3%", - width: "94%", - height: "8%", - }, - { - id: "home_bannerText", - label: "배너 텍스트", - description: "공지 배너에 표시할 텍스트를 입력합니다", - settingPath: "screens.home.bannerText", - type: "text", - top: "2%", - left: "3%", - width: "47%", - height: "8%", - }, - { - id: "home_iconThemeColor", - label: "아이콘 테마색", - description: "메뉴 아이콘의 테마 색상을 지정합니다 (hex)", - settingPath: "screens.home.iconThemeColor", - type: "text", - top: "46%", - left: "3%", - width: "47%", - height: "8%", - }, - { - id: "home_iconCustomImages", - label: "아이콘 이미지 커스텀", - description: "메뉴 아이콘에 커스텀 이미지를 사용합니다", - settingPath: "screens.home.iconCustomImages", - type: "toggle", - top: "46%", - left: "52%", - width: "46%", - height: "8%", - }, - { - id: "home_dashboardLayout", - label: "대시보드 구성", - description: "대시보드 레이아웃 모드를 선택합니다", - settingPath: "screens.home.dashboardLayout", - type: "select", - top: "56%", - left: "3%", - width: "94%", - height: "8%", - selectOptions: [ + key: "dashboardLayout", label: "대시보드 구성", description: "홈 대시보드 레이아웃", type: "select", options: [ { value: "default", label: "기본" }, - { value: "compact", label: "간결" }, + { value: "compact", label: "컴팩트" }, { value: "detailed", label: "상세" }, ], }, ], - "/pop/inbound": [ + plc: [ { - id: "inbound_barcode", - label: "바코드 스캔", - description: "거래처/품목 바코드 스캔 기능을 사용합니다", - settingPath: "screens.inbound.barcodeEnabled", - type: "toggle", - top: "8%", - left: "80%", - width: "17%", - height: "15%", - }, - { - id: "inbound_inspection", - label: "검사 필수", - description: "입고 시 검사 항목을 필수로 표시합니다", - settingPath: "screens.inbound.inspectionRequired", - type: "toggle", - top: "28%", - left: "65%", - width: "30%", - height: "8%", - }, - { - id: "inbound_photo", - label: "사진 첨부", - description: "입고 확정 시 사진 첨부를 허용합니다", - settingPath: "screens.inbound.photoUpload", - type: "toggle", - top: "85%", - left: "3%", - width: "94%", - height: "10%", - }, - { - id: "inbound_packagingRecord", - label: "포장/적재 기록", - description: "입고 시 포장 및 적재 기록을 입력합니다", - settingPath: "screens.inbound.packagingRecord", - type: "toggle", - top: "38%", - left: "3%", - width: "60%", - height: "8%", - }, - { - id: "inbound_defectSeparation", - label: "불량 분리 입력", - description: "입고 시 불량 분리 입력을 표시합니다", - settingPath: "screens.inbound.defectSeparation", - type: "toggle", - top: "48%", - left: "3%", - width: "60%", - height: "8%", - }, - ], - "/pop/outbound": [ - { - id: "outbound_barcode", - label: "바코드 스캔", - description: "고객사/품목 바코드 스캔 기능을 사용합니다", - settingPath: "screens.outbound.barcodeEnabled", - type: "toggle", - top: "8%", - left: "80%", - width: "17%", - height: "15%", - }, - { - id: "outbound_photo", - label: "사진 첨부", - description: "출고 시 사진 첨부를 허용합니다", - settingPath: "screens.outbound.photoUpload", - type: "toggle", - top: "85%", - left: "3%", - width: "94%", - height: "10%", - }, - ], - "/pop/production": [ - { - id: "pe_material", - label: "자재 투입", - description: "BOM 기반 자재 투입 탭을 표시합니다", - settingPath: "screens.processExecution.materialInput", - type: "toggle", - top: "65%", - left: "52%", - width: "46%", - height: "12%", - }, - { - id: "pe_bomFlexible", - label: "BOM 유동 투입", - description: "기준과 다른 수량 투입을 허용합니다", - settingPath: "screens.processExecution.bomFlexible", - type: "toggle", - top: "65%", - left: "52%", - width: "23%", - height: "6%", - }, - { - id: "pe_photo", - label: "사진 첨부", - description: "실적 입력 시 사진 첨부를 허용합니다", - settingPath: "screens.processExecution.photoUpload", - type: "toggle", - top: "78%", - left: "52%", - width: "46%", - height: "8%", - }, - { - id: "pe_groupPhoto", - label: "그룹별 사진", - description: "체크리스트 그룹마다 사진을 첨부합니다", - settingPath: "screens.processExecution.groupPhotoEnabled", - type: "toggle", - top: "40%", - left: "52%", - width: "46%", - height: "8%", - }, - { - id: "pe_plc", - label: "PLC 연동", - description: "설비 PLC 데이터를 자동 연동합니다", - settingPath: "screens.processExecution.plcEnabled", - type: "toggle", - top: "50%", - left: "52%", - width: "46%", - height: "8%", - }, - { - id: "pe_rework", - label: "재작업 공정 지정", - description: "불량 처리 시 특정 공정을 선택할 수 있습니다", - settingPath: "screens.processExecution.reworkTargetSelection", - type: "toggle", - top: "88%", - left: "52%", - width: "46%", - height: "8%", - }, - { - id: "pe_packaging", - label: "포장 옵션", - description: "포장 단위 선택지를 관리합니다", - settingPath: "screens.processExecution.packagingOptions", - type: "tags", - top: "58%", - left: "52%", - width: "23%", - height: "6%", - }, - { - id: "pe_defects", - label: "불량 유형", - description: "불량 유형 선택지를 관리합니다", - settingPath: "screens.processExecution.defectTypes", - type: "tags", - top: "88%", - left: "52%", - width: "23%", - height: "6%", - }, - { - id: "pe_dateFilter", - label: "날짜 필터", - description: "작업지시 날짜 필터를 표시합니다", - settingPath: "screens.processExecution.dateFilter", - type: "toggle", - top: "8%", - left: "3%", - width: "46%", - height: "8%", - }, - { - id: "pe_lastProcessInventory", - label: "마지막 공정 재고 입고", - description: "마지막 공정에서 재고 입고 방식을 선택합니다", - settingPath: "screens.processExecution.lastProcessInventory", - type: "select", - top: "18%", - left: "3%", - width: "46%", - height: "8%", - selectOptions: [ - { value: "auto", label: "자동 입고" }, - { value: "manual", label: "수동 입력" }, - { value: "button", label: "버튼 클릭" }, - ], - }, - { - id: "pe_defaultWarehouse", - label: "기본 창고 기억", - description: "마지막 선택 창고를 기억하여 자동 적용합니다", - settingPath: "screens.processExecution.defaultWarehouse", - type: "toggle", - top: "28%", - left: "3%", - width: "46%", - height: "8%", - }, - { - id: "pe_inspectionAutoJudge", - label: "검사 자동 판정", - description: "검사 결과를 자동으로 판정하는 방식을 선택합니다", - settingPath: "screens.processExecution.inspectionAutoJudge", - type: "select", - top: "38%", - left: "3%", - width: "46%", - height: "8%", - selectOptions: [ - { value: "off", label: "사용 안함" }, - { value: "warn", label: "경고만" }, - { value: "fail", label: "불합격 처리" }, - ], - }, - { - id: "pe_standardTimeDisplay", - label: "표준시간 비교", - description: "표준시간 대비 실제 시간을 비교 표시합니다", - settingPath: "screens.processExecution.standardTimeDisplay", - type: "toggle", - top: "18%", - left: "52%", - width: "46%", - height: "8%", - }, - { - id: "pe_progressDisplay", - label: "진행률 표시", - description: "작업지시 전체 진행률을 표시합니다", - settingPath: "screens.processExecution.progressDisplay", - type: "toggle", - top: "28%", - left: "52%", - width: "46%", - height: "8%", - }, - ], - "/pop/plc": [ - { - id: "plc_connectionType", - label: "PLC 연결 방식", - description: "PLC와의 연결 방식을 선택합니다", - settingPath: "screens.plc.connectionType", - type: "select", - top: "8%", - left: "3%", - width: "94%", - height: "10%", - selectOptions: [ - { value: "db", label: "DB 연동" }, + key: "connectionType", label: "연결 방식", description: "PLC 데이터 연동 방식", type: "select", options: [ + { value: "db", label: "DB 직접 연결" }, { value: "opcua", label: "OPC-UA" }, { value: "rest", label: "REST API" }, ], }, + { key: "refreshInterval", label: "갱신 주기(초)", description: "PLC 데이터 갱신 주기", type: "number" }, { - id: "plc_refreshInterval", - label: "값 갱신 주기", - description: "PLC 값을 갱신하는 주기(초)를 설정합니다", - settingPath: "screens.plc.refreshInterval", - type: "number", - top: "20%", - left: "3%", - width: "94%", - height: "10%", - }, - { - id: "plc_tagMappings", - label: "PLC 태그 매핑", - description: "PLC 태그와 공정/체크리스트 항목을 매핑합니다", - settingPath: "screens.plc.tagMappings", - type: "array-object", - top: "32%", - left: "3%", - width: "94%", - height: "30%", - arrayObjectFields: [ - { key: "tagName", label: "태그명", type: "string" }, - { key: "processCode", label: "공정코드", type: "string" }, - { key: "checklistItemId", label: "체크리스트 항목 ID", type: "string" }, - { key: "unit", label: "단위", type: "string" }, + key: "tagMappings", label: "태그 매핑", description: "PLC 태그와 공정/체크리스트 연결", type: "array-object", fields: [ + { key: "tagName", label: "태그명", type: "text" }, + { key: "processCode", label: "공정코드", type: "text" }, + { key: "checklistItemId", label: "체크리스트 항목", type: "text" }, + { key: "unit", label: "단위", type: "text" }, ], }, { - id: "plc_alarmThresholds", - label: "임계값 경고", - description: "PLC 태그 값의 임계값과 경고 동작을 설정합니다", - settingPath: "screens.plc.alarmThresholds", - type: "array-object", - top: "64%", - left: "3%", - width: "94%", - height: "30%", - arrayObjectFields: [ - { key: "tagName", label: "태그명", type: "string" }, + key: "alarmThresholds", label: "알람 임계값", description: "PLC 값 임계치 경고 설정", type: "array-object", fields: [ + { key: "tagName", label: "태그명", type: "text" }, { key: "lowerLimit", label: "하한", type: "number" }, { key: "upperLimit", label: "상한", type: "number" }, - { key: "action", label: "동작", type: "select", selectOptions: [{ value: "warn", label: "경고" }, { value: "stop", label: "정지" }] }, + { key: "action", label: "동작", type: "select" }, ], }, ], }; -// Screen path to display name mapping -const SCREEN_LABELS: Record = { - "/pop/home": "홈", - "/pop/inbound": "구매입고", - "/pop/outbound": "판매출고", - "/pop/production": "공정실행", - "/pop/plc": "PLC 설정", -}; +// ============================================================ +// Sub-components: TagEditor, ArrayObjectEditor +// ============================================================ -// ============================================================ -// getSettingValue / setSettingValue utilities -// ============================================================ -function getSettingValue(settings: PopSettings, path: string): unknown { - return path - .split(".") - .reduce( - (obj: Record | unknown, key: string) => - (obj as Record)?.[key], - settings as unknown - ); -} - -function setSettingValue( - settings: PopSettings, - path: string, - value: unknown -): PopSettings { - const keys = path.split("."); - const newSettings = JSON.parse(JSON.stringify(settings)) as Record< - string, - unknown - >; - let obj: Record = newSettings; - for (let i = 0; i < keys.length - 1; i++) { - obj = obj[keys[i]] as Record; - } - obj[keys[keys.length - 1]] = value; - return newSettings as unknown as PopSettings; -} - -// ============================================================ -// Tag Editor Component -// ============================================================ function TagEditor({ + label, + description, tags, onChange, }: { + label: string; + description: string; tags: string[]; onChange: (tags: string[]) => void; }) { const [input, setInput] = useState(""); - const handleAdd = () => { - const value = input.trim(); - if (value && !tags.includes(value)) { - onChange([...tags, value]); - setInput(""); - } - }; - return ( -
-
- {tags.map((tag) => ( - +
+ +

{description}

+
+ {tags.map((tag, idx) => ( + {tag} ))} - {tags.length === 0 && ( - - 항목이 없습니다 - - )}
setInput(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter") { + if (e.key === "Enter" && input.trim()) { e.preventDefault(); - handleAdd(); + onChange([...tags, input.trim()]); + setInput(""); } }} - placeholder="입력 후 Enter 또는 추가 버튼" - className="h-8 text-sm" + placeholder="추가 후 Enter" + className="flex-1 h-8 text-sm" />
); } -// ============================================================ -// Array Object Editor Component -// ============================================================ function ArrayObjectEditor({ + field, items, - fields, onChange, }: { + field: SettingField; items: Record[]; - fields: NonNullable; onChange: (items: Record[]) => void; }) { - const handleAdd = () => { - const newItem: Record = {}; - fields.forEach((f) => { - if (f.type === "number") newItem[f.key] = 0; - else if (f.type === "select" && f.selectOptions?.length) - newItem[f.key] = f.selectOptions[0].value; - else newItem[f.key] = ""; + const addRow = () => { + const newRow: Record = {}; + field.fields?.forEach((f) => { + newRow[f.key] = f.type === "number" ? 0 : ""; }); - onChange([...items, newItem]); + onChange([...items, newRow]); }; - const handleRemove = (index: number) => { - onChange(items.filter((_, i) => i !== index)); - }; - - const handleChange = ( - index: number, - key: string, - value: string | number - ) => { - const updated = items.map((item, i) => - i === index ? { ...item, [key]: value } : item - ); + const updateRow = (index: number, key: string, value: unknown) => { + const updated = items.map((item, i) => (i === index ? { ...item, [key]: value } : item)); onChange(updated); }; + const removeRow = (index: number) => { + onChange(items.filter((_, i) => i !== index)); + }; + return ( -
- {items.length === 0 && ( - 항목이 없습니다 - )} - {items.map((item, idx) => ( -
-
- - #{idx + 1} - - -
-
- {fields.map((field) => ( -
- - {field.type === "select" && field.selectOptions ? ( - - ) : field.type === "number" ? ( - - handleChange( - idx, - field.key, - parseFloat(e.target.value) || 0 - ) - } - className="h-7 text-xs" - /> - ) : ( - - handleChange(idx, field.key, e.target.value) - } - className="h-7 text-xs" - /> - )} -
- ))} -
+
+
+
+ +

{field.description}

- ))} - + +
+ {items.length === 0 && ( +

항목이 없습니다. 추가 버튼을 눌러주세요.

+ )} +
+ {items.map((item, index) => ( +
+
+ {field.fields?.map((f) => ( +
+ + {f.type === "number" ? ( + updateRow(index, f.key, Number(e.target.value))} + className="h-7 text-xs" + /> + ) : f.type === "select" ? ( + + ) : ( + updateRow(index, f.key, e.target.value)} + className="h-7 text-xs" + /> + )} +
+ ))} +
+ +
+ ))} +
); } // ============================================================ -// Main Page +// SettingRow — renders a single setting field +// ============================================================ +function SettingRow({ + field, + value, + onChange, +}: { + field: SettingField; + value: unknown; + onChange: (value: unknown) => void; +}) { + switch (field.type) { + case "toggle": + return ( +
+
+ +

{field.description}

+
+ +
+ ); + case "text": + return ( +
+ +

{field.description}

+ onChange(e.target.value)} + placeholder={field.label} + className="h-9" + /> +
+ ); + case "number": + return ( +
+ +

{field.description}

+ onChange(Number(e.target.value))} + className="h-9 w-32" + /> +
+ ); + case "select": + return ( +
+ +

{field.description}

+ +
+ ); + case "color": + return ( +
+ +

{field.description}

+
+ onChange(e.target.value)} + className="w-9 h-9 rounded-md cursor-pointer border border-input p-0.5" + /> + onChange(e.target.value)} + className="h-9 w-32" + placeholder="#hex" + /> +
+
+ ); + case "tags": + return ( + + ); + case "array-object": + return ( + []) || []} + onChange={onChange} + /> + ); + default: + return null; + } +} + +// ============================================================ +// ScreenNav — top collapsible screen selector (세로 펼침) +// ============================================================ +function ScreenNav({ + groups, + selectedScreen, + onSelect, + collapsed, + onToggleCollapse, +}: { + groups: ScreenGroup[]; + selectedScreen: ScreenItem | null; + onSelect: (screen: ScreenItem) => void; + collapsed: boolean; + onToggleCollapse: () => void; +}) { + const [expandedGroup, setExpandedGroup] = useState(null); + + const handleGroupClick = (groupId: string) => { + setExpandedGroup(expandedGroup === groupId ? null : groupId); + }; + + const handleScreenSelect = (screen: ScreenItem) => { + onSelect(screen); + setExpandedGroup(null); + if (!collapsed) onToggleCollapse(); // 선택 후 자동 접기 + }; + + if (collapsed) { + // 접힌 상태: 현재 선택된 화면명 + 펼치기 버튼 + return ( +
+ + {selectedScreen && ( + + 📍 {selectedScreen.name} + + )} +
+ ); + } + + // 펼친 상태: 메뉴 그룹 가로 나열 + 클릭 시 하위 화면 드롭 + return ( +
+ {/* 상단: 그룹 탭 가로 나열 + 접기 버튼 */} +
+ + {groups.map((group) => { + const Icon = ICON_MAP[group.icon]; + const isExpanded = expandedGroup === group.id; + const hasSelected = group.screens.some((s) => s.id === selectedScreen?.id); + + return ( + + ); + })} +
+ + {/* 하위 화면 목록 (펼쳐진 그룹) */} + {expandedGroup && ( +
+ + {groups.find((g) => g.id === expandedGroup)?.name}: + + {groups + .find((g) => g.id === expandedGroup) + ?.screens.map((screen) => { + const isSelected = selectedScreen?.id === screen.id; + return ( + + ); + })} +
+ )} +
+ ); +} + +// ============================================================ +// SettingsForm — auto-rendered from schema +// ============================================================ +function SettingsForm({ + screenName, + settingsKey, + fields, + values, + onChange, +}: { + screenName: string; + settingsKey: string; + fields: SettingField[]; + values: Record; + onChange: (key: string, value: unknown) => void; +}) { + return ( +
+
+ +

{screenName} 설정

+ + {settingsKey} + +
+ {fields.map((field) => ( + onChange(field.key, v)} + /> + ))} +
+ ); +} + +// ============================================================ +// Main Page Component // ============================================================ export default function PopSettingsMngPage() { const { user } = useAuth(); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(true); + const [selectedScreen, setSelectedScreen] = useState( + SCREEN_GROUPS[0].screens[0], + ); + const [navCollapsed, setNavCollapsed] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + const [lastPath, setLastPath] = useState(""); const iframeRef = useRef(null); - // Settings state - const [settings, setSettings] = useState(DEFAULT_SETTINGS); - const [originalSettings, setOriginalSettings] = - useState(DEFAULT_SETTINGS); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [existingId, setExistingId] = useState(null); - const [hasChanges, setHasChanges] = useState(false); - - // Overlay state - const [currentPath, setCurrentPath] = useState("/pop/home"); - const [selectedZone, setSelectedZone] = useState(null); - const [hoveredZone, setHoveredZone] = useState(null); - - // Current zones based on detected path - const currentZones = ZONE_MAP[currentPath] || []; - - // ---- Fetch settings ---- + // ---- Load settings ---- const fetchSettings = useCallback(async () => { setLoading(true); try { @@ -772,42 +710,21 @@ export default function PopSettingsMngPage() { screens: { ...DEFAULT_SETTINGS.screens, ...parsed.screens, - processExecution: { - ...DEFAULT_SETTINGS.screens.processExecution, - ...parsed.screens?.processExecution, - }, - inbound: { - ...DEFAULT_SETTINGS.screens.inbound, - ...parsed.screens?.inbound, - }, - outbound: { - ...DEFAULT_SETTINGS.screens.outbound, - ...parsed.screens?.outbound, - }, - home: { - ...DEFAULT_SETTINGS.screens.home, - ...parsed.screens?.home, - }, - plc: { - ...DEFAULT_SETTINGS.screens.plc, - ...parsed.screens?.plc, - }, + processExecution: { ...DEFAULT_SETTINGS.screens.processExecution, ...parsed.screens?.processExecution }, + inbound: { ...DEFAULT_SETTINGS.screens.inbound, ...parsed.screens?.inbound }, + outbound: { ...DEFAULT_SETTINGS.screens.outbound, ...parsed.screens?.outbound }, + home: { ...DEFAULT_SETTINGS.screens.home, ...parsed.screens?.home }, + plc: { ...DEFAULT_SETTINGS.screens.plc, ...parsed.screens?.plc }, }, }; setSettings(merged); - setOriginalSettings(merged); - if (rows[0].id) { - setExistingId(rows[0].id); - } } } catch { const local = localStorage.getItem("pop_settings"); if (local) { try { const parsed = JSON.parse(local); - const merged = { ...DEFAULT_SETTINGS, ...parsed }; - setSettings(merged); - setOriginalSettings(merged); + setSettings({ ...DEFAULT_SETTINGS, ...parsed }); } catch { /* use default */ } @@ -820,33 +737,49 @@ export default function PopSettingsMngPage() { fetchSettings(); }, [fetchSettings]); - // Track changes - useEffect(() => { - setHasChanges( - JSON.stringify(settings) !== JSON.stringify(originalSettings) - ); - }, [settings, originalSettings]); - - // ---- iframe URL change detection ---- + // ---- iframe navigation sync ---- useEffect(() => { const timer = setInterval(() => { try { - const path = - iframeRef.current?.contentWindow?.location.pathname || ""; - if (path && path !== currentPath) { - setCurrentPath(path); - setSelectedZone(null); + const path = iframeRef.current?.contentWindow?.location.pathname; + if (path && path !== lastPath) { + setLastPath(path); + for (const group of SCREEN_GROUPS) { + const found = group.screens.find((s) => path === s.url || path.startsWith(s.url + "/")); + if (found) { + setSelectedScreen(found); + break; + } + } } } catch { - /* cross-origin: ignore */ + // cross-origin: silently ignore } - }, 500); + }, 1000); return () => clearInterval(timer); - }, [currentPath]); + }, [lastPath]); - // ---- Zone selection ---- - const selectZone = (zone: SettingZone) => { - setSelectedZone(zone); + // ---- Screen select handler ---- + const handleScreenSelect = (screen: ScreenItem) => { + setSelectedScreen(screen); + if (iframeRef.current) { + iframeRef.current.src = screen.url; + } + }; + + // ---- Settings update helper ---- + const updateScreenSetting = (settingsKey: string, fieldKey: string, value: unknown) => { + setSettings((prev) => ({ + ...prev, + screens: { + ...prev.screens, + [settingsKey]: { + ...(prev.screens as Record>)[settingsKey], + [fieldKey]: value, + }, + }, + })); + setHasChanges(true); }; // ---- Save ---- @@ -857,576 +790,112 @@ export default function PopSettingsMngPage() { taskType: "data-save", targetTable: "pop_settings", columnMapping: { - id: existingId || crypto.randomUUID(), - company_code: user?.companyCode || user?.company_code || "COMPANY_7", + id: crypto.randomUUID(), + company_code: user?.companyCode || "COMPANY_7", settings_data: JSON.stringify(settings), updated_by: user?.userId, }, }); - setOriginalSettings(settings); - setExistingId(existingId || null); - // Reload iframe so POP picks up new settings - try { - iframeRef.current?.contentWindow?.location.reload(); - } catch { - /* cross-origin fallback */ - if (iframeRef.current) { - iframeRef.current.src = iframeRef.current.src; - } + setHasChanges(false); + // Reload iframe to apply settings + if (iframeRef.current) { + iframeRef.current.contentWindow?.location.reload(); } - alert("POP 설정이 저장되었습니다."); } catch { localStorage.setItem("pop_settings", JSON.stringify(settings)); - alert( - "설정이 로컬에 저장되었습니다. (DB 테이블 생성 후 자동 동기화됩니다)" - ); + alert("설정이 로컬에 저장되었습니다 (DB 테이블 생성 후 동기화 필요)"); } setSaving(false); }; - // ---- Reset to default ---- + // ---- Reset to defaults ---- const handleReset = () => { - if ( - confirm( - "모든 설정을 기본값으로 초기화하시겠습니까?\n저장 버튼을 눌러야 DB에 반영됩니다." - ) - ) { + if (window.confirm("모든 설정을 기본값으로 초기화하시겠습니까?")) { setSettings(DEFAULT_SETTINGS); - setSelectedZone(null); + setHasChanges(true); } }; - // ---- Setting value update from panel ---- - const handleSettingChange = (zone: SettingZone, value: unknown) => { - setSettings((prev) => setSettingValue(prev, zone.settingPath, value)); - }; - - // ---- Loading state ---- - if (loading) { - return ( -
-
- - 설정을 불러오는 중... -
-
- ); - } - - // Collect all zones for current screen as summary list - const summaryZones = currentZones; + // ---- Current screen schema values ---- + const currentSettingsKey = selectedScreen?.settingsKey || "inbound"; + const currentFields = SETTINGS_SCHEMA[currentSettingsKey] || []; + const currentValues = (settings.screens as Record>)[currentSettingsKey] || {}; return ( -
-
- {/* ---- Page Header ---- */} -
-
-
- -

- POP 화면 설정 -

+
+ {/* ---- Header ---- */} +
+
+ +

POP 화면 설정

+ + {user?.companyCode || "COMPANY_7"} + + {hasChanges && ( + + 미저장 변경 + + )} +
+
+ + +
+
+ + {/* ---- Screen Nav (top, collapsible vertically) ---- */} + setNavCollapsed((prev) => !prev)} + /> + + {/* ---- Body: iframe (left) + settings (right) ---- */} +
+ {/* Left: iframe (POP preview) */} +
+ {loading ? ( +
+
-

- 왼쪽 POP 화면에서 설정할 영역을 클릭하면 오른쪽에 설정 패널이 열립니다. - {user?.companyCode && ( - - {user.companyCode} - - )} -

-
-
- - -
+ ) : ( +