feat(vesselSelect): 선박 그룹 저장/불러오기 + 컬럼 정렬 + 선택 초기화

다중 항적 조회 모달에서 반복 선택을 줄이기 위해 선박 그룹 관리 기능 추가.
계정별 localStorage 영속화(usePersistedState), 최대 10개 그룹, 동명 덮어쓰기.
그리드 헤더 클릭으로 6개 컬럼 asc/desc 정렬, 푸터에 선택 초기화 버튼 추가.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-18 13:45:35 +09:00
부모 37ee016cb7
커밋 ed3aef8e2a
7개의 변경된 파일278개의 추가작업 그리고 13개의 파일을 삭제

파일 보기

@ -0,0 +1,66 @@
import { useCallback, useMemo } from 'react';
import { usePersistedState } from '../../../shared/hooks/usePersistedState';
import type { VesselGroup } from '../model/types';
import { MAX_VESSEL_GROUPS } from '../model/types';
export interface VesselGroupsState {
groups: VesselGroup[];
/** 동명 그룹 존재 시 갱신, 신규 시 추가. 10개 초과 시 경고 문자열 반환 */
saveGroup: (name: string, mmsis: number[]) => string | null;
deleteGroup: (id: string) => void;
applyGroup: (group: VesselGroup) => void;
}
export function useVesselGroups(
userId: number | null,
setMmsis: (mmsis: Set<number>) => void,
): VesselGroupsState {
const [rawGroups, setRawGroups] = usePersistedState<VesselGroup[]>(userId, 'vesselGroups', []);
const groups = useMemo(
() => [...rawGroups].sort((a, b) => b.updatedAt - a.updatedAt),
[rawGroups],
);
const saveGroup = useCallback(
(name: string, mmsis: number[]): string | null => {
const trimmed = name.trim();
if (!trimmed) return '그룹명을 입력해주세요';
let warning: string | null = null;
setRawGroups((prev) => {
const existing = prev.find((g) => g.name === trimmed);
if (existing) {
return prev.map((g) =>
g.id === existing.id ? { ...g, mmsis, updatedAt: Date.now() } : g,
);
}
if (prev.length >= MAX_VESSEL_GROUPS) {
warning = `최대 ${MAX_VESSEL_GROUPS}개까지 저장 가능합니다`;
return prev;
}
return [...prev, { id: Date.now().toString(36), name: trimmed, mmsis, updatedAt: Date.now() }];
});
return warning;
},
[setRawGroups],
);
const deleteGroup = useCallback(
(id: string) => {
setRawGroups((prev) => prev.filter((g) => g.id !== id));
},
[setRawGroups],
);
const applyGroup = useCallback(
(group: VesselGroup) => {
setMmsis(new Set(group.mmsis));
},
[setMmsis],
);
return { groups, saveGroup, deleteGroup, applyGroup };
}

파일 보기

