diff --git a/apps/web/src/features/vesselSelect/hooks/useVesselGroups.ts b/apps/web/src/features/vesselSelect/hooks/useVesselGroups.ts new file mode 100644 index 0000000..da9ea92 --- /dev/null +++ b/apps/web/src/features/vesselSelect/hooks/useVesselGroups.ts @@ -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) => void, +): VesselGroupsState { + const [rawGroups, setRawGroups] = usePersistedState(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 }; +} diff --git a/apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts b/apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts index 6e5e7b5..0428ba0 100644 --- a/apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts +++ b/apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts @@ -3,6 +3,8 @@ import type { DerivedLegacyVessel } from '../../legacyDashboard/model/types'; import type { MultiTrackQueryContext } from '../../trackReplay/model/track.types'; import { useTrackQueryStore } from '../../trackReplay/stores/trackQueryStore'; 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) */ function toDateTimeLocalKST(ms: number): string { @@ -56,9 +58,14 @@ export interface VesselSelectModalState { setPosition: (pos: { x: number; y: number }) => void; 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 [selectedMmsis, setSelectedMmsis] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(''); @@ -121,6 +128,8 @@ export function useVesselSelectModal(): VesselSelectModalState { setSelectionWarning(null); }, []); + const { groups, saveGroup, deleteGroup, applyGroup } = useVesselGroups(userId, setMmsis); + const selectAllFiltered = useCallback((filtered: DerivedLegacyVessel[]) => { const capped = filtered.slice(0, MAX_VESSEL_SELECT); setSelectedMmsis(new Set(capped.map((v) => v.mmsi))); @@ -269,5 +278,9 @@ export function useVesselSelectModal(): VesselSelectModalState { position, setPosition, selectionWarning, + groups, + saveGroup, + deleteGroup, + applyGroup, }; } diff --git a/apps/web/src/features/vesselSelect/index.ts b/apps/web/src/features/vesselSelect/index.ts index a309c7d..e9a540b 100644 --- a/apps/web/src/features/vesselSelect/index.ts +++ b/apps/web/src/features/vesselSelect/index.ts @@ -1,3 +1,3 @@ -export type { VesselDescriptor } from './model/types'; -export { MAX_VESSEL_SELECT } from './model/types'; +export type { VesselDescriptor, VesselGroup } from './model/types'; +export { MAX_VESSEL_SELECT, MAX_VESSEL_GROUPS } from './model/types'; export { useVesselSelectModal } from './hooks/useVesselSelectModal'; diff --git a/apps/web/src/features/vesselSelect/model/types.ts b/apps/web/src/features/vesselSelect/model/types.ts index a8f6c7c..27fe660 100644 --- a/apps/web/src/features/vesselSelect/model/types.ts +++ b/apps/web/src/features/vesselSelect/model/types.ts @@ -15,5 +15,13 @@ export interface VesselDescriptor { shipCode?: string; } +export interface VesselGroup { + id: string; + name: string; + mmsis: number[]; + updatedAt: number; +} + export const MAX_VESSEL_SELECT = 20; export const MAX_QUERY_DAYS = 28; +export const MAX_VESSEL_GROUPS = 10; diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index a80d705..f22cc60 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -70,7 +70,7 @@ export function DashboardPage() { const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid); // ── Vessel select modal (multi-track) ── - const vesselSelectModal = useVesselSelectModal(); + const vesselSelectModal = useVesselSelectModal(uid); // ── Data fetching ── const { data: zones, error: zonesError } = useZones(); diff --git a/apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx b/apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx index f47e3e0..f9c42f5 100644 --- a/apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx +++ b/apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx @@ -1,12 +1,16 @@ 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; toggleMmsi: (mmsi: number) => void; setMmsis: (mmsis: Set) => void; + sortKey: SortKey | null; + sortDir: SortDir; + onSort: (key: SortKey) => void; } interface DragState { @@ -30,8 +34,15 @@ const STYLE_TH: CSSProperties = { 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, @@ -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)'; } -export function VesselSelectGrid({ vessels, selectedMmsis, toggleMmsi, setMmsis }: VesselSelectGridProps) { +export function VesselSelectGrid({ vessels, selectedMmsis, toggleMmsi, setMmsis, sortKey, sortDir, onSort }: VesselSelectGridProps) { const [dragState, setDragState] = useState(null); const handleMouseDown = useCallback( @@ -134,12 +145,12 @@ export function VesselSelectGrid({ vessels, selectedMmsis, toggleMmsi, setMmsis - 업종 - 등록번호 - 선명 - MMSI - 속력 - 상태 + onSort('shipCode')}>업종{getSortIndicator('shipCode', sortKey, sortDir)} + onSort('permitNo')}>등록번호{getSortIndicator('permitNo', sortKey, sortDir)} + onSort('name')}>선명{getSortIndicator('name', sortKey, sortDir)} + onSort('mmsi')}>MMSI{getSortIndicator('mmsi', sortKey, sortDir)} + onSort('sog')}>속력{getSortIndicator('sog', sortKey, sortDir)} + onSort('state')}>상태{getSortIndicator('state', sortKey, sortDir)} diff --git a/apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx b/apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx index d30dc78..1497803 100644 --- a/apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx +++ b/apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx @@ -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 { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types'; 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 { ToggleButton, TextInput, Button } from '@wing/ui'; +export type SortKey = 'shipCode' | 'permitNo' | 'name' | 'mmsi' | 'sog' | 'state'; +export type SortDir = 'asc' | 'desc'; + interface VesselSelectModalProps { modal: VesselSelectModalState; vessels: DerivedLegacyVessel[]; @@ -121,6 +125,50 @@ const STYLE_DOT = (color: string): CSSProperties => ({ 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 = { padding: '6px 16px', borderBottom: '1px solid rgba(148,163,184,0.1)', @@ -201,8 +249,48 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) { position, setPosition, selectionWarning, + groups, + saveGroup, + deleteGroup, + applyGroup, } = modal; + // ── 그룹 저장 입력 ── + const [isSavingGroup, setIsSavingGroup] = useState(false); + const [groupNameDraft, setGroupNameDraft] = useState(''); + const [groupWarning, setGroupWarning] = useState(null); + const groupInputRef = useRef(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(null); + const [sortDir, setSortDir] = useState('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(() => { let list = vessels; @@ -222,6 +310,24 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) { return list; }, [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 닫기 ── const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -353,6 +459,56 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) { + {/* 그룹 바 */} + {(groups.length > 0 || selectedMmsis.size > 0) && ( +
+ {isSavingGroup ? ( + setGroupNameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveGroup(); + if (e.key === 'Escape') { setIsSavingGroup(false); setGroupNameDraft(''); } + }} + onBlur={handleSaveGroup} + autoFocus + /> + ) : ( + + )} + {groupWarning && {groupWarning}} + {groups.map((g) => ( + + ))} +
+ )} + {/* 검색 */}
setSearchQuery(e.target.value)} /> @@ -360,7 +516,15 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) { {/* 그리드 */}
- +
{/* 기간 설정 바 */} @@ -396,6 +560,9 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) { 전체 선택 ({filteredVessels.length}척) + {selectionWarning && {selectionWarning}}
선택 {selectedMmsis.size}척