333 lines
13 KiB
TypeScript
333 lines
13 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
||
import { api } from '@common/services/api';
|
||
|
||
interface SensitiveLayerPanelProps {
|
||
categoryCode: string;
|
||
title: string;
|
||
}
|
||
|
||
interface LayerAdminItem {
|
||
layerCd: string;
|
||
upLayerCd: string | null;
|
||
layerFullNm: string;
|
||
layerNm: string;
|
||
layerLevel: number;
|
||
wmsLayerNm: string | null;
|
||
useYn: string;
|
||
sortOrd: number;
|
||
regDtm: string | null;
|
||
}
|
||
|
||
interface LayerListResponse {
|
||
items: LayerAdminItem[];
|
||
total: number;
|
||
page: number;
|
||
totalPages: number;
|
||
}
|
||
|
||
const PAGE_SIZE = 10;
|
||
|
||
async function fetchSensitiveLayers(
|
||
page: number,
|
||
search: string,
|
||
useYn: string,
|
||
rootCd: string,
|
||
): Promise<LayerListResponse> {
|
||
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE), rootCd });
|
||
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 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;
|
||
}
|
||
|
||
// ---------- SensitiveLayerPanel ----------
|
||
|
||
const SensitiveLayerPanel = ({ categoryCode, title }: SensitiveLayerPanelProps) => {
|
||
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 load = useCallback(
|
||
async (p: number, search: string, useYn: string) => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const res = await fetchSensitiveLayers(p, search, useYn, categoryCode);
|
||
setItems(res.items);
|
||
setTotal(res.total);
|
||
setTotalPages(res.totalPages);
|
||
} catch {
|
||
setError('레이어 목록을 불러오지 못했습니다.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
},
|
||
[categoryCode],
|
||
);
|
||
|
||
useEffect(() => {
|
||
setPage(1);
|
||
setAppliedSearch('');
|
||
setFilterUseYn('');
|
||
setSearchInput('');
|
||
}, [categoryCode]);
|
||
|
||
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) =>
|
||
item.layerCd === result.layerCd ? { ...item, useYn: result.useYn } : item,
|
||
),
|
||
);
|
||
} catch {
|
||
setError('사용여부 변경에 실패했습니다.');
|
||
} finally {
|
||
setToggling(null);
|
||
}
|
||
};
|
||
|
||
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">{title}</h1>
|
||
<p className="text-xs text-fg-disabled mt-1 font-korean">총 {total}개</p>
|
||
</div>
|
||
</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-xs 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-xs 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-xs 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-xs 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-sm 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>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{items.length === 0 ? (
|
||
<tr>
|
||
<td
|
||
colSpan={9}
|
||
className="px-4 py-12 text-center text-fg-disabled text-sm 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-xs 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-xs text-fg font-korean">{item.layerNm}</td>
|
||
<td className="px-4 py-3 text-xs 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>
|
||
<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-xs 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}
|
||
title={
|
||
item.useYn === 'Y'
|
||
? '사용 중 (클릭하여 비활성화)'
|
||
: '미사용 (클릭하여 활성화)'
|
||
}
|
||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:opacity-50 ${
|
||
item.useYn === 'Y'
|
||
? 'bg-color-accent'
|
||
: '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>
|
||
</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>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SensitiveLayerPanel;
|