From 11b242d4b34807c586da47502fa5954afc4e0b8c Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 24 May 2026 17:15:44 +0300 Subject: [PATCH] feat(tradein-ui): placement history card New /estimate/{id}/placement-history endpoint surfaces historical lots from house_placement_history for the target house (linked after PR #527 bootstrap). Card hidden when no data. - PlacementHistoryItem type added to trade-in.ts - useEstimatePlacementHistory hook added to trade-in-api.ts - PlacementHistoryCard component uses TanStack Query (no raw fetch) - Wired into page.tsx after HouseInfoCard --- tradein-mvp/frontend/src/app/page.tsx | 4 + .../trade-in/PlacementHistoryCard.tsx | 101 ++++++++++++++++++ tradein-mvp/frontend/src/lib/trade-in-api.ts | 17 +++ tradein-mvp/frontend/src/types/trade-in.ts | 19 ++++ 4 files changed, 141 insertions(+) create mode 100644 tradein-mvp/frontend/src/components/trade-in/PlacementHistoryCard.tsx diff --git a/tradein-mvp/frontend/src/app/page.tsx b/tradein-mvp/frontend/src/app/page.tsx index 99afcf14..500f55b8 100644 --- a/tradein-mvp/frontend/src/app/page.tsx +++ b/tradein-mvp/frontend/src/app/page.tsx @@ -17,6 +17,7 @@ import { SourcesProgress } from "@/components/trade-in/SourcesProgress"; import { HeroSummary } from "@/components/trade-in/HeroSummary"; import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark"; import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard"; +import { PlacementHistoryCard } from "@/components/trade-in/PlacementHistoryCard"; import { PhotoUpload } from "@/components/trade-in/PhotoUpload"; import { ListingsCard } from "@/components/trade-in/ListingsCard"; import { DealsCard } from "@/components/trade-in/DealsCard"; @@ -169,6 +170,9 @@ export default function TradeInPage() { houses={houses.data} isLoading={houses.isPending} /> + {currentEstimateId && ( + + )} diff --git a/tradein-mvp/frontend/src/components/trade-in/PlacementHistoryCard.tsx b/tradein-mvp/frontend/src/components/trade-in/PlacementHistoryCard.tsx new file mode 100644 index 00000000..801bff7e --- /dev/null +++ b/tradein-mvp/frontend/src/components/trade-in/PlacementHistoryCard.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useEstimatePlacementHistory } from "@/lib/trade-in-api"; +import type { PlacementHistoryItem } from "@/types/trade-in"; + +type Props = { estimateId: string }; + +const fmtPrice = (n: number | null): string => + n == null ? "—" : `${(n / 1_000_000).toFixed(2)} М ₽`; + +const fmtDate = (d: string | null): string => { + if (!d) return "—"; + try { + return new Date(d).toLocaleDateString("ru-RU"); + } catch { + return d; + } +}; + +function lotLabel(it: PlacementHistoryItem): string { + if (it.title) return it.title; + const rooms = it.rooms != null ? `${it.rooms}к` : "?к"; + const area = it.area_m2 != null ? `${it.area_m2} м²` : "? м²"; + const floor = + it.floor != null && it.total_floors != null + ? `, эт. ${it.floor}/${it.total_floors}` + : ""; + return `${rooms}, ${area}${floor}`; +} + +export function PlacementHistoryCard({ estimateId }: Props) { + const { data: items, isPending, isError } = useEstimatePlacementHistory(estimateId); + + if (isPending || isError) return null; + if (!items || items.length === 0) return null; + + const visible = items.slice(0, 20); + + return ( +
+
+

История продаж в этом доме

+ + {items.length} лот{items.length === 1 ? "" : "ов"} + +
+
+ + + + + + + + + + + {visible.map((it) => ( + + + + + + + ))} + +
ЛотЦена нач. → посл.ДатаЭкспозиция
{lotLabel(it)} + {fmtPrice(it.start_price)} →{" "} + {fmtPrice(it.last_price)} + + {fmtDate(it.last_price_date ?? it.start_price_date)} + + {it.exposure_days ?? "—"} дн. +
+ {items.length > 20 && ( + + показано 20 из {items.length} + + )} +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/lib/trade-in-api.ts b/tradein-mvp/frontend/src/lib/trade-in-api.ts index 58d7e087..5df23522 100644 --- a/tradein-mvp/frontend/src/lib/trade-in-api.ts +++ b/tradein-mvp/frontend/src/lib/trade-in-api.ts @@ -7,6 +7,7 @@ import type { AggregatedEstimate, HouseInfoForEstimate, IMVBenchmarkResponse, + PlacementHistoryItem, TradeInEstimateInput, } from "@/types/trade-in"; @@ -54,6 +55,22 @@ export function useEstimateHouses(estimate_id: string | null) { }); } +/** + * GET /api/v1/trade-in/estimate/{id}/placement-history + * Historical listings from house_placement_history for the target house. + */ +export function useEstimatePlacementHistory(estimate_id: string | null) { + return useQuery({ + queryKey: ["trade-in", "estimate", estimate_id, "placement-history"], + queryFn: () => + apiFetch( + `${BASE}/estimate/${estimate_id}/placement-history`, + ), + enabled: estimate_id !== null && estimate_id.length > 0, + staleTime: 10 * 60_000, + }); +} + /** * GET /api/v1/trade-in/estimate/{id}/imv-benchmark * Avito IMV benchmark для UI badge (Stage 3 IMV cache lookup). diff --git a/tradein-mvp/frontend/src/types/trade-in.ts b/tradein-mvp/frontend/src/types/trade-in.ts index 60cbfd89..2f1ea8be 100644 --- a/tradein-mvp/frontend/src/types/trade-in.ts +++ b/tradein-mvp/frontend/src/types/trade-in.ts @@ -101,6 +101,25 @@ export interface HouseInfoForEstimate { raw_characteristics: Array>; } +export interface PlacementHistoryItem { + id: number; + source: string; + house_id: number; + ext_item_id: string; + title: string | null; + rooms: number | null; + area_m2: number | null; + floor: number | null; + total_floors: number | null; + start_price: number | null; + start_price_date: string | null; + last_price: number | null; + last_price_date: string | null; + removed_date: string | null; + exposure_days: number | null; + notes: string | null; +} + export interface IMVBenchmarkResponse { available: boolean; cache_key: string | null;