Dirbol commited on
Commit
4308ec9
·
1 Parent(s): 2718788

feat(frontend): v3.0 — project-first 3-step wizard

Browse files

- Rewrite landing to health-badge + 4 entry tiles
- /projects list (GET /projects), /projects/new form (POST /projects)
- /projects/{id} 3-step wizard:
- Step A BlueprintsPanel — multi-upload to project (POST /uploads/blueprint)
- Step B DocumentationPanel — GET /dokumentatsiya + POST /extract-all with
Cached badge when aggregate_sha256 matches last_summary, Run button otherwise
- Step C SheetVisionPanel — POST /extract-sheets w/ (upload_id, page_no) pairs
- /documents — template picker + .docx render & download (POST /documents/render)
- /history — GET /inference/history with project_id chip filter
- lib/api.ts: add v2.0 types & 6 new functions
(listProjectUploads, projectDokumentatsiya, extractProjectAll,
extractProjectSheetsV2, uploadBlueprintV2) + UploadRow/DokumentatsiyaResponse
- Drop legacy /blueprint single-upload flow

frontend/app/blueprint/page.tsx DELETED
@@ -1,396 +0,0 @@
1
- "use client";
2
-
3
- /** Two-step project-documentation flow (D-light, no OCR).
4
- *
5
- * Step 1 — upload PDF → server extracts native text on all pages →
6
- * render the page list (stamp + title). User can run
7
- * "Сводка по тексту" to get an LLM project summary.
8
- *
9
- * Step 2 — user picks specific pages → server runs VLM *only* on
10
- * those pages with sibling text as context.
11
- */
12
-
13
- import { useMemo, useState } from "react";
14
- import {
15
- extractProjectSheets,
16
- extractProjectText,
17
- getPagePreview,
18
- uploadBlueprint,
19
- type PageExcerpt,
20
- type ProjectSummary,
21
- type ProjectSummaryResponse,
22
- type UploadBlueprintStatus,
23
- } from "@/lib/api";
24
-
25
- type Phase = "idle" | "uploading" | "ingested" | "text_summary" | "vision" | "error";
26
-
27
- function summariseShort(p: PageExcerpt): string {
28
- if (p.stamp) return p.stamp;
29
- if (p.title_guess) return p.title_guess;
30
- if (p.text_preview) return p.text_preview.slice(0, 60);
31
- return `стр. ${p.page_no}`;
32
- }
33
-
34
- function Section({
35
- title,
36
- children,
37
- }: {
38
- title: string;
39
- children: React.ReactNode;
40
- }) {
41
- return (
42
- <section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
43
- <h2 className="mb-3 text-lg font-semibold">{title}</h2>
44
- {children}
45
- </section>
46
- );
47
- }
48
-
49
- function SummaryBlock({ s }: { s: ProjectSummary }) {
50
- return (
51
- <div className="space-y-3 text-sm">
52
- <div>
53
- <div className="text-slate-500 text-xs uppercase">Объект</div>
54
- <div className="font-medium">{s.object_name}</div>
55
- {s.object_address && (
56
- <div className="text-slate-600">{s.object_address}</div>
57
- )}
58
- {s.project_section && (
59
- <div className="text-slate-600">Раздел: {s.project_section}</div>
60
- )}
61
- </div>
62
- <div className="grid grid-cols-3 gap-2 text-center">
63
- <Stat label="Листов" value={s.totals.sheet_count} />
64
- <Stat label="Со штампом" value={s.totals.sheets_with_stamp} />
65
- <Stat label="Текст. стр." value={s.totals.text_pages} />
66
- </div>
67
- {s.spec_summary && (
68
- <div>
69
- <div className="text-slate-500 text-xs uppercase">Состав</div>
70
- <p className="whitespace-pre-wrap text-slate-800">{s.spec_summary}</p>
71
- </div>
72
- )}
73
- {s.sheets_index.length > 0 && (
74
- <div>
75
- <div className="text-slate-500 text-xs uppercase">Штамповый индекс</div>
76
- <ul className="mt-1 grid grid-cols-2 gap-x-3 gap-y-1 md:grid-cols-3">
77
- {s.sheets_index.map((x) => (
78
- <li key={x.page_no} className="truncate">
79
- <span className="text-slate-500">стр. {x.page_no}</span>{" "}
80
- <span className="font-mono text-xs">
81
- {x.stamp?.split(" ")[0] ?? "—"}
82
- </span>
83
- {x.title_guess && (
84
- <span className="ml-1 text-slate-600 truncate">
85
- — {x.title_guess.slice(0, 50)}
86
- </span>
87
- )}
88
- </li>
89
- ))}
90
- </ul>
91
- </div>
92
- )}
93
- {s.missing_info_prompts.length > 0 && (
94
- <div>
95
- <div className="text-slate-500 text-xs uppercase">
96
- Что нужно уточнить
97
- </div>
98
- <ul className="list-disc pl-5 text-slate-800">
99
- {s.missing_info_prompts.map((p, i) => (
100
- <li key={i}>{p}</li>
101
- ))}
102
- </ul>
103
- </div>
104
- )}
105
- </div>
106
- );
107
- }
108
-
109
- function Stat({ label, value }: { label: string; value: number }) {
110
- return (
111
- <div className="rounded border border-slate-200 bg-slate-50 p-2">
112
- <div className="text-2xl font-semibold tabular-nums">{value}</div>
113
- <div className="text-[11px] uppercase text-slate-500">{label}</div>
114
- </div>
115
- );
116
- }
117
-
118
- export default function BlueprintPage() {
119
- const [phase, setPhase] = useState<Phase>("idle");
120
- const [error, setError] = useState<string | null>(null);
121
- const [status, setStatus] = useState<UploadBlueprintStatus | null>(null);
122
- const [textSummary, setTextSummary] =
123
- useState<ProjectSummaryResponse | null>(null);
124
- const [visionSummary, setVisionSummary] =
125
- useState<ProjectSummaryResponse | null>(null);
126
- const [selected, setSelected] = useState<Set<number>>(new Set());
127
- const [previewUrl, setPreviewUrl] = useState<{ page_no: number; url: string } | null>(
128
- null,
129
- );
130
-
131
- async function onFile(file: File | undefined) {
132
- if (!file) return;
133
- setError(null);
134
- setStatus(null);
135
- setTextSummary(null);
136
- setVisionSummary(null);
137
- setSelected(new Set());
138
- setPhase("uploading");
139
- try {
140
- const s = await uploadBlueprint(file);
141
- setStatus(s);
142
- setPhase("ingested");
143
- } catch (e) {
144
- setError(e instanceof Error ? e.message : String(e));
145
- setPhase("error");
146
- }
147
- }
148
-
149
- async function runTextSummary() {
150
- if (!status?.upload_id) return;
151
- setError(null);
152
- setPhase("text_summary");
153
- try {
154
- const r = await extractProjectText(status.upload_id);
155
- setTextSummary(r);
156
- setPhase("ingested");
157
- } catch (e) {
158
- setError(e instanceof Error ? e.message : String(e));
159
- setPhase("error");
160
- }
161
- }
162
-
163
- async function runVisionSummary() {
164
- if (!status?.upload_id || selected.size === 0) return;
165
- // v1.0.1 — defensive filtering on the wire payload.
166
- // Backend rejects anything < 1; we already use Set<number>, but a
167
- // double-click / future state-mutation could push a 0 or negative.
168
- const requested = Array.from(selected)
169
- .filter((n): n is number => Number.isInteger(n) && n > 0)
170
- .filter((n, idx, arr) => arr.indexOf(n) === idx) // de-dup just in case
171
- .sort((a, b) => a - b);
172
- if (requested.length === 0) {
173
- setError("Не выбрано ни одной валидной страницы");
174
- return;
175
- }
176
- if (typeof window !== "undefined" && window.console) {
177
- // eslint-disable-next-line no-console
178
- console.log("[blueprint] vision payload pages=", requested, "upload_id=", status.upload_id);
179
- }
180
- if (requested.length !== selected.size) {
181
- // eslint-disable-next-line no-console
182
- console.warn(
183
- `[blueprint] dropped ${selected.size - requested.length} invalid page(s) — sent ${requested.length}`,
184
- );
185
- }
186
- setError(null);
187
- setPhase("vision");
188
- try {
189
- const r = await extractProjectSheets(status.upload_id, requested);
190
- setVisionSummary(r);
191
- setPhase("ingested");
192
- } catch (e) {
193
- setError(e instanceof Error ? e.message : String(e));
194
- setPhase("error");
195
- }
196
- }
197
-
198
- async function showPreview(page_no: number) {
199
- if (!status?.upload_id) return;
200
- setError(null);
201
- try {
202
- const p = await getPagePreview(status.upload_id, page_no);
203
- const url = `data:${p.mime};base64,${p.data_b64}`;
204
- setPreviewUrl({ page_no, url });
205
- } catch (e) {
206
- setError(e instanceof Error ? e.message : String(e));
207
- }
208
- }
209
-
210
- const pageRows = useMemo(() => status?.pages ?? [], [status?.pages]);
211
-
212
- const toggleSelected = (no: number) =>
213
- setSelected((prev) => {
214
- const next = new Set(prev);
215
- if (next.has(no)) next.delete(no);
216
- else next.add(no);
217
- return next;
218
- });
219
-
220
- return (
221
- <main className="mx-auto max-w-5xl space-y-6 px-6 py-10">
222
- <header>
223
- <h1 className="text-2xl font-semibold tracking-tight">
224
- Проектная документация
225
- </h1>
226
- <p className="mt-1 text-slate-600">
227
- Шаг 1 — загрузите PDF: текст всех страниц распознаётся автоматически.
228
- Шаг 2 — выберите конкретные листы, чтобы получить распознавание
229
- через VLM с учётом соседних страниц.
230
- </p>
231
- </header>
232
-
233
- {error && (
234
- <div className="rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-800">
235
- {error}
236
- </div>
237
- )}
238
-
239
- <Section title="Шаг 1 — загрузка">
240
- <label className="flex w-full cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-slate-300 bg-slate-50 px-4 py-10 text-slate-500 hover:bg-slate-100">
241
- <input
242
- type="file"
243
- accept="application/pdf,image/png,image/jpeg,image/heic,image/heif,image/webp"
244
- className="hidden"
245
- onChange={(e) => onFile(e.target.files?.[0])}
246
- disabled={phase === "uploading" || phase === "text_summary" || phase === "vision"}
247
- data-testid="blueprint-file"
248
- />
249
- {phase === "uploading"
250
- ? "Загружаем и распознаём…"
251
- : "Кликните или перетащите PDF / изображение сюда"}
252
- </label>
253
-
254
- {status && (
255
- <div className="mt-4 space-y-3 text-sm">
256
- <div className="flex flex-wrap gap-3 text-slate-700">
257
- <span>Файл: <b>{status.file_name}</b></span>
258
- <span>·</span>
259
- <span>Стр.: <b>{status.page_count}</b></span>
260
- <span>·</span>
261
- <span>
262
- В Supabase:{" "}
263
- <b className={status.stored_in_supabase ? "text-emerald-600" : "text-amber-600"}>
264
- {status.stored_in_supabase ? "да" : "нет"}
265
- </b>
266
- </span>
267
- <span>·</span>
268
- <span>
269
- С текстом:{" "}
270
- <b className={status.has_native_text ? "text-emerald-600" : "text-amber-600"}>
271
- {status.has_native_text ? "да" : "нет"}
272
- </b>
273
- </span>
274
- </div>
275
-
276
- <div className="flex flex-wrap gap-2">
277
- <button
278
- type="button"
279
- onClick={runTextSummary}
280
- disabled={!status.has_native_text}
281
- className="rounded bg-slate-900 px-3 py-1.5 text-white shadow hover:bg-slate-800 disabled:opacity-50"
282
- >
283
- Сводка по тексту (все страницы)
284
- </button>
285
- {!status.has_native_text && (
286
- <span className="self-center text-xs text-amber-700">
287
- Без native-текста (скан) — шаг 2 через VLM доступен.
288
- </span>
289
- )}
290
- </div>
291
- </div>
292
- )}
293
- </Section>
294
-
295
- {textSummary && (
296
- <Section title={`Текст-сводка · модель ${textSummary.model_used}`}>
297
- <SummaryBlock s={textSummary.summary} />
298
- </Section>
299
- )}
300
-
301
- {status && status.pages.length > 0 && (
302
- <Section title="Шаг 2 — выберите листы для VLM-распознавания">
303
- <p className="mb-3 text-xs text-slate-500">
304
- Сервер прогонит vision-LLM только по выбранным страницам и
305
- подаст соседние страницы как контекст.
306
- </p>
307
- <ul className="grid grid-cols-1 gap-2 md:grid-cols-2">
308
- {pageRows.map((p) => (
309
- <li
310
- key={p.page_no}
311
- className={`flex items-start gap-2 rounded border p-2 text-sm ${
312
- selected.has(p.page_no)
313
- ? "border-slate-900 bg-slate-50"
314
- : "border-slate-200"
315
- }`}
316
- >
317
- <input
318
- type="checkbox"
319
- className="mt-1"
320
- checked={selected.has(p.page_no)}
321
- onChange={() => toggleSelected(p.page_no)}
322
- />
323
- <div className="min-w-0 flex-1">
324
- <div className="flex items-baseline gap-2">
325
- <span className="font-mono text-xs text-slate-500">
326
- стр. {p.page_no}
327
- </span>
328
- {p.stamp && (
329
- <span className="font-mono text-xs font-semibold">
330
- {p.stamp.split(" ")[0]}
331
- </span>
332
- )}
333
- </div>
334
- <div className="truncate text-slate-800">
335
- {p.title_guess || summariseShort(p)}
336
- </div>
337
- {p.text_preview && (
338
- <div className="line-clamp-2 text-[11px] text-slate-500">
339
- {p.text_preview}
340
- </div>
341
- )}
342
- </div>
343
- <button
344
- type="button"
345
- onClick={() => showPreview(p.page_no)}
346
- className="shrink-0 rounded border border-slate-300 px-2 py-0.5 text-xs hover:bg-slate-100"
347
- >
348
- превью
349
- </button>
350
- </li>
351
- ))}
352
- </ul>
353
-
354
- <div className="mt-4 flex items-center gap-3">
355
- <button
356
- type="button"
357
- onClick={runVisionSummary}
358
- disabled={selected.size === 0 || phase === "vision"}
359
- className="rounded bg-slate-900 px-3 py-1.5 text-white shadow hover:bg-slate-800 disabled:opacity-50"
360
- >
361
- {phase === "vision"
362
- ? "VLM обрабатывает…"
363
- : `VLM по выбранным (${selected.size})`}
364
- </button>
365
- <button
366
- type="button"
367
- onClick={() => setSelected(new Set())}
368
- className="text-xs text-slate-500 underline"
369
- >
370
- снять выделение
371
- </button>
372
- </div>
373
- </Section>
374
- )}
375
-
376
- {visionSummary && (
377
- <Section title={`VLM-сводка · модель ${visionSummary.model_used} · ${visionSummary.page_count} лист(а/ов)`}>
378
- <SummaryBlock s={visionSummary.summary} />
379
- </Section>
380
- )}
381
-
382
- {previewUrl && (
383
- <div
384
- className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
385
- onClick={() => setPreviewUrl(null)}
386
- >
387
- <img
388
- src={previewUrl.url}
389
- alt={`стр. ${previewUrl.page_no}`}
390
- className="max-h-full max-w-full rounded shadow-2xl"
391
- />
392
- </div>
393
- )}
394
- </main>
395
- );
396
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/app/documents/page.tsx ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /** /documents — list templates + render to .docx. */
4
+
5
+ import Link from "next/link";
6
+ import { useEffect, useMemo, useState } from "react";
7
+ import { useSearchParams } from "next/navigation";
8
+ import {
9
+ listDocumentTemplates,
10
+ listProjects,
11
+ renderDocument,
12
+ type Project,
13
+ } from "@/lib/api";
14
+
15
+ const TEMPLATE_LABELS: Record<string, string> = {
16
+ act_hidden_works: "Акт освидетельствования скрытых работ",
17
+ act_osmotr_responsibility: "Акт осмотра ответственных конструкций",
18
+ general_work_log: "Общий журнал работ",
19
+ material_certificate: "Сертификат на материал",
20
+ };
21
+
22
+ export default function DocumentsPage() {
23
+ const params = useSearchParams();
24
+ const presetProjectId = params?.get("project_id") || "";
25
+
26
+ const [templates, setTemplates] = useState<string[]>([]);
27
+ const [projects, setProjects] = useState<Project[]>([]);
28
+ const [template, setTemplate] = useState<string>("");
29
+ const [projectId, setProjectId] = useState<string>(presetProjectId);
30
+ const [contextRaw, setContextRaw] = useState<string>("{}");
31
+ const [loading, setLoading] = useState<{ templates: boolean; render: boolean }>({
32
+ templates: true,
33
+ render: false,
34
+ });
35
+ const [error, setError] = useState<string | null>(null);
36
+ const [lastDownload, setLastDownload] = useState<{
37
+ name: string;
38
+ size: number;
39
+ ts: string;
40
+ } | null>(null);
41
+
42
+ useEffect(() => {
43
+ listDocumentTemplates()
44
+ .then((r) => {
45
+ setTemplates(r.supported);
46
+ if (r.supported.length > 0) setTemplate(r.supported[0]);
47
+ })
48
+ .catch((e) =>
49
+ setError(e instanceof Error ? e.message : String(e)),
50
+ )
51
+ .finally(() =>
52
+ setLoading((s) => ({ ...s, templates: false })),
53
+ );
54
+
55
+ listProjects()
56
+ .then((rows) => {
57
+ setProjects(rows);
58
+ if (!projectId && rows.length > 0)
59
+ setProjectId(rows[0].id);
60
+ })
61
+ .catch(() => setProjects([]));
62
+ // eslint-disable-next-line react-hooks/exhaustive-deps
63
+ }, []);
64
+
65
+ const projectLabel = useMemo(() => {
66
+ if (!projectId) return "не выбран";
67
+ const p = projects.find((x) => x.id === projectId);
68
+ return p ? p.object_name || "(без названия)" : projectId.slice(0, 8);
69
+ }, [projectId, projects]);
70
+
71
+ const handleRender = async () => {
72
+ setError(null);
73
+ if (!template) {
74
+ setError("Шаблон не выбран.");
75
+ return;
76
+ }
77
+ if (!projectId) {
78
+ setError("Проект обязателен для рендеринга.");
79
+ return;
80
+ }
81
+ let ctx: Record<string, unknown> = {};
82
+ try {
83
+ const parsed = JSON.parse(contextRaw || "{}");
84
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
85
+ ctx = parsed as Record<string, unknown>;
86
+ } else {
87
+ setError('Контекст должен быть JSON-объектом, например {"work":"..."}');
88
+ return;
89
+ }
90
+ } catch (e) {
91
+ setError(
92
+ `Невалидный JSON в поле "контекст": ${e instanceof Error ? e.message : String(e)}`,
93
+ );
94
+ return;
95
+ }
96
+
97
+ setLoading((s) => ({ ...s, render: true }));
98
+ try {
99
+ const blob = await renderDocument(template, projectId, ctx);
100
+ const filename = `${template}_${projectId.slice(0, 8)}.docx`;
101
+ const url = URL.createObjectURL(blob);
102
+ const a = document.createElement("a");
103
+ a.href = url;
104
+ a.download = filename;
105
+ a.click();
106
+ URL.revokeObjectURL(url);
107
+ setLastDownload({
108
+ name: filename,
109
+ size: blob.size,
110
+ ts: new Date().toLocaleString("ru-RU"),
111
+ });
112
+ } catch (e) {
113
+ setError(e instanceof Error ? e.message : String(e));
114
+ } finally {
115
+ setLoading((s) => ({ ...s, render: false }));
116
+ }
117
+ };
118
+
119
+ return (
120
+ <main className="mx-auto max-w-xl space-y-5 px-4 py-8">
121
+ <header>
122
+ <h1 className="text-2xl font-bold tracking-tight text-slate-900">
123
+ Документы и шаблоны
124
+ </h1>
125
+ <p className="mt-1 text-sm text-slate-600">
126
+ Выберите проект и шаблон → сгенерируйте .docx и скачайте.
127
+ </p>
128
+ </header>
129
+
130
+ <section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
131
+ {loading.templates ? (
132
+ <p className="text-sm text-slate-500">Загружаем шаблоны...</p>
133
+ ) : templates.length === 0 ? (
134
+ <p className="text-sm text-rose-700">
135
+ Сервер не вернул список шаблонов.
136
+ </p>
137
+ ) : (
138
+ <div className="space-y-4">
139
+ <label className="block">
140
+ <span className="mb-1 block text-sm font-medium text-slate-700">
141
+ Шаблон
142
+ </span>
143
+ <select
144
+ value={template}
145
+ onChange={(e) => setTemplate(e.target.value)}
146
+ className="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-base outline-none focus:border-slate-900 focus:ring-2 focus:ring-slate-900/20"
147
+ >
148
+ {templates.map((t) => (
149
+ <option key={t} value={t}>
150
+ {TEMPLATE_LABELS[t] || t} ({t})
151
+ </option>
152
+ ))}
153
+ </select>
154
+ </label>
155
+
156
+ <label className="block">
157
+ <span className="mb-1 block text-sm font-medium text-slate-700">
158
+ Проект
159
+ </span>
160
+ <select
161
+ value={projectId}
162
+ onChange={(e) => setProjectId(e.target.value)}
163
+ className="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-base outline-none focus:border-slate-900 focus:ring-2 focus:ring-slate-900/20"
164
+ >
165
+ <option value="">— не выбран —</option>
166
+ {projects.map((p) => (
167
+ <option key={p.id} value={p.id}>
168
+ {p.object_name || "(без названия)"} · {p.contract_no || "—"}
169
+ </option>
170
+ ))}
171
+ </select>
172
+ <span className="mt-1 block text-[11px] text-slate-500">
173
+ выбрано: <span className="font-mono">{projectLabel}</span>
174
+ </span>
175
+ </label>
176
+
177
+ <label className="block">
178
+ <span className="mb-1 block text-sm font-medium text-slate-700">
179
+ Контекст (JSON, опционально)
180
+ </span>
181
+ <textarea
182
+ value={contextRaw}
183
+ onChange={(e) => setContextRaw(e.target.value)}
184
+ rows={6}
185
+ spellCheck={false}
186
+ className="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 font-mono text-xs outline-none focus:border-slate-900 focus:ring-2 focus:ring-slate-900/20"
187
+ placeholder='{"work_name":"Армирование","date":"2026-06-28"}'
188
+ />
189
+ <span className="mt-1 block text-[11px] text-slate-500">
190
+ Шаблон подставляет значения из этого объекта в .docx.
191
+ </span>
192
+ </label>
193
+ </div>
194
+ )}
195
+ </section>
196
+
197
+ <button
198
+ type="button"
199
+ onClick={handleRender}
200
+ disabled={
201
+ loading.render || loading.templates || !template || !projectId
202
+ }
203
+ className="w-full rounded-2xl bg-slate-900 py-3 font-semibold text-white shadow hover:bg-slate-800 disabled:opacity-50"
204
+ >
205
+ {loading.render ? "Рендерим..." : "Сгенерировать и скачать .docx"}
206
+ </button>
207
+
208
+ {error && (
209
+ <div className="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
210
+ {error}
211
+ </div>
212
+ )}
213
+
214
+ {lastDownload && (
215
+ <section className="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-xs text-emerald-800">
216
+ Готово: <code className="font-mono">{lastDownload.name}</code> ·{" "}
217
+ {Math.round(lastDownload.size / 1024)} KB · {lastDownload.ts}
218
+ </section>
219
+ )}
220
+
221
+ <footer className="flex justify-between pt-4">
222
+ <Link
223
+ href="/projects"
224
+ className="text-xs text-slate-400 hover:text-slate-600"
225
+ >
226
+ ← все проекты
227
+ </Link>
228
+ <Link
229
+ href="/"
230
+ className="text-xs text-slate-400 hover:text-slate-600"
231
+ >
232
+ на главную →
233
+ </Link>
234
+ </footer>
235
+ </main>
236
+ );
237
+ }
frontend/app/history/page.tsx ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /** /history — read /inference/history. Optional project_id filter. */
4
+
5
+ import Link from "next/link";
6
+ import { useEffect, useMemo, useState } from "react";
7
+ import { useSearchParams } from "next/navigation";
8
+ import {
9
+ listInferenceHistory,
10
+ listProjects,
11
+ type InferenceHistoryRow,
12
+ type Project,
13
+ } from "@/lib/api";
14
+
15
+ export default function HistoryPage() {
16
+ const params = useSearchParams();
17
+ const projectId = params?.get("project_id") || "";
18
+ const [projects, setProjects] = useState<Project[]>([]);
19
+ const [rows, setRows] = useState<InferenceHistoryRow[] | null>(null);
20
+ const [error, setError] = useState<string | null>(null);
21
+
22
+ useEffect(() => {
23
+ listProjects().then(setProjects).catch(() => setProjects([]));
24
+ }, []);
25
+
26
+ useEffect(() => {
27
+ setRows(null);
28
+ setError(null);
29
+ listInferenceHistory(projectId || undefined)
30
+ .then(setRows)
31
+ .catch((e) =>
32
+ setError(e instanceof Error ? e.message : String(e)),
33
+ );
34
+ }, [projectId]);
35
+
36
+ const projectLabel = useMemo(() => {
37
+ if (!projectId) return "все проекты";
38
+ const p = projects.find((x) => x.id === projectId);
39
+ return p
40
+ ? `${p.object_name || "(без названия)"} (${p.id.slice(0, 8)})`
41
+ : `id: ${projectId.slice(0, 8)}`;
42
+ }, [projectId, projects]);
43
+
44
+ return (
45
+ <main className="mx-auto max-w-3xl px-4 py-8">
46
+ <header className="mb-6">
47
+ <h1 className="text-2xl font-bold tracking-tight text-slate-900">
48
+ История инференсов
49
+ </h1>
50
+ <p className="mt-1 text-sm text-slate-600">
51
+ Проект: <span className="font-mono">{projectLabel}</span>
52
+ </p>
53
+ </header>
54
+
55
+ <div className="mb-6 flex flex-wrap items-center gap-2">
56
+ <Link
57
+ href="/history"
58
+ className={
59
+ "rounded-full px-3 py-1 text-xs font-medium " +
60
+ (!projectId
61
+ ? "bg-slate-900 text-white"
62
+ : "bg-slate-100 text-slate-700 hover:bg-slate-200")
63
+ }
64
+ >
65
+ Все
66
+ </Link>
67
+ {projects.map((p) => (
68
+ <Link
69
+ key={p.id}
70
+ href={`/history?project_id=${p.id}`}
71
+ className={
72
+ "rounded-full px-3 py-1 text-xs font-medium " +
73
+ (projectId === p.id
74
+ ? "bg-slate-900 text-white"
75
+ : "bg-slate-100 text-slate-700 hover:bg-slate-200")
76
+ }
77
+ >
78
+ {p.object_name || "(без названия)"}
79
+ </Link>
80
+ ))}
81
+ </div>
82
+
83
+ {error && (
84
+ <div className="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
85
+ {error}
86
+ </div>
87
+ )}
88
+
89
+ {rows === null && !error && (
90
+ <p className="text-sm text-slate-500">Загружаем...</p>
91
+ )}
92
+
93
+ {rows && rows.length === 0 && (
94
+ <div className="rounded-2xl border border-slate-200 bg-white p-5 text-sm text-slate-600">
95
+ Записей нет.
96
+ </div>
97
+ )}
98
+
99
+ {rows && rows.length > 0 && (
100
+ <ul className="space-y-2">
101
+ {rows.map((r) => (
102
+ <li
103
+ key={r.id}
104
+ className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
105
+ >
106
+ <div className="flex items-baseline justify-between gap-3">
107
+ <div className="min-w-0">
108
+ <div className="font-mono text-[11px] text-slate-500">
109
+ {new Date(r.created_at).toLocaleString("ru-RU")} ·{" "}
110
+ <span>{r.request_id || "—"}</span>
111
+ </div>
112
+ <div className="mt-1 text-xs text-slate-700">
113
+ модель:{" "}
114
+ <code className="font-mono">
115
+ {r.model_used || "—"}
116
+ </code>
117
+ </div>
118
+ </div>
119
+ {r.project_id && (
120
+ <Link
121
+ href={`/projects/${r.project_id}`}
122
+ className="shrink-0 rounded-md border border-slate-300 bg-slate-50 px-2 py-0.5 text-[10px] font-medium text-slate-700 hover:bg-slate-100"
123
+ >
124
+ перейти к проекту
125
+ </Link>
126
+ )}
127
+ </div>
128
+ {r.source_text && (
129
+ <details className="mt-2">
130
+ <summary className="cursor-pointer text-xs text-slate-500 hover:text-slate-700">
131
+ источник
132
+ </summary>
133
+ <pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded-lg bg-slate-50 p-2 text-[11px] text-slate-700">
134
+ {r.source_text}
135
+ </pre>
136
+ </details>
137
+ )}
138
+ {r.payload != null && (
139
+ <details className="mt-1">
140
+ <summary className="cursor-pointer text-xs text-slate-500 hover:text-slate-700">
141
+ payload
142
+ </summary>
143
+ <pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg bg-slate-50 p-2 text-[11px] text-slate-700">
144
+ {JSON.stringify(r.payload, null, 2)}
145
+ </pre>
146
+ </details>
147
+ )}
148
+ </li>
149
+ ))}
150
+ </ul>
151
+ )}
152
+
153
+ <footer className="mt-10 text-center">
154
+ <Link
155
+ href="/"
156
+ className="text-xs text-slate-400 hover:text-slate-600"
157
+ >
158
+ ← на главную
159
+ </Link>
160
+ </footer>
161
+ </main>
162
+ );
163
+ }
frontend/app/page.tsx CHANGED
@@ -1,24 +1,82 @@
1
- /** Landing — short explainer + entry to the blueprint flow. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- export default function Page() {
4
  return (
5
- <main className="mx-auto max-w-3xl px-6 py-12">
6
- <h1 className="text-3xl font-semibold tracking-tight">
7
- Virtual Foreman
8
- </h1>
9
- <p className="mt-3 text-slate-600">
10
- Виртуальный Прораб — конструктор тех-карт и сопровождение проектной
11
- документации. Загрузите проектную документацию мы разберём текст
12
- по всем страницам и соберём сводку по проекту; дальше можно выбрать
13
- конкретные листы и получить их распознавание через VLM.
14
- </p>
15
-
16
- <a
17
- href="/blueprint"
18
- className="mt-6 inline-block rounded-md bg-slate-900 px-4 py-2 text-white shadow hover:bg-slate-800"
19
- >
20
- Открыть разбор проектной документации →
21
- </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  </main>
23
  );
24
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /** Landing — health badge + four entry tiles. v3.0. */
4
+
5
+ import Link from "next/link";
6
+ import { useEffect, useState } from "react";
7
+ import { API_URL, APP_NAME } from "@/lib/config";
8
+
9
+ type HealthState = "loading" | "ok" | "down";
10
+
11
+ export default function LandingPage() {
12
+ const [health, setHealth] = useState<HealthState>("loading");
13
+
14
+ useEffect(() => {
15
+ fetch(`${API_URL}/health`)
16
+ .then((r) => r.json())
17
+ .then((j) => (j.status === "ok" ? setHealth("ok") : setHealth("down")))
18
+ .catch(() => setHealth("down"));
19
+ }, []);
20
 
 
21
  return (
22
+ <main className="mx-auto max-w-md px-4 py-10">
23
+ <header className="mb-8 text-center">
24
+ <h1 className="text-3xl font-bold tracking-tight text-slate-900">
25
+ {APP_NAME}
26
+ </h1>
27
+ <p className="mt-2 text-sm text-slate-600">
28
+ Виртуальный Прораб · тех-стек и документация · BY
29
+ </p>
30
+ <span
31
+ className={
32
+ "mt-4 inline-block rounded-full px-3 py-1 text-xs font-semibold " +
33
+ (health === "ok"
34
+ ? "bg-emerald-100 text-emerald-700"
35
+ : health === "down"
36
+ ? "bg-rose-100 text-rose-700"
37
+ : "bg-slate-100 text-slate-600")
38
+ }
39
+ >
40
+ backend: {health === "ok" ? "online" : health === "down" ? "offline" : "проверка..."}
41
+ </span>
42
+ </header>
43
+
44
+ <nav className="space-y-3">
45
+ <Tile primary href="/projects">
46
+ Все проекты
47
+ </Tile>
48
+ <Tile href="/projects/new">Новый проект</Tile>
49
+ <Tile href="/documents">Шаблоны документов</Tile>
50
+ <Tile href="/history">История инференсов</Tile>
51
+ </nav>
52
+
53
+ <footer className="mt-12 text-center text-xs text-slate-400">
54
+ v3.0 · project-aware blueprints
55
+ </footer>
56
  </main>
57
  );
58
  }
