2f675660b4
- 입고/출고 설정에 채번규칙(numberingRuleId) 텍스트 필드 추가 - 하드코딩 POP에 미연동된 설정 항목에 (미구현) 라벨 추가 - 구현 완료: 배너(ON/OFF+텍스트), 자재투입, 그룹별사진 - 미구현: 바코드, 검사필수, 사진첨부, 포장, PLC, 날짜필터 등
958 lines
35 KiB
TypeScript
958 lines
35 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import type { PopSettings } from "@/hooks/pop/usePopSettings";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Save,
|
|
RotateCcw,
|
|
X,
|
|
Plus,
|
|
Trash2,
|
|
Settings2,
|
|
Loader2,
|
|
ChevronRight,
|
|
ChevronDown,
|
|
PackageOpen,
|
|
Truck,
|
|
Factory,
|
|
Home,
|
|
Cpu,
|
|
} from "lucide-react";
|
|
|
|
// ============================================================
|
|
// Default Settings (mirrors usePopSettings.ts DEFAULT_SETTINGS)
|
|
// ============================================================
|
|
const DEFAULT_SETTINGS: PopSettings = {
|
|
version: "hardcoded-1.0",
|
|
screens: {
|
|
processExecution: {
|
|
materialInput: true,
|
|
photoUpload: true,
|
|
plcEnabled: false,
|
|
bomFlexible: true,
|
|
packagingOptions: ["낱개", "박스", "파렛트"],
|
|
defectTypes: ["스크래치", "치수불량", "변색", "크랙", "기포"],
|
|
reworkTargetSelection: true,
|
|
groupPhotoEnabled: false,
|
|
dateFilter: false,
|
|
lastProcessInventory: "manual",
|
|
defaultWarehouse: false,
|
|
inspectionAutoJudge: "off",
|
|
standardTimeDisplay: false,
|
|
progressDisplay: false,
|
|
},
|
|
inbound: {
|
|
inspectionRequired: false,
|
|
photoUpload: false,
|
|
barcodeEnabled: true,
|
|
packagingRecord: false,
|
|
defectSeparation: false,
|
|
},
|
|
outbound: {
|
|
photoUpload: false,
|
|
barcodeEnabled: true,
|
|
},
|
|
home: {
|
|
kpiCarousel: true,
|
|
recentActivity: true,
|
|
bannerEnabled: false,
|
|
bannerText: "",
|
|
iconThemeColor: "#2563eb",
|
|
iconCustomImages: false,
|
|
dashboardLayout: "default",
|
|
},
|
|
plc: {
|
|
connectionType: "db",
|
|
refreshInterval: 5,
|
|
tagMappings: [],
|
|
alarmThresholds: [],
|
|
},
|
|
},
|
|
};
|
|
|
|
// ============================================================
|
|
// Screen Groups & Items
|
|
// ============================================================
|
|
interface ScreenItem {
|
|
id: string;
|
|
name: string;
|
|
url: string;
|
|
settingsKey: string;
|
|
screenId: number;
|
|
}
|
|
|
|
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", screenId: 6528 },
|
|
{ id: "inbound-cart", name: "입고 장바구니", url: "/pop/inbound/cart", settingsKey: "inbound", screenId: 6527 },
|
|
{ id: "inbound-type", name: "입고유형선택", url: "/pop/inbound", settingsKey: "inbound", screenId: 6529 },
|
|
],
|
|
},
|
|
{
|
|
id: "outbound",
|
|
name: "출고",
|
|
icon: "Truck",
|
|
screens: [
|
|
{ id: "sales-outbound", name: "판매출고", url: "/pop/outbound/sales", settingsKey: "outbound", screenId: 5 },
|
|
{ id: "outbound-type", name: "출고유형선택", url: "/pop/outbound", settingsKey: "outbound", screenId: 6 },
|
|
],
|
|
},
|
|
{
|
|
id: "production",
|
|
name: "생산",
|
|
icon: "Factory",
|
|
screens: [
|
|
{ id: "process-execution", name: "공정실행", url: "/pop/production/process", settingsKey: "processExecution", screenId: 7 },
|
|
{ id: "production-main", name: "생산관리", url: "/pop/production", settingsKey: "processExecution", screenId: 8 },
|
|
],
|
|
},
|
|
{
|
|
id: "home",
|
|
name: "홈",
|
|
icon: "Home",
|
|
screens: [
|
|
{ id: "home-screen", name: "홈 화면", url: "/pop/home", settingsKey: "home", screenId: 6526 },
|
|
],
|
|
},
|
|
{
|
|
id: "plc",
|
|
name: "PLC",
|
|
icon: "Cpu",
|
|
screens: [
|
|
{ id: "plc-settings", name: "PLC 연동", url: "/pop/home", settingsKey: "plc", screenId: 6526 },
|
|
],
|
|
},
|
|
];
|
|
|
|
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
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<string, SettingField[]> = {
|
|
inbound: [
|
|
{ key: "numberingRuleId", label: "📋 입고번호 채번규칙", description: "입고 확정 시 사용할 채번규칙을 선택합니다", type: "text" },
|
|
{ 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: "numberingRuleId", label: "📋 출고번호 채번규칙", description: "출고 확정 시 사용할 채번규칙을 선택합니다", type: "text" },
|
|
{ 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" },
|
|
{
|
|
key: "lastProcessInventory", label: "마지막 공정 입고 (미구현)", description: "마지막 공정 완료 시 재고 입고 방식", type: "select", options: [
|
|
{ value: "auto", label: "자동 입고" },
|
|
{ value: "manual", label: "수동 선택" },
|
|
{ value: "button", label: "버튼 활성화" },
|
|
],
|
|
},
|
|
{ key: "defaultWarehouse", label: "기본 창고 기억 (미구현)", description: "선택한 창고를 다음에도 자동 선택합니다", type: "toggle" },
|
|
{
|
|
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" },
|
|
{
|
|
key: "dashboardLayout", label: "대시보드 구성 (미구현)", description: "홈 대시보드 레이아웃", type: "select", options: [
|
|
{ value: "default", label: "기본" },
|
|
{ value: "compact", label: "컴팩트" },
|
|
{ value: "detailed", label: "상세" },
|
|
],
|
|
},
|
|
],
|
|
plc: [
|
|
{
|
|
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" },
|
|
{
|
|
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" },
|
|
],
|
|
},
|
|
{
|
|
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" },
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
// ============================================================
|
|
// Sub-components: TagEditor, ArrayObjectEditor
|
|
// ============================================================
|
|
|
|
function TagEditor({
|
|
label,
|
|
description,
|
|
tags,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
description: string;
|
|
tags: string[];
|
|
onChange: (tags: string[]) => void;
|
|
}) {
|
|
const [input, setInput] = useState("");
|
|
|
|
return (
|
|
<div className="py-3 border-b last:border-0">
|
|
<Label className="text-sm font-medium">{label}</Label>
|
|
<p className="text-xs text-muted-foreground mt-0.5 mb-2">{description}</p>
|
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
{tags.map((tag, idx) => (
|
|
<Badge key={`${tag}-${idx}`} variant="secondary" className="gap-1 pr-1">
|
|
{tag}
|
|
<button
|
|
onClick={() => onChange(tags.filter((_, i) => i !== idx))}
|
|
className="ml-0.5 hover:text-destructive"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && input.trim()) {
|
|
e.preventDefault();
|
|
onChange([...tags, input.trim()]);
|
|
setInput("");
|
|
}
|
|
}}
|
|
placeholder="추가 후 Enter"
|
|
className="flex-1 h-8 text-sm"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
if (input.trim()) {
|
|
onChange([...tags, input.trim()]);
|
|
setInput("");
|
|
}
|
|
}}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ArrayObjectEditor({
|
|
field,
|
|
items,
|
|
onChange,
|
|
}: {
|
|
field: SettingField;
|
|
items: Record<string, unknown>[];
|
|
onChange: (items: Record<string, unknown>[]) => void;
|
|
}) {
|
|
const addRow = () => {
|
|
const newRow: Record<string, unknown> = {};
|
|
field.fields?.forEach((f) => {
|
|
newRow[f.key] = f.type === "number" ? 0 : "";
|
|
});
|
|
onChange([...items, newRow]);
|
|
};
|
|
|
|
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 (
|
|
<div className="py-3 border-b last:border-0">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div>
|
|
<Label className="text-sm font-medium">{field.label}</Label>
|
|
<p className="text-xs text-muted-foreground mt-0.5">{field.description}</p>
|
|
</div>
|
|
<Button type="button" variant="outline" size="sm" onClick={addRow}>
|
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
{items.length === 0 && (
|
|
<p className="text-xs text-muted-foreground py-2">항목이 없습니다. 추가 버튼을 눌러주세요.</p>
|
|
)}
|
|
<div className="space-y-2">
|
|
{items.map((item, index) => (
|
|
<div key={index} className="flex items-start gap-2 p-2 bg-muted/30 rounded-md">
|
|
<div className="flex-1 grid grid-cols-2 gap-2">
|
|
{field.fields?.map((f) => (
|
|
<div key={f.key}>
|
|
<Label className="text-xs text-muted-foreground">{f.label}</Label>
|
|
{f.type === "number" ? (
|
|
<Input
|
|
type="number"
|
|
value={item[f.key] as number || 0}
|
|
onChange={(e) => updateRow(index, f.key, Number(e.target.value))}
|
|
className="h-7 text-xs"
|
|
/>
|
|
) : f.type === "select" ? (
|
|
<Select
|
|
value={(item[f.key] as string) || ""}
|
|
onValueChange={(v) => updateRow(index, f.key, v)}
|
|
>
|
|
<SelectTrigger size="sm" className="h-7 text-xs">
|
|
<SelectValue placeholder="선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="warn">경고</SelectItem>
|
|
<SelectItem value="stop">정지</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
value={(item[f.key] as string) || ""}
|
|
onChange={(e) => updateRow(index, f.key, e.target.value)}
|
|
className="h-7 text-xs"
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-7 w-7 mt-4 text-muted-foreground hover:text-destructive"
|
|
onClick={() => removeRow(index)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// 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 (
|
|
<div className="flex items-center justify-between py-3 border-b last:border-0">
|
|
<div className="pr-4">
|
|
<Label className="text-sm font-medium">{field.label}</Label>
|
|
<p className="text-xs text-muted-foreground mt-0.5">{field.description}</p>
|
|
</div>
|
|
<Switch checked={!!value} onCheckedChange={onChange} />
|
|
</div>
|
|
);
|
|
case "text":
|
|
return (
|
|
<div className="py-3 border-b last:border-0 space-y-1.5">
|
|
<Label className="text-sm font-medium">{field.label}</Label>
|
|
<p className="text-xs text-muted-foreground">{field.description}</p>
|
|
<Input
|
|
value={(value as string) || ""}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={field.label}
|
|
className="h-9"
|
|
/>
|
|
</div>
|
|
);
|
|
case "number":
|
|
return (
|
|
<div className="py-3 border-b last:border-0 space-y-1.5">
|
|
<Label className="text-sm font-medium">{field.label}</Label>
|
|
<p className="text-xs text-muted-foreground">{field.description}</p>
|
|
<Input
|
|
type="number"
|
|
value={(value as number) ?? 0}
|
|
onChange={(e) => onChange(Number(e.target.value))}
|
|
className="h-9 w-32"
|
|
/>
|
|
</div>
|
|
);
|
|
case "select":
|
|
return (
|
|
<div className="py-3 border-b last:border-0 space-y-1.5">
|
|
<Label className="text-sm font-medium">{field.label}</Label>
|
|
<p className="text-xs text-muted-foreground">{field.description}</p>
|
|
<Select value={(value as string) || field.options?.[0]?.value || ""} onValueChange={onChange}>
|
|
<SelectTrigger size="sm" className="w-48">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{field.options?.map((o) => (
|
|
<SelectItem key={o.value} value={o.value}>
|
|
{o.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
);
|
|
case "color":
|
|
return (
|
|
<div className="py-3 border-b last:border-0 space-y-1.5">
|
|
<Label className="text-sm font-medium">{field.label}</Label>
|
|
<p className="text-xs text-muted-foreground">{field.description}</p>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="color"
|
|
value={(value as string) || "#2563eb"}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="w-9 h-9 rounded-md cursor-pointer border border-input p-0.5"
|
|
/>
|
|
<Input
|
|
value={(value as string) || "#2563eb"}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
className="h-9 w-32"
|
|
placeholder="#hex"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
case "tags":
|
|
return (
|
|
<TagEditor
|
|
label={field.label}
|
|
description={field.description}
|
|
tags={(value as string[]) || []}
|
|
onChange={onChange}
|
|
/>
|
|
);
|
|
case "array-object":
|
|
return (
|
|
<ArrayObjectEditor
|
|
field={field}
|
|
items={(value as Record<string, unknown>[]) || []}
|
|
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<string | null>(null);
|
|
|
|
const handleGroupClick = (groupId: string) => {
|
|
setExpandedGroup(expandedGroup === groupId ? null : groupId);
|
|
};
|
|
|
|
const handleScreenSelect = (screen: ScreenItem) => {
|
|
onSelect(screen);
|
|
setExpandedGroup(null);
|
|
if (!collapsed) onToggleCollapse(); // 선택 후 자동 접기
|
|
};
|
|
|
|
if (collapsed) {
|
|
// 접힌 상태: 현재 선택된 화면명 + 펼치기 버튼
|
|
return (
|
|
<div className="border-b bg-muted/20 px-4 py-2 flex items-center gap-3 shrink-0">
|
|
<button
|
|
onClick={onToggleCollapse}
|
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors text-sm"
|
|
>
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground rotate-90" />
|
|
<span className="text-muted-foreground">화면 선택</span>
|
|
</button>
|
|
{selectedScreen && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
📍 {selectedScreen.name}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 펼친 상태: 메뉴 그룹 가로 나열 + 클릭 시 하위 화면 드롭
|
|
return (
|
|
<div className="border-b bg-muted/20 shrink-0">
|
|
{/* 상단: 그룹 탭 가로 나열 + 접기 버튼 */}
|
|
<div className="flex items-center gap-1 px-4 py-2 border-b border-border/50">
|
|
<button
|
|
onClick={onToggleCollapse}
|
|
className="p-1.5 rounded-md hover:bg-muted transition-colors mr-2"
|
|
title="접기"
|
|
>
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground -rotate-90" />
|
|
</button>
|
|
{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 (
|
|
<button
|
|
key={group.id}
|
|
onClick={() => handleGroupClick(group.id)}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
|
isExpanded
|
|
? "bg-primary text-primary-foreground"
|
|
: hasSelected
|
|
? "bg-primary/10 text-primary"
|
|
: "hover:bg-muted text-foreground/70"
|
|
}`}
|
|
>
|
|
{Icon && <Icon className="h-3.5 w-3.5" />}
|
|
{group.name}
|
|
<ChevronRight
|
|
className={`h-3 w-3 transition-transform ${isExpanded ? "rotate-90" : ""}`}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 하위 화면 목록 (펼쳐진 그룹) */}
|
|
{expandedGroup && (
|
|
<div className="flex items-center gap-2 px-4 py-2 bg-muted/30">
|
|
<span className="text-xs text-muted-foreground mr-2">
|
|
{groups.find((g) => g.id === expandedGroup)?.name}:
|
|
</span>
|
|
{groups
|
|
.find((g) => g.id === expandedGroup)
|
|
?.screens.map((screen) => {
|
|
const isSelected = selectedScreen?.id === screen.id;
|
|
return (
|
|
<button
|
|
key={screen.id}
|
|
onClick={() => handleScreenSelect(screen)}
|
|
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
|
|
isSelected
|
|
? "bg-primary text-primary-foreground font-medium"
|
|
: "bg-background border hover:bg-muted/50"
|
|
}`}
|
|
>
|
|
{screen.name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// SettingsForm — auto-rendered from schema
|
|
// ============================================================
|
|
function SettingsForm({
|
|
screenName,
|
|
settingsKey,
|
|
fields,
|
|
values,
|
|
onChange,
|
|
}: {
|
|
screenName: string;
|
|
settingsKey: string;
|
|
fields: SettingField[];
|
|
values: Record<string, unknown>;
|
|
onChange: (key: string, value: unknown) => void;
|
|
}) {
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2 pb-3 border-b mb-1">
|
|
<Settings2 className="h-5 w-5 text-primary" />
|
|
<h2 className="text-lg font-bold">{screenName} 설정</h2>
|
|
<Badge variant="outline" className="text-xs">
|
|
{settingsKey}
|
|
</Badge>
|
|
</div>
|
|
{fields.map((field) => (
|
|
<SettingRow
|
|
key={field.key}
|
|
field={field}
|
|
value={values[field.key]}
|
|
onChange={(v) => onChange(field.key, v)}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// Main Page Component
|
|
// ============================================================
|
|
export default function PopSettingsMngPage() {
|
|
const { user } = useAuth();
|
|
const [settings, setSettings] = useState<PopSettings>(DEFAULT_SETTINGS);
|
|
const [saving, setSaving] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedScreen, setSelectedScreen] = useState<ScreenItem | null>(
|
|
SCREEN_GROUPS[0].screens[0],
|
|
);
|
|
const [navCollapsed, setNavCollapsed] = useState(false);
|
|
const [hasChanges, setHasChanges] = useState(false);
|
|
const [syncToAll, setSyncToAll] = useState(false);
|
|
const [lastPath, setLastPath] = useState("");
|
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
|
|
// ---- Load settings from screen_layouts_pop per screen ----
|
|
const fetchSettings = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
// Collect all unique screenIds from SCREEN_GROUPS
|
|
const allScreens: { screenId: number; settingsKey: string }[] = [];
|
|
for (const group of SCREEN_GROUPS) {
|
|
for (const screen of group.screens) {
|
|
if (!allScreens.some((s) => s.screenId === screen.screenId && s.settingsKey === screen.settingsKey)) {
|
|
allScreens.push({ screenId: screen.screenId, settingsKey: screen.settingsKey });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch popConfig from each screen's layout-pop
|
|
const merged: PopSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
|
|
|
await Promise.all(
|
|
allScreens.map(async ({ screenId, settingsKey }) => {
|
|
try {
|
|
const res = await apiClient.get(`/screen-management/screens/${screenId}/layout-pop`);
|
|
const popConfig = res.data?.data?.settings?.popConfig;
|
|
if (popConfig) {
|
|
const key = settingsKey as keyof PopSettings["screens"];
|
|
(merged.screens as Record<string, Record<string, unknown>>)[key] = {
|
|
...(merged.screens as Record<string, Record<string, unknown>>)[key],
|
|
...popConfig,
|
|
};
|
|
}
|
|
} catch {
|
|
// Screen may not have layout-pop yet, use defaults
|
|
}
|
|
}),
|
|
);
|
|
|
|
setSettings(merged);
|
|
} catch {
|
|
// Fallback: use defaults
|
|
}
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchSettings();
|
|
}, [fetchSettings]);
|
|
|
|
// ---- iframe navigation sync ----
|
|
useEffect(() => {
|
|
const timer = setInterval(() => {
|
|
try {
|
|
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: silently ignore
|
|
}
|
|
}, 1000);
|
|
return () => clearInterval(timer);
|
|
}, [lastPath]);
|
|
|
|
// ---- 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<string, Record<string, unknown>>)[settingsKey],
|
|
[fieldKey]: value,
|
|
},
|
|
},
|
|
}));
|
|
setHasChanges(true);
|
|
};
|
|
|
|
// ---- Save to screen_layouts_pop per screen ----
|
|
const handleSave = async () => {
|
|
if (!selectedScreen) return;
|
|
setSaving(true);
|
|
|
|
try {
|
|
const currentKey = selectedScreen.settingsKey as keyof PopSettings["screens"];
|
|
const popConfigToSave = (settings.screens as Record<string, Record<string, unknown>>)[currentKey];
|
|
|
|
// Helper: save popConfig to a single screen's layout-pop
|
|
const saveToScreen = async (screenId: number, popConfig: Record<string, unknown>) => {
|
|
// 1. Get current layout
|
|
const layoutRes = await apiClient.get(`/screen-management/screens/${screenId}/layout-pop`).catch(() => null);
|
|
const layoutData = layoutRes?.data?.data || { version: "pop-5.0", components: {}, settings: {}, gridConfig: {} };
|
|
|
|
// 2. Update settings.popConfig
|
|
layoutData.settings = {
|
|
...(layoutData.settings || {}),
|
|
popConfig,
|
|
};
|
|
|
|
// 3. Save
|
|
await apiClient.post(`/screen-management/screens/${screenId}/layout-pop`, layoutData);
|
|
};
|
|
|
|
// Save to the selected screen
|
|
await saveToScreen(selectedScreen.screenId, popConfigToSave);
|
|
|
|
// If syncToAll is on, save to all other screens with the same settingsKey
|
|
if (syncToAll) {
|
|
const otherScreens: number[] = [];
|
|
for (const group of SCREEN_GROUPS) {
|
|
for (const screen of group.screens) {
|
|
if (screen.settingsKey === selectedScreen.settingsKey && screen.screenId !== selectedScreen.screenId) {
|
|
if (!otherScreens.includes(screen.screenId)) {
|
|
otherScreens.push(screen.screenId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
await Promise.all(otherScreens.map((sid) => saveToScreen(sid, popConfigToSave)));
|
|
}
|
|
|
|
setHasChanges(false);
|
|
// Reload iframe to apply settings
|
|
if (iframeRef.current) {
|
|
iframeRef.current.contentWindow?.location.reload();
|
|
}
|
|
alert("설정이 저장되었습니다.");
|
|
} catch {
|
|
alert("저장에 실패했습니다.");
|
|
}
|
|
setSaving(false);
|
|
};
|
|
|
|
// ---- Reset to defaults ----
|
|
const handleReset = () => {
|
|
if (window.confirm("모든 설정을 기본값으로 초기화하시겠습니까?")) {
|
|
setSettings(DEFAULT_SETTINGS);
|
|
setHasChanges(true);
|
|
}
|
|
};
|
|
|
|
// ---- Current screen schema values ----
|
|
const currentSettingsKey = selectedScreen?.settingsKey || "inbound";
|
|
const currentFields = SETTINGS_SCHEMA[currentSettingsKey] || [];
|
|
const currentValues = (settings.screens as Record<string, Record<string, unknown>>)[currentSettingsKey] || {};
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
{/* ---- Header ---- */}
|
|
<div className="flex items-center justify-between px-4 py-2 border-b bg-background">
|
|
<div className="flex items-center gap-3">
|
|
<Settings2 className="h-5 w-5 text-primary" />
|
|
<h1 className="text-base font-bold">POP 화면 설정</h1>
|
|
<Badge variant="outline" className="text-xs">
|
|
{user?.companyCode || "COMPANY_7"}
|
|
</Badge>
|
|
{hasChanges && (
|
|
<Badge variant="warning" className="text-xs">
|
|
미저장 변경
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={handleReset} disabled={saving}>
|
|
<RotateCcw className="h-3.5 w-3.5 mr-1" />
|
|
기본값 초기화
|
|
</Button>
|
|
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges}>
|
|
{saving ? (
|
|
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
|
) : (
|
|
<Save className="h-3.5 w-3.5 mr-1" />
|
|
)}
|
|
{saving ? "저장중..." : "저장"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ---- Screen Nav (top, collapsible vertically) ---- */}
|
|
<ScreenNav
|
|
groups={SCREEN_GROUPS}
|
|
selectedScreen={selectedScreen}
|
|
onSelect={handleScreenSelect}
|
|
collapsed={navCollapsed}
|
|
onToggleCollapse={() => setNavCollapsed((prev) => !prev)}
|
|
/>
|
|
|
|
{/* ---- Body: iframe (left) + settings (right) ---- */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* Left: iframe (POP preview) */}
|
|
<div className="flex-1 min-w-0 bg-muted/10 relative">
|
|
{loading ? (
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : (
|
|
<iframe
|
|
ref={iframeRef}
|
|
src={selectedScreen?.url || "/pop/home"}
|
|
className="w-full h-full border-0"
|
|
title="POP 미리보기"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Settings form */}
|
|
<div className="w-[420px] flex-shrink-0 overflow-y-auto p-4 border-l bg-background">
|
|
{selectedScreen && currentFields.length > 0 ? (
|
|
<>
|
|
<SettingsForm
|
|
screenName={selectedScreen.name}
|
|
settingsKey={currentSettingsKey}
|
|
fields={currentFields}
|
|
values={currentValues}
|
|
onChange={(key, value) => updateScreenSetting(currentSettingsKey, key, value)}
|
|
/>
|
|
{/* Sync to all screens with same settingsKey */}
|
|
<div className="mt-4 pt-4 border-t">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="sync-to-all"
|
|
checked={syncToAll}
|
|
onCheckedChange={(checked) => setSyncToAll(!!checked)}
|
|
/>
|
|
<Label htmlFor="sync-to-all" className="text-sm cursor-pointer">
|
|
같은 유형의 모든 화면에 적용
|
|
</Label>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1 ml-6">
|
|
저장 시 동일 설정키({currentSettingsKey})를 공유하는 화면에도 설정을 동기화합니다.
|
|
</p>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
|
<Settings2 className="h-12 w-12 mb-3 opacity-30" />
|
|
<p className="text-sm">상단에서 화면을 선택하세요</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|