wing-ops/frontend/src/tabs/admin/components/LayerPanel.tsx

695 lines
26 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<LayerListResponse> {
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<LayerListResponse>(`/layers/admin/list?${params}`);
return res.data;
}
async function fetchLayerOptions(): Promise<LayerOption[]> {
const res = await api.get<LayerOption[]>('/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<void> {
await api.post('/layers/admin/create', body);
}
async function updateLayer(body: LayerFormData): Promise<void> {
await api.post('/layers/admin/update', body);
}
async function deleteLayer(layerCd: string): Promise<void> {
await api.post('/layers/admin/delete', { layerCd });
}
async function fetchNextLayerCode(upLayerCd: string): Promise<string> {
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<LayerFormData>({
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<LayerOption[]>([]);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [parentInfo, setParentInfo] = useState<{ fullNm: string; level: number } | null>(null);
useEffect(() => {
fetchLayerOptions()
.then(setOptions)
.catch(() => {});
}, []);
const handleField = <K extends keyof LayerFormData>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke shrink-0">
<h2 className="text-body-2 font-bold text-fg font-korean">
{mode === 'create' ? '레이어 등록' : '레이어 수정'}
</h2>
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
</button>
</div>
{/* 폼 */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-4">
{/* 상위 레이어코드 */}
<div>
<label className={labelCls}> </label>
<select
value={form.upLayerCd}
onChange={(e) =>
mode === 'create'
? handleParentChange(e.target.value)
: handleField('upLayerCd', e.target.value)
}
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
>
<option value="">()</option>
{options
.filter((o) => o.layerCd !== form.layerCd)
.map((o) => (
<option key={o.layerCd} value={o.layerCd}>
{o.layerCd} {o.layerNm}
</option>
))}
</select>
</div>
{/* 레이어코드 */}
<div>
<label className={labelCls}>
<span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.layerCd}
onChange={(e) => 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' : ''}`}
/>
</div>
{/* 레이어명 */}
<div>
<label className={labelCls}>
<span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.layerNm}
onChange={(e) =>
mode === 'create'
? handleLayerNmChange(e.target.value)
: handleField('layerNm', e.target.value)
}
maxLength={100}
placeholder="레이어 이름"
className={inputCls}
/>
</div>
{/* 레이어전체명 */}
<div>
<label className={labelCls}>
<span className="text-red-400">*</span>
</label>
<input
type="text"
value={form.layerFullNm}
onChange={(e) => handleField('layerFullNm', e.target.value)}
maxLength={200}
placeholder="레이어 전체 경로명"
className={inputCls}
/>
</div>
{/* 레벨 */}
<div>
<label className={labelCls}></label>
<input
type="number"
value={form.layerLevel}
onChange={(e) => handleField('layerLevel', Number(e.target.value))}
min={1}
max={10}
className={inputCls}
/>
</div>
{/* WMS레이어명 */}
<div>
<label className={labelCls}>WMS레이어명</label>
<input
type="text"
value={form.wmsLayerNm}
onChange={(e) => handleField('wmsLayerNm', e.target.value)}
placeholder="WMS 레이어명 (선택)"
className={`${inputCls} font-mono`}
/>
</div>
{/* 정렬순서 */}
<div>
<label className={labelCls}></label>
<input
type="number"
value={form.sortOrd}
onChange={(e) => handleField('sortOrd', Number(e.target.value))}
className={inputCls}
/>
</div>
{/* 사용여부 */}
<div>
<label className={labelCls}></label>
<select
value={form.useYn}
onChange={(e) => handleField('useYn', e.target.value)}
className="w-full px-3 py-2 text-caption bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none"
>
<option value="Y"></option>
<option value="N"></option>
</select>
</div>
</div>
{/* 에러 */}
{formError && (
<div className="px-6 pb-2">
<p className="text-label-2 text-red-400 font-korean">{formError}</p>
</div>
)}
{/* 버튼 */}
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-stroke shrink-0">
<button
type="button"
onClick={onClose}
className="px-3 py-1.5 text-caption border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
<button
type="submit"
disabled={saving}
className="px-3 py-1.5 text-caption bg-color-accent text-bg-0 rounded hover:opacity-90 disabled:opacity-50 transition-all font-korean"
>
{saving ? '저장 중...' : mode === 'create' ? '등록' : '저장'}
</button>
</div>
</form>
</div>
</div>
);
};
// ---------- LayerPanel ----------
const LayerPanel = () => {
const queryClient = useQueryClient();
const [items, setItems] = useState<LayerAdminItem[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [toggling, setToggling] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="flex flex-col h-full overflow-hidden">
{/* 헤더 */}
<div className="px-6 py-4 border-b border-stroke shrink-0">
<div className="flex items-center justify-between mb-3">
<div>
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<p className="text-caption text-fg-disabled mt-1 font-korean"> {total}</p>
</div>
<button
onClick={() => setModal({ mode: 'create' })}
className="px-3 py-1.5 text-caption font-semibold bg-color-accent text-bg-0 rounded hover:opacity-90 transition-opacity font-korean"
>
</button>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={searchInput}
onChange={(e) => 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"
/>
<select
value={filterUseYn}
onChange={(e) => setFilterUseYn(e.target.value)}
className="px-2 py-1.5 text-caption bg-bg-elevated border border-stroke rounded text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""></option>
<option value="Y"></option>
<option value="N"></option>
</select>
<button
onClick={handleSearch}
className="px-3 py-1.5 text-caption border border-stroke text-fg-sub rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
</div>
</div>
{/* 오류 메시지 */}
{error && (
<div className="px-6 py-2 text-caption text-red-400 bg-[rgba(239,68,68,0.05)] border-b border-stroke shrink-0 font-korean">
{error}
</div>
)}
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-full text-fg-disabled text-body-2 font-korean">
...
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-stroke bg-bg-surface sticky top-0 z-10">
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean w-10 whitespace-nowrap">
</th>
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-mono">
</th>
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean w-12 whitespace-nowrap">
</th>
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-mono">
WMS레이어명
</th>
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean w-16">
</th>
<th className="px-4 py-3 text-left text-label-2 font-semibold text-fg-disabled font-korean w-28">
</th>
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean w-20">
</th>
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean w-28 whitespace-nowrap">
</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td
colSpan={10}
className="px-4 py-12 text-center text-fg-disabled text-body-2 font-korean"
>
.
</td>
</tr>
) : (
items.map((item, idx) => (
<tr
key={item.layerCd}
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
>
{/* 번호 */}
<td className="px-4 py-3 text-caption text-fg-disabled font-mono">
{(page - 1) * PAGE_SIZE + idx + 1}
</td>
{/* 레이어코드 */}
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">{item.layerCd}</td>
{/* 레이어명 */}
<td className="px-4 py-3 text-caption text-fg font-korean">{item.layerNm}</td>
{/* 레이어전체명 */}
<td className="px-4 py-3 text-caption text-fg-sub font-korean max-w-[200px]">
<span className="block truncate" title={item.layerFullNm}>
{item.layerFullNm}
</span>
</td>
{/* 레벨 */}
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center justify-center w-5 h-5 rounded text-caption font-semibold bg-[rgba(6,182,212,0.1)] text-color-accent border border-[rgba(6,182,212,0.3)]">
{item.layerLevel}
</span>
</td>
{/* WMS레이어명 */}
<td className="px-4 py-3 text-label-2 text-fg-sub font-mono">
{item.wmsLayerNm ?? <span className="text-fg-disabled">-</span>}
</td>
{/* 정렬순서 */}
<td className="px-4 py-3 text-caption text-fg-disabled text-center font-mono">
{item.sortOrd}
</td>
{/* 등록일시 */}
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono">
{item.regDtm ?? '-'}
</td>
{/* 사용여부 토글 */}
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggle(item.layerCd)}
disabled={toggling === item.layerCd || item.parentUseYn === 'N'}
title={
item.parentUseYn === 'N'
? '상위 레이어가 비활성화되어 있어 적용되지 않습니다'
: item.useYn === 'Y'
? '사용 중 (클릭하여 비활성화)'
: '미사용 (클릭하여 활성화)'
}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-40 ${
item.useYn === 'Y' && item.parentUseYn !== 'N'
? 'bg-color-accent'
: item.useYn === 'Y' && item.parentUseYn === 'N'
? 'bg-[rgba(6,182,212,0.4)]'
: 'bg-[rgba(255,255,255,0.08)] border border-stroke'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
item.useYn === 'Y' ? 'translate-x-[18px]' : 'translate-x-0.5'
}`}
/>
</button>
</td>
{/* 액션 */}
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1.5 flex-nowrap">
<button
onClick={() => setModal({ mode: 'edit', data: item })}
className="px-3 py-1 text-caption rounded bg-[rgba(6,182,212,0.15)] text-color-accent hover:bg-[rgba(6,182,212,0.25)] font-korean whitespace-nowrap"
>
</button>
<button
onClick={() => handleDelete(item.layerCd)}
className="px-3 py-1 text-caption rounded bg-red-500/20 text-red-400 hover:bg-red-500/30 font-korean whitespace-nowrap"
>
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
)}
</div>
{/* 페이지네이션 */}
{!loading && totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke bg-bg-surface shrink-0">
<span className="text-label-2 text-fg-disabled font-korean">
{(page - 1) * PAGE_SIZE + 1}{Math.min(page * PAGE_SIZE, total)} / {total}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>
</button>
{buildPageButtons().map((btn, i) =>
btn === 'ellipsis' ? (
<span key={`e${i}`} className="px-1.5 text-label-2 text-fg-disabled">
</span>
) : (
<button
key={btn}
onClick={() => setPage(btn)}
className={`px-2.5 py-1 text-label-2 rounded transition-all ${
page === btn
? 'bg-color-accent text-bg-0 font-semibold shadow-[0_0_8px_rgba(6,182,212,0.25)]'
: 'border border-stroke text-fg-disabled hover:bg-[rgba(255,255,255,0.04)]'
}`}
>
{btn}
</button>
),
)}
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-2.5 py-1 text-label-2 border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>
</button>
</div>
</div>
)}
{/* 모달 */}
{modal && (
<LayerFormModal
mode={modal.mode}
initialData={modal.data}
onClose={() => setModal(null)}
onSaved={() => load(page, appliedSearch, filterUseYn)}
/>
)}
</div>
);
};
export default LayerPanel;