wing-ops/frontend/src/tabs/assets/components/AssetManagement.tsx
htlee 793abb8fe2 feat(assets): 방제자산 탭 mock → DB/API 전환
- 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>
2026-02-28 21:36:56 +09:00

370 lines
19 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 } 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