695 lines
26 KiB
TypeScript
695 lines
26 KiB
TypeScript
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;
|