346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { menuApi } from "@/lib/api/menu";
|
|
import type { MenuItem, MenuFormData, LangKey } from "@/lib/api/menu";
|
|
import { companyAPI } from "@/lib/api/company";
|
|
import { MenuIconPicker } from "@/components/admin/MenuIconPicker";
|
|
|
|
interface Props {
|
|
menu: MenuItem;
|
|
parentName: string;
|
|
onSaved: () => void;
|
|
onDelete: (menuId: string) => void;
|
|
onOpenAdvanced: (menuId: string) => void;
|
|
}
|
|
|
|
interface CompanyOption {
|
|
company_code: string;
|
|
company_name: string;
|
|
status?: string;
|
|
}
|
|
|
|
interface FormState {
|
|
menuNameKor: string;
|
|
menuUrl: string;
|
|
menuDesc: string;
|
|
menuIcon: string;
|
|
seq: number;
|
|
companyCode: string;
|
|
status: string;
|
|
langKey: string;
|
|
}
|
|
|
|
const toForm = (m: MenuItem): FormState => ({
|
|
menuNameKor: m.menu_name_kor ?? m.MENU_NAME_KOR ?? "",
|
|
menuUrl: m.menu_url ?? m.MENU_URL ?? "",
|
|
menuDesc: m.menu_desc ?? m.MENU_DESC ?? "",
|
|
menuIcon: m.menu_icon ?? m.MENU_ICON ?? "",
|
|
seq: Number(m.seq ?? m.SEQ ?? 1),
|
|
companyCode: m.company_code ?? m.COMPANY_CODE ?? "*",
|
|
// DB는 소문자 'active'/'inactive' — 원본 그대로 유지
|
|
status: (m.status ?? m.STATUS ?? "active").toString().toLowerCase(),
|
|
langKey: m.lang_key ?? m.LANG_KEY ?? "",
|
|
});
|
|
|
|
export function MenuSettingsPanel({ menu, parentName, onSaved, onDelete, onOpenAdvanced }: Props) {
|
|
const menuId = String(menu.objid ?? menu.OBJID ?? "");
|
|
const initial = toForm(menu);
|
|
const [form, setForm] = useState<FormState>(initial);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// 드롭다운 옵션 소스
|
|
const [companies, setCompanies] = useState<CompanyOption[]>([]);
|
|
const [langKeys, setLangKeys] = useState<LangKey[]>([]);
|
|
|
|
useEffect(() => {
|
|
setForm(toForm(menu));
|
|
}, [menu]);
|
|
|
|
// 회사 목록 로드 (1회)
|
|
useEffect(() => {
|
|
let alive = true;
|
|
(async () => {
|
|
try {
|
|
const list = await companyAPI.getList({ status: "active" });
|
|
if (alive) setCompanies(list || []);
|
|
} catch {
|
|
/* silent - 기본값 사용 */
|
|
}
|
|
})();
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, []);
|
|
|
|
// 다국어 키 로드 (companyCode 변경 시 재로드, 활성 키만 필터)
|
|
useEffect(() => {
|
|
let alive = true;
|
|
(async () => {
|
|
try {
|
|
const resp = await menuApi.getLangKeys({
|
|
companyCode: form.companyCode || "*",
|
|
});
|
|
if (alive && resp.success && resp.data) {
|
|
setLangKeys(resp.data.filter((k) => k.isActive === "Y"));
|
|
}
|
|
} catch {
|
|
if (alive) setLangKeys([]);
|
|
}
|
|
})();
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [form.companyCode]);
|
|
|
|
const isDirty =
|
|
form.menuNameKor !== initial.menuNameKor ||
|
|
form.menuUrl !== initial.menuUrl ||
|
|
form.menuDesc !== initial.menuDesc ||
|
|
form.menuIcon !== initial.menuIcon ||
|
|
form.seq !== initial.seq ||
|
|
form.companyCode !== initial.companyCode ||
|
|
form.status !== initial.status ||
|
|
form.langKey !== initial.langKey;
|
|
|
|
const set = <K extends keyof FormState>(k: K, v: FormState[K]) =>
|
|
setForm((f) => ({ ...f, [k]: v }));
|
|
|
|
const handleSave = async () => {
|
|
if (!form.menuNameKor.trim()) {
|
|
toast.error("메뉴명을 입력하세요");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const payload: MenuFormData = {
|
|
objid: menuId,
|
|
parentObjId: String(menu.parent_obj_id ?? menu.PARENT_OBJ_ID ?? "0"),
|
|
menuNameKor: form.menuNameKor,
|
|
menuUrl: form.menuUrl,
|
|
menuDesc: form.menuDesc,
|
|
seq: form.seq,
|
|
menu_type: String(menu.menu_type ?? menu.MENU_TYPE ?? "1"),
|
|
status: form.status,
|
|
company_code: form.companyCode,
|
|
langKey: form.langKey || undefined,
|
|
menuIcon: form.menuIcon || undefined,
|
|
};
|
|
const resp = await menuApi.updateMenu(menuId, payload);
|
|
if (resp.success) {
|
|
toast.success(resp.message || "저장되었습니다");
|
|
onSaved();
|
|
} else {
|
|
toast.error(resp.message || "저장 실패");
|
|
}
|
|
} catch (e) {
|
|
toast.error("저장 중 오류가 발생했습니다");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const statusOn = form.status === "active";
|
|
|
|
return (
|
|
<div className="v5-mm-pane on v5-mm-pane-wrap">
|
|
<div className="v5-mm-sv-hero">
|
|
<div className="v5-mm-sv-hero-top">
|
|
<div className="v5-mm-sv-hero-ico">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M4 6h16M4 12h16M4 18h10" />
|
|
</svg>
|
|
</div>
|
|
<div className="v5-mm-sv-hero-info">
|
|
<div className="v5-mm-sv-hero-path">
|
|
관리자 / <b>{parentName || "최상위"}</b>
|
|
</div>
|
|
<h2>
|
|
{form.menuNameKor || "(이름 없음)"}
|
|
<span className={`v5-mm-chip${statusOn ? " on" : ""}`}>{statusOn ? "Active" : "Inactive"}</span>
|
|
<span className="v5-mm-chip scope">{form.companyCode === "*" ? "공용 *" : form.companyCode || "—"}</span>
|
|
<span className="v5-mm-chip">L{Number(menu.lev ?? menu.LEV ?? 1)}</span>
|
|
</h2>
|
|
</div>
|
|
</div>
|
|
<div className="v5-mm-sv-hero-meta">
|
|
<span>
|
|
OBJID <b>{menuId}</b>
|
|
</span>
|
|
<span>
|
|
수정 <b>{menu.regdate ?? menu.REGDATE ?? "—"}</b>
|
|
</span>
|
|
<span>
|
|
작성 <b>{menu.writer ?? menu.WRITER ?? "—"}</b>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="v5-mm-sv-grid">
|
|
<div className="v5-mm-sv-side">
|
|
<h4>기본 정보</h4>
|
|
<p>메뉴 이름, 다국어 키, 사용자에게 표시되는 설명을 설정합니다.</p>
|
|
</div>
|
|
<div className="v5-mm-sv-fields">
|
|
<div className="v5-mm-sv-row">
|
|
<label>
|
|
메뉴명<span className="req">*</span>
|
|
</label>
|
|
<input
|
|
className="v5-mm-inp"
|
|
value={form.menuNameKor}
|
|
onChange={(e) => set("menuNameKor", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="v5-mm-sv-row-2">
|
|
<div className="v5-mm-sv-row">
|
|
<label>다국어 키</label>
|
|
<select
|
|
className="v5-mm-inp"
|
|
value={form.langKey}
|
|
onChange={(e) => set("langKey", e.target.value)}
|
|
>
|
|
<option value="">(사용 안 함)</option>
|
|
{langKeys.map((k) => (
|
|
<option key={k.keyId} value={k.langKey}>
|
|
{k.langKey}
|
|
{k.description ? ` — ${k.description}` : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="v5-mm-sv-row">
|
|
<label>아이콘</label>
|
|
<div className="v5-mm-iconpicker-slot">
|
|
<MenuIconPicker
|
|
value={form.menuIcon}
|
|
onChange={(iconName) => set("menuIcon", iconName)}
|
|
label=""
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="v5-mm-sv-row">
|
|
<label>설명</label>
|
|
<textarea
|
|
className="v5-mm-inp"
|
|
value={form.menuDesc}
|
|
onChange={(e) => set("menuDesc", e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="v5-mm-sv-grid">
|
|
<div className="v5-mm-sv-side">
|
|
<h4>연결</h4>
|
|
<p>메뉴 클릭 시 이동할 URL 또는 화면을 지정합니다. 화면 선택은 고급 편집에서 가능합니다.</p>
|
|
</div>
|
|
<div className="v5-mm-sv-fields">
|
|
<div className="v5-mm-sv-row">
|
|
<label>URL</label>
|
|
<input
|
|
className="v5-mm-inp"
|
|
value={form.menuUrl}
|
|
onChange={(e) => set("menuUrl", e.target.value)}
|
|
placeholder="/admin/..."
|
|
/>
|
|
<div className="help">화면/대시보드/POP 할당은 고급 편집을 이용하세요.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="v5-mm-sv-grid">
|
|
<div className="v5-mm-sv-side">
|
|
<h4>스코프 & 표시</h4>
|
|
<p>어느 회사에서 보이고 트리 상 어떤 순서/상태로 나타날지 결정합니다.</p>
|
|
</div>
|
|
<div className="v5-mm-sv-fields">
|
|
<div className="v5-mm-sv-row-2">
|
|
<div className="v5-mm-sv-row">
|
|
<label>회사</label>
|
|
<select
|
|
className="v5-mm-inp"
|
|
value={form.companyCode || "*"}
|
|
onChange={(e) => set("companyCode", e.target.value)}
|
|
>
|
|
<option value="*">공용 (*)</option>
|
|
{companies.map((c) => (
|
|
<option key={c.company_code} value={c.company_code}>
|
|
{c.company_name}
|
|
{c.company_code ? ` (${c.company_code})` : ""}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="v5-mm-sv-row">
|
|
<label>부모</label>
|
|
<input className="v5-mm-inp" value={parentName || "최상위"} readOnly />
|
|
</div>
|
|
</div>
|
|
<div className="v5-mm-sv-row-2">
|
|
<div className="v5-mm-sv-row">
|
|
<label>순서</label>
|
|
<input
|
|
className="v5-mm-inp"
|
|
type="number"
|
|
value={form.seq}
|
|
onChange={(e) => set("seq", Number(e.target.value) || 0)}
|
|
/>
|
|
</div>
|
|
<div className="v5-mm-sv-row">
|
|
<label>상태</label>
|
|
<label
|
|
className={`v5-mm-tg${statusOn ? " on" : ""}`}
|
|
onClick={() => set("status", statusOn ? "inactive" : "active")}
|
|
>
|
|
<span className="sw" />
|
|
<span className="lbl">{statusOn ? "활성" : "비활성"}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="v5-mm-sv-ft">
|
|
<div style={{ display: "flex", gap: ".4rem" }}>
|
|
<button className="v5-mm-btn danger sm" onClick={() => onDelete(menuId)}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
|
</svg>
|
|
삭제
|
|
</button>
|
|
<button className="v5-mm-btn sm" onClick={() => onOpenAdvanced(menuId)}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
|
</svg>
|
|
고급 편집
|
|
</button>
|
|
</div>
|
|
<div style={{ display: "flex", gap: ".4rem", alignItems: "center" }}>
|
|
{isDirty && (
|
|
<span className="v5-mm-unsaved">
|
|
<span className="d" />
|
|
저장되지 않음
|
|
</span>
|
|
)}
|
|
<button
|
|
className="v5-mm-btn"
|
|
disabled={!isDirty || saving}
|
|
onClick={() => setForm(initial)}
|
|
>
|
|
되돌리기
|
|
</button>
|
|
<button className="v5-mm-btn primary" disabled={!isDirty || saving} onClick={handleSave}>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
<path d="M5 12l5 5L20 7" />
|
|
</svg>
|
|
{saving ? "저장 중…" : "저장"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|