Merge pull request 'feat: 선박 그룹 관리 + 우클릭 컨텍스트 메뉴 확장' (#57) from feature/vessel-group-context-menu into develop
This commit is contained in:
커밋
492e5a04c9
@ -2,30 +2,30 @@
|
|||||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(npm run *)",
|
"Bash(curl -s *)",
|
||||||
"Bash(npm -w *)",
|
"Bash(fnm *)",
|
||||||
"Bash(npm install *)",
|
"Bash(git add *)",
|
||||||
"Bash(npm test *)",
|
|
||||||
"Bash(npx *)",
|
|
||||||
"Bash(node *)",
|
|
||||||
"Bash(git status)",
|
|
||||||
"Bash(git diff *)",
|
|
||||||
"Bash(git log *)",
|
|
||||||
"Bash(git branch *)",
|
"Bash(git branch *)",
|
||||||
"Bash(git checkout *)",
|
"Bash(git checkout *)",
|
||||||
"Bash(git add *)",
|
|
||||||
"Bash(git commit *)",
|
"Bash(git commit *)",
|
||||||
"Bash(git pull *)",
|
|
||||||
"Bash(git fetch *)",
|
|
||||||
"Bash(git merge *)",
|
|
||||||
"Bash(git stash *)",
|
|
||||||
"Bash(git remote *)",
|
|
||||||
"Bash(git config *)",
|
"Bash(git config *)",
|
||||||
|
"Bash(git diff *)",
|
||||||
|
"Bash(git fetch *)",
|
||||||
|
"Bash(git log *)",
|
||||||
|
"Bash(git merge *)",
|
||||||
|
"Bash(git pull *)",
|
||||||
|
"Bash(git remote *)",
|
||||||
"Bash(git rev-parse *)",
|
"Bash(git rev-parse *)",
|
||||||
"Bash(git show *)",
|
"Bash(git show *)",
|
||||||
|
"Bash(git stash *)",
|
||||||
|
"Bash(git status)",
|
||||||
"Bash(git tag *)",
|
"Bash(git tag *)",
|
||||||
"Bash(curl -s *)",
|
"Bash(node *)",
|
||||||
"Bash(fnm *)"
|
"Bash(npm -w *)",
|
||||||
|
"Bash(npm install *)",
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(npm test *)",
|
||||||
|
"Bash(npx *)"
|
||||||
],
|
],
|
||||||
"deny": [
|
"deny": [
|
||||||
"Bash(git push --force*)",
|
"Bash(git push --force*)",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-03-08",
|
"applied_date": "2026-03-18",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
|
|||||||
66
apps/web/src/features/vesselSelect/hooks/useVesselGroups.ts
Normal file
66
apps/web/src/features/vesselSelect/hooks/useVesselGroups.ts
Normal file
@ -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();
|
||||||
|
|||||||
@ -79,8 +79,8 @@ export function useDashboardState(uid: number | null) {
|
|||||||
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
|
||||||
|
|
||||||
// ── Track context menu ──
|
// ── Track context menu ──
|
||||||
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null);
|
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean } | null>(null);
|
||||||
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => setTrackContextMenu(info), []);
|
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean }) => setTrackContextMenu(info), []);
|
||||||
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
|
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
|
||||||
|
|
||||||
// ── Projection loading ──
|
// ── Projection loading ──
|
||||||
|
|||||||
@ -708,11 +708,12 @@ export function Map3D({
|
|||||||
if (hovered.length > 0) mmsi = hovered[0];
|
if (hovered.length > 0) mmsi = hovered[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mmsi == null || !legacyHits?.has(mmsi)) return;
|
if (mmsi == null) return;
|
||||||
|
|
||||||
const target = shipByMmsi.get(mmsi);
|
const target = shipByMmsi.get(mmsi);
|
||||||
const vesselName = (target?.name || '').trim() || `MMSI ${mmsi}`;
|
const vesselName = (target?.name || '').trim() || `MMSI ${mmsi}`;
|
||||||
onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName });
|
const isPermitted = legacyHits?.has(mmsi) ?? false;
|
||||||
|
onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName, isPermitted });
|
||||||
};
|
};
|
||||||
container.addEventListener('contextmenu', onContextMenu);
|
container.addEventListener('contextmenu', onContextMenu);
|
||||||
return () => container.removeEventListener('contextmenu', onContextMenu);
|
return () => container.removeEventListener('contextmenu', onContextMenu);
|
||||||
@ -734,13 +735,14 @@ export function Map3D({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||||
{trackContextMenu && onRequestTrack && onCloseTrackMenu && (
|
{trackContextMenu && onCloseTrackMenu && (
|
||||||
<VesselContextMenu
|
<VesselContextMenu
|
||||||
x={trackContextMenu.x}
|
x={trackContextMenu.x}
|
||||||
y={trackContextMenu.y}
|
y={trackContextMenu.y}
|
||||||
mmsi={trackContextMenu.mmsi}
|
mmsi={trackContextMenu.mmsi}
|
||||||
vesselName={trackContextMenu.vesselName}
|
vesselName={trackContextMenu.vesselName}
|
||||||
onRequestTrack={onRequestTrack}
|
isPermitted={trackContextMenu.isPermitted}
|
||||||
|
onRequestTrack={onRequestTrack ?? undefined}
|
||||||
onClose={onCloseTrackMenu}
|
onClose={onCloseTrackMenu}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
mmsi: number;
|
mmsi: number;
|
||||||
vesselName: string;
|
vesselName: string;
|
||||||
onRequestTrack: (mmsi: number, minutes: number) => void;
|
isPermitted: boolean;
|
||||||
|
onRequestTrack?: (mmsi: number, minutes: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,12 +21,40 @@ const TRACK_OPTIONS = [
|
|||||||
const MENU_WIDTH = 180;
|
const MENU_WIDTH = 180;
|
||||||
const MENU_PAD = 8;
|
const MENU_PAD = 8;
|
||||||
|
|
||||||
export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onClose }: Props) {
|
const STYLE_ITEM: CSSProperties = {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
padding: '5px 12px 5px 24px',
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#e2e2e2',
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
lineHeight: 1.4,
|
||||||
|
};
|
||||||
|
|
||||||
// 화면 밖 보정
|
const STYLE_SEPARATOR: CSSProperties = {
|
||||||
|
height: 1,
|
||||||
|
background: 'rgba(255,255,255,0.08)',
|
||||||
|
margin: '3px 0',
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleHover(e: React.MouseEvent) {
|
||||||
|
(e.target as HTMLElement).style.background = 'rgba(59,130,246,0.18)';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLeave(e: React.MouseEvent) {
|
||||||
|
(e.target as HTMLElement).style.background = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VesselContextMenu({ x, y, mmsi, vesselName, isPermitted, onRequestTrack, onClose }: Props) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [copiedField, setCopiedField] = useState<'name' | 'mmsi' | null>(null);
|
||||||
|
|
||||||
|
const estimatedHeight = (isPermitted && onRequestTrack ? TRACK_OPTIONS.length * 30 + 56 : 0) + 90;
|
||||||
const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_PAD);
|
const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_PAD);
|
||||||
const maxTop = window.innerHeight - (TRACK_OPTIONS.length * 30 + 56) - MENU_PAD;
|
const maxTop = window.innerHeight - estimatedHeight - MENU_PAD;
|
||||||
const top = Math.min(y, maxTop);
|
const top = Math.min(y, maxTop);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -47,8 +76,18 @@ export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onCl
|
|||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const handleSelect = (minutes: number) => {
|
const handleCopy = async (text: string, field: 'name' | 'mmsi') => {
|
||||||
onRequestTrack(mmsi, minutes);
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopiedField(field);
|
||||||
|
setTimeout(() => setCopiedField(null), 1200);
|
||||||
|
} catch {
|
||||||
|
// clipboard API 불가 시 무시
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectTrack = (minutes: number) => {
|
||||||
|
onRequestTrack?.(mmsi, minutes);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -92,7 +131,32 @@ export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onCl
|
|||||||
{vesselName}
|
{vesselName}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 항적조회 항목 */}
|
{/* 선명 복사 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={STYLE_ITEM}
|
||||||
|
onClick={() => handleCopy(vesselName, 'name')}
|
||||||
|
onMouseEnter={handleHover}
|
||||||
|
onMouseLeave={handleLeave}
|
||||||
|
>
|
||||||
|
{copiedField === 'name' ? '복사됨' : '선명 복사'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* MMSI 복사 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={STYLE_ITEM}
|
||||||
|
onClick={() => handleCopy(String(mmsi), 'mmsi')}
|
||||||
|
onMouseEnter={handleHover}
|
||||||
|
onMouseLeave={handleLeave}
|
||||||
|
>
|
||||||
|
{copiedField === 'mmsi' ? '복사됨' : 'MMSI 복사'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 항적조회 (대상선박만) */}
|
||||||
|
{isPermitted && onRequestTrack && (
|
||||||
|
<>
|
||||||
|
<div style={STYLE_SEPARATOR} />
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '4px 12px 2px',
|
padding: '4px 12px 2px',
|
||||||
@ -103,33 +167,20 @@ export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onCl
|
|||||||
>
|
>
|
||||||
항적조회
|
항적조회
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{TRACK_OPTIONS.map((opt) => (
|
{TRACK_OPTIONS.map((opt) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
key={opt.minutes}
|
key={opt.minutes}
|
||||||
onClick={() => handleSelect(opt.minutes)}
|
onClick={() => handleSelectTrack(opt.minutes)}
|
||||||
style={{
|
style={STYLE_ITEM}
|
||||||
display: 'block',
|
onMouseEnter={handleHover}
|
||||||
width: '100%',
|
onMouseLeave={handleLeave}
|
||||||
padding: '5px 12px 5px 24px',
|
|
||||||
background: 'none',
|
|
||||||
border: 'none',
|
|
||||||
color: '#e2e2e2',
|
|
||||||
fontSize: 12,
|
|
||||||
textAlign: 'left',
|
|
||||||
cursor: 'pointer',
|
|
||||||
lineHeight: 1.4,
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
(e.target as HTMLElement).style.background = 'rgba(59,130,246,0.18)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
(e.target as HTMLElement).style.background = 'none';
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,10 +66,10 @@ export interface Map3DProps {
|
|||||||
onViewStateChange?: (view: MapViewState) => void;
|
onViewStateChange?: (view: MapViewState) => void;
|
||||||
onGlobeShipsReady?: (ready: boolean) => void;
|
onGlobeShipsReady?: (ready: boolean) => void;
|
||||||
activeTrack?: ActiveTrack | null;
|
activeTrack?: ActiveTrack | null;
|
||||||
trackContextMenu?: { x: number; y: number; mmsi: number; vesselName: string } | null;
|
trackContextMenu?: { x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean } | null;
|
||||||
onRequestTrack?: (mmsi: number, minutes: number) => void;
|
onRequestTrack?: (mmsi: number, minutes: number) => void;
|
||||||
onCloseTrackMenu?: () => void;
|
onCloseTrackMenu?: () => void;
|
||||||
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void;
|
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean }) => void;
|
||||||
/** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */
|
/** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */
|
||||||
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||||
/** 사진 있는 선박 클릭 시 콜백 (사진 표시자 or 선박 아이콘) */
|
/** 사진 있는 선박 클릭 시 콜백 (사진 표시자 or 선박 아이콘) */
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -4,6 +4,15 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 다중 항적 선박 그룹 저장/불러오기 (계정별 localStorage, 최대 10개)
|
||||||
|
- 선박 목록 컬럼별 정렬 (업종/등록번호/선명/MMSI/속력/상태)
|
||||||
|
- 선박 선택 초기화 버튼
|
||||||
|
- 모든 선박 우클릭 컨텍스트 메뉴 — 선명 복사, MMSI 복사
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- 우클릭 항적조회를 대상선박 외 모든 선박으로 컨텍스트 메뉴 확장 (항적조회는 대상선박만 유지)
|
||||||
|
|
||||||
## [2026-03-10]
|
## [2026-03-10]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user