fix(sidebar): 검색 복원, 경고 필터, 내부 스크롤 수정 #25
@ -228,8 +228,6 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
|
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
|
||||||
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
|
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
|
||||||
const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKinds.length}/${LEGACY_ALARM_KINDS.length}`;
|
|
||||||
|
|
||||||
const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => {
|
const handleFleetContextMenu = (ownerKey: string, mmsis: number[]) => {
|
||||||
if (!mmsis.length) return;
|
if (!mmsis.length) return;
|
||||||
const members = mmsis
|
const members = mmsis
|
||||||
@ -279,8 +277,6 @@ export function DashboardPage() {
|
|||||||
filteredAlarms={filteredAlarms}
|
filteredAlarms={filteredAlarms}
|
||||||
alarms={alarms}
|
alarms={alarms}
|
||||||
alarmKindCounts={alarmKindCounts}
|
alarmKindCounts={alarmKindCounts}
|
||||||
allAlarmKindsEnabled={allAlarmKindsEnabled}
|
|
||||||
alarmFilterSummary={alarmFilterSummary}
|
|
||||||
speedPanelType={speedPanelType}
|
speedPanelType={speedPanelType}
|
||||||
onFleetContextMenu={handleFleetContextMenu}
|
onFleetContextMenu={handleFleetContextMenu}
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ToggleButton, Section } from '@wing/ui';
|
import { ToggleButton, Section } from '@wing/ui';
|
||||||
import type { AisTarget } from '../../entities/aisTarget/model/types';
|
import type { AisTarget } from '../../entities/aisTarget/model/types';
|
||||||
import type { LegacyVesselIndex } from '../../entities/legacyVessel/lib';
|
import type { LegacyVesselIndex } from '../../entities/legacyVessel/lib';
|
||||||
@ -41,8 +41,6 @@ interface DashboardSidebarProps {
|
|||||||
filteredAlarms: LegacyAlarm[];
|
filteredAlarms: LegacyAlarm[];
|
||||||
alarms: LegacyAlarm[];
|
alarms: LegacyAlarm[];
|
||||||
alarmKindCounts: Record<LegacyAlarmKind, number>;
|
alarmKindCounts: Record<LegacyAlarmKind, number>;
|
||||||
allAlarmKindsEnabled: boolean;
|
|
||||||
alarmFilterSummary: string;
|
|
||||||
speedPanelType: VesselTypeCode;
|
speedPanelType: VesselTypeCode;
|
||||||
onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void;
|
onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void;
|
||||||
snapshot: AisPollingSnapshot;
|
snapshot: AisPollingSnapshot;
|
||||||
@ -67,8 +65,6 @@ export function DashboardSidebar({
|
|||||||
filteredAlarms,
|
filteredAlarms,
|
||||||
alarms,
|
alarms,
|
||||||
alarmKindCounts,
|
alarmKindCounts,
|
||||||
allAlarmKindsEnabled,
|
|
||||||
alarmFilterSummary,
|
|
||||||
speedPanelType,
|
speedPanelType,
|
||||||
onFleetContextMenu,
|
onFleetContextMenu,
|
||||||
snapshot,
|
snapshot,
|
||||||
@ -97,6 +93,8 @@ export function DashboardSidebar({
|
|||||||
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
|
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
|
||||||
} = state;
|
} = state;
|
||||||
|
|
||||||
|
const [isAlarmFilterOpen, setIsAlarmFilterOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
@ -115,12 +113,12 @@ export function DashboardSidebar({
|
|||||||
fixed inset-y-0 left-0 z-[1200] w-[310px] max-w-[100vw] transform overflow-y-auto
|
fixed inset-y-0 left-0 z-[1200] w-[310px] max-w-[100vw] transform overflow-y-auto
|
||||||
border-r border-wing-border bg-wing-surface transition-transform duration-200
|
border-r border-wing-border bg-wing-surface transition-transform duration-200
|
||||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||||
md:static md:z-auto md:translate-x-0 md:transition-none
|
md:static md:z-auto md:translate-x-0 md:transition-none md:overflow-hidden md:flex md:flex-col
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="h-[44px] md:hidden" />
|
<div className="h-[44px] md:hidden" />
|
||||||
|
|
||||||
<Section title="업종 필터">
|
<Section title="업종 필터" className="md:shrink-0">
|
||||||
<div className="flex flex-wrap gap-0.75 mb-1.5">
|
<div className="flex flex-wrap gap-0.75 mb-1.5">
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
on={showTargets}
|
on={showTargets}
|
||||||
@ -158,13 +156,13 @@ export function DashboardSidebar({
|
|||||||
|
|
||||||
<Section
|
<Section
|
||||||
title="지도 표시 설정"
|
title="지도 표시 설정"
|
||||||
|
className="md:shrink-0"
|
||||||
actions={
|
actions={
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
on={projection === 'globe'}
|
on={projection === 'globe'}
|
||||||
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
|
onClick={isProjectionToggleDisabled ? undefined : () => setProjection((p) => (p === 'globe' ? 'mercator' : 'globe'))}
|
||||||
title={isProjectionToggleDisabled ? '3D 모드 준비 중...' : '3D 지구본 투영'}
|
title={isProjectionToggleDisabled ? '3D 모드 준비 중...' : '3D 지구본 투영'}
|
||||||
className="px-2 py-0.5 text-[9px]"
|
className={`px-2 py-0.5 text-[9px]${isProjectionToggleDisabled ? " opacity-40 cursor-not-allowed" : ""}`}
|
||||||
style={{ opacity: isProjectionToggleDisabled ? 0.4 : 1, cursor: isProjectionToggleDisabled ? 'not-allowed' : 'pointer' }}
|
|
||||||
>
|
>
|
||||||
3D
|
3D
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
@ -173,7 +171,7 @@ export function DashboardSidebar({
|
|||||||
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="속도 프로파일" defaultOpen={false}>
|
<Section title="속도 프로파일" defaultOpen={false} className="md:shrink-0">
|
||||||
<SpeedProfilePanel selectedType={speedPanelType} />
|
<SpeedProfilePanel selectedType={speedPanelType} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
@ -198,7 +196,8 @@ export function DashboardSidebar({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
className="max-h-[260px] flex flex-col overflow-hidden [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0"
|
className="md:shrink-0 max-h-[260px] flex flex-col overflow-hidden"
|
||||||
|
contentClassName="flex-1 min-h-0 overflow-y-auto"
|
||||||
>
|
>
|
||||||
<RelationsPanel
|
<RelationsPanel
|
||||||
selectedVessel={selectedLegacyVessel}
|
selectedVessel={selectedLegacyVessel}
|
||||||
@ -232,7 +231,8 @@ export function DashboardSidebar({
|
|||||||
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">({legacyVesselsFiltered.length}척)</span>
|
<span className="text-[8px] font-normal normal-case tracking-normal text-wing-accent">({legacyVesselsFiltered.length}척)</span>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
className="flex-1 min-h-0 flex flex-col [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
|
className="md:flex-1 md:min-h-0 flex flex-col overflow-hidden"
|
||||||
|
contentClassName="md:flex-1 md:min-h-0 flex flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
<VesselList
|
<VesselList
|
||||||
vessels={legacyVesselsFiltered}
|
vessels={legacyVesselsFiltered}
|
||||||
@ -253,63 +253,40 @@ export function DashboardSidebar({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
LEGACY_ALARM_KINDS.length <= 3 ? (
|
<ToggleButton on={isAlarmFilterOpen} onClick={() => setIsAlarmFilterOpen((v) => !v)}>
|
||||||
<div className="flex gap-1.5 items-center">
|
필터
|
||||||
{LEGACY_ALARM_KINDS.map((k) => (
|
</ToggleButton>
|
||||||
<label key={k} className="inline-flex gap-1 items-center cursor-pointer select-none">
|
|
||||||
<input type="checkbox" checked={!!alarmKindEnabled[k]} onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
|
||||||
<span className="text-[8px] text-wing-muted whitespace-nowrap">{LEGACY_ALARM_KIND_LABEL[k]}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<details className="alarm-filter">
|
|
||||||
<summary className="alarm-filter__summary" title="경고 종류 필터">{alarmFilterSummary}</summary>
|
|
||||||
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
|
|
||||||
<label className="alarm-filter__row">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={allAlarmKindsEnabled}
|
|
||||||
onChange={() =>
|
|
||||||
setAlarmKindEnabled((prev) => {
|
|
||||||
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
|
|
||||||
const nextVal = !allOn;
|
|
||||||
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
전체
|
|
||||||
<span className="alarm-filter__cnt">{alarms.length}</span>
|
|
||||||
</label>
|
|
||||||
<div className="alarm-filter__sep" />
|
|
||||||
{LEGACY_ALARM_KINDS.map((k) => (
|
|
||||||
<label key={k} className="alarm-filter__row">
|
|
||||||
<input type="checkbox" checked={!!alarmKindEnabled[k]} onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
|
||||||
{LEGACY_ALARM_KIND_LABEL[k]}
|
|
||||||
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="max-h-[130px] flex flex-col overflow-visible [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
|
className="md:shrink-0 max-h-[180px] flex flex-col overflow-hidden"
|
||||||
|
contentClassName="flex-1 min-h-0 overflow-y-auto"
|
||||||
>
|
>
|
||||||
|
{isAlarmFilterOpen && (
|
||||||
|
<div className="flex flex-wrap gap-x-2.5 gap-y-1 mb-1.5">
|
||||||
|
{LEGACY_ALARM_KINDS.map((k) => (
|
||||||
|
<label key={k} className="inline-flex gap-1 items-center cursor-pointer select-none">
|
||||||
|
<input type="checkbox" checked={!!alarmKindEnabled[k]} onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||||
|
<span className="text-[8px] text-wing-muted whitespace-nowrap">
|
||||||
|
{LEGACY_ALARM_KIND_LABEL[k]} <span className="text-wing-text">{alarmKindCounts[k] ?? 0}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
|
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{adminMode ? (
|
{adminMode ? (
|
||||||
<>
|
<>
|
||||||
<Section title="ADMIN · AIS Target Polling" defaultOpen={false}>
|
<Section title="ADMIN · AIS Target Polling" defaultOpen={false} className="md:shrink-0">
|
||||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>엔드포인트</div>
|
<div style={{ color: 'var(--muted)', fontSize: 10 }}>엔드포인트</div>
|
||||||
<div style={{ wordBreak: 'break-all' }}>{AIS_API_BASE}/api/ais-target/search</div>
|
<div style={{ wordBreak: 'break-all' }}>{AIS_API_BASE}/api/ais-target/search</div>
|
||||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>상태</div>
|
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>상태</div>
|
||||||
<div>
|
<div>
|
||||||
<b style={{ color: snapshot.status === 'ready' ? '#22C55E' : snapshot.status === 'error' ? '#EF4444' : '#F59E0B' }}>
|
<b style={{ color: snapshot.status === 'ready' ? 'var(--wing-success)' : snapshot.status === 'error' ? 'var(--wing-danger)' : 'var(--wing-warning)' }}>
|
||||||
{snapshot.status.toUpperCase()}
|
{snapshot.status.toUpperCase()}
|
||||||
</b>
|
</b>
|
||||||
{snapshot.error ? <span style={{ marginLeft: 6, color: '#EF4444' }}>{snapshot.error}</span> : null}
|
{snapshot.error ? <span style={{ marginLeft: 6, color: 'var(--wing-danger)' }}>{snapshot.error}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>최근 fetch</div>
|
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>최근 fetch</div>
|
||||||
<div>
|
<div>
|
||||||
@ -323,16 +300,16 @@ export function DashboardSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="ADMIN · Legacy (CN Permit)" defaultOpen={false}>
|
<Section title="ADMIN · Legacy (CN Permit)" defaultOpen={false} className="md:shrink-0">
|
||||||
{legacyError ? (
|
{legacyError ? (
|
||||||
<div style={{ fontSize: 11, color: '#EF4444' }}>legacy load error: {legacyError}</div>
|
<div style={{ fontSize: 11, color: 'var(--wing-danger)' }}>legacy load error: {legacyError}</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>데이터셋</div>
|
<div style={{ color: 'var(--muted)', fontSize: 10 }}>데이터셋</div>
|
||||||
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
|
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
|
||||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>매칭(현재 scope)</div>
|
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>매칭(현재 scope)</div>
|
||||||
<div>
|
<div>
|
||||||
<b style={{ color: '#F59E0B' }}>{legacyVesselsAll.length}</b>{' '}
|
<b style={{ color: 'var(--wing-warning)' }}>{legacyVesselsAll.length}</b>{' '}
|
||||||
<span style={{ color: 'var(--muted)', fontSize: 10 }}>/ {targetsInScope.length}</span>
|
<span style={{ color: 'var(--muted)', fontSize: 10 }}>/ {targetsInScope.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>생성시각</div>
|
<div style={{ marginTop: 6, color: 'var(--muted)', fontSize: 10 }}>생성시각</div>
|
||||||
@ -341,7 +318,7 @@ export function DashboardSidebar({
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="ADMIN · Viewport / BBox" defaultOpen={false}>
|
<Section title="ADMIN · Viewport / BBox" defaultOpen={false} className="md:shrink-0">
|
||||||
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
|
||||||
<div style={{ color: 'var(--muted)', fontSize: 10 }}>현재 View BBox</div>
|
<div style={{ color: 'var(--muted)', fontSize: 10 }}>현재 View BBox</div>
|
||||||
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
|
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
|
||||||
@ -400,7 +377,7 @@ export function DashboardSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="ADMIN · Map (Extras)" defaultOpen={false}>
|
<Section title="ADMIN · Map (Extras)" defaultOpen={false} className="md:shrink-0">
|
||||||
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 6 }}>단일 WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
|
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 6 }}>단일 WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
|
||||||
</Section>
|
</Section>
|
||||||
@ -408,14 +385,15 @@ export function DashboardSidebar({
|
|||||||
<Section
|
<Section
|
||||||
title="ADMIN · AIS Targets (All)"
|
title="ADMIN · AIS Targets (All)"
|
||||||
defaultOpen={false}
|
defaultOpen={false}
|
||||||
className="flex-1 min-h-0 flex flex-col [&>div:last-child]:overflow-y-auto [&>div:last-child]:min-h-0 [&>div:last-child]:flex-1"
|
className="md:shrink-0 md:max-h-[200px] flex flex-col md:overflow-hidden"
|
||||||
|
contentClassName="md:flex-1 md:min-h-0 md:overflow-y-auto"
|
||||||
>
|
>
|
||||||
<AisTargetList targets={targetsInScope} selectedMmsi={selectedMmsi} onSelectMmsi={setSelectedMmsi} legacyIndex={legacyIndex} />
|
<AisTargetList targets={targetsInScope} selectedMmsi={selectedMmsi} onSelectMmsi={setSelectedMmsi} legacyIndex={legacyIndex} />
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="ADMIN · 수역 데이터" defaultOpen={false} className="max-h-[130px] overflow-y-auto">
|
<Section title="ADMIN · 수역 데이터" defaultOpen={false} className="md:shrink-0">
|
||||||
{zonesError ? (
|
{zonesError ? (
|
||||||
<div style={{ fontSize: 11, color: '#EF4444' }}>zones load error: {zonesError}</div>
|
<div style={{ fontSize: 11, color: 'var(--wing-danger)' }}>zones load error: {zonesError}</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{zones ? `loaded (${zones.features.length} features)` : 'loading...'}</div>
|
<div style={{ fontSize: 11, color: 'var(--muted)' }}>{zones ? `loaded (${zones.features.length} features)` : 'loading...'}</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -24,16 +24,16 @@ function StatChips({ total, fishing, transit, pairLinks, alarms }: Pick<Props, "
|
|||||||
<b className="text-xs text-wing-text">{total}</b>척
|
<b className="text-xs text-wing-text">{total}</b>척
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
조업 <b style={{ color: "#22C55E" }}>{fishing}</b>
|
조업 <b className="text-wing-success">{fishing}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
항해 <b style={{ color: "#3B82F6" }}>{transit}</b>
|
항해 <b className="text-wing-accent">{transit}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
쌍연결 <b style={{ color: "#F59E0B" }}>{pairLinks}</b>
|
쌍연결 <b className="text-wing-warning">{pairLinks}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
<div className="flex items-center gap-1 text-[10px] text-wing-muted">
|
||||||
경고 <b style={{ color: "#EF4444" }}>{alarms}</b>
|
경고 <b className="text-wing-danger">{alarms}</b>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@ -71,9 +71,8 @@ export function Topbar({ total, fishing, transit, pairLinks, alarms, clock, admi
|
|||||||
|
|
||||||
{/* 로고 */}
|
{/* 로고 */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 whitespace-nowrap text-sm font-extrabold"
|
className={`flex items-center gap-1.5 whitespace-nowrap text-sm font-extrabold${onLogoClick ? " cursor-pointer" : ""}`}
|
||||||
onClick={onLogoClick}
|
onClick={onLogoClick}
|
||||||
style={{ cursor: onLogoClick ? "pointer" : undefined }}
|
|
||||||
title={adminMode ? "ADMIN" : undefined}
|
title={adminMode ? "ADMIN" : undefined}
|
||||||
>
|
>
|
||||||
<span className="text-wing-accent">WING</span>
|
<span className="text-wing-accent">WING</span>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { TextInput } from "@wing/ui";
|
||||||
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
||||||
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
||||||
import type { MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
@ -12,6 +14,26 @@ type Props = {
|
|||||||
onClearHover?: () => void;
|
onClearHover?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isFiniteNumber(x: unknown): x is number {
|
||||||
|
return typeof x === "number" && Number.isFinite(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STRIP_RE = /[\s\-.,_]/g;
|
||||||
|
|
||||||
|
function normalize(s: string): string {
|
||||||
|
return s.replace(STRIP_RE, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesQuery(v: DerivedLegacyVessel, nq: string): boolean {
|
||||||
|
if (normalize(v.permitNo).includes(nq)) return true;
|
||||||
|
if (normalize(v.name).includes(nq)) return true;
|
||||||
|
if (v.legacy.shipNameRoman && normalize(v.legacy.shipNameRoman).includes(nq)) return true;
|
||||||
|
if (v.legacy.shipNameCn && normalize(v.legacy.shipNameCn).includes(nq)) return true;
|
||||||
|
if (v.legacy.callSign && normalize(v.legacy.callSign).includes(nq)) return true;
|
||||||
|
if (normalize(String(v.mmsi)).includes(nq)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function VesselList({
|
export function VesselList({
|
||||||
vessels,
|
vessels,
|
||||||
selectedMmsi,
|
selectedMmsi,
|
||||||
@ -21,6 +43,8 @@ export function VesselList({
|
|||||||
onHoverMmsi,
|
onHoverMmsi,
|
||||||
onClearHover,
|
onClearHover,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
const handlePrimaryAction = (e: MouseEvent, mmsi: number) => {
|
const handlePrimaryAction = (e: MouseEvent, mmsi: number) => {
|
||||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||||
onToggleHighlightMmsi(mmsi);
|
onToggleHighlightMmsi(mmsi);
|
||||||
@ -29,17 +53,23 @@ export function VesselList({
|
|||||||
onSelectMmsi(mmsi);
|
onSelectMmsi(mmsi);
|
||||||
};
|
};
|
||||||
|
|
||||||
function isFiniteNumber(x: unknown): x is number {
|
const sorted = useMemo(() => {
|
||||||
return typeof x === "number" && Number.isFinite(x);
|
const nq = searchQuery.length >= 2 ? normalize(searchQuery) : "";
|
||||||
}
|
const filtered = nq ? vessels.filter((v) => matchesQuery(v, nq)) : vessels;
|
||||||
|
return filtered.slice().sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1));
|
||||||
const sorted = vessels
|
}, [vessels, searchQuery]);
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1))
|
|
||||||
.slice(0, 80);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vlist">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="mb-1.5 shrink-0 px-1">
|
||||||
|
<TextInput
|
||||||
|
placeholder="검색: 등록번호 / 선박명 / MMSI"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="h-5 w-full py-0 text-[9px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="vlist">
|
||||||
{sorted.map((v) => {
|
{sorted.map((v) => {
|
||||||
const meta = VESSEL_TYPES[v.shipCode];
|
const meta = VESSEL_TYPES[v.shipCode];
|
||||||
const primarySegs = meta.speedProfile.filter((s) => s.primary);
|
const primarySegs = meta.speedProfile.filter((s) => s.primary);
|
||||||
@ -76,6 +106,7 @@ export function VesselList({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{sorted.length === 0 ? <div style={{ fontSize: 11, color: "var(--muted)" }}>(표시할 대상 선박 없음)</div> : null}
|
{sorted.length === 0 ? <div style={{ fontSize: 11, color: "var(--muted)" }}>(표시할 대상 선박 없음)</div> : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,9 +13,9 @@ export function ListItem({ selected, highlighted, className, children, ...props
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-1 text-[10px] transition-colors duration-100 select-none',
|
'flex cursor-pointer items-center gap-1.5 rounded px-1.5 py-1 text-[10px] transition-colors duration-100 select-none',
|
||||||
'hover:bg-wing-card',
|
'hover:bg-wing-card',
|
||||||
selected && 'bg-[rgba(14,234,255,0.16)] border-[rgba(14,234,255,0.55)]',
|
selected && 'bg-[var(--wing-select-bg)] border-[var(--wing-select-border)]',
|
||||||
highlighted && !selected && 'bg-[rgba(245,158,11,0.16)] border border-[rgba(245,158,11,0.4)]',
|
highlighted && !selected && 'bg-[var(--wing-highlight-bg)] border border-[var(--wing-highlight-border)]',
|
||||||
selected && highlighted && 'bg-gradient-to-r from-[rgba(14,234,255,0.16)] to-[rgba(245,158,11,0.16)] border-[rgba(14,234,255,0.7)]',
|
selected && highlighted && 'bg-gradient-to-r from-[var(--wing-select-bg)] to-[var(--wing-highlight-bg)] border-[var(--wing-select-border)]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import type { HTMLAttributes, ReactNode } from 'react';
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
import { cn } from '../utils/cn.ts';
|
import { cn } from '../utils/cn.ts';
|
||||||
|
|
||||||
@ -5,25 +6,33 @@ interface SectionProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
|
|||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
|
contentClassName?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Section({ title, actions, defaultOpen = true, className, children, ...props }: SectionProps) {
|
export function Section({ title, actions, defaultOpen = true, className, contentClassName, children, ...props }: SectionProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details open={defaultOpen || undefined} className={cn('border-b border-wing-border', className)} {...props}>
|
<div className={cn('border-b border-wing-border', className)} {...props}>
|
||||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-2 px-3 py-2.5 select-none [&::-webkit-details-marker]:hidden">
|
<div
|
||||||
|
className="shrink-0 flex cursor-pointer items-center justify-between gap-2 px-3 py-2.5 select-none"
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
>
|
||||||
<span className="text-[9px] font-bold uppercase tracking-[1.5px] text-wing-muted">
|
<span className="text-[9px] font-bold uppercase tracking-[1.5px] text-wing-muted">
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{actions && (
|
{actions && (
|
||||||
<span onClick={(e) => e.preventDefault()} className="flex items-center gap-1.5">
|
<span onClick={(e) => e.stopPropagation()} className="flex items-center gap-1.5">
|
||||||
{actions}
|
{actions}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</summary>
|
|
||||||
<div className="px-3 pb-2.5">
|
|
||||||
{children}
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
{isOpen && (
|
||||||
|
<div className={cn('px-3 pb-2.5', contentClassName)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,10 @@
|
|||||||
--wing-overlay: rgba(2, 6, 23, 0.42);
|
--wing-overlay: rgba(2, 6, 23, 0.42);
|
||||||
--wing-card-alpha: rgba(30, 41, 59, 0.55);
|
--wing-card-alpha: rgba(30, 41, 59, 0.55);
|
||||||
--wing-subtle: rgba(255, 255, 255, 0.03);
|
--wing-subtle: rgba(255, 255, 255, 0.03);
|
||||||
|
--wing-select-bg: rgba(14, 234, 255, 0.16);
|
||||||
|
--wing-select-border: rgba(14, 234, 255, 0.55);
|
||||||
|
--wing-highlight-bg: rgba(245, 158, 11, 0.16);
|
||||||
|
--wing-highlight-border: rgba(245, 158, 11, 0.4);
|
||||||
|
|
||||||
/* Legacy aliases (backward compatibility) */
|
/* Legacy aliases (backward compatibility) */
|
||||||
--bg: var(--wing-bg);
|
--bg: var(--wing-bg);
|
||||||
@ -48,6 +52,10 @@
|
|||||||
--wing-overlay: rgba(0, 0, 0, 0.25);
|
--wing-overlay: rgba(0, 0, 0, 0.25);
|
||||||
--wing-card-alpha: rgba(226, 232, 240, 0.6);
|
--wing-card-alpha: rgba(226, 232, 240, 0.6);
|
||||||
--wing-subtle: rgba(0, 0, 0, 0.03);
|
--wing-subtle: rgba(0, 0, 0, 0.03);
|
||||||
|
--wing-select-bg: rgba(14, 182, 210, 0.14);
|
||||||
|
--wing-select-border: rgba(14, 182, 210, 0.5);
|
||||||
|
--wing-highlight-bg: rgba(217, 119, 6, 0.14);
|
||||||
|
--wing-highlight-border: rgba(217, 119, 6, 0.4);
|
||||||
|
|
||||||
--bg: var(--wing-bg);
|
--bg: var(--wing-bg);
|
||||||
--panel: var(--wing-surface);
|
--panel: var(--wing-surface);
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user