feat: implement dashboard detail panel with interactive charts and forecast modal components
This commit is contained in:
@@ -3107,6 +3107,33 @@
|
|||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
|
||||||
|
position: relative;
|
||||||
|
inset: auto;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100vh - 32px);
|
||||||
|
z-index: auto;
|
||||||
|
transform: none;
|
||||||
|
transition: none;
|
||||||
|
border: 1px solid rgba(82, 114, 161, 0.18);
|
||||||
|
border-left: 1px solid rgba(82, 114, 161, 0.18);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(180deg, rgba(15, 28, 47, 0.94), rgba(9, 18, 32, 0.94));
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail.visible) {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-city-detail-rail .panel-header) {
|
||||||
|
padding: 20px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.panel-header) {
|
.root :global(.panel-header) {
|
||||||
padding: 20px 20px 16px;
|
padding: 20px 20px 16px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
@@ -10565,6 +10592,11 @@
|
|||||||
grid-template-columns: 240px minmax(0, 1fr);
|
grid-template-columns: 240px minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.scan-detail-panel) {
|
.root :global(.scan-detail-panel) {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
@@ -10578,7 +10610,8 @@
|
|||||||
|
|
||||||
.root :global(.scan-filter-panel),
|
.root :global(.scan-filter-panel),
|
||||||
.root :global(.scan-data-grid),
|
.root :global(.scan-data-grid),
|
||||||
.root :global(.scan-detail-panel) {
|
.root :global(.scan-detail-panel),
|
||||||
|
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,10 +130,15 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DetailPanel() {
|
export function DetailPanel({
|
||||||
|
variant = "overlay",
|
||||||
|
}: {
|
||||||
|
variant?: "overlay" | "rail";
|
||||||
|
} = {}) {
|
||||||
const store = useDashboardStore();
|
const store = useDashboardStore();
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isRail = variant === "rail";
|
||||||
const detail = store.selectedDetail;
|
const detail = store.selectedDetail;
|
||||||
const selectedCityItem = useMemo(
|
const selectedCityItem = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -155,8 +160,9 @@ export function DetailPanel() {
|
|||||||
const [heavyContentReady, setHeavyContentReady] = useState(false);
|
const [heavyContentReady, setHeavyContentReady] = useState(false);
|
||||||
const isOverlayOpen =
|
const isOverlayOpen =
|
||||||
Boolean(store.futureModalDate) || store.historyState.isOpen;
|
Boolean(store.futureModalDate) || store.historyState.isOpen;
|
||||||
const isVisible =
|
const isVisible = isRail
|
||||||
store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
|
? Boolean(store.selectedCity) && !isOverlayOpen
|
||||||
|
: store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
|
||||||
const hasBasicPanelContent = Boolean(
|
const hasBasicPanelContent = Boolean(
|
||||||
detail || selectedSummary || selectedCityItem,
|
detail || selectedSummary || selectedCityItem,
|
||||||
);
|
);
|
||||||
@@ -311,20 +317,26 @@ export function DetailPanel() {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className={clsx("detail-panel", isVisible && "visible")}
|
className={clsx(
|
||||||
|
"detail-panel",
|
||||||
|
isRail && "scan-city-detail-rail",
|
||||||
|
(isVisible || isRail) && "visible",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<button
|
{!isRail ? (
|
||||||
type="button"
|
<button
|
||||||
className="panel-close"
|
type="button"
|
||||||
aria-label={t("detail.closeAria")}
|
className="panel-close"
|
||||||
onClick={() => {
|
aria-label={t("detail.closeAria")}
|
||||||
blurActiveElement();
|
onClick={() => {
|
||||||
store.closePanel();
|
blurActiveElement();
|
||||||
}}
|
store.closePanel();
|
||||||
>
|
}}
|
||||||
×
|
>
|
||||||
</button>
|
×
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<div className="panel-title-area">
|
<div className="panel-title-area">
|
||||||
<div className="panel-title-stack">
|
<div className="panel-title-stack">
|
||||||
<div className="panel-overline">
|
<div className="panel-overline">
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import type {
|
|||||||
MarketScan,
|
MarketScan,
|
||||||
} from "@/lib/dashboard-types";
|
} from "@/lib/dashboard-types";
|
||||||
|
|
||||||
|
const TODAY_MARKET_SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
function normalizeMarketValue(value?: number | null) {
|
function normalizeMarketValue(value?: number | null) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
@@ -830,7 +832,7 @@ function FutureForecastModalContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
refreshMarketScan();
|
refreshMarketScan();
|
||||||
}, 30_000);
|
}, TODAY_MARKET_SCAN_AUTO_REFRESH_MS);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ const DEFAULT_FILTERS: FilterState = {
|
|||||||
limit: 28,
|
limit: 28,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
type ContentView = "list" | "map" | "calendar";
|
type ContentView = "list" | "map" | "calendar";
|
||||||
type ThemeMode = "dark" | "light";
|
type ThemeMode = "dark" | "light";
|
||||||
type AssistantMessage = {
|
type AssistantMessage = {
|
||||||
@@ -1223,8 +1225,6 @@ function ScanTerminalScreen() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
||||||
const [detailByRowId, setDetailByRowId] = useState<Record<string, MarketScan | null>>({});
|
|
||||||
const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null);
|
|
||||||
const [activeView, setActiveView] = useState<ContentView>("list");
|
const [activeView, setActiveView] = useState<ContentView>("list");
|
||||||
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
||||||
const [userLocalTime, setUserLocalTime] = useState("--");
|
const [userLocalTime, setUserLocalTime] = useState("--");
|
||||||
@@ -1357,30 +1357,6 @@ function ScanTerminalScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchDetail = async (row: ScanOpportunityRow) => {
|
|
||||||
if (!row.market_slug || !row.selected_date || row.closed) return;
|
|
||||||
if (detailByRowId[row.id] !== undefined) return;
|
|
||||||
setDetailLoadingId(row.id);
|
|
||||||
try {
|
|
||||||
const response = await dashboardClient.getCityMarketScan(row.city, {
|
|
||||||
force: false,
|
|
||||||
marketSlug: row.market_slug,
|
|
||||||
targetDate: row.selected_date,
|
|
||||||
});
|
|
||||||
setDetailByRowId((current) => ({
|
|
||||||
...current,
|
|
||||||
[row.id]: response.market_scan || null,
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
setDetailByRowId((current) => ({
|
|
||||||
...current,
|
|
||||||
[row.id]: null,
|
|
||||||
}));
|
|
||||||
} finally {
|
|
||||||
setDetailLoadingId((current) => (current === row.id ? null : current));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fetchTerminal(DEFAULT_FILTERS, false);
|
void fetchTerminal(DEFAULT_FILTERS, false);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1388,7 +1364,7 @@ function ScanTerminalScreen() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const intervalId = window.setInterval(() => {
|
const intervalId = window.setInterval(() => {
|
||||||
void fetchTerminal(activeFilters, false);
|
void fetchTerminal(activeFilters, false);
|
||||||
}, 30_000);
|
}, SCAN_AUTO_REFRESH_MS);
|
||||||
return () => window.clearInterval(intervalId);
|
return () => window.clearInterval(intervalId);
|
||||||
}, [activeFilters]);
|
}, [activeFilters]);
|
||||||
|
|
||||||
@@ -1417,18 +1393,16 @@ function ScanTerminalScreen() {
|
|||||||
resolvedView === "map" && mapFocusedCity
|
resolvedView === "map" && mapFocusedCity
|
||||||
? mapFocusedRow || mapFallbackRow
|
? mapFocusedRow || mapFallbackRow
|
||||||
: selectedRow;
|
: selectedRow;
|
||||||
const selectedDetail = activeDetailRow ? detailByRowId[activeDetailRow.id] : null;
|
|
||||||
const scanStatus = terminalData?.status || (loading ? "loading" : error ? "failed" : "ready");
|
const scanStatus = terminalData?.status || (loading ? "loading" : error ? "failed" : "ready");
|
||||||
const staleReason =
|
const staleReason =
|
||||||
terminalData?.stale_reason || error || null;
|
terminalData?.stale_reason || error || null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeDetailRow) return;
|
if (!activeDetailRow) return;
|
||||||
void fetchDetail(activeDetailRow);
|
|
||||||
if (!store.cityDetailsByName[activeDetailRow.city]) {
|
if (!store.cityDetailsByName[activeDetailRow.city]) {
|
||||||
void store.ensureCityDetail(activeDetailRow.city, false, "panel").catch(() => {});
|
void store.ensureCityDetail(activeDetailRow.city, false, "panel").catch(() => {});
|
||||||
}
|
}
|
||||||
}, [activeDetailRow, detailByRowId]);
|
}, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]);
|
||||||
|
|
||||||
const handleMapCitySelect = useCallback((cityName: string) => {
|
const handleMapCitySelect = useCallback((cityName: string) => {
|
||||||
setMapSelectedCityName(cityName);
|
setMapSelectedCityName(cityName);
|
||||||
@@ -1618,19 +1592,7 @@ function ScanTerminalScreen() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<ScanOpportunityDetailPanel
|
<CityDetailPanel variant="rail" />
|
||||||
row={activeDetailRow}
|
|
||||||
marketScan={selectedDetail}
|
|
||||||
loading={detailLoadingId === activeDetailRow?.id}
|
|
||||||
/>
|
|
||||||
<CityDetailPanel />
|
|
||||||
<AssistantWidget
|
|
||||||
terminalData={terminalData}
|
|
||||||
rows={timeSortedRows}
|
|
||||||
selectedRow={activeDetailRow}
|
|
||||||
locale={locale}
|
|
||||||
totalCities={store.cities.length}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user