Add project transfer controls
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user