292 lines
9.3 KiB
TypeScript
292 lines
9.3 KiB
TypeScript
import { useState, useEffect, useRef, type CSSProperties, type ReactElement } from 'react';
|
|
import { List } from 'react-window';
|
|
import type { ScatSegment } from './scatTypes';
|
|
import type { ApiZoneItem } from '../services/scatApi';
|
|
import { esiColor, sensColor, statusColor, esiLevel } from './scatConstants';
|
|
|
|
interface ScatLeftPanelProps {
|
|
segments: ScatSegment[];
|
|
zones: ApiZoneItem[];
|
|
jurisdictions: string[];
|
|
offices: string[];
|
|
selectedOffice: string;
|
|
onOfficeChange: (v: string) => void;
|
|
selectedSeg: ScatSegment;
|
|
onSelectSeg: (s: ScatSegment) => void;
|
|
onOpenPopup: (sn: number) => void;
|
|
jurisdictionFilter: string;
|
|
onJurisdictionChange: (v: string) => void;
|
|
areaFilter: string;
|
|
onAreaChange: (v: string) => void;
|
|
phaseFilter: string;
|
|
onPhaseChange: (v: string) => void;
|
|
statusFilter: string;
|
|
onStatusChange: (v: string) => void;
|
|
searchTerm: string;
|
|
onSearchChange: (v: string) => void;
|
|
}
|
|
|
|
interface SegRowData {
|
|
filtered: ScatSegment[];
|
|
selectedId: number;
|
|
onSelectSeg: (s: ScatSegment) => void;
|
|
onOpenPopup: (sn: number) => void;
|
|
}
|
|
|
|
function SegRow(
|
|
props: {
|
|
ariaAttributes: { 'aria-posinset': number; 'aria-setsize': number; role: 'listitem' };
|
|
index: number;
|
|
style: CSSProperties;
|
|
} & SegRowData,
|
|
): ReactElement | null {
|
|
const { index, style, filtered, selectedId, onSelectSeg, onOpenPopup } = props;
|
|
const seg = filtered[index];
|
|
if (!seg) return null;
|
|
|
|
const lvl = esiLevel(seg.esiNum);
|
|
const borderColor =
|
|
lvl === 'h'
|
|
? 'border-l-status-red'
|
|
: lvl === 'm'
|
|
? 'border-l-status-orange'
|
|
: 'border-l-status-green';
|
|
const isSelected = selectedId === seg.id;
|
|
|
|
return (
|
|
<div style={{ ...style, paddingBottom: 6, paddingRight: 2 }}>
|
|
<div
|
|
onClick={() => {
|
|
onSelectSeg(seg);
|
|
onOpenPopup(seg.id);
|
|
}}
|
|
className={`bg-bg-card border border-stroke rounded-sm p-2.5 px-3 cursor-pointer transition-all border-l-4 ${borderColor} ${
|
|
isSelected
|
|
? 'border-status-green bg-[color-mix(in_srgb,var(--color-success)_5%,transparent)]'
|
|
: 'hover:border-stroke-light hover:bg-bg-surface-hover'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-caption font-semibold font-korean flex items-center gap-1.5">
|
|
📍 {seg.code} {seg.area}
|
|
</span>
|
|
<span
|
|
className="text-caption font-bold px-1.5 py-0.5 rounded-lg text-white"
|
|
style={{ background: esiColor(seg.esiNum) }}
|
|
>
|
|
ESI {seg.esi}
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
|
|
<div className="flex justify-between text-label-2">
|
|
<span className="text-fg-sub font-korean">유형</span>
|
|
<span className="text-fg font-medium font-mono text-label-2">{seg.type}</span>
|
|
</div>
|
|
<div className="flex justify-between text-label-2">
|
|
<span className="text-fg-sub font-korean">길이</span>
|
|
<span className="text-fg font-medium font-mono text-label-2">{seg.length}</span>
|
|
</div>
|
|
<div className="flex justify-between text-label-2">
|
|
<span className="text-fg-sub font-korean">민감</span>
|
|
<span
|
|
className="font-medium font-mono text-label-2"
|
|
style={{ color: sensColor[seg.sensitivity] }}
|
|
>
|
|
{seg.sensitivity}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between text-label-2">
|
|
<span className="text-fg-sub font-korean">현황</span>
|
|
<span
|
|
className="font-medium font-mono text-label-2"
|
|
style={{ color: statusColor[seg.status] }}
|
|
>
|
|
{seg.status}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ScatLeftPanel({
|
|
segments,
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
zones,
|
|
jurisdictions,
|
|
offices,
|
|
selectedOffice,
|
|
onOfficeChange,
|
|
selectedSeg,
|
|
onSelectSeg,
|
|
onOpenPopup,
|
|
jurisdictionFilter,
|
|
onJurisdictionChange,
|
|
areaFilter,
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
onAreaChange,
|
|
phaseFilter,
|
|
onPhaseChange,
|
|
statusFilter,
|
|
onStatusChange,
|
|
searchTerm,
|
|
onSearchChange,
|
|
}: ScatLeftPanelProps) {
|
|
const filtered = segments.filter((s) => {
|
|
if (areaFilter && !s.area.includes(areaFilter)) return false;
|
|
if (statusFilter !== '전체' && s.status !== statusFilter) return false;
|
|
if (searchTerm && !s.code.includes(searchTerm) && !s.name.includes(searchTerm)) return false;
|
|
return true;
|
|
});
|
|
|
|
const listContainerRef = useRef<HTMLDivElement>(null);
|
|
const [listHeight, setListHeight] = useState(400);
|
|
|
|
useEffect(() => {
|
|
const el = listContainerRef.current;
|
|
if (!el) return;
|
|
const ro = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
setListHeight(entry.contentRect.height);
|
|
}
|
|
});
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, []);
|
|
|
|
return (
|
|
<div className="w-full h-full min-w-0 bg-bg-surface border-r border-stroke flex flex-col overflow-hidden">
|
|
{/* Filters */}
|
|
<div className="p-3.5 border-b border-stroke">
|
|
<div className="flex items-center gap-1.5 text-caption font-bold uppercase tracking-wider text-fg mb-3">
|
|
<span className="w-[3px] h-2.5 bg-color-success rounded-sm" />
|
|
해안 조사 구역
|
|
</div>
|
|
|
|
<div className="mb-2.5">
|
|
<label className="block text-label-2 font-medium text-fg mb-1 font-korean">
|
|
관할 해경
|
|
</label>
|
|
<select
|
|
value={selectedOffice}
|
|
onChange={(e) => onOfficeChange(e.target.value)}
|
|
className="prd-i w-full"
|
|
>
|
|
{offices.map((o) => (
|
|
<option key={o} value={o}>
|
|
{o}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="mb-2.5">
|
|
<label className="block text-label-2 font-medium text-fg mb-1 font-korean">
|
|
관할 구역
|
|
</label>
|
|
<select
|
|
value={jurisdictionFilter}
|
|
onChange={(e) => onJurisdictionChange(e.target.value)}
|
|
className="prd-i w-full"
|
|
>
|
|
<option value="">전체</option>
|
|
{jurisdictions.map((j) => (
|
|
<option key={j} value={j}>
|
|
{j}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* <div className="mb-2.5">
|
|
<label className="block text-label-2 font-medium text-fg mb-1 font-korean">
|
|
해안 구역
|
|
</label>
|
|
<select
|
|
value={areaFilter}
|
|
onChange={(e) => onAreaChange(e.target.value)}
|
|
className="prd-i w-full"
|
|
>
|
|
<option value="">전체</option>
|
|
{zones.map((z) => (
|
|
<option key={z.zoneCd} value={z.zoneNm}>
|
|
{z.zoneNm}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div> */}
|
|
|
|
<div className="mb-2.5">
|
|
<label className="block text-label-2 font-medium text-fg mb-1 font-korean">
|
|
조사 단계
|
|
</label>
|
|
<select
|
|
value={phaseFilter}
|
|
onChange={(e) => onPhaseChange(e.target.value)}
|
|
className="prd-i w-full"
|
|
>
|
|
<option>Pre-SCAT (사전조사)</option>
|
|
<option>SCAT (사고 시 조사)</option>
|
|
<option>Post-SCAT (사후 확인)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex gap-1.5 mt-1">
|
|
<input
|
|
type="text"
|
|
placeholder="🔍 구간 검색..."
|
|
value={searchTerm}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="prd-i flex-1"
|
|
/>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => onStatusChange(e.target.value)}
|
|
className="prd-i w-[70px]"
|
|
>
|
|
<option>전체</option>
|
|
<option>완료</option>
|
|
<option>진행중</option>
|
|
<option>미조사</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Segment List */}
|
|
<div className="flex-1 flex flex-col overflow-hidden p-3.5 pt-2">
|
|
<div className="flex items-center justify-between text-caption font-bold uppercase tracking-wider text-fg mb-2.5">
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="w-[3px] h-2.5 bg-color-success rounded-sm" />
|
|
해안 구간 목록
|
|
</span>
|
|
<span className="text-color-accent font-mono text-caption">
|
|
총 {filtered.length}개 구간
|
|
</span>
|
|
</div>
|
|
<div className="flex-1 overflow-hidden" ref={listContainerRef}>
|
|
<List<SegRowData>
|
|
rowCount={filtered.length}
|
|
rowHeight={88}
|
|
overscanCount={5}
|
|
style={{
|
|
height: listHeight,
|
|
scrollbarWidth: 'thin',
|
|
scrollbarColor: 'var(--stroke-default) transparent',
|
|
}}
|
|
rowComponent={SegRow}
|
|
rowProps={{
|
|
filtered,
|
|
selectedId: selectedSeg.id,
|
|
onSelectSeg,
|
|
onOpenPopup,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default ScatLeftPanel;
|