@ -3,6 +3,8 @@ import type { DerivedLegacyVessel } from '../../legacyDashboard/model/types';
import type { MultiTrackQueryContext } from '../../trackReplay/model/track.types'; import type { MultiTrackQueryContext } from '../../trackReplay/model/track.types';
import { useTrackQueryStore } from '../../trackReplay/stores/trackQueryStore'; import { useTrackQueryStore } from '../../trackReplay/stores/trackQueryStore';
import { MAX_VESSEL_SELECT, MAX_QUERY_DAYS } from '../model/types'; import { MAX_VESSEL_SELECT, MAX_QUERY_DAYS } from '../model/types';
import type { VesselGroup } from '../model/types';
import { useVesselGroups } from './useVesselGroups';
/** ms → datetime-local input value (KST = UTC+9) */ /** ms → datetime-local input value (KST = UTC+9) */
function toDateTimeLocalKST(ms: number): string { function toDateTimeLocalKST(ms: number): string {
@ -56,9 +58,14 @@ export interface VesselSelectModalState {
setPosition: (pos: { x: number; y: number }) => void; setPosition: (pos: { x: number; y: number }) => void;
selectionWarning: string | null; selectionWarning: string | null;
groups: VesselGroup[];
saveGroup: (name: string, mmsis: number[]) => string | null;
deleteGroup: (id: string) => void;
applyGroup: (group: VesselGroup) => void;
} }
export function useVesselSelectModal(): VesselSelectModalState { export function useVesselSelectModal(userId: number | null = null): VesselSelectModalState {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [selectedMmsis, setSelectedMmsis] = useState<Set<number>>(new Set()); const [selectedMmsis, setSelectedMmsis] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@ -121,6 +128,8 @@ export function useVesselSelectModal(): VesselSelectModalState {
setSelectionWarning(null); setSelectionWarning(null);
}, []); }, []);
const { groups, saveGroup, deleteGroup, applyGroup } = useVesselGroups(userId, setMmsis);
const selectAllFiltered = useCallback((filtered: DerivedLegacyVessel[]) => { const selectAllFiltered = useCallback((filtered: DerivedLegacyVessel[]) => {
const capped = filtered.slice(0, MAX_VESSEL_SELECT); const capped = filtered.slice(0, MAX_VESSEL_SELECT);
setSelectedMmsis(new Set(capped.map((v) => v.mmsi))); setSelectedMmsis(new Set(capped.map((v) => v.mmsi)));
@ -269,5 +278,9 @@ export function useVesselSelectModal(): VesselSelectModalState {
position, position,
setPosition, setPosition,
selectionWarning, selectionWarning,
groups,
saveGroup,
deleteGroup,
applyGroup,
}; };
} }

파일 보기

@ -1,3 +1,3 @@
export type { VesselDescriptor } from './model/types'; export type { VesselDescriptor, VesselGroup } from './model/types';
export { MAX_VESSEL_SELECT } from './model/types'; export { MAX_VESSEL_SELECT, MAX_VESSEL_GROUPS } from './model/types';
export { useVesselSelectModal } from './hooks/useVesselSelectModal'; export { useVesselSelectModal } from './hooks/useVesselSelectModal';

파일 보기

@ -15,5 +15,13 @@ export interface VesselDescriptor {
shipCode?: string; shipCode?: string;
} }
export interface VesselGroup {
id: string;
name: string;
mmsis: number[];
updatedAt: number;
}
export const MAX_VESSEL_SELECT = 20; export const MAX_VESSEL_SELECT = 20;
export const MAX_QUERY_DAYS = 28; export const MAX_QUERY_DAYS = 28;
export const MAX_VESSEL_GROUPS = 10;

파일 보기

@ -70,7 +70,7 @@ export function DashboardPage() {
const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid); const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid);
// ── Vessel select modal (multi-track) ── // ── Vessel select modal (multi-track) ──
const vesselSelectModal = useVesselSelectModal(); const vesselSelectModal = useVesselSelectModal(uid);
// ── Data fetching ── // ── Data fetching ──
const { data: zones, error: zonesError } = useZones(); const { data: zones, error: zonesError } = useZones();

파일 보기

