Add project overview import

This commit is contained in:
2026-07-29 08:36:57 +02:00
parent 0180f768ac
commit 194bc9c0b1
9 changed files with 268 additions and 32 deletions
+6 -1
View File
@@ -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 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- 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 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 `distribution-board.insert` versioniert die Anlage einer Verteilung als einen
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen. vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil. Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
+3 -24
View File
@@ -5,18 +5,6 @@ requirements and intended sequencing, not proof of implementation.
## Circuit List Editor ## 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 - Collect the remaining editor interaction issues before changing additional
behavior. behavior.
@@ -53,20 +41,11 @@ requirements and intended sequencing, not proof of implementation.
exceptional voltage is not silently overwritten when phase type or project exceptional voltage is not silently overwritten when phase type or project
settings change. 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 ## Recommended Sequence
1. Circuit-list presentation improvements. 1. Distribution-board floor and supply fields.
2. Project-overview import as a new project. 2. Device voltage default and override semantics.
3. Distribution-board floor and supply fields. 3. Snapshot-to-revision descriptions and history presentation.
4. Device voltage default and override semantics.
5. Snapshot-to-revision descriptions and history presentation.
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
must not be inferred from these tasks. must not be inferred from these tasks.
+2 -2
View File
@@ -15,8 +15,8 @@
"build:web": "next build", "build:web": "next build",
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json", "typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
"start": "node dist/server/index.js", "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": "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-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:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:backup": "tsx scripts/db-backup.ts", "db:backup": "tsx scripts/db-backup.ts",
+109 -1
View File
@@ -6,6 +6,7 @@ import {
createGlobalDevice, createGlobalDevice,
createProject, createProject,
deleteGlobalDevice, deleteGlobalDevice,
importProjectTransferAsNew,
listGlobalDevices, listGlobalDevices,
listProjects, listProjects,
updateGlobalDevice, updateGlobalDevice,
@@ -55,6 +56,16 @@ export default function ProjectsPage() {
}); });
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [projectImport, setProjectImport] = useState<unknown>(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() { async function loadData() {
setError(null); 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<HTMLFormElement>) { async function handleCreateGlobalDevice(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!globalDeviceForm.name.trim()) { if (!globalDeviceForm.name.trim()) {
@@ -179,7 +233,7 @@ export default function ProjectsPage() {
<div className="row g-4"> <div className="row g-4">
<section className="col-12 col-lg-4"> <section className="col-12 col-lg-4">
<div className="card shadow-sm"> <div className="card shadow-sm mb-4">
<div className="card-header">Neues Projekt</div> <div className="card-header">Neues Projekt</div>
<div className="card-body"> <div className="card-body">
<form className="vstack gap-3" onSubmit={handleCreateProject}> <form className="vstack gap-3" onSubmit={handleCreateProject}>
@@ -198,6 +252,60 @@ export default function ProjectsPage() {
</form> </form>
</div> </div>
</div> </div>
<div className="card shadow-sm">
<div className="card-header">Projekt importieren</div>
<div className="card-body">
<p className="text-secondary small">
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
mit neuen internen IDs angelegt.
</p>
<label className="form-label" htmlFor="project-overview-import">
Projektdatei auswählen
</label>
<input
accept=".json,application/json"
className="form-control"
disabled={isSaving}
id="project-overview-import"
key={projectImportInputKey}
onChange={(event) =>
void handleProjectImportFile(event.target.files?.[0])
}
type="file"
/>
{projectImportFilename ? (
<div className="form-text">
{projectImportFilename} ist bereit.
</div>
) : null}
{projectImportFileError ? (
<div className="text-danger small mt-1">
{projectImportFileError}
</div>
) : null}
<button
className="btn btn-outline-primary mt-3 w-100"
disabled={isSaving || projectImport === null}
onClick={() => void handleImportProject()}
type="button"
>
Als neues Projekt importieren
</button>
{importedProject ? (
<div className="alert alert-success mt-3 mb-0">
<div className="small mb-2">
{importedProject.name} wurde angelegt.
</div>
<Link
className="btn btn-sm btn-success"
href={`/projects/${importedProject.projectId}`}
>
Importiertes Projekt öffnen
</Link>
</div>
) : null}
</div>
</div>
</section> </section>
<section className="col-12 col-lg-8"> <section className="col-12 col-lg-8">
+19 -1
View File
@@ -64,7 +64,15 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
}); });
if (!response.ok) { 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})`); throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
} }
@@ -233,6 +241,16 @@ export function exportProjectTransfer(projectId: string) {
return request<unknown>(`/api/projects/${projectId}/export`); return request<unknown>(`/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( export function importProjectTransfer(
projectId: string, projectId: string,
mode: "replace" | "duplicate", mode: "replace" | "duplicate",
@@ -1,7 +1,10 @@
import type { NextFunction, Request, Response } from "express"; import type { NextFunction, Request, Response } from "express";
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js"; import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
import { ProjectStateConflictError } from "../../domain/errors/project-state-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 { projectCommandService } from "../composition/project-command-stores.js";
import { projectTransferStore } from "../composition/project-transfer-store.js"; import { projectTransferStore } from "../composition/project-transfer-store.js";
@@ -22,6 +25,31 @@ export function exportProjectTransfer(req: Request, res: Response) {
return res.json(transfer); 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( export function importProjectTransfer(
req: Request, req: Request,
res: Response, res: Response,
+2
View File
@@ -30,12 +30,14 @@ import {
import { import {
exportProjectTransfer, exportProjectTransfer,
importProjectTransfer, importProjectTransfer,
importProjectTransferAsNewProject,
} from "../controllers/project-transfer.controller.js"; } from "../controllers/project-transfer.controller.js";
export const projectRouter = Router(); export const projectRouter = Router();
projectRouter.get("/", listProjects); projectRouter.get("/", listProjects);
projectRouter.post("/", createProject); projectRouter.post("/", createProject);
projectRouter.post("/import", importProjectTransferAsNewProject);
projectRouter.get("/:projectId", getProject); projectRouter.get("/:projectId", getProject);
projectRouter.get("/:projectId/export", exportProjectTransfer); projectRouter.get("/:projectId/export", exportProjectTransfer);
projectRouter.post("/:projectId/import", importProjectTransfer); projectRouter.post("/:projectId/import", importProjectTransfer);
@@ -1,18 +1,30 @@
import { z } from "zod"; import { z } from "zod";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js"; 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", [ export const importProjectTransferSchema = z.discriminatedUnion("mode", [
z z
.object({ .object({
mode: z.literal("replace"), mode: z.literal("replace"),
expectedRevision: expectedProjectRevisionSchema, expectedRevision: expectedProjectRevisionSchema,
transfer: z.unknown(), transfer: requiredProjectTransferSchema,
}) })
.strict(), .strict(),
z z
.object({ .object({
mode: z.literal("duplicate"), mode: z.literal("duplicate"),
transfer: z.unknown(), transfer: requiredProjectTransferSchema,
}) })
.strict(), .strict(),
]); ]);
+84
View File
@@ -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
);
});
});