From 194bc9c0b12e08b7bde4c5db65c137355448eacf Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Wed, 29 Jul 2026 08:36:57 +0200 Subject: [PATCH] Add project overview import --- docs/current-architecture.md | 7 +- docs/spec/08-current-product-backlog.md | 27 +---- package.json | 4 +- src/app/projects/page.tsx | 110 +++++++++++++++++- src/frontend/utils/api.ts | 20 +++- .../project-transfer.controller.ts | 30 ++++- src/server/routes/project.routes.ts | 2 + .../validation/project-transfer.schemas.ts | 16 ++- tests/project-overview-import.test.ts | 84 +++++++++++++ 9 files changed, 268 insertions(+), 32 deletions(-) create mode 100644 tests/project-overview-import.test.ts diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 5bb9ca5..c30c00e 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -246,7 +246,12 @@ rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-, Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only- Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen -innerhalb des unterstützten Laufzeitmodells bleiben erhalten. +innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die +Projektübersicht verwendet dafür den separaten Collection-Endpunkt +`POST /api/projects/import`, der ausschließlich eine neue Kopie anlegt und +deshalb weder eine bestehende Projekt-ID noch `expectedRevision` annimmt. Das +Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den +projektgebundenen Endpunkt beschränkt. `distribution-board.insert` versioniert die Anlage einer Verteilung als einen vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen. Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil. diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index a741dc6..c24bd5b 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -5,18 +5,6 @@ requirements and intended sequencing, not proof of implementation. ## Circuit List Editor -- Open the ProjectDevice drawer on the right so it does not cover the leading - circuit identifier and display-name columns. -- Format displayed power values in kW with at most three decimal places. -- Format displayed factors and percentage-like values with at most two decimal - places. -- Do not round persisted values; formatting applies only to the displayed - value outside edit mode. -- Replace the separate `Zeilensumme` and `Stromkreissumme` columns with one - `Gesamtsumme` column: - - device rows show their device-row total; - - circuit summary rows show the complete circuit total; - - compact single-device rows show the identical device/circuit total once. - Collect the remaining editor interaction issues before changing additional behavior. @@ -53,20 +41,11 @@ requirements and intended sequencing, not proof of implementation. exceptional voltage is not silently overwritten when phase type or project settings change. -## Project Overview Import - -- Add JSON project import to the project overview so a portable project file - can create a new project without first opening another project. -- Keep replacement of an existing project inside that project's settings. -- Validate format version and checksum before creating project data. - ## Recommended Sequence -1. Circuit-list presentation improvements. -2. Project-overview import as a new project. -3. Distribution-board floor and supply fields. -4. Device voltage default and override semantics. -5. Snapshot-to-revision descriptions and history presentation. +1. Distribution-board floor and supply fields. +2. Device voltage default and override semantics. +3. Snapshot-to-revision descriptions and history presentation. The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and must not be inferred from these tasks. diff --git a/package.json b/package.json index 8583e15..7b76558 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "build:web": "next build", "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json", "start": "node dist/server/index.js", - "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", - "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", + "test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:backup": "tsx scripts/db-backup.ts", diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 7397850..befe3b9 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -6,6 +6,7 @@ import { createGlobalDevice, createProject, deleteGlobalDevice, + importProjectTransferAsNew, listGlobalDevices, listProjects, updateGlobalDevice, @@ -55,6 +56,16 @@ export default function ProjectsPage() { }); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); + const [projectImport, setProjectImport] = useState(null); + const [projectImportFilename, setProjectImportFilename] = useState(""); + const [projectImportInputKey, setProjectImportInputKey] = useState(0); + const [projectImportFileError, setProjectImportFileError] = useState< + string | null + >(null); + const [importedProject, setImportedProject] = useState<{ + projectId: string; + name: string; + } | null>(null); async function loadData() { setError(null); @@ -87,6 +98,49 @@ export default function ProjectsPage() { } } + async function handleProjectImportFile(file: File | undefined) { + setProjectImport(null); + setProjectImportFilename(""); + setProjectImportFileError(null); + setImportedProject(null); + if (!file) { + return; + } + try { + setProjectImport(JSON.parse(await file.text()) as unknown); + setProjectImportFilename(file.name); + } catch { + setProjectImportFileError( + "Die ausgewählte Datei enthält kein gültiges JSON." + ); + } + } + + async function handleImportProject() { + if (projectImport === null) { + return; + } + setIsSaving(true); + setError(null); + setImportedProject(null); + try { + const imported = await importProjectTransferAsNew(projectImport); + setProjects(await listProjects()); + setImportedProject(imported); + setProjectImport(null); + setProjectImportFilename(""); + setProjectImportInputKey((current) => current + 1); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Projektdatei konnte nicht importiert werden." + ); + } finally { + setIsSaving(false); + } + } + async function handleCreateGlobalDevice(event: FormEvent) { event.preventDefault(); if (!globalDeviceForm.name.trim()) { @@ -179,7 +233,7 @@ export default function ProjectsPage() {
-
+
Neues Projekt
@@ -198,6 +252,60 @@ export default function ProjectsPage() {
+
+
Projekt importieren
+
+

+ Eine exportierte JSON-Projektdatei wird als eigenständige Kopie + mit neuen internen IDs angelegt. +

+ + + void handleProjectImportFile(event.target.files?.[0]) + } + type="file" + /> + {projectImportFilename ? ( +
+ {projectImportFilename} ist bereit. +
+ ) : null} + {projectImportFileError ? ( +
+ {projectImportFileError} +
+ ) : null} + + {importedProject ? ( +
+
+ „{importedProject.name}“ wurde angelegt. +
+ + Importiertes Projekt öffnen + +
+ ) : null} +
+
diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 6300c10..5e76197 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -64,7 +64,15 @@ async function request(url: string, init?: RequestInit): Promise { }); if (!response.ok) { - const details = await response.text(); + let details = await response.text(); + try { + const parsed = JSON.parse(details) as { error?: unknown }; + if (typeof parsed.error === "string") { + details = parsed.error; + } + } catch { + // Keep non-JSON error responses unchanged. + } throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`); } @@ -233,6 +241,16 @@ export function exportProjectTransfer(projectId: string) { return request(`/api/projects/${projectId}/export`); } +export function importProjectTransferAsNew(transfer: unknown) { + return request<{ projectId: string; name: string }>( + "/api/projects/import", + { + method: "POST", + body: JSON.stringify({ transfer }), + } + ); +} + export function importProjectTransfer( projectId: string, mode: "replace" | "duplicate", diff --git a/src/server/controllers/project-transfer.controller.ts b/src/server/controllers/project-transfer.controller.ts index a56f3e5..05058f3 100644 --- a/src/server/controllers/project-transfer.controller.ts +++ b/src/server/controllers/project-transfer.controller.ts @@ -1,7 +1,10 @@ import type { NextFunction, Request, Response } from "express"; import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js"; import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js"; -import { importProjectTransferSchema } from "../../shared/validation/project-transfer.schemas.js"; +import { + importProjectTransferAsNewSchema, + importProjectTransferSchema, +} from "../../shared/validation/project-transfer.schemas.js"; import { projectCommandService } from "../composition/project-command-stores.js"; import { projectTransferStore } from "../composition/project-transfer-store.js"; @@ -22,6 +25,31 @@ export function exportProjectTransfer(req: Request, res: Response) { return res.json(transfer); } +export function importProjectTransferAsNewProject( + req: Request, + res: Response, + next: NextFunction +) { + const parsed = importProjectTransferAsNewSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + try { + return res + .status(201) + .json(projectTransferStore.importDuplicate(parsed.data.transfer)); + } catch (error) { + if ( + error instanceof Error && + (error.message.startsWith("Project transfer") || + error.message.startsWith("Snapshot")) + ) { + return res.status(400).json({ error: error.message }); + } + return next(error); + } +} + export function importProjectTransfer( req: Request, res: Response, diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 7ebfc11..9ee98f6 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -30,12 +30,14 @@ import { import { exportProjectTransfer, importProjectTransfer, + importProjectTransferAsNewProject, } from "../controllers/project-transfer.controller.js"; export const projectRouter = Router(); projectRouter.get("/", listProjects); projectRouter.post("/", createProject); +projectRouter.post("/import", importProjectTransferAsNewProject); projectRouter.get("/:projectId", getProject); projectRouter.get("/:projectId/export", exportProjectTransfer); projectRouter.post("/:projectId/import", importProjectTransfer); diff --git a/src/shared/validation/project-transfer.schemas.ts b/src/shared/validation/project-transfer.schemas.ts index 157c434..216791d 100644 --- a/src/shared/validation/project-transfer.schemas.ts +++ b/src/shared/validation/project-transfer.schemas.ts @@ -1,18 +1,30 @@ import { z } from "zod"; import { expectedProjectRevisionSchema } from "./project-command.schemas.js"; +const requiredProjectTransferSchema = z + .unknown() + .refine((value) => value !== undefined, { + message: "Projektdatei fehlt.", + }); + +export const importProjectTransferAsNewSchema = z + .object({ + transfer: requiredProjectTransferSchema, + }) + .strict(); + export const importProjectTransferSchema = z.discriminatedUnion("mode", [ z .object({ mode: z.literal("replace"), expectedRevision: expectedProjectRevisionSchema, - transfer: z.unknown(), + transfer: requiredProjectTransferSchema, }) .strict(), z .object({ mode: z.literal("duplicate"), - transfer: z.unknown(), + transfer: requiredProjectTransferSchema, }) .strict(), ]); diff --git a/tests/project-overview-import.test.ts b/tests/project-overview-import.test.ts new file mode 100644 index 0000000..770562e --- /dev/null +++ b/tests/project-overview-import.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import ProjectsPage from "../src/app/projects/page.js"; +import { importProjectTransferAsNew } from "../src/frontend/utils/api.js"; +import { importProjectTransferAsNewSchema } from "../src/shared/validation/project-transfer.schemas.js"; + +describe("project overview import", () => { + it("renders a dedicated import-as-new-project entry point", () => { + const globalWithReact = globalThis as typeof globalThis & { + React?: typeof React; + }; + const originalReact = globalWithReact.React; + globalWithReact.React = React; + let markup: string; + try { + markup = renderToStaticMarkup(React.createElement(ProjectsPage)); + } finally { + globalWithReact.React = originalReact; + } + + assert.match(markup, /Projekt importieren/); + assert.match(markup, /Projektdatei auswählen/); + assert.match(markup, /Als neues Projekt importieren/); + assert.doesNotMatch(markup, /Dieses Projekt ersetzen/); + }); + + it("uses the collection import endpoint without a target project id", async () => { + const requests: Array<{ + url: string; + method: string; + body: unknown; + }> = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + requests.push({ + url: String(input), + method: init?.method ?? "GET", + body: init?.body ? JSON.parse(String(init.body)) : null, + }); + return new Response( + JSON.stringify({ projectId: "project-copy", name: "Projekt (Kopie)" }), + { + status: 201, + headers: { "Content-Type": "application/json" }, + } + ); + }; + + try { + const transfer = { format: "test" }; + const result = await importProjectTransferAsNew(transfer); + assert.deepEqual(result, { + projectId: "project-copy", + name: "Projekt (Kopie)", + }); + assert.deepEqual(requests, [ + { + url: "/api/projects/import", + method: "POST", + body: { transfer }, + }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("accepts only the strict transfer wrapper", () => { + assert.equal( + importProjectTransferAsNewSchema.safeParse({ transfer: {} }).success, + true + ); + assert.equal(importProjectTransferAsNewSchema.safeParse({}).success, false); + assert.equal( + importProjectTransferAsNewSchema.safeParse({ + transfer: {}, + mode: "replace", + }).success, + false + ); + }); +});