359 lines
14 KiB
TypeScript
359 lines
14 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react';
|
||
import { fetchOrganizations } from '@tabs/assets/services/assetsApi';
|
||
import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi';
|
||
import { typeTagCls } from '@tabs/assets/components/assetTypes';
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
const regionShort = (j: string) =>
|
||
j.includes('남해')
|
||
? '남해청'
|
||
: j.includes('서해')
|
||
? '서해청'
|
||
: j.includes('중부')
|
||
? '중부청'
|
||
: j.includes('동해')
|
||
? '동해청'
|
||
: j.includes('제주')
|
||
? '제주청'
|
||
: j;
|
||
|
||
function VesselMaterialsPanel() {
|
||
const [organizations, setOrganizations] = useState<AssetOrgCompat[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [regionFilter, setRegionFilter] = useState('전체');
|
||
const [typeFilter, setTypeFilter] = useState('전체');
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
|
||
const load = () => {
|
||
setLoading(true);
|
||
fetchOrganizations()
|
||
.then(setOrganizations)
|
||
.catch((err) => console.error('[VesselMaterialsPanel] 데이터 로드 실패:', err))
|
||
.finally(() => setLoading(false));
|
||
};
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
fetchOrganizations()
|
||
.then((data) => {
|
||
if (!cancelled) setOrganizations(data);
|
||
})
|
||
.catch((err) => console.error('[VesselMaterialsPanel] 데이터 로드 실패:', err))
|
||
.finally(() => {
|
||
if (!cancelled) setLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
const typeOptions = useMemo(() => {
|
||
const set = new Set(organizations.map((o) => o.type));
|
||
return Array.from(set).sort();
|
||
}, [organizations]);
|
||
|
||
const filtered = useMemo(
|
||
() =>
|
||
organizations
|
||
.filter((o) => o.vessel > 0)
|
||
.filter((o) => regionFilter === '전체' || o.jurisdiction.includes(regionFilter))
|
||
.filter((o) => typeFilter === '전체' || o.type === typeFilter)
|
||
.filter(
|
||
(o) => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm),
|
||
),
|
||
[organizations, regionFilter, typeFilter, searchTerm],
|
||
);
|
||
|
||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||
const safePage = Math.min(currentPage, totalPages);
|
||
const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
|
||
|
||
const handleFilterChange =
|
||
(setter: (v: string) => void) => (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||
setter(e.target.value);
|
||
setCurrentPage(1);
|
||
};
|
||
|
||
const pageNumbers = (() => {
|
||
const range: number[] = [];
|
||
const start = Math.max(1, safePage - 2);
|
||
const end = Math.min(totalPages, safePage + 2);
|
||
for (let i = start; i <= end; i++) range.push(i);
|
||
return range;
|
||
})();
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* 헤더 */}
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
|
||
<div>
|
||
<h1 className="text-lg font-bold text-fg font-korean">방제선 보유자재 현황</h1>
|
||
<p className="text-xs text-fg-disabled mt-1 font-korean">
|
||
총 {filtered.length}개 기관 (방제선 보유)
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<select
|
||
value={regionFilter}
|
||
onChange={handleFilterChange(setRegionFilter)}
|
||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||
>
|
||
<option value="전체">전체 관할청</option>
|
||
<option value="남해">남해청</option>
|
||
<option value="서해">서해청</option>
|
||
<option value="중부">중부청</option>
|
||
<option value="동해">동해청</option>
|
||
<option value="제주">제주청</option>
|
||
</select>
|
||
<select
|
||
value={typeFilter}
|
||
onChange={handleFilterChange(setTypeFilter)}
|
||
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
|
||
>
|
||
<option value="전체">전체 유형</option>
|
||
{typeOptions.map((t) => (
|
||
<option key={t} value={t}>
|
||
{t}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<input
|
||
type="text"
|
||
placeholder="기관명, 주소 검색..."
|
||
value={searchTerm}
|
||
onChange={(e) => {
|
||
setSearchTerm(e.target.value);
|
||
setCurrentPage(1);
|
||
}}
|
||
className="w-56 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
|
||
/>
|
||
<button
|
||
onClick={load}
|
||
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-elevated border border-stroke text-fg-sub hover:border-color-accent hover:text-color-accent transition-all font-korean"
|
||
>
|
||
새로고침
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 테이블 */}
|
||
<div className="flex-1 overflow-auto">
|
||
{loading ? (
|
||
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
|
||
불러오는 중...
|
||
</div>
|
||
) : (
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b border-stroke bg-bg-surface">
|
||
<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-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-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 font-korean text-color-accent bg-[rgba(6,182,212,0.05)]">
|
||
방제선
|
||
</th>
|
||
<th className="px-4 py-3 text-center text-label-2 font-semibold font-korean text-fg-disabled">
|
||
유회수기
|
||
</th>
|
||
<th className="px-4 py-3 text-center text-label-2 font-semibold font-korean text-fg-disabled">
|
||
이송펌프
|
||
</th>
|
||
<th className="px-4 py-3 text-center text-label-2 font-semibold font-korean text-fg-disabled">
|
||
방제차량
|
||
</th>
|
||
<th className="px-4 py-3 text-center text-label-2 font-semibold font-korean text-fg-disabled">
|
||
살포장치
|
||
</th>
|
||
<th className="px-4 py-3 text-center text-label-2 font-semibold text-fg-disabled font-korean">
|
||
총자산
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{paged.length === 0 ? (
|
||
<tr>
|
||
<td
|
||
colSpan={11}
|
||
className="px-6 py-10 text-center text-xs text-fg-disabled font-korean"
|
||
>
|
||
조회된 기관이 없습니다.
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
paged.map((org, idx) => (
|
||
<tr
|
||
key={org.id}
|
||
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
|
||
>
|
||
<td className="px-4 py-3 text-label-2 text-fg-disabled font-mono text-center">
|
||
{(safePage - 1) * PAGE_SIZE + idx + 1}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span
|
||
className={`text-caption px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}
|
||
>
|
||
{org.type}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-label-2 text-fg-sub font-korean">
|
||
{regionShort(org.jurisdiction)}
|
||
</td>
|
||
<td className="px-4 py-3 text-label-2 text-fg font-korean font-semibold">
|
||
{org.name}
|
||
</td>
|
||
<td className="px-4 py-3 text-label-2 text-fg-disabled font-korean max-w-[200px] truncate">
|
||
{org.address}
|
||
</td>
|
||
<td className="px-4 py-3 text-label-2 font-mono text-center text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]">
|
||
{org.vessel > 0 ? org.vessel : <span className="text-fg-disabled">—</span>}
|
||
</td>
|
||
<td className="px-4 py-3 text-label-2 font-mono text-center text-fg-sub">
|
||
{org.skimmer > 0 ? org.skimmer : <span className="text-fg-disabled">—</span>}
|
||
</td>
|
||
<td className="px-4 py-3 text-label-2 font-mono text-center text-fg-sub">
|
||
{org.pump > 0 ? org.pump : <span className="text-fg-disabled">—</span>}
|
||
</td>
|
||
<td className="px-4 py-3 text-label-2 font-mono text-center text-fg-sub">
|
||
{org.vehicle > 0 ? org.vehicle : <span className="text-fg-disabled">—</span>}
|
||
</td>
|
||
<td className="px-4 py-3 text-label-2 font-mono text-center text-fg-sub">
|
||
{org.sprayer > 0 ? org.sprayer : <span className="text-fg-disabled">—</span>}
|
||
</td>
|
||
<td className="px-4 py-3 text-label-2 font-mono text-center font-bold text-color-accent">
|
||
{org.totalAssets.toLocaleString()}
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
|
||
{/* 합계 */}
|
||
{!loading && filtered.length > 0 && (
|
||
<div className="flex items-center gap-4 px-6 py-2 border-t border-stroke bg-bg-base/80">
|
||
<span className="text-caption text-fg-disabled font-korean font-semibold mr-auto">
|
||
합계 ({filtered.length}개 기관)
|
||
</span>
|
||
{[
|
||
{
|
||
label: '방제선',
|
||
value: filtered.reduce((s, o) => s + o.vessel, 0),
|
||
unit: '척',
|
||
active: true,
|
||
},
|
||
{
|
||
label: '유회수기',
|
||
value: filtered.reduce((s, o) => s + o.skimmer, 0),
|
||
unit: '대',
|
||
active: false,
|
||
},
|
||
{
|
||
label: '이송펌프',
|
||
value: filtered.reduce((s, o) => s + o.pump, 0),
|
||
unit: '대',
|
||
active: false,
|
||
},
|
||
{
|
||
label: '방제차량',
|
||
value: filtered.reduce((s, o) => s + o.vehicle, 0),
|
||
unit: '대',
|
||
active: false,
|
||
},
|
||
{
|
||
label: '살포장치',
|
||
value: filtered.reduce((s, o) => s + o.sprayer, 0),
|
||
unit: '대',
|
||
active: false,
|
||
},
|
||
{
|
||
label: '총자산',
|
||
value: filtered.reduce((s, o) => s + o.totalAssets, 0),
|
||
unit: '',
|
||
active: true,
|
||
},
|
||
].map((t) => (
|
||
<div
|
||
key={t.label}
|
||
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${t.active ? 'bg-[rgba(6,182,212,0.1)]' : ''}`}
|
||
>
|
||
<span
|
||
className={`text-caption font-korean ${t.active ? 'text-color-accent' : 'text-fg-disabled'}`}
|
||
>
|
||
{t.label}
|
||
</span>
|
||
<span
|
||
className={`text-caption font-mono font-bold ${t.active ? 'text-color-accent' : 'text-fg'}`}
|
||
>
|
||
{t.value.toLocaleString()}
|
||
{t.unit}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 페이지네이션 */}
|
||
{!loading && filtered.length > 0 && (
|
||
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke">
|
||
<span className="text-label-2 text-fg-disabled font-korean">
|
||
{(safePage - 1) * PAGE_SIZE + 1}–{Math.min(safePage * PAGE_SIZE, filtered.length)} /
|
||
전체 {filtered.length}개
|
||
</span>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||
disabled={safePage === 1}
|
||
className="px-2.5 py-1 text-label-2 border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
|
||
>
|
||
<
|
||
</button>
|
||
{pageNumbers.map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => setCurrentPage(p)}
|
||
className="px-2.5 py-1 text-label-2 border rounded transition-colors"
|
||
style={
|
||
p === safePage
|
||
? {
|
||
borderColor: 'var(--color-accent)',
|
||
color: 'var(--color-accent)',
|
||
background: 'rgba(6,182,212,0.1)',
|
||
}
|
||
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
|
||
}
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
<button
|
||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||
disabled={safePage === totalPages}
|
||
className="px-2.5 py-1 text-label-2 border border-stroke rounded text-fg-sub hover:border-color-accent hover:text-color-accent disabled:opacity-40 transition-colors"
|
||
>
|
||
>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default VesselMaterialsPanel;
|