다중 항적 조회 모달에서 반복 선택을 줄이기 위해 선박 그룹 관리 기능 추가. 계정별 localStorage 영속화(usePersistedState), 최대 10개 그룹, 동명 덮어쓰기. 그리드 헤더 클릭으로 6개 컬럼 asc/desc 정렬, 푸터에 선택 초기화 버튼 추가. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
215 lines
7.1 KiB
TypeScript
215 lines
7.1 KiB
TypeScript
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>
|
||
);
|
||
}
|