fix(sidebar): 검색 복원, 경고 필터, 내부 스크롤 수정

- VesselList: 선박 검색 인풋 복원 (등록번호/선명/MMSI like 검색)
- 실시간 경고: 드롭다운 → '필터' ToggleButton 토글 방식으로 변경
- Section: <details> → <div> + useState 전환 (flex 레이아웃 호환)
- Section: contentClassName prop 추가 (내부 스크롤 제어)
- DashboardSidebar: 데스크탑 flex column + 섹션별 내부 스크롤 적용
- DashboardPage: 불필요한 alarmFilterSummary props 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-17 08:11:34 +09:00
부모 538a260bc2
커밋 c336168d57
4개의 변경된 파일92개의 추가작업 그리고 77개의 파일을 삭제

파일 보기

@ -228,8 +228,6 @@ export function DashboardPage() {
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
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[]) => {
if (!mmsis.length) return;
const members = mmsis
@ -279,8 +277,6 @@ export function DashboardPage() {
filteredAlarms={filteredAlarms}
alarms={alarms}
alarmKindCounts={alarmKindCounts}
allAlarmKindsEnabled={allAlarmKindsEnabled}
alarmFilterSummary={alarmFilterSummary}
speedPanelType={speedPanelType}
onFleetContextMenu={handleFleetContextMenu}
snapshot={snapshot}

파일 보기

@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { ToggleButton, Section } from '@wing/ui';
import type { AisTarget } from '../../entities/aisTarget/model/types';
import type { LegacyVesselIndex } from '../../entities/legacyVessel/lib';
@ -41,8 +41,6 @@ interface DashboardSidebarProps {
filteredAlarms: LegacyAlarm[];
alarms: LegacyAlarm[];
alarmKindCounts: Record<LegacyAlarmKind, number>;
allAlarmKindsEnabled: boolean;
alarmFilterSummary: string;
speedPanelType: VesselTypeCode;
onFleetContextMenu: (ownerKey: string, mmsis: number[]) => void;
snapshot: AisPollingSnapshot;
@ -67,8 +65,6 @@ export function DashboardSidebar({
filteredAlarms,
alarms,
alarmKindCounts,
allAlarmKindsEnabled,
alarmFilterSummary,
speedPanelType,
onFleetContextMenu,
snapshot,
@ -97,6 +93,8 @@ export function DashboardSidebar({
setUniqueSorted, setSortedIfChanged, toggleHighlightedMmsi,
} = state;
const [isAlarmFilterOpen, setIsAlarmFilterOpen] = useState(false);
useEffect(() => {
if (!isOpen) return;
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
border-r border-wing-border bg-wing-surface transition-transform duration-200
${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" />
<Section title="업종 필터">
<Section title="업종 필터" className="md:shrink-0">
<div className="flex flex-wrap gap-0.75 mb-1.5">
<ToggleButton
on={showTargets}
@ -158,6 +156,7 @@ export function DashboardSidebar({
<Section
title="지도 표시 설정"
className="md:shrink-0"
actions={
<ToggleButton
on={projection === 'globe'}
@ -172,7 +171,7 @@ export function DashboardSidebar({
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
</Section>
<Section title="속도 프로파일" defaultOpen={false}>
<Section title="속도 프로파일" defaultOpen={false} className="md:shrink-0">
<SpeedProfilePanel selectedType={speedPanelType} />
</Section>
@ -197,7 +196,8 @@ export function DashboardSidebar({
</label>
</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
selectedVessel={selectedLegacyVessel}
@ -231,7 +231,8 @@ export function DashboardSidebar({
<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
vessels={legacyVesselsFiltered}
@ -252,54 +253,31 @@ export function DashboardSidebar({
</>
}
actions={
LEGACY_ALARM_KINDS.length <= 3 ? (
<div className="flex gap-1.5 items-center">
{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>
</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>
)
<ToggleButton on={isAlarmFilterOpen} onClick={() => setIsAlarmFilterOpen((v) => !v)}>
</ToggleButton>
}
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} />
</Section>
{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={{ color: 'var(--muted)', fontSize: 10 }}></div>
<div style={{ wordBreak: 'break-all' }}>{AIS_API_BASE}/api/ais-target/search</div>
@ -322,7 +300,7 @@ export function DashboardSidebar({
</div>
</Section>
<Section title="ADMIN · Legacy (CN Permit)" defaultOpen={false}>
<Section title="ADMIN · Legacy (CN Permit)" defaultOpen={false} className="md:shrink-0">
{legacyError ? (
<div style={{ fontSize: 11, color: 'var(--wing-danger)' }}>legacy load error: {legacyError}</div>
) : (
@ -340,7 +318,7 @@ export function DashboardSidebar({
)}
</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={{ color: 'var(--muted)', fontSize: 10 }}> View BBox</div>
<div style={{ wordBreak: 'break-all', fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
@ -399,7 +377,7 @@ export function DashboardSidebar({
</div>
</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] }))} />
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 6 }}> WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
</Section>
@ -407,12 +385,13 @@ export function DashboardSidebar({
<Section
title="ADMIN · AIS Targets (All)"
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} />
</Section>
<Section title="ADMIN · 수역 데이터" defaultOpen={false} className="max-h-[130px] overflow-y-auto">
<Section title="ADMIN · 수역 데이터" defaultOpen={false} className="md:shrink-0">
{zonesError ? (
<div style={{ fontSize: 11, color: 'var(--wing-danger)' }}>zones load error: {zonesError}</div>
) : (

파일 보기

@ -1,3 +1,5 @@
import { useMemo, useState } from "react";
import { TextInput } from "@wing/ui";
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
import type { MouseEvent } from "react";
@ -12,6 +14,26 @@ type Props = {
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({
vessels,
selectedMmsi,
@ -21,6 +43,8 @@ export function VesselList({
onHoverMmsi,
onClearHover,
}: Props) {
const [searchQuery, setSearchQuery] = useState("");
const handlePrimaryAction = (e: MouseEvent, mmsi: number) => {
if (e.shiftKey || e.ctrlKey || e.metaKey) {
onToggleHighlightMmsi(mmsi);
@ -29,17 +53,23 @@ export function VesselList({
onSelectMmsi(mmsi);
};
function isFiniteNumber(x: unknown): x is number {
return typeof x === "number" && Number.isFinite(x);
}
const sorted = vessels
.slice()
.sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1))
.slice(0, 80);
const sorted = useMemo(() => {
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));
}, [vessels, searchQuery]);
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) => {
const meta = VESSEL_TYPES[v.shipCode];
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}
</div>
</div>
);
}

파일 보기

@ -1,3 +1,4 @@
import { useState } from 'react';
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from '../utils/cn.ts';
@ -5,25 +6,33 @@ interface SectionProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
title: ReactNode;
actions?: ReactNode;
defaultOpen?: boolean;
contentClassName?: string;
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 (
<details open={defaultOpen || undefined} 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={cn('border-b border-wing-border', className)} {...props}>
<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">
{title}
</span>
{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}
</span>
)}
</summary>
<div className="px-3 pb-2.5">
{children}
</div>
</details>
{isOpen && (
<div className={cn('px-3 pb-2.5', contentClassName)}>
{children}
</div>
)}
</div>
);
}