59
+
60
+ function Tile({
61
+ href,
62
+ children,
63
+ primary = false,
64
+ }: {
65
+ href: string;
66
+ children: React.ReactNode;
67
+ primary?: boolean;
68
+ }) {
69
+ return (
70
+ <Link
71
+ href={href}
72
+ className={
73
+ "block w-full rounded-2xl py-4 text-center text-sm font-semibold shadow-sm transition " +
74
+ (primary
75
+ ? "bg-slate-900 text-white hover:bg-slate-800"
76
+ : "border border-slate-300 bg-white text-slate-700 hover:bg-slate-50")
77
+ }
78
+ >
79
+ {children}
80
+ </Link>
81
+ );
82
+ }
frontend/app/projects/[id]/BlueprintsPanel.tsx ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /** Step A — upload blueprints to a project. v3.0. */
4
+
5
+ import { useEffect, useRef, useState } from "react";
6
+ import {
7
+ listProjectUploads,
8
+ uploadBlueprintV2,
9
+ type UploadRow,
10
+ } from "@/lib/api";
11
+
12
+ interface Props {
13
+ projectId: string;
14
+ refreshKey: number;
15
+ }
16
+
17
+ export function BlueprintsPanel({ projectId, refreshKey }: Props) {
18
+ const [items, setItems] = useState<UploadRow[] | null>(null);
19
+ const [error, setError] = useState<string | null>(null);
20
+ const [uploadingName, setUploadingName] = useState<string | null>(null);
21
+ const fileRef = useRef<HTMLInputElement | null>(null);
22
+
23
+ const reload = async () => {
24
+ setError(null);
25
+ try {
26
+ const resp = await listProjectUploads(projectId);
27
+ setItems(resp.uploads);
28
+ } catch (e) {
29
+ setError(e instanceof Error ? e.message : String(e));
30
+ setItems([]);
31
+ }
32
+ };
33
+
34
+ useEffect(() => {
35
+ reload();
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ }, [projectId, refreshKey]);
38
+
39
+ async function onPick(file: File | undefined) {
40
+ if (!file) return;
41
+ setError(null);
42
+ setUploadingName(file.name);
43
+ try {
44
+ await uploadBlueprintV2(file, projectId);
45
+ await reload();
46
+ } catch (e) {
47
+ setError(e instanceof Error ? e.message : String(e));
48
+ } finally {
49
+ setUploadingName(null);
50
+ if (fileRef.current) fileRef.current.value = "";
51
+ }
52
+ }
53
+
54
+ return (
55
+ <section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
56
+ <header className="mb-3 flex items-center justify-between">
57
+ <h2 className="text-lg font-semibold">
58
+ Шаг 1 · чертежи проекта
59
+ </h2>
60
+ {items && (
61
+ <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
62
+ файлов: {items.length}
63
+ </span>
64
+ )}
65
+ </header>
66
+
67
+ <p className="mb-3 text-xs text-slate-500">
68
+ Загрузите один или несколько PDF с комплектом рабочих чертежей.
69
+ Сервер извлечёт native-текст и сохранит листы для последующего
70
+ разбора.
71
+ </p>
72
+
73
+ <label className="flex w-full cursor-pointer items-center justify-center rounded-xl border-2 border-dashed border-slate-300 bg-slate-50 px-4 py-8 text-sm text-slate-500 hover:bg-slate-100">
74
+ <input
75
+ ref={fileRef}
76
+ type="file"
77
+ accept="application/pdf,image/png,image/jpeg,image/heic,image/heif,image/webp"
78
+ className="hidden"
79
+ onChange={(e) => onPick(e.target.files?.[0])}
80
+ disabled={Boolean(uploadingName)}
81
+ data-testid="blueprint-file-input"
82
+ />
83
+ {uploadingName
84
+ ? `Загружаем "${uploadingName}"...`
85
+ : "Клик или drag PDF (можно несколько раз)"}
86
+ </label>
87
+
88
+ {error && (
89
+ <div className="mt-3 rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
90
+ {error}
91
+ </div>
92
+ )}
93
+
94
+ {items && items.length === 0 && (
95
+ <p className="mt-4 text-xs text-slate-400">Загрузок ещё нет.</p>
96
+ )}
97
+
98
+ {items && items.length > 0 && (
99
+ <ul className="mt-4 space-y-2 text-sm">
100
+ {items.map((u) => (
101
+ <li
102
+ key={u.upload_id}
103
+ className="flex items-center justify-between gap-3 rounded-xl border border-slate-200 bg-slate-50 p-3"
104
+ >
105
+ <div className="min-w-0">
106
+ <div className="truncate font-medium text-slate-900">
107
+ {u.file_name}
108
+ </div>
109
+ <div className="text-[11px] text-slate-500">
110
+ {u.page_count} стр. ·{" "}
111
+ sha{" "}
112
+ <code className="font-mono">
113
+ {u.sha256 ? u.sha256.slice(0, 10) : "—"}
114
+ </code>{" "}
115
+ · статус:{" "}
116
+ <span
117
+ className={
118
+ u.status === "uploaded"
119
+ ? "text-emerald-700"
120
+ : "text-amber-700"
121
+ }
122
+ >
123
+ {u.status}
124
+ </span>
125
+ </div>
126
+ </div>
127
+ <span
128
+ className={
129
+ "shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold " +
130
+ (u.has_native_text
131
+ ? "bg-emerald-100 text-emerald-700"
132
+ : "bg-amber-100 text-amber-700")
133
+ }
134
+ title={
135
+ u.has_native_text
136
+ ? "Извлечён native-текст PDF"
137
+ : "Скан / без текста — потребуется VLM"
138
+ }
139
+ >
140
+ {u.has_native_text ? "text" : "scan"}
141
+ </span>
142
+ </li>
143
+ ))}
144
+ </ul>
145
+ )}
146
+
147
+ <button
148
+ type="button"
149
+ onClick={reload}
150
+ className="mt-4 text-xs text-slate-500 underline"
151
+ >
152
+ обновить список
153
+ </button>
154
+ </section>
155
+ );
156
+ }
frontend/app/projects/[id]/DocumentationPanel.tsx ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /** Step B — aggregated dokumentatsiya + extract-all. v3.0.
4
+ *
5
+ * Cache UX:
6
+ * - cached_summary present && aggregate_sha256 matches persisted
7
+ * → show inline + green "Cached · {model}" badge.
8
+ * - cached_summary present but aggregate_sha256 differs (new upload)
9
+ * → amber "Новый sha — требуется выжимка" banner + button.
10
+ * - cached_summary absent
11
+ * → empty state + button.
12
+ */
13
+
14
+ import { useEffect, useState } from "react";
15
+ import {
16
+ extractProjectAll,
17
+ projectDokumentatsiya,
18
+ type DokumentatsiyaResponse,
19
+ type ExtractAllResponse,
20
+ } from "@/lib/api";
21
+ import { SummaryBlock } from "./SummaryBlock";
22
+
23
+ interface Props {
24
+ projectId: string;
25
+ refreshKey: number;
26
+ onUpdated: (r: ExtractAllResponse) => void;
27
+ }
28
+
29
+ type LoadState =
30
+ | { kind: "loading" }
31
+ | { kind: "ready"; data: DokumentatsiyaResponse; cached?: ExtractAllResponse | null }
32
+ | { kind: "empty" }
33
+ | { kind: "error"; message: string };
34
+
35
+ export function DocumentationPanel({ projectId, refreshKey, onUpdated }: Props) {
36
+ const [state, setState] = useState<LoadState>({ kind: "loading" });
37
+ const [running, setRunning] = useState(false);
38
+
39
+ const reload = async () => {
40
+ setState({ kind: "loading" });
41
+ try {
42
+ const data = await projectDokumentatsiya(projectId);
43
+ let cached: ExtractAllResponse | null = null;
44
+ if (data.cached_summary) {
45
+ // The dokumentatsiya endpoint doesn't surface generated_at; use
46
+ // a sentinel. The runtime treats this as cache-hit data.
47
+ cached = {
48
+ upload_id: projectId,
49
+ model_used: data.cached_summary_model || "cache",
50
+ page_count: data.total_pages,
51
+ generated_at: data.generated_at,
52
+ summary: data.cached_summary,
53
+ };
54
+ }
55
+ setState({ kind: "ready", data, cached });
56
+ } catch (e) {
57
+ const msg = e instanceof Error ? e.message : String(e);
58
+ if (/404/.test(msg) || /no blueprint uploads/.test(msg)) {
59
+ setState({ kind: "empty" });
60
+ } else {
61
+ setState({ kind: "error", message: msg });
62
+ }
63
+ }
64
+ };
65
+
66
+ useEffect(() => {
67
+ reload();
68
+ // eslint-disable-next-line react-hooks/exhaustive-deps
69
+ }, [projectId, refreshKey]);
70
+
71
+ const runExtractAll = async (force: boolean) => {
72
+ setRunning(true);
73
+ try {
74
+ const r = await extractProjectAll(projectId, { force });
75
+ onUpdated(r);
76
+ // Force re-read so aggregate_sha256 matches the persistence.
77
+ await reload();
78
+ } catch (e) {
79
+ const msg = e instanceof Error ? e.message : String(e);
80
+ setState({ kind: "error", message: msg });
81
+ } finally {
82
+ setRunning(false);
83
+ }
84
+ };
85
+
86
+ if (state.kind === "loading") {
87
+ return (
88
+ <section className="rounded-2xl border border-slate-200 bg-white p-5 text-sm text-slate-500 shadow-sm">
89
+ Шаг 2 · читаем сводный снимок чертежей...
90
+ </section>
91
+ );
92
+ }
93
+
94
+ if (state.kind === "empty") {
95
+ return (
96
+ <section className="rounded-2xl border border-amber-200 bg-amber-50 p-5 text-sm text-amber-800 shadow-sm">
97
+ <h2 className="mb-2 text-lg font-semibold">Шаг 2 · документация</h2>
98
+ <p>
99
+ К проекту пока не привязан ни один чертёж. Сначала загрузите PDF
100
+ в&nbsp;шаге&nbsp;1.
101
+ </p>
102
+ </section>
103
+ );
104
+ }
105
+
106
+ if (state.kind === "error") {
107
+ return (
108
+ <section className="rounded-2xl border border-rose-200 bg-rose-50 p-5 text-sm shadow-sm">
109
+ <h2 className="mb-2 text-lg font-semibold text-rose-700">
110
+ Шаг 2 · документация (ошибка)
111
+ </h2>
112
+ <p className="text-rose-700">{state.message}</p>
113
+ </section>
114
+ );
115
+ }
116
+
117
+ const { data, cached } = state;
118
+ const cachedValid = Boolean(cached); // sha equality already
119
+ // enforced server-side: any
120
+ // cached_summary == current sha.
121
+
122
+ return (
123
+ <section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
124
+ <header className="mb-3 flex items-center justify-between">
125
+ <h2 className="text-lg font-semibold">Шаг 2 · документация</h2>
126
+ <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
127
+ sha: <code className="font-mono">{data.aggregate_sha256.slice(0, 10)}</code>
128
+ </span>
129
+ </header>
130
+
131
+ <div className="mb-4 grid grid-cols-3 gap-2 text-center text-sm">
132
+ <Stat label="Файлов" value={data.total_uploads} />
133
+ <Stat label="Стр." value={data.total_pages} />
134
+ <Stat label="Символов" value={data.total_chars} />
135
+ </div>
136
+
137
+ {data.overall_project_section && (
138
+ <p className="mb-3 text-xs text-slate-600">
139
+ Основной раздел проекта:{" "}
140
+ <span className="font-mono">{data.overall_project_section}</span>
141
+ </p>
142
+ )}
143
+
144
+ {cachedValid && cached ? (
145
+ <div className="mb-4 rounded-xl border border-emerald-200 bg-emerald-50 p-4">
146
+ <div className="mb-2 flex items-center justify-between">
147
+ <div className="flex items-center gap-2">
148
+ <span className="rounded-full bg-emerald-200 px-2 py-0.5 text-[10px] font-semibold uppercase text-emerald-900">
149
+ Cached
150
+ </span>
151
+ <span className="text-xs text-emerald-800">
152
+ модель:{" "}
153
+ <code className="font-mono">{cached.model_used}</code>
154
+ </span>
155
+ </div>
156
+ <button
157
+ type="button"
158
+ onClick={() => runExtractAll(true)}
159
+ disabled={running}
160
+ className="rounded-lg border border-emerald-300 bg-white px-3 py-1 text-xs font-semibold text-emerald-800 hover:bg-emerald-100 disabled:opacity-50"
161
+ title="Принудительный rerun через LLM"
162
+ >
163
+ {running ? "обновляем..." : "обновить"}
164
+ </button>
165
+ </div>
166
+ <SummaryBlock s={cached.summary} />
167
+ </div>
168
+ ) : (
169
+ <div className="mb-4 rounded-xl border border-amber-200 bg-amber-50 p-4">
170
+ <p className="mb-3 text-xs text-amber-800">
171
+ Кэш сводки пуст или устарел (sha изменился). Запустите
172
+ полную выжимку по тексту.
173
+ </p>
174
+ <button
175
+ type="button"
176
+ onClick={() => runExtractAll(false)}
177
+ disabled={running}
178
+ className="rounded-lg bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-slate-800 disabled:opacity-50"
179
+ >
180
+ {running ? "LLM работает…" : "Сводка по тексту (LLM)"}
181
+ </button>
182
+ </div>
183
+ )}
184
+
185
+ {data.upload_summaries.length > 0 && (
186
+ <div className="mt-4">
187
+ <div className="mb-1 text-[10px] font-semibold uppercase text-slate-500">
188
+ Состав загрузок
189
+ </div>
190
+ <ul className="space-y-1 text-xs text-slate-700">
191
+ {data.upload_summaries.map((u) => (
192
+ <li
193
+ key={u.upload_id}
194
+ className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-2 py-1"
195
+ >
196
+ <span className="truncate">
197
+ <span className="font-medium">{u.file_name}</span>{" "}
198
+ <span className="text-slate-500">· {u.page_count} стр.</span>
199
+ </span>
200
+ {u.section_floor && (
201
+ <span className="shrink-0 rounded bg-slate-200 px-1.5 py-0.5 font-mono text-[10px]">
202
+ {u.section_floor}
203
+ </span>
204
+ )}
205
+ </li>
206
+ ))}
207
+ </ul>
208
+ </div>
209
+ )}
210
+ </section>
211
+ );
212
+ }
213
+
214
+ function Stat({ label, value }: { label: string; value: number }) {
215
+ return (
216
+ <div className="rounded-xl border border-slate-200 bg-slate-50 p-2">
217
+ <div className="text-xl font-semibold tabular-nums text-slate-900">
218
+ {value}
219
+ </div>
220
+ <div className="text-[10px] uppercase text-slate-500">{label}</div>
221
+ </div>
222
+ );
223
+ }
frontend/app/projects/[id]/SheetVisionPanel.tsx ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /** Step C — VLM over selected (upload_id, page_no) pairs. v3.0.
4
+ *
5
+ * Pages are grouped by upload so the user always knows which file
6
+ * they're picking from. The wire payload is list[SheetExtractPair].
7
+ */
8
+
9
+ import { useEffect, useMemo, useState } from "react";
10
+ import {
11
+ extractProjectSheetsV2,
12
+ projectDokumentatsiya,
13
+ type AggregatedPage,
14
+ type ExtractAllResponse,
15
+ type SheetExtractPair,
16
+ } from "@/lib/api";
17
+ import { SummaryBlock } from "./SummaryBlock";
18
+
19
+ interface Props {
20
+ projectId: string;
21
+ refreshKey: number;
22
+ onResult: (r: ExtractAllResponse) => void;
23
+ }
24
+
25
+ export function SheetVisionPanel({ projectId, refreshKey, onResult }: Props) {
26
+ const [pages, setPages] = useState<AggregatedPage[]>([]);
27
+ const [loadingPages, setLoadingPages] = useState(true);
28
+ const [error, setError] = useState<string | null>(null);
29
+ const [selected, setSelected] = useState<Set<string>>(new Set());
30
+ const [running, setRunning] = useState(false);
31
+ const [result, setResult] = useState<ExtractAllResponse | null>(null);
32
+
33
+ useEffect(() => {
34
+ setLoadingPages(true);
35
+ setError(null);
36
+ projectDokumentatsiya(projectId)
37
+ .then((d) => setPages(d.pages))
38
+ .catch((e) => setError(e instanceof Error ? e.message : String(e)))
39
+ .finally(() => setLoadingPages(false));
40
+ // eslint-disable-next-line react-hooks/exhaustive-deps
41
+ }, [projectId, refreshKey]);
42
+
43
+ const byUpload = useMemo(() => {
44
+ const m = new Map<
45
+ string,
46
+ { upload_file_name: string; pages: AggregatedPage[] }
47
+ >();
48
+ for (const p of pages) {
49
+ const cur = m.get(p.upload_id);
50
+ if (cur) cur.pages.push(p);
51
+ else
52
+ m.set(p.upload_id, {
53
+ upload_file_name: p.upload_file_name,
54
+ pages: [p],
55
+ });
56
+ }
57
+ return m;
58
+ }, [pages]);
59
+
60
+ const keyFor = (uid: string, pn: number) => `${uid}#${pn}`;
61
+
62
+ const toggle = (k: string) =>
63
+ setSelected((prev) => {
64
+ const nx = new Set(prev);
65
+ if (nx.has(k)) nx.delete(k);
66
+ else nx.add(k);
67
+ return nx;
68
+ });
69
+
70
+ const runVision = async () => {
71
+ const pairs: SheetExtractPair[] = Array.from(selected).flatMap((k) => {
72
+ const [uid, pn] = k.split("#");
73
+ const pageNo = Number(pn);
74
+ if (!uid || !Number.isFinite(pageNo) || pageNo < 1) return [];
75
+ return [{ upload_id: uid, page_no: pageNo }];
76
+ });
77
+ if (pairs.length === 0) {
78
+ setError("Ни одной валидной страницы не выбрано.");
79
+ return;
80
+ }
81
+ setError(null);
82
+ setRunning(true);
83
+ try {
84
+ const r = await extractProjectSheetsV2(projectId, pairs);
85
+ setResult(r);
86
+ onResult(r);
87
+ } catch (e) {
88
+ setError(e instanceof Error ? e.message : String(e));
89
+ } finally {
90
+ setRunning(false);
91
+ }
92
+ };
93
+
94
+ return (
95
+ <section className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
96
+ <header className="mb-3 flex items-center justify-between">
97
+ <h2 className="text-lg font-semibold">Шаг 3 · VLM по листам</h2>
98
+ {selected.size > 0 && (
99
+ <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
100
+ выбрано: {selected.size}
101
+ </span>
102
+ )}
103
+ </header>
104
+
105
+ <p className="mb-3 text-xs text-slate-500">
106
+ Сервер прогонит vision-LLM по выбранным листам и вернёт сводную
107
+ выжимку. Полезно когда native-текст не распознан (скан) или нужна
108
+ детализация конкретных листов.
109
+ </p>
110
+
111
+ {loadingPages ? (
112
+ <p className="text-sm text-slate-500">Загружаем карту страниц...</p>
113
+ ) : pages.length === 0 ? (
114
+ <p className="text-sm text-slate-400">
115
+ Сначала загрузите чертёж (шаг&nbsp;1).
116
+ </p>
117
+ ) : (
118
+ <div className="space-y-3">
119
+ {Array.from(byUpload.entries()).map(([uid, group]) => (
120
+ <div
121
+ key={uid}
122
+ className="rounded-xl border border-slate-200 bg-slate-50 p-3"
123
+ >
124
+ <div className="mb-2 truncate text-xs font-semibold text-slate-700">
125
+ {group.upload_file_name}{" "}
126
+ <code className="ml-1 font-mono text-[10px] text-slate-500">
127
+ {uid.slice(0, 8)}
128
+ </code>
129
+ </div>
130
+ <ul className="grid grid-cols-1 gap-1 md:grid-cols-2">
131
+ {group.pages.map((p) => {
132
+ const k = keyFor(uid, p.page_no);
133
+ return (
134
+ <li
135
+ key={k}
136
+ className={
137
+ "flex items-start gap-2 rounded-lg border p-2 text-sm " +
138
+ (selected.has(k)
139
+ ? "border-slate-900 bg-white"
140
+ : "border-slate-200 bg-white")
141
+ }
142
+ >
143
+ <input
144
+ type="checkbox"
145
+ className="mt-0.5"
146
+ checked={selected.has(k)}
147
+ onChange={() => toggle(k)}
148
+ />
149
+ <div className="min-w-0 flex-1">
150
+ <div className="flex items-baseline gap-2">
151
+ <span className="font-mono text-[11px] text-slate-500">
152
+ стр. {p.page_no}
153
+ </span>
154
+ {p.stamp && (
155
+ <span className="font-mono text-[11px] font-semibold">
156
+ {p.stamp.split(" ")[0]}
157
+ </span>
158
+ )}
159
+ </div>
160
+ <div className="truncate text-xs text-slate-800">
161
+ {p.title_guess || "—"}
162
+ </div>
163
+ </div>
164
+ </li>
165
+ );
166
+ })}
167
+ </ul>
168
+ </div>
169
+ ))}
170
+ </div>
171
+ )}
172
+
173
+ <div className="mt-4 flex items-center gap-3">
174
+ <button
175
+ type="button"
176
+ onClick={runVision}
177
+ disabled={running || selected.size === 0 || loadingPages}
178
+ className="rounded-lg bg-slate-900 px-3 py-1.5 text-sm font-semibold text-white shadow hover:bg-slate-800 disabled:opacity-50"
179
+ >
180
+ {running
181
+ ? "VLM обрабатывает…"
182
+ : `VLM по выбранным (${selected.size})`}
183
+ </button>
184
+ <button
185
+ type="button"
186
+ onClick={() => setSelected(new Set())}
187
+ disabled={selected.size === 0}
188
+ className="text-xs text-slate-500 underline disabled:opacity-30"
189
+ >
190
+ снять выделение
191
+ </button>
192
+ </div>
193
+
194
+ {error && (
195
+ <div className="mt-3 rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700">
196
+ {error}
197
+ </div>
198
+ )}
199
+
200
+ {result && (
201
+ <div className="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
202
+ <div className="mb-2 text-xs text-slate-500">
203
+ VLM-сводка · модель{" "}
204
+ <code className="font-mono">{result.model_used}</code> ·{" "}
205
+ {result.page_count} лист(а/ов)
206
+ </div>
207
+ <SummaryBlock s={result.summary} />
208
+ </div>
209
+ )}
210
+ </section>
211
+ );
212
+ }
frontend/app/projects/[id]/SummaryBlock.tsx ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /** Shared rendering blocks for project blueprints. v3.0. */
4
+
5
+ import type { ProjectSummary } from "@/lib/api";
6
+
7
+ function Stat({ label, value }: { label: string; value: number }) {
8
+ return (
9
+ <div className="rounded-xl border border-slate-200 bg-slate-50 p-3">
10
+ <div className="text-2xl font-semibold tabular-nums text-slate-900">
11
+ {value}
12
+ </div>
13
+ <div className="text-[10px] uppercase text-slate-500">{label}</div>
14
+ </div>
15
+ );
16
+ }
17
+
18
+ interface SummaryBlockProps {
19
+ s: ProjectSummary;
20
+ }
21
+
22
+ export function SummaryBlock({ s }: SummaryBlockProps) {
23
+ return (
24
+ <div className="space-y-4 text-sm">
25
+ <div>
26
+ <div className="text-[10px] font-semibold uppercase text-slate-500">
27
+ Объект
28
+ </div>
29
+ <div className="mt-1 font-medium text-slate-900">{s.object_name}</div>
30
+ {s.object_address && (
31
+ <div className="text-slate-600">{s.object_address}</div>
32
+ )}
33
+ {s.project_section && (
34
+ <div className="text-slate-600">
35
+ Раздел: <span className="font-mono">{s.project_section}</span>
36
+ </div>
37
+ )}
38
+ </div>
39
+
40
+ <div className="grid grid-cols-3 gap-2 text-center">
41
+ <Stat label="Листов" value={s.totals.sheet_count} />
42
+ <Stat label="Со штампом" value={s.totals.sheets_with_stamp} />
43
+ <Stat label="Текст. стр." value={s.totals.text_pages} />
44
+ </div>
45
+
46
+ {s.spec_summary && (
47
+ <div>
48
+ <div className="text-[10px] font-semibold uppercase text-slate-500">
49
+ Состав
50
+ </div>
51
+ <p className="mt-1 whitespace-pre-wrap text-slate-800">
52
+ {s.spec_summary}
53
+ </p>
54
+ </div>
55
+ )}
56
+
57
+ {s.sheets_index.length > 0 && (
58
+ <div>
59
+ <div className="text-[10px] font-semibold uppercase text-slate-500">
60
+ Штамповый индекс
61
+ </div>
62
+ <ul className="mt-2 grid grid-cols-1 gap-x-3 gap-y-1 md:grid-cols-2">
63
+ {s.sheets_index.map((x, i) => (
64
+ <li key={`${x.page_no}-${i}`} className="truncate">
65
+ <span className="text-slate-500">стр. {x.page_no}</span>{" "}
66
+ <span className="font-mono text-xs">
67
+ {x.stamp?.split(" ")[0] ?? "—"}
68
+ </span>
69
+ {x.title_guess && (
70
+ <span className="ml-1 text-slate-600 truncate">
71
+ — {x.title_guess.slice(0, 60)}
72
+ </span>
73
+ )}
74
+ </li>
75
+ ))}
76
+ </ul>
77
+ </div>
78
+ )}
79
+
80
+ {s.missing_info_prompts.length > 0 && (
81
+ <div>
82
+ <div className="text-[10px] font-semibold uppercase text-slate-500">
83
+ Что уточнить
84
+ </div>
85
+ <ul className="mt-2 list-disc space-y-1 pl-5 text-slate-800">
86
+ {s.missing_info_prompts.map((p, i) => (
87
+ <li key={i}>{p}</li>
88
+ ))}
89
+ </ul>
90
+ </div>
91
+ )}
92
+ </div>
93
+ );
94
+ }
frontend/app/projects/[id]/page.tsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /** /projects/[id] — 3-step wizard for one project. v3.0.
4
+ *
5
+ * - BlueprintsPanel : upload PDFs, list, status pills
6
+ * - DocumentationPanel: aggregated snapshot + extract-all w/ cache UX
7
+ * - SheetVisionPanel : per-sheet VLM selection
8
+ *
9
+ * refreshKey is bumped whenever a panel signals a state change
10
+ * (new upload, extract-all success, etc), forcing other panels to
11
+ * re-hydrate from the canonical snapshot.
12
+ */
13
+
14
+ import Link from "next/link";
15
+ import { useEffect, useState } from "react";
16
+ import { useParams } from "next/navigation";
17
+ import { getProject, type ExtractAllResponse, type Project } from "@/lib/api";
18
+ import { BlueprintsPanel } from "./BlueprintsPanel";
19
+ import { DocumentationPanel } from "./DocumentationPanel";
20
+ import { SheetVisionPanel } from "./SheetVisionPanel";
21
+
22
+ export default function ProjectWizardPage() {
23
+ const params = useParams<{ id: string }>();
24
+ const projectId = params?.id ?? "";
25
+
26
+ const [project, setProject] = useState<Project | null>(null);
27
+ const [loadErr, setLoadErr] = useState<string | null>(null);
28
+ const [refreshKey, setRefreshKey] = useState(0);
29
+ const [lastResult, setLastResult] = useState<{
30
+ label: string;
31
+ r: ExtractAllResponse;
32
+ } | null>(null);
33
+
34
+ useEffect(() => {
35
+ let cancelled = false;
36
+ setLoadErr(null);
37
+ setProject(null);
38
+ if (!projectId) return;
39
+ getProject(projectId)
40
+ .then((p) => {
41
+ if (!cancelled) setProject(p);
42
+ })
43
+ .catch((e) => {
44
+ if (!cancelled)
45
+ setLoadErr(e instanceof Error ? e.message : String(e));
46
+ });
47
+ return () => {
48
+ cancelled = true;
49
+ };
50
+ }, [projectId]);
51
+
52
+ return (
53
+ <main className="mx-auto max-w-3xl space-y-5 px-4 py-8">
54
+ <header>
55
+ <div className="text-xs uppercase text-slate-500">Проект</div>
56
+ <h1 className="text-2xl font-bold tracking-tight text-slate-900">
57
+ {project?.object_name || loadErr || "(загрузка...)"}
58
+ </h1>
59
+ {project && (
60
+ <div className="mt-1 text-xs text-slate-500">
61
+ Договор: <code className="font-mono">{project.contract_no || "—"}</code>{" "}
62
+ · Заказчик: {project.client || "—"}
63
+ <span className="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-mono text-slate-500">
64
+ {projectId.slice(0, 8)}
65
+ </span>
66
+ </div>
67
+ )}
68
+ {project?.warning && (
69
+ <div className="mt-2 rounded-xl border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
70
+ {project.warning}
71
+ </div>
72
+ )}
73
+ </header>
74
+
75
+ <BlueprintsPanel
76
+ projectId={projectId}
77
+ refreshKey={refreshKey}
78
+ />
79
+
80
+ <DocumentationPanel
81
+ projectId={projectId}
82
+ refreshKey={refreshKey}
83
+ onUpdated={(r) => {
84
+ setLastResult({ label: "extract-all", r });
85
+ setRefreshKey((k) => k + 1);
86
+ }}
87
+ />
88
+
89
+ <SheetVisionPanel
90
+ projectId={projectId}
91
+ refreshKey={refreshKey}
92
+ onResult={(r) => {
93
+ setLastResult({ label: "extract-sheets", r });
94
+ }}
95
+ />
96
+
97
+ {lastResult && (
98
+ <section className="rounded-2xl border border-slate-200 bg-white p-4 text-xs text-slate-600 shadow-sm">
99
+ <div className="font-semibold text-slate-700">
100
+ Последний ответ сервера ·{" "}
101
+ <span className="font-mono">{lastResult.label}</span>
102
+ </div>
103
+ <div className="mt-1">
104
+ модель:{" "}
105
+ <code className="font-mono">{lastResult.r.model_used}</code>{" "}
106
+ · {lastResult.r.page_count} стр. ·{" "}
107
+ {new Date(lastResult.r.generated_at).toLocaleString("ru-RU")}
108
+ </div>
109
+ </section>
110
+ )}
111
+
112
+ <footer className="flex justify-between pt-4">
113
+ <Link
114
+ href="/projects"
115
+ className="text-xs text-slate-400 hover:text-slate-600"
116
+ >
117
+ ← все проекты
118
+ </Link>
119
+ <Link
120
+ href="/"
121
+ className="text-xs text-slate-400 hover:text-slate-600"
122
+ >
123
+ на главную →
124
+ </Link>
125
+ </footer>
126
+ </main>
127
+ );
128
+ }
frontend/app/projects/new/page.tsx ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /** /projects/new — create form. After success: navigate to /projects/{id}. v3.0. */
4
+
5
+ import Link from "next/link";
6
+ import { useRouter } from "next/navigation";
7
+ import { useState } from "react";
8
+ import { createProject, type Project } from "@/lib/api";
9
+
10
+ type FormState = {
11
+ contract_no: string;
12
+ object_name: string;
13
+ client: string;
14
+ technical_supervisor: string;
15
+ contractor: string;
16
+ work_producer: string;
17
+ chief_engineer: string;
18
+ };
19
+
20
+ const initial: FormState = {
21
+ contract_no: "",
22
+ object_name: "",
23
+ client: "",
24
+ technical_supervisor: "",
25
+ contractor: "",
26
+ work_producer: "",
27
+ chief_engineer: "",
28
+ };
29
+
30
+ export default function NewProjectPage() {
31
+ const router = useRouter();
32
+ const [form, setForm] = useState<FormState>(initial);
33
+ const [loading, setLoading] = useState(false);
34
+ const [project, setProject] = useState<Project | null>(null);
35
+ const [error, setError] = useState<string | null>(null);
36
+
37
+ const handle =
38
+ <K extends keyof FormState>(k: K) =>
39
+ (e: React.ChangeEvent<HTMLInputElement>) =>
40
+ setForm((s) => ({ ...s, [k]: e.target.value }));
41
+
42
+ const submit = async (e: React.FormEvent) => {
43
+ e.preventDefault();
44
+ setError(null);
45
+ setProject(null);
46
+ setLoading(true);
47
+ try {
48
+ const resp = await createProject(form);
49
+ setProject(resp);
50
+ // Auto-navigate after a brief confirmation beat.
51
+ setTimeout(() => router.push(`/projects/${resp.id}`), 600);
52
+ } catch (err) {
53
+ setError(err instanceof Error ? err.message : "Неизвестная ошибка");
54
+ } finally {
55
+ setLoading(false);
56
+ }
57
+ };
58
+
59
+ const fields: {
60
+ key: keyof FormState;
61
+ label: string;
62
+ placeholder?: string;
63
+ }[] = [
64
+ { key: "contract_no", label: "Договор №", placeholder: "12-2026/БН" },
65
+ { key: "object_name", label: "Объект", placeholder: "Жилой дом по ул. ..." },
66
+ { key: "client", label: "Заказчик" },
67
+ { key: "technical_supervisor", label: "Технадзор" },
68
+ { key: "contractor", label: "Подрядчик" },
69
+ { key: "work_producer", label: "Производитель работ (прораб)" },
70
+ { key: "chief_engineer", label: "Главный инженер" },
71
+ ];
72
+
73
+ return (
74
+ <main className="mx-auto max-w-xl px-4 py-10">
75
+ <h1 className="mb-6 text-2xl font-bold tracking-tight text-slate-900">
76
+ Новый проект
77
+ </h1>
78
+
79
+ {project && (
80
+ <div className="mb-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-5">
81
+ <p className="font-semibold text-emerald-700">Проект создан</p>
82
+ <p className="mt-2 text-sm text-slate-700">
83
+ {project.object_name} (договор {project.contract_no})
84
+ </p>
85
+ <p className="mt-1 text-[11px] font-mono text-slate-500">
86
+ id: {project.id}
87
+ </p>
88
+ {project.warning && (
89
+ <p className="mt-2 text-xs text-amber-700">{project.warning}</p>
90
+ )}
91
+ <p className="mt-3 text-xs text-slate-500">
92
+ Переходим к чертёжной панели...
93
+ </p>
94
+ </div>
95
+ )}
96
+
97
+ {!project && (
98
+ <form onSubmit={submit} className="space-y-4">
99
+ {fields.map((f) => (
100
+ <label key={f.key} className="block">
101
+ <span className="mb-1 block text-sm font-medium text-slate-700">
102
+ {f.label}
103
+ </span>
104
+ <input
105
+ required
106
+ value={form[f.key]}
107
+ onChange={handle(f.key)}
108
+ placeholder={f.placeholder}
109
+ className="w-full rounded-xl border border-slate-300 bg-white px-4 py-3 text-base outline-none focus:border-slate-900 focus:ring-2 focus:ring-slate-900/20"
110
+ />
111
+ </label>
112
+ ))}
113
+ <button
114
+ type="submit"
115
+ disabled={loading}
116
+ className="w-full rounded-2xl bg-slate-900 py-3 font-semibold text-white shadow hover:bg-slate-800 disabled:bg-slate-400"
117
+ >
118
+ {loading ? "Создание..." : "Создать проект"}
119
+ </button>
120
+ </form>
121
+ )}
122
+
123
+ {error && (
124
+ <div className="mt-6 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
125
+ {error}
126
+ </div>
127
+ )}
128
+
129
+ <footer className="mt-10 flex justify-between">
130
+ <Link href="/" className="text-xs text-slate-400 hover:text-slate-600">
131
+ ← на главную
132
+ </Link>
133
+ <Link
134
+ href="/projects"
135
+ className="text-xs text-slate-900 hover:text-slate-700"
136
+ >
137
+ Все проекты →
138
+ </Link>
139
+ </footer>
140
+ </main>
141
+ );
142
+ }
frontend/app/projects/page.tsx ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /** /projects — list of all projects. v3.0. */
4
+
5
+ import Link from "next/link";
6
+ import { useEffect, useState } from "react";
7
+ import { listProjects, type Project } from "@/lib/api";
8
+
9
+ export default function ProjectsListPage() {
10
+ const [items, setItems] = useState<Project[] | null>(null);
11
+ const [error, setError] = useState<string | null>(null);
12
+
13
+ useEffect(() => {
14
+ listProjects()
15
+ .then(setItems)
16
+ .catch((e) => setError(e instanceof Error ? e.message : "error"));
17
+ }, []);
18
+
19
+ return (
20
+ <main className="mx-auto max-w-xl px-4 py-10">
21
+ <header className="mb-6 flex items-center justify-between">
22
+ <h1 className="text-2xl font-bold tracking-tight text-slate-900">
23
+ Проекты
24
+ </h1>
25
+ <Link
26
+ href="/projects/new"
27
+ className="rounded-2xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800"
28
+ >
29
+ + Новый
30
+ </Link>
31
+ </header>
32
+
33
+ {items === null && !error && (
34
+ <p className="text-sm text-slate-500">Загрузка...</p>
35
+ )}
36
+
37
+ {error && (
38
+ <div className="rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
39
+ {error}
40
+ </div>
41
+ )}
42
+
43
+ {items && items.length === 0 && (
44
+ <div className="rounded-2xl border border-slate-200 bg-white p-5 text-sm text-slate-600">
45
+ Пока нет проектов. Создайте первый — кнопка выше.
46
+ </div>
47
+ )}
48
+
49
+ {items && items.length > 0 && (
50
+ <ul className="space-y-2">
51
+ {items.map((p) => (
52
+ <li
53
+ key={p.id}
54
+ className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
55
+ >
56
+ <Link
57
+ href={`/projects/${p.id}`}
58
+ className="block hover:opacity-90"
59
+ >
60
+ <div className="flex items-start justify-between gap-3">
61
+ <div className="min-w-0">
62
+ <p className="truncate text-sm font-semibold text-slate-900">
63
+ {p.object_name || "(без названия)"}
64
+ </p>
65
+ <p className="mt-0.5 text-xs text-slate-500">
66
+ Договор {p.contract_no || "—"} · {p.client || "—"}
67
+ </p>
68
+ </div>
69
+ <code className="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-500">
70
+ {p.id.slice(0, 8)}
71
+ </code>
72
+ </div>
73
+ </Link>
74
+ </li>
75
+ ))}
76
+ </ul>
77
+ )}
78
+
79
+ <footer className="mt-10 text-center">
80
+ <Link
81
+ href="/"
82
+ className="text-xs text-slate-400 hover:text-slate-600"
83
+ >
84
+ ← на главную
85
+ </Link>
86
+ </footer>
87
+ </main>
88
+ );
89
+ }
frontend/lib/api.ts CHANGED
@@ -344,3 +344,118 @@ export async function extractProjectSheets(
344
  { pages }
345
  );