@ -1,12 +1,16 @@
import { useState, useEffect, useCallback, type CSSProperties } from 'react'; import { useState, useEffect, useCallback, type CSSProperties } from 'react';
import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types'; import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types';
import { VESSEL_TYPES } from '../../entities/vessel/model/meta'; import { VESSEL_TYPES } from '../../entities/vessel/model/meta';
import type { SortKey, SortDir } from './VesselSelectModal';
interface VesselSelectGridProps { interface VesselSelectGridProps {
vessels: DerivedLegacyVessel[]; vessels: DerivedLegacyVessel[];
selectedMmsis: Set<number>; selectedMmsis: Set<number>;
toggleMmsi: (mmsi: number) => void; toggleMmsi: (mmsi: number) => void;
setMmsis: (mmsis: Set<number>) => void; setMmsis: (mmsis: Set<number>) => void;
sortKey: SortKey | null;
sortDir: SortDir;
onSort: (key: SortKey) => void;
} }
interface DragState { interface DragState {
@ -30,8 +34,15 @@ const STYLE_TH: CSSProperties = {
padding: '6px 8px', padding: '6px 8px',
borderBottom: '1px solid rgba(148,163,184,0.2)', borderBottom: '1px solid rgba(148,163,184,0.2)',
fontWeight: 500, 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 = { const STYLE_TH_CHECKBOX: CSSProperties = {
...STYLE_TH, ...STYLE_TH,
width: 28, width: 28,
@ -79,7 +90,7 @@ function getDragHighlight(direction: 'check' | 'uncheck'): string {
return direction === 'check' ? 'rgba(59,130,246,0.18)' : 'rgba(148,163,184,0.12)'; return direction === 'check' ? 'rgba(59,130,246,0.18)' : 'rgba(148,163,184,0.12)';
} }
export function VesselSelectGrid({ vessels, selectedMmsis, toggleMmsi, setMmsis }: VesselSelectGridProps) { export function VesselSelectGrid({ vessels, selectedMmsis, toggleMmsi, setMmsis, sortKey, sortDir, onSort }: VesselSelectGridProps) {
const [dragState, setDragState] = useState<DragState | null>(null); const [dragState, setDragState] = useState<DragState | null>(null);
const handleMouseDown = useCallback( const handleMouseDown = useCallback(
@ -134,12 +145,12 @@ export function VesselSelectGrid({ vessels, selectedMmsis, toggleMmsi, setMmsis
<thead> <thead>
<tr> <tr>
<th style={STYLE_TH_CHECKBOX} /> <th style={STYLE_TH_CHECKBOX} />
<th style={STYLE_TH}></th> <th style={STYLE_TH} onClick={() => onSort('shipCode')}>{getSortIndicator('shipCode', sortKey, sortDir)}</th>
<th style={STYLE_TH}></th> <th style={STYLE_TH} onClick={() => onSort('permitNo')}>{getSortIndicator('permitNo', sortKey, sortDir)}</th>
<th style={STYLE_TH}></th> <th style={STYLE_TH} onClick={() => onSort('name')}>{getSortIndicator('name', sortKey, sortDir)}</th>
<th style={STYLE_TH}>MMSI</th> <th style={STYLE_TH} onClick={() => onSort('mmsi')}>MMSI{getSortIndicator('mmsi', sortKey, sortDir)}</th>
<th style={STYLE_TH}></th> <th style={STYLE_TH} onClick={() => onSort('sog')}>{getSortIndicator('sog', sortKey, sortDir)}</th>
<th style={STYLE_TH}></th> <th style={STYLE_TH} onClick={() => onSort('state')}>{getSortIndicator('state', sortKey, sortDir)}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

파일 보기

@ -1,10 +1,14 @@
import { useMemo, useEffect, useCallback, type CSSProperties } from 'react'; import { useMemo, useEffect, useCallback, useState, useRef, type CSSProperties } from 'react';
import type { VesselSelectModalState } from '../../features/vesselSelect/hooks/useVesselSelectModal'; import type { VesselSelectModalState } from '../../features/vesselSelect/hooks/useVesselSelectModal';
import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types'; import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types';
import { VESSEL_TYPE_ORDER, VESSEL_TYPES } from '../../entities/vessel/model/meta'; import { VESSEL_TYPE_ORDER, VESSEL_TYPES } from '../../entities/vessel/model/meta';
import { MAX_VESSEL_GROUPS } from '../../features/vesselSelect/model/types';
import { VesselSelectGrid } from './VesselSelectGrid'; import { VesselSelectGrid } from './VesselSelectGrid';
import { ToggleButton, TextInput, Button } from '@wing/ui'; import { ToggleButton, TextInput, Button } from '@wing/ui';
export type SortKey = 'shipCode' | 'permitNo' | 'name' | 'mmsi' | 'sog' | 'state';
export type SortDir = 'asc' | 'desc';
interface VesselSelectModalProps { interface VesselSelectModalProps {
modal: VesselSelectModalState; modal: VesselSelectModalState;
vessels: DerivedLegacyVessel[]; vessels: DerivedLegacyVessel[];
@ -121,6 +125,50 @@ const STYLE_DOT = (color: string): CSSProperties => ({
verticalAlign: 'middle', verticalAlign: 'middle',
}); });
const STYLE_GROUP_BAR: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 16px',
borderBottom: '1px solid rgba(148,163,184,0.1)',
flexShrink: 0,
overflowX: 'auto',
};
const STYLE_GROUP_BTN: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '3px 8px',
fontSize: 11,
borderRadius: 4,
border: '1px solid rgba(148,163,184,0.3)',
background: 'rgba(30,41,59,0.6)',
color: '#cbd5e1',
cursor: 'pointer',
whiteSpace: 'nowrap',
flexShrink: 0,
};
const STYLE_GROUP_DELETE: CSSProperties = {
fontSize: 9,
color: '#94a3b8',
cursor: 'pointer',
padding: '0 2px',
lineHeight: 1,
};
const STYLE_GROUP_INPUT: CSSProperties = {
fontSize: 11,
padding: '3px 6px',
borderRadius: 4,
border: '1px solid rgba(59,130,246,0.5)',
background: 'rgba(30,41,59,0.8)',
color: '#e2e8f0',
outline: 'none',
width: 100,
};
const STYLE_SEARCH: CSSProperties = { const STYLE_SEARCH: CSSProperties = {
padding: '6px 16px', padding: '6px 16px',
borderBottom: '1px solid rgba(148,163,184,0.1)', borderBottom: '1px solid rgba(148,163,184,0.1)',
@ -201,8 +249,48 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) {
position, position,
setPosition, setPosition,
selectionWarning, selectionWarning,
groups,
saveGroup,
deleteGroup,
applyGroup,
} = modal; } = modal;
// ── 그룹 저장 입력 ──
const [isSavingGroup, setIsSavingGroup] = useState(false);
const [groupNameDraft, setGroupNameDraft] = useState('');
const [groupWarning, setGroupWarning] = useState<string | null>(null);
const groupInputRef = useRef<HTMLInputElement>(null);
const handleSaveGroup = useCallback(() => {
if (!groupNameDraft.trim()) {
setIsSavingGroup(false);
return;
}
const warn = saveGroup(groupNameDraft, [...selectedMmsis]);
if (warn) {
setGroupWarning(warn);
} else {
setGroupWarning(null);
}
setIsSavingGroup(false);
setGroupNameDraft('');
}, [groupNameDraft, saveGroup, selectedMmsis]);
// ── 정렬 ──
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('asc');
const handleSort = useCallback((key: SortKey) => {
setSortKey((prev) => {
if (prev === key) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
return key;
}
setSortDir('asc');
return key;
});
}, []);
// ── 필터 ── // ── 필터 ──
const filteredVessels = useMemo(() => { const filteredVessels = useMemo(() => {
let list = vessels; let list = vessels;
@ -222,6 +310,24 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) {
return list; return list;
}, [vessels, shipCodeFilter, stateFilter, onlySailing, searchQuery]); }, [vessels, shipCodeFilter, stateFilter, onlySailing, searchQuery]);
const sortedVessels = useMemo(() => {
if (!sortKey) return filteredVessels;
const arr = [...filteredVessels];
arr.sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case 'shipCode': cmp = a.shipCode.localeCompare(b.shipCode); break;
case 'permitNo': cmp = a.permitNo.localeCompare(b.permitNo); break;
case 'name': cmp = a.name.localeCompare(b.name, 'ko'); break;
case 'mmsi': cmp = a.mmsi - b.mmsi; break;
case 'sog': cmp = (a.sog ?? -1) - (b.sog ?? -1); break;
case 'state': cmp = a.state.label.localeCompare(b.state.label, 'ko'); break;
}
return sortDir === 'asc' ? cmp : -cmp;
});
return arr;
}, [filteredVessels, sortKey, sortDir]);
// ── Escape 닫기 ── // ── Escape 닫기 ──
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
@ -353,6 +459,56 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) {
</div> </div>
</div> </div>
{/* 그룹 바 */}
{(groups.length > 0 || selectedMmsis.size > 0) && (
<div style={STYLE_GROUP_BAR}>
{isSavingGroup ? (
<input
ref={groupInputRef}
style={STYLE_GROUP_INPUT}
placeholder="그룹명 입력"
value={groupNameDraft}
onChange={(e) => setGroupNameDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveGroup();
if (e.key === 'Escape') { setIsSavingGroup(false); setGroupNameDraft(''); }
}}
onBlur={handleSaveGroup}
autoFocus
/>
) : (
<Button
variant="ghost"
size="sm"
disabled={selectedMmsis.size === 0 || (groups.length >= MAX_VESSEL_GROUPS && !groups.some((g) => g.mmsis.length === selectedMmsis.size))}
title={groups.length >= MAX_VESSEL_GROUPS ? `최대 ${MAX_VESSEL_GROUPS}` : undefined}
onClick={() => { setIsSavingGroup(true); setGroupWarning(null); }}
>
</Button>
)}
{groupWarning && <span style={{ color: '#fca5a5', fontSize: 10 }}>{groupWarning}</span>}
{groups.map((g) => (
<button
type="button"
key={g.id}
style={STYLE_GROUP_BTN}
onClick={() => applyGroup(g)}
title={`${g.mmsis.length}`}
>
{g.name}
<span
style={STYLE_GROUP_DELETE}
onClick={(e) => { e.stopPropagation(); deleteGroup(g.id); }}
title="삭제"
>
</span>
</button>
))}
</div>
)}
{/* 검색 */} {/* 검색 */}
<div style={STYLE_SEARCH}> <div style={STYLE_SEARCH}>
<TextInput placeholder="검색: 등록번호 / 선박명 / MMSI" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} /> <TextInput placeholder="검색: 등록번호 / 선박명 / MMSI" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} />
@ -360,7 +516,15 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) {
{/* 그리드 */} {/* 그리드 */}
<div style={STYLE_GRID}> <div style={STYLE_GRID}>
<VesselSelectGrid vessels={filteredVessels} selectedMmsis={selectedMmsis} toggleMmsi={toggleMmsi} setMmsis={setMmsis} /> <VesselSelectGrid
vessels={sortedVessels}
selectedMmsis={selectedMmsis}
toggleMmsi={toggleMmsi}
setMmsis={setMmsis}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
/>
</div> </div>
{/* 기간 설정 바 */} {/* 기간 설정 바 */}
@ -396,6 +560,9 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) {
<input type="checkbox" checked={isAllSelected} onChange={handleSelectAllChange} style={{ cursor: 'pointer' }} /> <input type="checkbox" checked={isAllSelected} onChange={handleSelectAllChange} style={{ cursor: 'pointer' }} />
({filteredVessels.length}) ({filteredVessels.length})
</label> </label>
<Button variant="ghost" size="sm" disabled={selectedMmsis.size === 0} onClick={clearAll}>
</Button>
{selectionWarning && <span style={{ color: '#fca5a5', fontSize: 11 }}>{selectionWarning}</span>} {selectionWarning && <span style={{ color: '#fca5a5', fontSize: 11 }}>{selectionWarning}</span>}
<div style={STYLE_FOOTER_SPACER} /> <div style={STYLE_FOOTER_SPACER} />
<span style={{ color: '#93c5fd', fontSize: 12 }}> {selectedMmsis.size}</span> <span style={{ color: '#93c5fd', fontSize: 12 }}> {selectedMmsis.size}</span>