wing-ops/frontend/src/tabs/scat/components/ScatLeftPanel.tsx

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;