gendesign/frontend/src/components/analytics/ObjectDocumentsList.tsx
lekss361 cac06b2ff9 feat(22k): object page full exposure — 9 blocks (sales/quartirography/metro/photos/docs/specs/checks)
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)
2026-05-17 18:50:41 +03:00

106 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}