Add project transfer controls

This commit is contained in:
2026-07-28 19:36:26 +02:00
parent da8115fe1d
commit feec814dc5
5 changed files with 353 additions and 8 deletions
+65
View File
@@ -12,6 +12,7 @@ import {
createRoom,
deleteProjectDevice,
disconnectProjectDeviceRows,
exportProjectTransfer,
getProjectDeviceSyncPreview,
listCircuitLists,
listDistributionBoards,
@@ -20,6 +21,7 @@ import {
listProjectDevices,
listProjects,
listRooms,
importProjectTransfer,
synchronizeProjectDeviceRows,
updateProjectDevice,
updateProjectSettings,
@@ -349,6 +351,67 @@ export default function ProjectDetailPage() {
}
}
async function handleExportProject() {
if (!project) {
return;
}
setIsSaving(true);
setError(null);
try {
const transfer = await exportProjectTransfer(project.id);
const blob = new Blob([JSON.stringify(transfer, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${project.name.replace(/[^a-zA-Z0-9_-]+/g, "-") || "projekt"}.leistungsbilanz.json`;
anchor.click();
URL.revokeObjectURL(url);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Projekt konnte nicht exportiert werden."
);
} finally {
setIsSaving(false);
}
}
async function handleImportProject(
transfer: unknown,
mode: "replace" | "duplicate"
) {
if (!project) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = await importProjectTransfer(
project.id,
mode,
project.currentRevision,
transfer
);
if (mode === "duplicate" && "projectId" in result) {
window.location.href = `/projects/${result.projectId}`;
return;
}
window.location.reload();
} catch (err) {
setError(
err instanceof Error
? err.message
: "Projektdatei konnte nicht importiert werden."
);
setIsProjectSettingsOpen(false);
} finally {
setIsSaving(false);
}
}
async function openProjectDeviceSyncPreview(projectDeviceId: string) {
if (!projectId) {
return;
@@ -1015,6 +1078,8 @@ export default function ProjectDetailPage() {
<ProjectSettingsModal
isSaving={isSaving}
onClose={() => setIsProjectSettingsOpen(false)}
onExport={handleExportProject}
onImport={handleImportProject}
onSave={handleSaveProjectSettings}
project={project}
/>
@@ -6,7 +6,6 @@ import {
} from "../../domain/models/project-state-restore-command.model.js";
import {
parseProjectStateSnapshot,
serializeProjectStateSnapshot,
type ProjectStateSnapshot,
} from "../../domain/models/project-state-snapshot.model.js";
import type {
@@ -24,11 +23,11 @@ import { floors } from "../schema/floors.js";
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
import { projectDevices } from "../schema/project-devices.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
import { projects } from "../schema/projects.js";
import { rooms } from "../schema/rooms.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import {
hashProjectStatePayload,
readProjectStateSnapshot,
} from "./project-state-snapshot.persistence.js";
@@ -63,7 +62,8 @@ export class ProjectStateRestoreCommandRepository
}
if (
current.payloadSha256 !==
input.command.payload.expectedStateSha256
input.command.payload.expectedStateSha256 &&
!matchesHistoryRestoreTarget(tx, input, current.state)
) {
throw new ProjectStateConflictError(
input.projectId,
@@ -75,18 +75,90 @@ export class ProjectStateRestoreCommandRepository
const targetState = parseProjectStateSnapshot(
input.command.payload.targetState
);
const targetPayloadJson = serializeProjectStateSnapshot(targetState);
replaceProjectState(tx, targetState);
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
if (!persistedTarget) {
throw new Error("Restored project could not be loaded.");
}
const inverse = createProjectStateRestoreCommand(
hashProjectStatePayload(targetPayloadJson),
persistedTarget.payloadSha256,
current.state
);
replaceProjectState(tx, targetState);
return inverse;
}
}
function matchesHistoryRestoreTarget(
database: AppDatabase,
input: ExecuteProjectStateRestoreCommandInput,
currentState: ProjectStateSnapshot
) {
if (
(input.source !== "undo" && input.source !== "redo") ||
!input.historyTargetChangeSetId
) {
return false;
}
const changeSet = database
.select({
forwardPayloadJson: projectChangeSets.forwardPayloadJson,
inversePayloadJson: projectChangeSets.inversePayloadJson,
})
.from(projectChangeSets)
.where(eq(projectChangeSets.id, input.historyTargetChangeSetId))
.get();
if (!changeSet) {
return false;
}
try {
const serialized =
input.source === "undo"
? changeSet.forwardPayloadJson
: changeSet.inversePayloadJson;
const command = JSON.parse(serialized) as {
type?: unknown;
payload?: { targetState?: unknown };
};
if (command.type !== "project.restore-state") {
return false;
}
const expectedState = parseProjectStateSnapshot(
command.payload?.targetState
);
return (
canonicalProjectState(expectedState) ===
canonicalProjectState(currentState)
);
} catch {
return false;
}
}
function canonicalProjectState(state: ProjectStateSnapshot) {
return JSON.stringify({
...state,
distributionBoards: byId(state.distributionBoards),
circuitLists: byId(state.circuitLists),
circuitSections: byId(state.circuitSections),
circuits: byId(
state.circuits.map((circuit) => ({
...circuit,
deviceRows: byId(circuit.deviceRows),
}))
),
projectDevices: byId(state.projectDevices),
floors: byId(state.floors),
rooms: byId(state.rooms),
});
}
function byId<T extends { id: string }>(entries: readonly T[]) {
return [...entries].sort((left, right) =>
left.id.localeCompare(right.id)
);
}
function replaceProjectState(
database: AppDatabase,
state: ProjectStateSnapshot
@@ -16,6 +16,11 @@ export interface ProjectSettingsInput {
interface ProjectSettingsModalProps {
isSaving: boolean;
onClose: () => void;
onExport: () => Promise<void>;
onImport: (
transfer: unknown,
mode: "replace" | "duplicate"
) => Promise<void>;
onSave: (input: ProjectSettingsInput) => Promise<void>;
project: ProjectDto;
}
@@ -23,6 +28,8 @@ interface ProjectSettingsModalProps {
export function ProjectSettingsModal({
isSaving,
onClose,
onExport,
onImport,
onSave,
project,
}: ProjectSettingsModalProps) {
@@ -45,6 +52,13 @@ export function ProjectSettingsModal({
const [threePhaseVoltageV, setThreePhaseVoltageV] = useState(
String(project.threePhaseVoltageV)
);
const [transfer, setTransfer] = useState<unknown>(null);
const [transferFilename, setTransferFilename] = useState("");
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
"duplicate"
);
const [replaceConfirmed, setReplaceConfirmed] = useState(false);
const [fileError, setFileError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -64,6 +78,21 @@ export function ProjectSettingsModal({
Number(singlePhaseVoltageV) > 0 &&
Number(threePhaseVoltageV) > 0;
async function handleTransferFile(file: File | undefined) {
setTransfer(null);
setTransferFilename("");
setFileError(null);
if (!file) {
return;
}
try {
setTransfer(JSON.parse(await file.text()) as unknown);
setTransferFilename(file.name);
} catch {
setFileError("Die ausgewählte Datei enthält kein gültiges JSON.");
}
}
return (
<>
<div
@@ -193,6 +222,99 @@ export function ProjectSettingsModal({
value={threePhaseVoltageV}
/>
</div>
<div className="col-12">
<hr className="my-2" />
<h3 className="h6">Projekt importieren oder exportieren</h3>
<p className="text-secondary small">
Der Export enthält den vollständigen unterstützten
Projektzustand. Beim Duplizieren bleibt dieses Projekt
unverändert.
</p>
<button
className="btn btn-outline-primary mb-3"
disabled={isSaving}
onClick={() => void onExport()}
type="button"
>
Projektdatei herunterladen
</button>
<label className="form-label d-block" htmlFor="project-import">
Projektdatei auswählen
</label>
<input
accept=".json,application/json"
className="form-control"
id="project-import"
onChange={(event) =>
void handleTransferFile(event.target.files?.[0])
}
type="file"
/>
{transferFilename ? (
<div className="form-text">{transferFilename} ist bereit.</div>
) : null}
{fileError ? (
<div className="text-danger small mt-1">{fileError}</div>
) : null}
<div className="d-flex flex-wrap gap-3 mt-3">
<label className="form-check">
<input
checked={importMode === "duplicate"}
className="form-check-input"
name="import-mode"
onChange={() => setImportMode("duplicate")}
type="radio"
/>
<span className="form-check-label">
Als neues Projekt duplizieren
</span>
</label>
<label className="form-check">
<input
checked={importMode === "replace"}
className="form-check-input"
name="import-mode"
onChange={() => setImportMode("replace")}
type="radio"
/>
<span className="form-check-label">
Dieses Projekt ersetzen
</span>
</label>
</div>
{importMode === "replace" ? (
<label className="form-check mt-2">
<input
checked={replaceConfirmed}
className="form-check-input"
onChange={(event) =>
setReplaceConfirmed(event.target.checked)
}
type="checkbox"
/>
<span className="form-check-label text-danger">
Ich bestätige, dass der aktuelle Projektstand ersetzt
wird.
</span>
</label>
) : null}
<button
className="btn btn-outline-danger mt-3"
disabled={
isSaving ||
transfer === null ||
(importMode === "replace" && !replaceConfirmed)
}
onClick={() =>
transfer === null
? undefined
: void onImport(transfer, importMode)
}
type="button"
>
Projektdatei importieren
</button>
</div>
</div>
</div>
<div className="modal-footer">
+23
View File
@@ -229,6 +229,29 @@ export function updateProjectSettings(
);
}
export function exportProjectTransfer(projectId: string) {
return request<unknown>(`/api/projects/${projectId}/export`);
}
export function importProjectTransfer(
projectId: string,
mode: "replace" | "duplicate",
expectedRevision: number,
transfer: unknown
) {
return request<
| { projectId: string; name: string }
| ProjectSettingsCommandResultDto
>(`/api/projects/${projectId}/import`, {
method: "POST",
body: JSON.stringify(
mode === "replace"
? { mode, expectedRevision, transfer }
: { mode, transfer }
),
});
}
export function listDistributionBoards(projectId: string) {
return request<DistributionBoardDto[]>(`/api/projects/${projectId}/distribution-boards`);
}
@@ -22,9 +22,9 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/reposi
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitLists } from "../src/db/schema/circuit-lists.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js";
@@ -37,6 +37,12 @@ import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
import { projects } from "../src/db/schema/projects.js";
import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import {
hashProjectStatePayload,
readProjectStateSnapshot,
} from "../src/db/repositories/project-state-snapshot.persistence.js";
import { serializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
import { rooms } from "../src/db/schema/rooms.js";
import { ProjectStateConflictError } from "../src/domain/errors/project-state-conflict.error.js";
import { ProjectCommandService } from "../src/domain/services/project-command.service.js";
@@ -253,6 +259,63 @@ function seedUpgradeOnlyData(context: DatabaseContext) {
}
describe("project state restore command", () => {
it("undoes a remapped cross-project import using the persisted target hash", () => {
const context = createTestDatabase();
try {
const transfers = new ProjectTransferRepository(context.db);
const transfer = transfers.exportProject("project-1");
assert.ok(transfer);
const duplicate = transfers.importDuplicate(transfer);
const before = readProjectStateSnapshot(
context.db,
duplicate.projectId
);
assert.ok(before);
const prepared = transfers.prepareReplace(
duplicate.projectId,
transfer
);
assert.ok(prepared);
const service = createService(context);
const restored = service.executeRestore({
projectId: duplicate.projectId,
expectedRevision: 0,
description: "Projektimport wiederherstellen",
command: prepared.command,
});
const legacyExpectedHash = hashProjectStatePayload(
serializeProjectStateSnapshot(
prepared.command.payload.targetState
)
);
const storedChangeSet = context.db
.select()
.from(projectChangeSets)
.where(eq(projectChangeSets.id, restored.revision.changeSetId))
.get();
assert.ok(storedChangeSet);
const inverse = JSON.parse(storedChangeSet.inversePayloadJson);
inverse.payload.expectedStateSha256 = legacyExpectedHash;
context.db
.update(projectChangeSets)
.set({ inversePayloadJson: JSON.stringify(inverse) })
.where(eq(projectChangeSets.id, storedChangeSet.id))
.run();
const undone = service.undo({
projectId: duplicate.projectId,
expectedRevision: 1,
});
assert.equal(undone.revision.revisionNumber, 2);
assert.equal(
readProjectStateSnapshot(context.db, duplicate.projectId)
?.payloadSha256,
before.payloadSha256
);
} finally {
context.close();
}
});
it("restores a complete snapshot as a revision and supports undo and redo", () => {
const context = createTestDatabase();
try {