Adds backend queries + 4 API endpoints + 5 frontend components to expose all
Wave A+B data on the per-object drill-in page (/analytics/objects/[id]).
Backend (analytics_queries.py + analytics.py):
- object_full_detail(): extended SELECT with all 30 new cols from 113_22begh
(building specs, yard, OVZ, catalog UI, metro, scores, dev_group_name)
- object_flats_quartirography(): GROUP BY rooms from domrf_kn_flats
- object_obj_checks(): SELECT from domrf_obj_checks (22f)
- object_documents(): SELECT from domrf_kn_documents (22i)
- 4 new endpoints: /object/{id}/full, /quartirography, /checks, /documents
Frontend:
- ObjectSpecsTable.tsx — 30+ spec rows, conditional (nulls hidden)
- ObjectQuartirographyTable.tsx — rooms x count/free/area/price
- ObjectChecksBadges.tsx — 6 badge pills (passed/failed)
- ObjectDocumentsList.tsx — PDF links grouped by doc_type with safeUrl()
- ObjectMetroList.tsx — top3 metro entries with line color dot
- page.tsx: 9 blocks incl. DOM.RF scores KPI row, checks, documents, specs
Closes #297 (Phase 6 — 22k frontend exposure)
106 lines
3.2 KiB
TypeScript
106 lines
3.2 KiB
TypeScript
import type { ObjectDocumentRow } from "@/types/analytics";
|
||
|
||
function safeUrl(url: string): string {
|
||
try {
|
||
const parsed = new URL(url);
|
||
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
|
||
return url;
|
||
}
|
||
} catch {
|
||
// fall through
|
||
}
|
||
return "#";
|
||
}
|
||
|
||
function fmtSize(bytes: number | null): string {
|
||
if (bytes === null) return "";
|
||
if (bytes < 1024) return `${bytes} Б`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
|
||
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
|
||
}
|
||
|
||
const DOC_TYPE_ORDER: Record<string, number> = {
|
||
декларация: 1,
|
||
разрешение: 2,
|
||
проектная: 3,
|
||
отчётность: 4,
|
||
прочее: 5,
|
||
};
|
||
|
||
export function ObjectDocumentsList({ docs }: { docs: ObjectDocumentRow[] }) {
|
||
if (docs.length === 0) {
|
||
return (
|
||
<p style={{ color: "#9ca3af", fontSize: 13 }}>Документы отсутствуют.</p>
|
||
);
|
||
}
|
||
|
||
// Group by doc_type
|
||
const byType = new Map<string, ObjectDocumentRow[]>();
|
||
for (const d of docs) {
|
||
const group = byType.get(d.doc_type) ?? [];
|
||
group.push(d);
|
||
byType.set(d.doc_type, group);
|
||
}
|
||
|
||
const sortedTypes = [...byType.keys()].sort((a, b) => {
|
||
const oa = DOC_TYPE_ORDER[a.toLowerCase()] ?? 99;
|
||
const ob = DOC_TYPE_ORDER[b.toLowerCase()] ?? 99;
|
||
return oa - ob || a.localeCompare(b, "ru");
|
||
});
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||
{sortedTypes.map((type) => {
|
||
const group = byType.get(type)!;
|
||
return (
|
||
<div key={type}>
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
textTransform: "uppercase",
|
||
letterSpacing: 0.5,
|
||
color: "#5b6066",
|
||
marginBottom: 6,
|
||
}}
|
||
>
|
||
{type}
|
||
</div>
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||
{group.map((doc, i) => (
|
||
<div
|
||
key={`${doc.doc_type}-${doc.doc_num ?? i}`}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "baseline",
|
||
gap: 8,
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
<span style={{ fontSize: 16, lineHeight: 1 }}>📄</span>
|
||
<a
|
||
href={safeUrl(doc.file_url)}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ color: "#1e40af", textDecoration: "none" }}
|
||
>
|
||
{doc.doc_num ? `${doc.doc_num}` : `Документ ${i + 1}`}
|
||
</a>
|
||
{doc.posted_at ? (
|
||
<span style={{ color: "#9ca3af", fontSize: 12 }}>
|
||
{doc.posted_at.slice(0, 10)}
|
||
</span>
|
||
) : null}
|
||
{doc.size_bytes ? (
|
||
<span style={{ color: "#9ca3af", fontSize: 12 }}>
|
||
{fmtSize(doc.size_bytes)}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|