- DB 스키마: ASSET_ORG, ASSET_EQUIP, ASSET_CONTACT, ASSET_UPLOAD_LOG 4개 테이블 - 초기 데이터: 84개 기관, 469개 장비, 86개 담당자 시드 - 백엔드: assetsService + assetsRouter (기관 목록/상세/업로드이력 3개 API) - 프론트: AssetManagement, AssetMap, AssetUpload mock → API 호출 전환 - ShipInsurance: 외부 API 의존 데모 데이터 컴포넌트 내부 상수로 이동 - assetMockData.ts 의존성 완전 제거 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
370 lines
19 KiB
TypeScript
370 lines
19 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { typeTagCls } from './assetTypes'
|
||
import { fetchOrganizations, fetchOrganizationDetail } from '../services/assetsApi'
|
||
import type { AssetOrgCompat } from '../services/assetsApi'
|
||
import AssetMap from './AssetMap'
|
||
|
||
function AssetManagement() {
|
||
const [viewMode, setViewMode] = useState<'list' | 'map'>('list')
|
||
const [organizations, setOrganizations] = useState<AssetOrgCompat[]>([])
|
||
const [selectedOrg, setSelectedOrg] = useState<AssetOrgCompat | null>(null)
|
||
const [detailTab, setDetailTab] = useState<'equip' | 'material' | 'contact'>('equip')
|
||
const [regionFilter, setRegionFilter] = useState('all')
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [typeFilterVal, setTypeFilterVal] = useState('all')
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
const [loading, setLoading] = useState(true)
|
||
const pageSize = 15
|
||
|
||
// API에서 기관 목록 로드
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetchOrganizations()
|
||
.then(data => {
|
||
if (cancelled) return
|
||
setOrganizations(data)
|
||
if (data.length > 0) setSelectedOrg(data[0])
|
||
})
|
||
.catch(err => console.error('[assets] 기관 목록 로드 실패:', err))
|
||
.finally(() => { if (!cancelled) setLoading(false) })
|
||
return () => { cancelled = true }
|
||
}, [])
|
||
|
||
// 기관 선택 시 상세 조회 (장비/담당자)
|
||
const handleSelectOrg = async (org: AssetOrgCompat) => {
|
||
setSelectedOrg(org)
|
||
try {
|
||
const detail = await fetchOrganizationDetail(org.id)
|
||
setSelectedOrg(detail)
|
||
} catch {
|
||
// 상세 조회 실패 시 기본 정보 유지
|
||
}
|
||
}
|
||
|
||
const filtered = organizations.filter(o => {
|
||
if (regionFilter !== 'all' && !o.jurisdiction.includes(regionFilter)) return false
|
||
if (typeFilterVal !== 'all' && o.type !== typeFilterVal) return false
|
||
if (searchTerm && !o.name.includes(searchTerm) && !o.address.includes(searchTerm)) return false
|
||
return true
|
||
})
|
||
|
||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize))
|
||
const safePage = Math.min(currentPage, totalPages)
|
||
const paged = filtered.slice((safePage - 1) * pageSize, safePage * pageSize)
|
||
|
||
// 필터 변경 시 첫 페이지로
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
useEffect(() => { setCurrentPage(1) }, [regionFilter, typeFilterVal, searchTerm])
|
||
|
||
const regionShort = (j: string) => {
|
||
if (j.includes('중부')) return '중부청'
|
||
if (j.includes('서해')) return '서해청'
|
||
if (j.includes('남해')) return '남해청'
|
||
if (j.includes('동해')) return '동해청'
|
||
if (j.includes('중앙')) return '중특단'
|
||
return '제주청'
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-full">
|
||
<div className="text-text-3 text-sm font-korean">방제자산 데이터를 불러오는 중...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* View Switcher & Filters */}
|
||
<div className="flex items-center justify-between mb-3 pb-3 border-b border-border">
|
||
<div className="flex gap-1">
|
||
<button
|
||
onClick={() => setViewMode('list')}
|
||
className={`px-3 py-1.5 text-[11px] font-semibold rounded-sm font-korean transition-colors ${
|
||
viewMode === 'list'
|
||
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
|
||
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||
}`}
|
||
>
|
||
📋 방제자산리스트
|
||
</button>
|
||
<button
|
||
onClick={() => setViewMode('map')}
|
||
className={`px-3 py-1.5 text-[11px] font-semibold rounded-sm font-korean transition-colors ${
|
||
viewMode === 'map'
|
||
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan/30'
|
||
: 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
|
||
}`}
|
||
>
|
||
🗺 지도 보기
|
||
</button>
|
||
</div>
|
||
<div className="flex gap-1.5 items-center">
|
||
<input
|
||
type="text"
|
||
placeholder="기관명 검색..."
|
||
value={searchTerm}
|
||
onChange={e => setSearchTerm(e.target.value)}
|
||
className="prd-i w-40 py-1.5 px-2.5"
|
||
/>
|
||
<select value={regionFilter} onChange={e => setRegionFilter(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
|
||
<option value="all">전체 관할</option>
|
||
<option value="남해">남해청</option>
|
||
<option value="서해">서해청</option>
|
||
<option value="중부">중부청</option>
|
||
<option value="동해">동해청</option>
|
||
<option value="제주">제주청</option>
|
||
</select>
|
||
<select value={typeFilterVal} onChange={e => setTypeFilterVal(e.target.value)} className="prd-i w-[100px] py-1.5 px-2">
|
||
<option value="all">전체 유형</option>
|
||
<option value="해경관할">해경관할</option>
|
||
<option value="해경경찰서">해경경찰서</option>
|
||
<option value="파출소">파출소</option>
|
||
<option value="관련기관">관련기관</option>
|
||
<option value="해양환경공단">해양환경공단</option>
|
||
<option value="업체">업체</option>
|
||
<option value="지자체">지자체</option>
|
||
<option value="기름저장시설">기름저장시설</option>
|
||
<option value="정유사">정유사</option>
|
||
<option value="해군">해군</option>
|
||
<option value="기타">기타</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{viewMode === 'list' ? (
|
||
/* ── LIST VIEW ── */
|
||
<div className="flex-1 bg-bg-3 border border-border rounded-md overflow-hidden flex flex-col">
|
||
<div className="flex-1">
|
||
<table className="w-full text-left" style={{ tableLayout: 'fixed' }}>
|
||
<colgroup>
|
||
<col style={{ width: '3.5%' }} />
|
||
<col style={{ width: '7%' }} />
|
||
<col style={{ width: '7%' }} />
|
||
<col style={{ width: '12%' }} />
|
||
<col />
|
||
<col style={{ width: '8%' }} />
|
||
<col style={{ width: '7%' }} />
|
||
<col style={{ width: '7%' }} />
|
||
<col style={{ width: '5%' }} />
|
||
<col style={{ width: '5%' }} />
|
||
<col style={{ width: '5%' }} />
|
||
</colgroup>
|
||
<thead>
|
||
<tr className="border-b border-border bg-bg-0">
|
||
{['번호', '유형', '관할청', '기관명', '주소', '방제선', '유회수기', '이송펌프', '방제차량', '살포장치', '총자산'].map((h, i) => (
|
||
<th key={i} className={`px-2.5 py-2.5 text-[10px] font-bold text-text-2 font-korean border-b border-border ${[0,5,6,7,8,9,10].includes(i) ? 'text-center' : ''}`}>
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{paged.map((org, idx) => (
|
||
<tr
|
||
key={org.id}
|
||
className={`border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] cursor-pointer transition-colors ${
|
||
selectedOrg?.id === org.id ? 'bg-[rgba(6,182,212,0.03)]' : ''
|
||
}`}
|
||
onClick={() => { handleSelectOrg(org); setViewMode('map') }}
|
||
>
|
||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{(safePage - 1) * pageSize + idx + 1}</td>
|
||
<td className="px-2.5 py-2">
|
||
<span className={`text-[9px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}>{org.type}</span>
|
||
</td>
|
||
<td className="px-2.5 py-2 text-[10px] font-semibold font-korean">{regionShort(org.jurisdiction)}</td>
|
||
<td className="px-2.5 py-2 text-[10px] font-semibold text-primary-cyan font-korean cursor-pointer truncate">{org.name}</td>
|
||
<td className="px-2.5 py-2 text-[10px] text-text-3 font-korean truncate">{org.address}</td>
|
||
<td className="px-2.5 py-2 text-center font-mono text-[10px] font-semibold">{org.vessel}척</td>
|
||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.skimmer}대</td>
|
||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.pump}대</td>
|
||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.vehicle}대</td>
|
||
<td className="px-2.5 py-2 text-center font-mono text-[10px]">{org.sprayer}대</td>
|
||
<td className="px-2.5 py-2 text-center font-bold text-primary-cyan font-mono text-[10px]">{org.totalAssets}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
<div className="flex items-center justify-center gap-4 px-4 py-2.5 border-t border-border bg-bg-0">
|
||
<span className="text-[10px] text-text-3 font-korean">
|
||
전체 <span className="font-semibold text-text-2">{filtered.length}</span>건 중{' '}
|
||
<span className="font-semibold text-text-2">{(safePage - 1) * pageSize + 1}-{Math.min(safePage * pageSize, filtered.length)}</span>
|
||
</span>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => setCurrentPage(1)}
|
||
disabled={safePage <= 1}
|
||
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||
>«</button>
|
||
<button
|
||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||
disabled={safePage <= 1}
|
||
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||
>‹</button>
|
||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
|
||
<button
|
||
key={p}
|
||
onClick={() => setCurrentPage(p)}
|
||
className={`w-6 h-6 text-[10px] font-bold rounded transition-colors cursor-pointer ${
|
||
p === safePage
|
||
? 'bg-primary-cyan/20 text-primary-cyan border border-primary-cyan/40'
|
||
: 'border border-border bg-bg-3 text-text-3 hover:bg-bg-hover'
|
||
}`}
|
||
>{p}</button>
|
||
))}
|
||
<button
|
||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||
disabled={safePage >= totalPages}
|
||
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||
>›</button>
|
||
<button
|
||
onClick={() => setCurrentPage(totalPages)}
|
||
disabled={safePage >= totalPages}
|
||
className="px-1.5 py-1 text-[10px] rounded border border-border bg-bg-3 text-text-2 disabled:opacity-30 hover:bg-bg-hover transition-colors cursor-pointer disabled:cursor-default"
|
||
>»</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* ── MAP VIEW ── */
|
||
<div className="flex-1 flex overflow-hidden rounded-md border border-border">
|
||
{/* Map */}
|
||
<div className="flex-1 relative overflow-hidden">
|
||
<AssetMap
|
||
organizations={filtered}
|
||
selectedOrg={selectedOrg!}
|
||
onSelectOrg={handleSelectOrg}
|
||
regionFilter={regionFilter}
|
||
onRegionFilterChange={setRegionFilter}
|
||
/>
|
||
</div>
|
||
|
||
{/* Right Detail Panel */}
|
||
{selectedOrg && (
|
||
<aside className="w-[340px] min-w-[340px] bg-bg-1 border-l border-border flex flex-col">
|
||
{/* Header */}
|
||
<div className="p-4 border-b border-border">
|
||
<div className="text-sm font-bold mb-1 font-korean">{selectedOrg.name}</div>
|
||
<div className="text-[11px] text-text-2 font-semibold font-korean mb-1">{selectedOrg.type} · {regionShort(selectedOrg.jurisdiction)} · {selectedOrg.area}</div>
|
||
<div className="text-[11px] text-text-3 font-korean">{selectedOrg.address}</div>
|
||
</div>
|
||
|
||
{/* Sub-tabs */}
|
||
<div className="flex border-b border-border">
|
||
{(['equip', 'material', 'contact'] as const).map(t => (
|
||
<button
|
||
key={t}
|
||
onClick={() => setDetailTab(t)}
|
||
className={`flex-1 py-2.5 text-center text-[11px] font-semibold font-korean border-b-2 transition-colors cursor-pointer ${
|
||
detailTab === t
|
||
? 'text-primary-cyan border-primary-cyan'
|
||
: 'text-text-3 border-transparent hover:text-text-2'
|
||
}`}
|
||
>
|
||
{t === 'equip' ? '장비' : t === 'material' ? '자재' : '연락처'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto p-3.5 scrollbar-thin">
|
||
{/* Summary */}
|
||
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
||
{[
|
||
{ value: `${selectedOrg.vessel}척`, label: '방제선' },
|
||
{ value: `${selectedOrg.skimmer}대`, label: '유회수기' },
|
||
{ value: String(selectedOrg.totalAssets), label: '총 자산' },
|
||
].map((s, i) => (
|
||
<div key={i} className="bg-bg-3 border border-border rounded-sm p-2.5 text-center">
|
||
<div className="text-lg font-bold text-primary-cyan font-mono">{s.value}</div>
|
||
<div className="text-[9px] text-text-3 mt-0.5 font-korean">{s.label}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{detailTab === 'equip' && (
|
||
<div className="flex flex-col gap-1">
|
||
{selectedOrg.equipment.length > 0 ? selectedOrg.equipment.map((cat, ci) => {
|
||
const unitMap: Record<string, string> = {
|
||
'방제선': '척', '유회수기': '대', '비치크리너': '대', '이송펌프': '대', '방제차량': '대',
|
||
'해안운반차': '대', '고압세척기': '대', '저압세척기': '대', '동력분무기': '대', '유량계측기': '대',
|
||
'방제창고': '개소', '발전기': '대', '현장지휘소': '개', '지원장비': '대', '장비부품': '개',
|
||
'경비함정방제': '대', '살포장치': '대',
|
||
}
|
||
const unit = unitMap[cat.category] || '개'
|
||
return (
|
||
<div key={ci} className="flex items-center justify-between px-2.5 py-2 bg-bg-3 border border-border rounded-sm hover:bg-bg-hover transition-colors">
|
||
<span className="text-[11px] font-semibold flex items-center gap-1.5 font-korean">
|
||
{cat.icon} {cat.category}
|
||
</span>
|
||
<span className="text-[11px] font-bold font-mono"><span className="text-primary-cyan">{cat.count}</span><span className="text-text-3 font-normal ml-0.5">{unit}</span></span>
|
||
</div>
|
||
)
|
||
}) : (
|
||
<div className="text-center text-text-3 text-xs py-8 font-korean">상세 장비 데이터가 없습니다.</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{detailTab === 'material' && (
|
||
<div className="flex flex-col gap-1.5">
|
||
{[
|
||
['방제선', `${selectedOrg.vessel}척`],
|
||
['유회수기', `${selectedOrg.skimmer}대`],
|
||
['이송펌프', `${selectedOrg.pump}대`],
|
||
['방제차량', `${selectedOrg.vehicle}대`],
|
||
['살포장치', `${selectedOrg.sprayer}대`],
|
||
['총 자산', `${selectedOrg.totalAssets}건`],
|
||
].map(([k, v], i) => (
|
||
<div key={i} className="flex justify-between px-2.5 py-2 bg-bg-0 rounded text-[11px]">
|
||
<span className="text-text-3 font-korean">{k}</span>
|
||
<span className="font-mono font-semibold text-text-1">{v}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{detailTab === 'contact' && (
|
||
<div className="bg-bg-3 border border-border rounded-sm p-3">
|
||
{selectedOrg.contacts.length > 0 ? selectedOrg.contacts.map((c, i) => (
|
||
<div key={i} className="flex flex-col gap-1 mb-3 last:mb-0">
|
||
{[
|
||
['기관/업체', c.name],
|
||
['연락처', c.phone],
|
||
].map(([k, v], j) => (
|
||
<div key={j} className="flex justify-between py-1 text-[11px]">
|
||
<span className="text-text-3 font-korean">{k}</span>
|
||
<span className="font-mono text-text-1">{v}</span>
|
||
</div>
|
||
))}
|
||
{i < selectedOrg.contacts.length - 1 && <div className="border-t border-border my-1" />}
|
||
</div>
|
||
)) : (
|
||
<div className="text-center text-text-3 text-xs py-4 font-korean">연락처 정보가 없습니다.</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Bottom Actions */}
|
||
<div className="p-3.5 border-t border-border flex gap-2">
|
||
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean text-white border-none cursor-pointer" style={{ background: 'linear-gradient(135deg, var(--cyan), var(--blue))' }}>
|
||
📥 다운로드
|
||
</button>
|
||
<button className="flex-1 py-2.5 rounded-sm text-xs font-semibold font-korean bg-bg-3 border border-border text-text-2 cursor-pointer hover:bg-bg-hover transition-colors">
|
||
✏ 수정
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default AssetManagement
|