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

373 lines
16 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 { 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 CleanupEquipPanel() {
const [organizations, setOrganizations] = useState<AssetOrgCompat[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [regionFilter, setRegionFilter] = useState('전체');
const [typeFilter, setTypeFilter] = useState('전체');
const [equipFilter, setEquipFilter] = useState('전체');
const [currentPage, setCurrentPage] = useState(1);
const load = () => {
setLoading(true);
fetchOrganizations()
.then(setOrganizations)
.catch((err) => console.error('[CleanupEquipPanel] 데이터 로드 실패:', err))
.finally(() => setLoading(false));
};
useEffect(() => {
let cancelled = false;
fetchOrganizations()
.then((data) => {
if (!cancelled) setOrganizations(data);
})
.catch((err) => console.error('[CleanupEquipPanel] 데이터 로드 실패:', 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 EQUIP_FIELDS: Record<string, keyof AssetOrgCompat> = {
: 'vessel',
: 'skimmer',
: 'pump',
: 'vehicle',
: 'sprayer',
};
const filtered = useMemo(
() =>
organizations
.filter((o) => regionFilter === '전체' || o.jurisdiction.includes(regionFilter))
.filter((o) => typeFilter === '전체' || o.type === typeFilter)
.filter((o) => equipFilter === '전체' || (o[EQUIP_FIELDS[equipFilter]] as number) > 0)
.filter(
(o) => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm),
),
[organizations, regionFilter, typeFilter, equipFilter, 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-caption 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-caption 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-caption 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>
<select
value={equipFilter}
onChange={handleFilterChange(setEquipFilter)}
className="px-3 py-2 text-caption 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>
<input
type="text"
placeholder="기관명, 주소 검색..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="w-56 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 font-korean"
/>
<button
onClick={load}
className="px-4 py-2 text-caption 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-body-2 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 ${equipFilter === '방제선' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : 'text-fg-disabled'}`}
>
</th>
<th
className={`px-4 py-3 text-center text-label-2 font-semibold font-korean ${equipFilter === '유회수기' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : 'text-fg-disabled'}`}
>
</th>
<th
className={`px-4 py-3 text-center text-label-2 font-semibold font-korean ${equipFilter === '이송펌프' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : 'text-fg-disabled'}`}
>
</th>
<th
className={`px-4 py-3 text-center text-label-2 font-semibold font-korean ${equipFilter === '방제차량' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : 'text-fg-disabled'}`}
>
</th>
<th
className={`px-4 py-3 text-center text-label-2 font-semibold font-korean ${equipFilter === '살포장치' ? 'text-color-accent bg-[rgba(6,182,212,0.05)]' : '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-caption 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 ${equipFilter === '방제선' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : 'text-fg-sub'}`}
>
{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 ${equipFilter === '유회수기' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : '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 ${equipFilter === '이송펌프' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : '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 ${equipFilter === '방제차량' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : '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 ${equipFilter === '살포장치' ? 'text-color-accent font-semibold bg-[rgba(6,182,212,0.05)]' : '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: '척' },
{ label: '유회수기', value: filtered.reduce((s, o) => s + o.skimmer, 0), unit: '대' },
{ label: '이송펌프', value: filtered.reduce((s, o) => s + o.pump, 0), unit: '대' },
{ label: '방제차량', value: filtered.reduce((s, o) => s + o.vehicle, 0), unit: '대' },
{ label: '살포장치', value: filtered.reduce((s, o) => s + o.sprayer, 0), unit: '대' },
{ label: '총자산', value: filtered.reduce((s, o) => s + o.totalAssets, 0), unit: '' },
].map((t) => {
const isActive = t.label === equipFilter || t.label === '총자산';
return (
<div
key={t.label}
className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${isActive ? 'bg-[rgba(6,182,212,0.1)]' : ''}`}
>
<span
className={`text-caption font-korean ${isActive ? 'text-color-accent' : 'text-fg-disabled'}`}
>
{t.label}
</span>
<span
className={`text-caption font-mono font-bold ${isActive ? '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"
>
&lt;
</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"
>
&gt;
</button>
</div>
</div>
)}
</div>
);
}
export default CleanupEquipPanel;