import { useEffect, useState, useCallback } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { api } from '@common/services/api'; interface LayerAdminItem { layerCd: string; upLayerCd: string | null; layerFullNm: string; layerNm: string; layerLevel: number; wmsLayerNm: string | null; useYn: string; sortOrd: number; regDtm: string | null; parentUseYn: string | null; } interface LayerListResponse { items: LayerAdminItem[]; total: number; page: number; totalPages: number; } interface LayerOption { layerCd: string; layerNm: string; layerFullNm: string; layerLevel: number; } interface LayerFormData { layerCd: string; upLayerCd: string; layerFullNm: string; layerNm: string; layerLevel: number; wmsLayerNm: string; useYn: string; sortOrd: number; } const PAGE_SIZE = 10; async function fetchLayers( page: number, search: string, useYn: string, ): Promise { const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) }); if (search) params.set('search', search); if (useYn) params.set('useYn', useYn); const res = await api.get(`/layers/admin/list?${params}`); return res.data; } async function fetchLayerOptions(): Promise { const res = await api.get('/layers/admin/options'); return res.data; } async function toggleLayerUse(layerCd: string): Promise<{ layerCd: string; useYn: string }> { const res = await api.post<{ layerCd: string; useYn: string }>('/layers/admin/toggle-use', { layerCd, }); return res.data; } async function createLayer(body: LayerFormData): Promise { await api.post('/layers/admin/create', body); } async function updateLayer(body: LayerFormData): Promise { await api.post('/layers/admin/update', body); } async function deleteLayer(layerCd: string): Promise { await api.post('/layers/admin/delete', { layerCd }); } async function fetchNextLayerCode(upLayerCd: string): Promise { const params = new URLSearchParams({ upLayerCd }); const res = await api.get<{ nextCode: string }>(`/layers/admin/next-code?${params}`); return res.data.nextCode; } // ---------- LayerFormModal ---------- interface LayerFormModalProps { mode: 'create' | 'edit'; initialData?: LayerAdminItem; onClose: () => void; onSaved: () => void; } const LayerFormModal = ({ mode, initialData, onClose, onSaved }: LayerFormModalProps) => { const [form, setForm] = useState({ layerCd: initialData?.layerCd ?? '', upLayerCd: initialData?.upLayerCd ?? '', layerFullNm: initialData?.layerFullNm ?? '', layerNm: initialData?.layerNm ?? '', layerLevel: initialData?.layerLevel ?? 1, wmsLayerNm: initialData?.wmsLayerNm ?? '', useYn: initialData?.useYn ?? 'Y', sortOrd: initialData?.sortOrd ?? 0, }); const [options, setOptions] = useState([]); const [saving, setSaving] = useState(false); const [formError, setFormError] = useState(null); const [parentInfo, setParentInfo] = useState<{ fullNm: string; level: number } | null>(null); useEffect(() => { fetchLayerOptions() .then(setOptions) .catch(() => {}); }, []); const handleField = (key: K, value: LayerFormData[K]) => { setForm((prev) => ({ ...prev, [key]: value })); }; const handleParentChange = async (upLayerCd: string) => { if (!upLayerCd) { setParentInfo(null); setForm((prev) => ({ ...prev, upLayerCd: '' })); return; } const parent = options.find((o) => o.layerCd === upLayerCd); if (parent) { setParentInfo({ fullNm: parent.layerFullNm, level: parent.layerLevel }); setForm((prev) => ({ ...prev, upLayerCd, layerLevel: parent.layerLevel + 1, layerFullNm: prev.layerNm ? `${parent.layerFullNm} ${prev.layerNm}` : parent.layerFullNm, })); } try { const nextCode = await fetchNextLayerCode(upLayerCd); setForm((prev) => ({ ...prev, layerCd: nextCode })); } catch { /* 실패 시 사용자 수동 입력 */ } }; const handleLayerNmChange = (value: string) => { setForm((prev) => ({ ...prev, layerNm: value, ...(parentInfo && { layerFullNm: value ? `${parentInfo.fullNm} ${value}` : parentInfo.fullNm, }), })); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!form.layerCd.trim()) { setFormError('레이어코드는 필수입니다.'); return; } if (!form.layerNm.trim()) { setFormError('레이어명은 필수입니다.'); return; } if (!form.layerFullNm.trim()) { setFormError('레이어전체명은 필수입니다.'); return; } setSaving(true); setFormError(null); try { if (mode === 'create') { await createLayer(form); } else { await updateLayer(form); } onSaved(); onClose(); } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setFormError(msg ?? (mode === 'create' ? '등록에 실패했습니다.' : '수정에 실패했습니다.')); } finally { setSaving(false); } }; const inputCls = 'w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none'; const labelCls = 'block text-label-2 font-semibold text-fg-sub font-korean mb-1.5'; return (
{/* 헤더 */}

{mode === 'create' ? '레이어 등록' : '레이어 수정'}

