feat: POP 설정 페이지 v3 — 상단 화면 선택 + 스키마 기반 설정폼

- 오버레이 클릭존 제거 (위치 불일치 문제 근본 해결)
- 상단: 메뉴 그룹 (입고/출고/생산/홈/PLC) 접기/펼치기
- 왼쪽: iframe POP 미리보기
- 오른쪽: 스키마 기반 동적 설정폼 (7가지 타입)
- /pop/admin 페이지 삭제 (PC 설정으로 일원화)
This commit is contained in:
SeongHyun Kim
2026-04-05 21:51:25 +09:00
parent 9ea59e94df
commit aba2de7ee3
2 changed files with 647 additions and 1437 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,259 +0,0 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiClient } from "@/lib/api/client";
import { PopShell } from "@/components/pop/hardcoded";
interface PopSettings {
version: string;
screens: {
processExecution: {
materialInput: boolean;
photoUpload: boolean;
plcEnabled: boolean;
bomFlexible: boolean;
packagingOptions: string[];
defectTypes: string[];
reworkTargetSelection: boolean;
groupPhotoEnabled: boolean;
};
inbound: {
inspectionRequired: boolean;
photoUpload: boolean;
barcodeEnabled: boolean;
};
outbound: {
photoUpload: boolean;
barcodeEnabled: boolean;
};
home: {
kpiCarousel: boolean;
recentActivity: boolean;
};
};
}
const DEFAULT_SETTINGS: PopSettings = {
version: "hardcoded-1.0",
screens: {
processExecution: {
materialInput: true,
photoUpload: true,
plcEnabled: false,
bomFlexible: true,
packagingOptions: ["낱개", "박스", "파렛트"],
defectTypes: ["스크래치", "치수불량", "변색", "크랙", "기포"],
reworkTargetSelection: true,
groupPhotoEnabled: false,
},
inbound: {
inspectionRequired: false,
photoUpload: false,
barcodeEnabled: true,
},
outbound: {
photoUpload: false,
barcodeEnabled: true,
},
home: {
kpiCarousel: true,
recentActivity: true,
},
},
};
function Toggle({ label, description, value, onChange }: {
label: string; description?: string; value: boolean; onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
<div>
<p className="text-sm font-semibold text-gray-900">{label}</p>
{description && <p className="text-xs text-gray-400 mt-0.5">{description}</p>}
</div>
<button
onClick={() => onChange(!value)}
className={`w-12 h-7 rounded-full transition-all ${value ? "bg-blue-500" : "bg-gray-200"}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow-sm transition-transform mx-1 ${value ? "translate-x-5" : ""}`} />
</button>
</div>
);
}
function TagEditor({ label, tags, onChange }: {
label: string; tags: string[]; onChange: (tags: string[]) => void;
}) {
const [input, setInput] = useState("");
return (
<div className="py-3 border-b border-gray-100">
<p className="text-sm font-semibold text-gray-900 mb-2">{label}</p>
<div className="flex flex-wrap gap-1.5 mb-2">
{tags.map((tag) => (
<span key={tag} className="flex items-center gap-1 px-2.5 py-1 bg-blue-50 text-blue-700 text-xs font-medium rounded-full">
{tag}
<button onClick={() => onChange(tags.filter((t) => t !== tag))} className="text-blue-400 hover:text-blue-600">×</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && input.trim()) {
onChange([...tags, input.trim()]);
setInput("");
}
}}
placeholder="추가 후 Enter"
className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm"
/>
</div>
</div>
);
}
export default function PopAdminSettingsPage() {
const { user } = useAuth();
const [settings, setSettings] = useState<PopSettings>(DEFAULT_SETTINGS);
const [saving, setSaving] = useState(false);
const [activeTab, setActiveTab] = useState<"process" | "inbound" | "outbound" | "home">("process");
const fetchSettings = useCallback(async () => {
try {
const res = await apiClient.get("/data/pop_settings?pageSize=1");
const rows = res.data?.data?.data || res.data?.data || [];
if (rows.length > 0 && rows[0].settings_data) {
const parsed = typeof rows[0].settings_data === "string"
? JSON.parse(rows[0].settings_data)
: rows[0].settings_data;
setSettings({ ...DEFAULT_SETTINGS, ...parsed });
}
} catch {
// 테이블 없으면 기본값 사용
}
}, []);
useEffect(() => { fetchSettings(); }, [fetchSettings]);
const handleSave = async () => {
setSaving(true);
try {
// pop_settings 테이블에 저장 (없으면 generic data API 사용)
await apiClient.post("/pop/execute-action", {
taskType: "data-save",
targetTable: "pop_settings",
columnMapping: {
id: crypto.randomUUID(),
company_code: user?.companyCode || "COMPANY_7",
settings_data: JSON.stringify(settings),
updated_by: user?.userId,
},
});
alert("설정이 저장되었습니다");
} catch {
// 테이블이 없을 수 있으므로 localStorage fallback
localStorage.setItem("pop_settings", JSON.stringify(settings));
alert("설정이 로컬에 저장되었습니다 (DB 테이블 생성 후 동기화 필요)");
}
setSaving(false);
};
const pe = settings.screens.processExecution;
const ib = settings.screens.inbound;
const ob = settings.screens.outbound;
const hm = settings.screens.home;
const updatePE = (key: string, value: unknown) => {
setSettings((prev) => ({
...prev,
screens: { ...prev.screens, processExecution: { ...prev.screens.processExecution, [key]: value } },
}));
};
const tabs = [
{ key: "process" as const, label: "공정실행" },
{ key: "inbound" as const, label: "입고" },
{ key: "outbound" as const, label: "출고" },
{ key: "home" as const, label: "홈" },
];
return (
<PopShell title="POP 설정 관리" showBanner={false} showBack>
<div className="max-w-2xl mx-auto p-4">
{/* 탭 */}
<div className="flex gap-1 mb-4 bg-gray-100 rounded-xl p-1">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 py-2.5 rounded-lg text-sm font-semibold transition-all ${
activeTab === tab.key ? "bg-white text-gray-900 shadow-sm" : "text-gray-500"
}`}
>
{tab.label}
</button>
))}
</div>
{/* 공정실행 설정 */}
{activeTab === "process" && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<h3 className="text-base font-bold text-gray-900 mb-3"> </h3>
<Toggle label="자재 투입" description="BOM 기반 자재 투입 탭 표시" value={pe.materialInput} onChange={(v) => updatePE("materialInput", v)} />
<Toggle label="BOM 유동 투입" description="기준과 다른 수량 투입 허용" value={pe.bomFlexible} onChange={(v) => updatePE("bomFlexible", v)} />
<Toggle label="사진 첨부" description="실적 입력 시 사진 첨부 허용" value={pe.photoUpload} onChange={(v) => updatePE("photoUpload", v)} />
<Toggle label="그룹별 사진" description="체크리스트 그룹마다 사진 첨부" value={pe.groupPhotoEnabled} onChange={(v) => updatePE("groupPhotoEnabled", v)} />
<Toggle label="PLC 연동" description="설비 PLC 데이터 자동 연동" value={pe.plcEnabled} onChange={(v) => updatePE("plcEnabled", v)} />
<Toggle label="재작업 공정 지정" description="불량 처리 시 특정 공정 선택 가능" value={pe.reworkTargetSelection} onChange={(v) => updatePE("reworkTargetSelection", v)} />
<TagEditor label="포장 옵션" tags={pe.packagingOptions} onChange={(v) => updatePE("packagingOptions", v)} />
<TagEditor label="불량 유형" tags={pe.defectTypes} onChange={(v) => updatePE("defectTypes", v)} />
</div>
)}
{/* 입고 설정 */}
{activeTab === "inbound" && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<h3 className="text-base font-bold text-gray-900 mb-3"> </h3>
<Toggle label="검사 필수" description="입고 시 검사 항목 필수 체크" value={ib.inspectionRequired} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, inbound: { ...p.screens.inbound, inspectionRequired: v } } }))} />
<Toggle label="사진 첨부" description="입고 확정 시 사진 첨부" value={ib.photoUpload} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, inbound: { ...p.screens.inbound, photoUpload: v } } }))} />
<Toggle label="바코드 스캔" description="바코드/QR 스캔 기능" value={ib.barcodeEnabled} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, inbound: { ...p.screens.inbound, barcodeEnabled: v } } }))} />
</div>
)}
{/* 출고 설정 */}
{activeTab === "outbound" && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<h3 className="text-base font-bold text-gray-900 mb-3"> </h3>
<Toggle label="사진 첨부" value={ob.photoUpload} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, outbound: { ...p.screens.outbound, photoUpload: v } } }))} />
<Toggle label="바코드 스캔" value={ob.barcodeEnabled} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, outbound: { ...p.screens.outbound, barcodeEnabled: v } } }))} />
</div>
)}
{/* 홈 설정 */}
{activeTab === "home" && (
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<h3 className="text-base font-bold text-gray-900 mb-3"> </h3>
<Toggle label="KPI 캐러셀" description="오늘의 현황 캐러셀 표시" value={hm.kpiCarousel} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, home: { ...p.screens.home, kpiCarousel: v } } }))} />
<Toggle label="최근 활동" description="최근 입출고 활동 표시" value={hm.recentActivity} onChange={(v) => setSettings((p) => ({ ...p, screens: { ...p.screens, home: { ...p.screens.home, recentActivity: v } } }))} />
</div>
)}
{/* 저장 버튼 */}
<button
onClick={handleSave}
disabled={saving}
className="w-full mt-4 py-4 rounded-xl text-base font-bold text-white bg-blue-500 active:scale-[0.98] transition-all disabled:opacity-40"
>
{saving ? "저장중..." : "설정 저장"}
</button>
<p className="text-xs text-gray-400 text-center mt-3">
({user?.companyCode}) . .
</p>
</div>
</PopShell>
);
}