shared/lib/datetime.ts에 KST 고정 포맷 함수 추가. AIS 정보, 선박 목록, 대시보드 등의 날짜 표시를 로컬 포맷에서 KST 명시적 포맷으로 통일. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
128 lines
4.5 KiB
TypeScript
128 lines
4.5 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import type { AisTarget } from "../../entities/aisTarget/model/types";
|
|
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
|
|
import { matchLegacyVessel } from "../../entities/legacyVessel/lib";
|
|
import { fmtIsoTime } from "../../shared/lib/datetime";
|
|
|
|
type SortMode = "recent" | "speed";
|
|
|
|
type Props = {
|
|
targets: AisTarget[];
|
|
selectedMmsi: number | null;
|
|
onSelectMmsi: (mmsi: number) => void;
|
|
legacyIndex?: LegacyVesselIndex | null;
|
|
};
|
|
|
|
function isFiniteNumber(x: unknown): x is number {
|
|
return typeof x === "number" && Number.isFinite(x);
|
|
}
|
|
|
|
function getSpeedColor(sog: unknown) {
|
|
if (!isFiniteNumber(sog)) return "#64748B";
|
|
if (sog >= 10) return "#3B82F6";
|
|
if (sog >= 1) return "#22C55E";
|
|
return "#64748B";
|
|
}
|
|
|
|
export function AisTargetList({ targets, selectedMmsi, onSelectMmsi, legacyIndex }: Props) {
|
|
const [q, setQ] = useState("");
|
|
const [mode, setMode] = useState<SortMode>("recent");
|
|
|
|
const rows = useMemo(() => {
|
|
const query = q.trim();
|
|
let out = targets;
|
|
|
|
if (query) {
|
|
const qLower = query.toLowerCase();
|
|
const isDigits = /^[0-9]+$/.test(query);
|
|
|
|
out = targets.filter((t) => {
|
|
const name = (t.name || "").trim();
|
|
const callsign = (t.callsign || "").trim();
|
|
const mmsi = t.mmsi;
|
|
|
|
if (isDigits) return String(mmsi).includes(query);
|
|
return name.toLowerCase().includes(qLower) || callsign.toLowerCase().includes(qLower) || String(mmsi).includes(query);
|
|
});
|
|
}
|
|
|
|
const sorted = out.slice().sort((a, b) => {
|
|
if (mode === "speed") {
|
|
const as = isFiniteNumber(a.sog) ? a.sog : -1;
|
|
const bs = isFiniteNumber(b.sog) ? b.sog : -1;
|
|
if (bs !== as) return bs - as;
|
|
}
|
|
|
|
const at = Date.parse(a.messageTimestamp || "");
|
|
const bt = Date.parse(b.messageTimestamp || "");
|
|
const ats = Number.isFinite(at) ? at : 0;
|
|
const bts = Number.isFinite(bt) ? bt : 0;
|
|
if (bts !== ats) return bts - ats;
|
|
|
|
return (a.mmsi ?? 0) - (b.mmsi ?? 0);
|
|
});
|
|
|
|
return sorted.slice(0, 200);
|
|
}, [targets, q, mode]);
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
|
|
<div style={{ display: "flex", gap: 6, marginBottom: 6 }}>
|
|
<input
|
|
value={q}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
placeholder="검색: MMSI / 선박명 / CallSign"
|
|
className="ais-q"
|
|
/>
|
|
<div className="ais-mode">
|
|
<button className={`ais-mode-btn ${mode === "recent" ? "on" : ""}`} onClick={() => setMode("recent")}>
|
|
최신
|
|
</button>
|
|
<button className={`ais-mode-btn ${mode === "speed" ? "on" : ""}`} onClick={() => setMode("speed")}>
|
|
속도
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ais-list">
|
|
{rows.map((t) => {
|
|
const name = (t.name || "").trim() || "(no name)";
|
|
const sel = selectedMmsi && t.mmsi === selectedMmsi;
|
|
const sp = isFiniteNumber(t.sog) ? t.sog.toFixed(1) : "?";
|
|
const sc = getSpeedColor(t.sog);
|
|
const ts = fmtIsoTime(t.messageTimestamp);
|
|
const legacy = legacyIndex ? matchLegacyVessel(t, legacyIndex) : null;
|
|
const legacyCode = legacy?.shipCode || "";
|
|
|
|
return (
|
|
<div key={t.mmsi} className={`ais-row ${sel ? "sel" : ""}`} onClick={() => onSelectMmsi(t.mmsi)} title={t.vesselType || ""}>
|
|
<div className="ais-dot" style={{ background: sc }} />
|
|
<div className="ais-nm">
|
|
<div className="ais-nm1">{name}</div>
|
|
<div className="ais-nm2">
|
|
MMSI <b>{t.mmsi}</b>
|
|
{t.callsign ? <span style={{ marginLeft: 6, opacity: 0.8 }}>{t.callsign}</span> : null}
|
|
</div>
|
|
</div>
|
|
<div className="ais-right">
|
|
{legacy ? (
|
|
<div className="ais-badges">
|
|
<span className={`ais-badge ${legacyCode}`}>
|
|
CN {legacyCode}
|
|
</span>
|
|
<span className="ais-badge pn">{legacy.permitNo}</span>
|
|
</div>
|
|
) : null}
|
|
<div className="ais-sp" style={{ color: legacy ? "var(--text)" : sc }}>{sp}kt</div>
|
|
<div className="ais-ts">{ts}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{rows.length === 0 ? <div style={{ fontSize: 11, color: "var(--muted)" }}>(검색 결과 없음)</div> : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|