346
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  { pages }
345
  );
346
  }
347
+
348
+ // ===== v2.0 — Project-aware blueprint aggregation =====
349
+
350
+ export interface UploadRow {
351
+ upload_id: string;
352
+ file_name: string;
353
+ mime_type: string;
354
+ sha256: string;
355
+ page_count: number;
356
+ has_native_text: boolean;
357
+ status: string;
358
+ created_at: string | null;
359
+ }
360
+
361
+ export interface UploadsListResponse {
362
+ project_id: string;
363
+ uploads_count: number;
364
+ uploads: UploadRow[];
365
+ generated_at: string;
366
+ }
367
+
368
+ export interface AggregatedPage {
369
+ upload_id: string;
370
+ upload_file_name: string;
371
+ page_no: number;
372
+ char_count: number;
373
+ stamp: string | null;
374
+ title_guess: string | null;
375
+ is_empty: boolean;
376
+ section_guess: string | null;
377
+ text_preview: string;
378
+ }
379
+
380
+ export interface UploadSummary {
381
+ upload_id: string;
382
+ file_name: string;
383
+ mime_type: string;
384
+ sha256: string;
385
+ page_count: number;
386
+ total_chars: number;
387
+ section_floor: string | null;
388
+ sections: string[];
389
+ }
390
+
391
+ export interface DokumentatsiyaResponse {
392
+ project_id: string;
393
+ aggregate_sha256: string;
394
+ total_uploads: number;
395
+ total_pages: number;
396
+ total_chars: number;
397
+ overall_project_section: string | null;
398
+ upload_summaries: UploadSummary[];
399
+ pages: AggregatedPage[];
400
+ cached_summary: ProjectSummary | null;
401
+ cached_summary_model: string | null;
402
+ generated_at: string;
403
+ }
404
+
405
+ export interface ExtractAllResponse {
406
+ upload_id: string; // field reuse — UI sees project_id here
407
+ model_used: string;
408
+ page_count: number;
409
+ generated_at: string;
410
+ summary: ProjectSummary;
411
+ }
412
+
413
+ export interface SheetExtractPair {
414
+ upload_id: string | null;
415
+ page_no: number;
416
+ }
417
+
418
+ /** GET /projects/{id}/uploads. */
419
+ export async function listProjectUploads(project_id: string): Promise<UploadsListResponse> {
420
+ return getJSON<UploadsListResponse>(`/projects/${project_id}/uploads`);
421
+ }
422
+
423
+ /** GET /projects/{id}/dokumentatsiya. */
424
+ export async function projectDokumentatsiya(project_id: string): Promise<DokumentatsiyaResponse> {
425
+ return getJSON<DokumentatsiyaResponse>(`/projects/${project_id}/dokumentatsiya`);
426
+ }
427
+
428
+ /** POST /projects/{id}/extract-all. */
429
+ export async function extractProjectAll(
430
+ project_id: string,
431
+ opts: { force?: boolean; max_chars?: number } = {}
432
+ ): Promise<ExtractAllResponse> {
433
+ const qs = new URLSearchParams();
434
+ if (opts.force) qs.set("force", "true");
435
+ if (typeof opts.max_chars === "number") qs.set("max_chars", String(opts.max_chars));
436
+ const path = `/projects/${project_id}/extract-all${qs.toString() ? `?${qs}` : ""}`;
437
+ return postJSON<ExtractAllResponse>(path, {});
438
+ }
439
+
440
+ /** POST /projects/{id}/extract-sheets. */
441
+ export async function extractProjectSheetsV2(
442
+ project_id: string,
443
+ pairs: SheetExtractPair[]
444
+ ): Promise<ExtractAllResponse> {
445
+ return postJSON<ExtractAllResponse>(
446
+ `/projects/${project_id}/extract-sheets`,
447
+ { pages: pairs }
448
+ );
449
+ }
450
+
451
+ /** POST /uploads/blueprint (project-scoped upload). */
452
+ export async function uploadBlueprintV2(
453
+ file: File,
454
+ project_id: string,
455
+ opts: { signal?: AbortSignal } = {}
456
+ ): Promise<UploadBlueprintStatus> {
457
+ const form = new FormData();
458
+ form.append("file", file);
459
+ form.append("project_id", project_id);
460
+ return postForm<UploadBlueprintStatus>("/uploads/blueprint", form, opts);
461
+ }