gc-wing/apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx
htlee ed3aef8e2a feat(vesselSelect): 선박 그룹 저장/불러오기 + 컬럼 정렬 + 선택 초기화
다중 항적 조회 모달에서 반복 선택을 줄이기 위해 선박 그룹 관리 기능 추가.
계정별 localStorage 영속화(usePersistedState), 최대 10개 그룹, 동명 덮어쓰기.
그리드 헤더 클릭으로 6개 컬럼 asc/desc 정렬, 푸터에 선택 초기화 버튼 추가.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 13:45:35 +09:00

215 lines
7.1 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, useCallback, type CSSProperties } from 'react';
import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types';
import { VESSEL_TYPES } from '../../entities/vessel/model/meta';
import type { SortKey, SortDir } from './VesselSelectModal';
interface VesselSelectGridProps {
vessels: DerivedLegacyVessel[];
selectedMmsis: Set<number>;
toggleMmsi: (mmsi: number) => void;
setMmsis: (mmsis: Set<number>) => void;
sortKey: SortKey | null;
sortDir: SortDir;
onSort: (key: SortKey) => void;
}
interface DragState {
startIdx: number;
endIdx: number;
direction: 'check' | 'uncheck';
}
const STYLE_TABLE: CSSProperties = {
width: '100%',
borderCollapse: 'collapse',
fontSize: 11,
};
const STYLE_TH: CSSProperties = {
position: 'sticky',
top: 0,
background: 'rgba(15,23,42,0.98)',
color: '#94a3b8',
textAlign: 'left',
padding: '6px 8px',
borderBottom: '1px solid rgba(148,163,184,0.2)',
fontWeight: 500,
cursor: 'pointer',
userSelect: 'none',
};
function getSortIndicator(col: SortKey, sortKey: SortKey | null, sortDir: SortDir): string {
if (sortKey !== col) return ' ';
return sortDir === 'asc' ? ' ▲' : ' ▼';
}
const STYLE_TH_CHECKBOX: CSSProperties = {
...STYLE_TH,
width: 28,
};
function getTdStyle(isSelected: boolean): CSSProperties {
return {
padding: '5px 8px',
borderBottom: '1px solid rgba(148,163,184,0.08)',
cursor: 'pointer',
background: isSelected ? 'rgba(59,130,246,0.12)' : undefined,
};
}
function getStateBadgeStyle(isFishing: boolean, isTransit: boolean): CSSProperties {
const color = isFishing ? '#22C55E' : isTransit ? '#3B82F6' : '#64748B';
return {
background: `${color}22`,
color,
borderRadius: 3,
padding: '1px 4px',
fontSize: 10,
};
}
function getDotStyle(color: string): CSSProperties {
return {
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
background: color,
marginRight: 4,
verticalAlign: 'middle',
};
}
function isInDragRange(idx: number, drag: DragState): boolean {
const min = Math.min(drag.startIdx, drag.endIdx);
const max = Math.max(drag.startIdx, drag.endIdx);
return idx >= min && idx <= max;
}
function getDragHighlight(direction: 'check' | 'uncheck'): string {
return direction === 'check' ? 'rgba(59,130,246,0.18)' : 'rgba(148,163,184,0.12)';
}
export function VesselSelectGrid({ vessels, selectedMmsis, toggleMmsi, setMmsis, sortKey, sortDir, onSort }: VesselSelectGridProps) {
const [dragState, setDragState] = useState<DragState | null>(null);
const handleMouseDown = useCallback(
(idx: number, e: React.MouseEvent) => {
// 체크박스 직접 클릭은 무시 (기존 onChange 처리)
if ((e.target as HTMLElement).tagName === 'INPUT') return;
e.preventDefault();
const isSelected = selectedMmsis.has(vessels[idx].mmsi);
setDragState({ startIdx: idx, endIdx: idx, direction: isSelected ? 'uncheck' : 'check' });
},
[vessels, selectedMmsis],
);
const handleMouseEnter = useCallback(
(idx: number) => {
if (!dragState) return;
setDragState((prev) => (prev ? { ...prev, endIdx: idx } : null));
},
[dragState],
);
// document-level mouseup: 드래그 종료
useEffect(() => {
if (!dragState) return;
const handleMouseUp = () => {
const { startIdx, endIdx, direction } = dragState;
if (startIdx === endIdx) {
// 단일 클릭
toggleMmsi(vessels[startIdx].mmsi);
} else {
// 범위 선택
const min = Math.min(startIdx, endIdx);
const max = Math.max(startIdx, endIdx);
const newSet = new Set(selectedMmsis);
for (let i = min; i <= max; i++) {
const mmsi = vessels[i].mmsi;
if (direction === 'check') newSet.add(mmsi);
else newSet.delete(mmsi);
}
setMmsis(newSet);
}
setDragState(null);
};
document.addEventListener('mouseup', handleMouseUp);
return () => document.removeEventListener('mouseup', handleMouseUp);
}, [dragState, vessels, selectedMmsis, toggleMmsi, setMmsis]);
return (
<table style={{ ...STYLE_TABLE, userSelect: dragState ? 'none' : undefined }}>
<thead>
<tr>
<th style={STYLE_TH_CHECKBOX} />
<th style={STYLE_TH} onClick={() => onSort('shipCode')}>{getSortIndicator('shipCode', sortKey, sortDir)}</th>
<th style={STYLE_TH} onClick={() => onSort('permitNo')}>{getSortIndicator('permitNo', sortKey, sortDir)}</th>
<th style={STYLE_TH} onClick={() => onSort('name')}>{getSortIndicator('name', sortKey, sortDir)}</th>
<th style={STYLE_TH} onClick={() => onSort('mmsi')}>MMSI{getSortIndicator('mmsi', sortKey, sortDir)}</th>
<th style={STYLE_TH} onClick={() => onSort('sog')}>{getSortIndicator('sog', sortKey, sortDir)}</th>
<th style={STYLE_TH} onClick={() => onSort('state')}>{getSortIndicator('state', sortKey, sortDir)}</th>
</tr>
</thead>
<tbody>
{vessels.map((v, idx) => {
const isSelected = selectedMmsis.has(v.mmsi);
const meta = VESSEL_TYPES[v.shipCode];
const inRange = dragState ? isInDragRange(idx, dragState) : false;
// 드래그 중 범위 내 행 → 예상 상태 미리보기
let rowBg: string | undefined;
if (inRange && dragState) {
rowBg = getDragHighlight(dragState.direction);
} else if (isSelected) {
rowBg = 'rgba(59,130,246,0.12)';
}
const tdStyle = getTdStyle(false); // 배경은 tr에서 관리
const stateBadgeStyle = getStateBadgeStyle(v.state.isFishing, v.state.isTransit);
const mmsiDisplay = String(v.mmsi);
const sogDisplay = v.sog !== null ? `${v.sog.toFixed(1)} kt` : '';
// 드래그 중 범위 내 체크 상태 미리보기
const previewChecked = inRange && dragState
? dragState.direction === 'check'
: isSelected;
return (
<tr
key={v.mmsi}
style={{ cursor: 'pointer', background: rowBg }}
onMouseDown={(e) => handleMouseDown(idx, e)}
onMouseEnter={() => handleMouseEnter(idx)}
>
<td style={tdStyle}>
<input
type="checkbox"
title="선택"
checked={previewChecked}
onChange={() => toggleMmsi(v.mmsi)}
onClick={(e) => e.stopPropagation()}
style={{ cursor: 'pointer' }}
/>
</td>
<td style={tdStyle}>
<span style={getDotStyle(meta.color)} />
{v.shipCode}
</td>
<td style={tdStyle}>{v.permitNo}</td>
<td style={tdStyle}>{v.name}</td>
<td style={tdStyle}>{mmsiDisplay}</td>
<td style={tdStyle}>{sogDisplay}</td>
<td style={tdStyle}>
<span style={stateBadgeStyle}>{v.state.label}</span>
</td>
</tr>
);
})}
</tbody>
</table>
);
}