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
+109 -1
View File
@@ -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<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() {
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>) {
event.preventDefault();
if (!globalDeviceForm.name.trim()) {
@@ -179,7 +233,7 @@ export default function ProjectsPage() {
<div className="row g-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-body">
<form className="vstack gap-3" onSubmit={handleCreateProject}>
@@ -198,6 +252,60 @@ export default function ProjectsPage() {
</form>
</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 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) {
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<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(
projectId: string,
mode: "replace" | "duplicate",
@@ -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,
+2
View File
@@ -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);
@@ -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(),
]);