Add print cover fields and office profile

- Project settings gain siteAddress, workStage, projectLead and
  projectEditor (project.update-settings schema version 3, migration 0009);
  versions 2 and 1 stay executable and keep the current cover values.
  Snapshot schema 6 carries the fields; older versions upgrade with null.
- Global office profile (office name/address) next to the document logo:
  table document_branding_settings, PUT /api/document-branding/office,
  editable from the print view toolbar.
- Cover page reads all fields; empty values show the "[ergänzen]" marker.
  The Planstände editor column shows the project editor.
- Shared logo constants moved to a dependency-free module so the Next.js
  bundler can import them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-25 20:42:44 +02:00
parent 4779e4a1a7
commit 7692699ee2
35 changed files with 3479 additions and 113 deletions

View file

@ -1,9 +1,10 @@
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import {
documentBrandingAssets,
type DocumentBrandingAsset,
} from "../schema/document-branding-assets.js";
import { documentBrandingSettings } from "../schema/document-branding-settings.js";
// Plain repository without revision semantics: branding is global office
// configuration, not a project mutation.
@ -49,4 +50,37 @@ export class DocumentBrandingRepository {
.run().changes > 0
);
}
getSettings<TKey extends string>(keys: readonly TKey[]): Record<TKey, string | null> {
const rows = this.db
.select()
.from(documentBrandingSettings)
.where(inArray(documentBrandingSettings.key, [...keys]))
.all();
return Object.fromEntries(
keys.map((key) => [key, rows.find((row) => row.key === key)?.value ?? null])
) as Record<TKey, string | null>;
}
/** Stores the given values atomically; `null` removes a setting. */
setSettings(values: Record<string, string | null>) {
const updatedAt = new Date();
this.db.transaction((tx) => {
for (const [key, value] of Object.entries(values)) {
if (value === null) {
tx.delete(documentBrandingSettings)
.where(eq(documentBrandingSettings.key, key))
.run();
} else {
tx.insert(documentBrandingSettings)
.values({ key, value, updatedAt })
.onConflictDoUpdate({
target: documentBrandingSettings.key,
set: { value, updatedAt },
})
.run();
}
}
});
}
}

View file

@ -1,9 +1,12 @@
import { and, eq, isNotNull } from "drizzle-orm";
import {
assertProjectSettingsUpdateProjectCommand,
baselineProjectSettingsUpdateCommandSchemaVersion,
createProjectSettingsUpdateProjectCommand,
withoutCoverProjectSettingsUpdateCommandSchemaVersion,
type ProjectSettingsValues,
} from "../../domain/models/project-settings-project-command.model.js";
import { projectCoverFields } from "../../shared/constants/project-cover.js";
import type {
ExecuteProjectSettingsUpdateCommandInput,
ProjectSettingsProjectCommandStore,
@ -40,13 +43,27 @@ export class ProjectSettingsProjectCommandRepository
if (!current) {
throw new Error("Project not found.");
}
const currentCoverValues = {
siteAddress: current.siteAddress,
workStage: current.workStage,
projectLead: current.projectLead,
projectEditor: current.projectEditor,
};
// Older command versions do not carry every value; missing values keep
// the current project state and are therefore also absent from the
// inverse of the same version.
const command = input.command;
const target: ProjectSettingsValues =
input.command.schemaVersion === 1
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
? {
...input.command.payload,
...command.payload,
isPublicBuilding: current.isPublicBuilding,
...currentCoverValues,
}
: input.command.payload;
: command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? { ...command.payload, ...currentCoverValues }
: command.payload;
if (projectSettingsEqual(current, target)) {
throw new Error("Project settings did not change.");
}
@ -62,11 +79,15 @@ export class ProjectSettingsProjectCommandRepository
threePhaseVoltageV: current.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
current.enabledDistributionBoardSupplyTypes,
...currentCoverValues,
};
const inverse =
input.command.schemaVersion === 1
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
? createBaselineProjectSettingsUpdateCommand(inverseValues)
: createProjectSettingsUpdateProjectCommand(inverseValues);
: command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? createWithoutCoverProjectSettingsUpdateCommand(inverseValues)
: createProjectSettingsUpdateProjectCommand(inverseValues);
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
const updated = tx
.update(projects)
@ -82,11 +103,32 @@ export class ProjectSettingsProjectCommandRepository
}
}
function createWithoutCoverProjectSettingsUpdateCommand(
values: ProjectSettingsValues
) {
return {
schemaVersion: withoutCoverProjectSettingsUpdateCommandSchemaVersion,
type: "project.update-settings" as const,
payload: {
name: values.name,
internalProjectNumber: values.internalProjectNumber,
externalProjectNumber: values.externalProjectNumber,
buildingOwner: values.buildingOwner,
description: values.description,
isPublicBuilding: values.isPublicBuilding,
singlePhaseVoltageV: values.singlePhaseVoltageV,
threePhaseVoltageV: values.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
values.enabledDistributionBoardSupplyTypes,
},
};
}
function createBaselineProjectSettingsUpdateCommand(
values: ProjectSettingsValues
) {
return {
schemaVersion: 1 as const,
schemaVersion: baselineProjectSettingsUpdateCommandSchemaVersion,
type: "project.update-settings" as const,
payload: {
name: values.name,
@ -114,8 +156,9 @@ function projectSettingsEqual(
current.description === target.description &&
current.isPublicBuilding === target.isPublicBuilding &&
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
current.threePhaseVoltageV === target.threePhaseVoltageV
&& sameSupplyTypes(
current.threePhaseVoltageV === target.threePhaseVoltageV &&
projectCoverFields.every((field) => current[field] === target[field]) &&
sameSupplyTypes(
current.enabledDistributionBoardSupplyTypes,
target.enabledDistributionBoardSupplyTypes
)

View file

@ -233,6 +233,10 @@ function replaceProjectState(
threePhaseVoltageV: state.project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
state.project.enabledDistributionBoardSupplyTypes,
siteAddress: state.project.siteAddress,
workStage: state.project.workStage,
projectLead: state.project.projectLead,
projectEditor: state.project.projectEditor,
})
.where(eq(projects.id, state.project.id))
.run();

View file

@ -46,6 +46,10 @@ export function readProjectStateSnapshot(
threePhaseVoltageV: projects.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
siteAddress: projects.siteAddress,
workStage: projects.workStage,
projectLead: projects.projectLead,
projectEditor: projects.projectEditor,
currentRevision: projects.currentRevision,
})
.from(projects)
@ -249,6 +253,10 @@ export function readProjectStateSnapshot(
threePhaseVoltageV: project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
project.enabledDistributionBoardSupplyTypes,
siteAddress: project.siteAddress,
workStage: project.workStage,
projectLead: project.projectLead,
projectEditor: project.projectEditor,
},
distributionBoards: boardRows,
externalCsvConfiguration,

View file

@ -31,6 +31,10 @@ export class ProjectRepository {
enabledDistributionBoardSupplyTypes: [
...defaultDistributionBoardSupplyTypes,
],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
currentRevision: 0,
};
await db.insert(projects).values(project);