feat(tradein-ui): placement history card #529
4 changed files with 141 additions and 0 deletions
|
|
@ -17,6 +17,7 @@ import { SourcesProgress } from "@/components/trade-in/SourcesProgress";
|
||||||
import { HeroSummary } from "@/components/trade-in/HeroSummary";
|
import { HeroSummary } from "@/components/trade-in/HeroSummary";
|
||||||
import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark";
|
import { IMVBenchmark } from "@/components/trade-in/IMVBenchmark";
|
||||||
import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard";
|
import { HouseInfoCard } from "@/components/trade-in/HouseInfoCard";
|
||||||
|
import { PlacementHistoryCard } from "@/components/trade-in/PlacementHistoryCard";
|
||||||
import { PhotoUpload } from "@/components/trade-in/PhotoUpload";
|
import { PhotoUpload } from "@/components/trade-in/PhotoUpload";
|
||||||
import { ListingsCard } from "@/components/trade-in/ListingsCard";
|
import { ListingsCard } from "@/components/trade-in/ListingsCard";
|
||||||
import { DealsCard } from "@/components/trade-in/DealsCard";
|
import { DealsCard } from "@/components/trade-in/DealsCard";
|
||||||
|
|
@ -169,6 +170,9 @@ export default function TradeInPage() {
|
||||||
houses={houses.data}
|
houses={houses.data}
|
||||||
isLoading={houses.isPending}
|
isLoading={houses.isPending}
|
||||||
/>
|
/>
|
||||||
|
{currentEstimateId && (
|
||||||
|
<PlacementHistoryCard estimateId={currentEstimateId} />
|
||||||
|
)}
|
||||||
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
|
<PhotoUpload estimateId={resultData.estimate.estimate_id} />
|
||||||
<ListingsCard estimate={resultData.estimate} />
|
<ListingsCard estimate={resultData.estimate} />
|
||||||
<DealsCard estimate={resultData.estimate} />
|
<DealsCard estimate={resultData.estimate} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import type {
|
||||||
AggregatedEstimate,
|
AggregatedEstimate,
|
||||||
HouseInfoForEstimate,
|
HouseInfoForEstimate,
|
||||||
IMVBenchmarkResponse,
|
IMVBenchmarkResponse,
|
||||||
|
PlacementHistoryItem,
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
} from "@/types/trade-in";
|
} 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
|
* GET /api/v1/trade-in/estimate/{id}/imv-benchmark
|
||||||
* Avito IMV benchmark для UI badge (Stage 3 IMV cache lookup).
|
* Avito IMV benchmark для UI badge (Stage 3 IMV cache lookup).
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,25 @@ export interface HouseInfoForEstimate {
|
||||||
raw_characteristics: Array<Record<string, unknown>>;
|
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 {
|
export interface IMVBenchmarkResponse {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
cache_key: string | null;
|
cache_key: string | null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue