feat: POP 설정 페이지 v3 — 상단 화면 선택 + 스키마 기반 설정폼
- 오버레이 클릭존 제거 (위치 불일치 문제 근본 해결) - 상단: 메뉴 그룹 (입고/출고/생산/홈/PLC) 접기/펼치기 - 왼쪽: iframe POP 미리보기 - 오른쪽: 스키마 기반 동적 설정폼 (7가지 타입) - /pop/admin 페이지 삭제 (PC 설정으로 일원화)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user