{/* 폼 */}
{/* 상위 레이어코드 */}
{/* 레이어코드 */}
handleField('layerCd', e.target.value)} readOnly={mode === 'edit'} placeholder="예: LAYER_001" className={`${inputCls} font-mono${mode === 'edit' ? ' bg-bg-base text-fg-disabled cursor-not-allowed' : ''}`} />
{/* 레이어명 */}
mode === 'create' ? handleLayerNmChange(e.target.value) : handleField('layerNm', e.target.value) } maxLength={100} placeholder="레이어 이름" className={inputCls} />
{/* 레이어전체명 */}
handleField('layerFullNm', e.target.value)} maxLength={200} placeholder="레이어 전체 경로명" className={inputCls} />
{/* 레벨 */}
handleField('layerLevel', Number(e.target.value))} min={1} max={10} className={inputCls} />
{/* WMS레이어명 */}
handleField('wmsLayerNm', e.target.value)} placeholder="WMS 레이어명 (선택)" className={`${inputCls} font-mono`} />
{/* 정렬순서 */}
handleField('sortOrd', Number(e.target.value))} className={inputCls} />
{/* 사용여부 */}
{/* 에러 */} {formError && (

{formError}

)} {/* 버튼 */}
); }; // ---------- LayerPanel ---------- const LayerPanel = () => { const queryClient = useQueryClient(); const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [totalPages, setTotalPages] = useState(1); const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [toggling, setToggling] = useState(null); const [error, setError] = useState(null); // 검색 const [searchInput, setSearchInput] = useState(''); const [appliedSearch, setAppliedSearch] = useState(''); const [filterUseYn, setFilterUseYn] = useState(''); // 모달 const [modal, setModal] = useState<{ mode: 'create' | 'edit'; data?: LayerAdminItem } | null>( null, ); const load = useCallback(async (p: number, search: string, useYn: string) => { setLoading(true); setError(null); try { const res = await fetchLayers(p, search, useYn); setItems(res.items); setTotal(res.total); setTotalPages(res.totalPages); } catch { setError('레이어 목록을 불러오지 못했습니다.'); } finally { setLoading(false); } }, []); useEffect(() => { load(page, appliedSearch, filterUseYn); }, [load, page, appliedSearch, filterUseYn]); const handleSearch = () => { setAppliedSearch(searchInput); setPage(1); }; const handleToggle = async (layerCd: string) => { if (toggling) return; setToggling(layerCd); try { const result = await toggleLayerUse(layerCd); setItems((prev) => prev.map((item) => { if (item.layerCd === result.layerCd) return { ...item, useYn: result.useYn }; // 직접 자식의 parentUseYn도 즉시 동기화 if (item.upLayerCd === result.layerCd) return { ...item, parentUseYn: result.useYn }; return item; }), ); // 레이어 캐시 무효화 → 예측 탭 등 useLayerTree 구독자가 최신 데이터 수신 queryClient.invalidateQueries({ queryKey: ['layers'] }); } catch { setError('사용여부 변경에 실패했습니다.'); } finally { setToggling(null); } }; const handleDelete = async (layerCd: string) => { if (!confirm(`레이어 [${layerCd}]를 삭제하시겠습니까?`)) return; try { await deleteLayer(layerCd); load(page, appliedSearch, filterUseYn); } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; setError(msg ?? '레이어 삭제에 실패했습니다.'); } }; const buildPageButtons = () => { const buttons: (number | 'ellipsis')[] = []; const delta = 2; const left = page - delta; const right = page + delta; for (let i = 1; i <= totalPages; i++) { if (i === 1 || i === totalPages || (i >= left && i <= right)) { buttons.push(i); } else if (buttons[buttons.length - 1] !== 'ellipsis') { buttons.push('ellipsis'); } } return buttons; }; return (
{/* 헤더 */}

레이어 관리

총 {total}개

setSearchInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} placeholder="레이어코드 / 레이어명 검색" className="flex-1 px-3 py-1.5 text-caption bg-bg-elevated border border-stroke rounded text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean" />
{/* 오류 메시지 */} {error && (
{error}
)} {/* 테이블 영역 */}
{loading ? (
불러오는 중...
) : ( {items.length === 0 ? ( ) : ( items.map((item, idx) => ( {/* 번호 */} {/* 레이어코드 */} {/* 레이어명 */} {/* 레이어전체명 */} {/* 레벨 */} {/* WMS레이어명 */} {/* 정렬순서 */} {/* 등록일시 */} {/* 사용여부 토글 */} {/* 액션 */} )) )}
번호 레이어코드 레이어명 레이어전체명 레벨 WMS레이어명 정렬 등록일시 사용여부 액션
데이터가 없습니다.
{(page - 1) * PAGE_SIZE + idx + 1} {item.layerCd}{item.layerNm} {item.layerFullNm} {item.layerLevel} {item.wmsLayerNm ?? -} {item.sortOrd} {item.regDtm ?? '-'}
)}
{/* 페이지네이션 */} {!loading && totalPages > 1 && (
{(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} / {total}개
{buildPageButtons().map((btn, i) => btn === 'ellipsis' ? ( ) : ( ), )}
)} {/* 모달 */} {modal && ( setModal(null)} onSaved={() => load(page, appliedSearch, filterUseYn)} /> )}
); }; export default LayerPanel;