gc-wing/apps/web/src/widgets/aisTargetList/AisTargetList.tsx
htlee cc807bb5f6 feat: KST 타임스탬프 포맷 유틸 적용
shared/lib/datetime.ts에 KST 고정 포맷 함수 추가.
AIS 정보, 선박 목록, 대시보드 등의 날짜 표시를
로컬 포맷에서 KST 명시적 포맷으로 통일.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:12:43 +09:00

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>
);
}