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
This commit is contained in:
lekss361 2026-05-24 17:15:44 +03:00
parent 7ef4e91e50
commit 11b242d4b3
4 changed files with 141 additions and 0 deletions

View file

@ -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 && (
<PlacementHistoryCard estimateId={currentEstimateId} />
)}
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
<ListingsCard estimate={resultData.estimate} />
<DealsCard estimate={resultData.estimate} />

View file

@ -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 (
<article className="card" style={{ marginTop: 16 }}>
<header className="card-head">
<h3 style={{ margin: 0, fontSize: 15 }}>История продаж в этом доме</h3>
<small style={{ color: "var(--muted, #6b7280)" }}>
{items.length} лот{items.length === 1 ? "" : "ов"}
</small>
</header>
<div style={{ padding: "8px 16px 16px" }}>
<table
style={{ width: "100%", fontSize: 13, borderCollapse: "collapse" }}
>
<thead>
<tr
style={{
borderBottom: "1px solid var(--border, #e5e7eb)",
textAlign: "left",
}}
>
<th style={{ padding: "6px 8px" }}>Лот</th>
<th style={{ padding: "6px 8px" }}>Цена нач. посл.</th>
<th style={{ padding: "6px 8px" }}>Дата</th>
<th style={{ padding: "6px 8px" }}>Экспозиция</th>
</tr>
</thead>
<tbody>
{visible.map((it) => (
<tr
key={it.id}
style={{
borderBottom: "1px solid var(--border-soft, #f3f4f6)",
}}
>
<td style={{ padding: "6px 8px" }}>{lotLabel(it)}</td>
<td style={{ padding: "6px 8px", whiteSpace: "nowrap" }}>
{fmtPrice(it.start_price)} {" "}
<strong>{fmtPrice(it.last_price)}</strong>
</td>
<td style={{ padding: "6px 8px", whiteSpace: "nowrap" }}>
{fmtDate(it.last_price_date ?? it.start_price_date)}
</td>
<td style={{ padding: "6px 8px" }}>
{it.exposure_days ?? "—"} дн.
</td>
</tr>
))}
</tbody>
</table>
{items.length > 20 && (
<small
style={{
color: "var(--muted, #6b7280)",
display: "block",
marginTop: 8,
}}
>
показано 20 из {items.length}
</small>
)}
</div>
</article>
);
}

View file

@ -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<PlacementHistoryItem[]>({
queryKey: ["trade-in", "estimate", estimate_id, "placement-history"],
queryFn: () =>
apiFetch<PlacementHistoryItem[]>(
`${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).

View file

@ -101,6 +101,25 @@ export interface HouseInfoForEstimate {
raw_characteristics: Array<Record<string, unknown>>;
}
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;