forked from jappel/leistungsbilanz-ts
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:
parent
4779e4a1a7
commit
7692699ee2
35 changed files with 3479 additions and 113 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ChangeEvent, useEffect, useMemo, useState } from "react";
|
||||
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import "../../../../frontend/print/print-document.css";
|
||||
import {
|
||||
deleteDocumentLogo,
|
||||
|
|
@ -14,13 +14,20 @@ import {
|
|||
listDistributionBoards,
|
||||
listFloors,
|
||||
listProjectSnapshots,
|
||||
updateOfficeProfile,
|
||||
uploadDocumentLogo,
|
||||
} from "../../../../frontend/utils/api";
|
||||
import type {
|
||||
DocumentLogoMetadataDto,
|
||||
OfficeProfileDto,
|
||||
ProjectDto,
|
||||
ProjectSnapshotMetadataDto,
|
||||
} from "../../../../frontend/types";
|
||||
import { FormModal } from "../../../../frontend/components/form-modal";
|
||||
import {
|
||||
officeProfileFieldLabels,
|
||||
officeProfileFieldMaxLengths,
|
||||
} from "../../../../shared/constants/project-cover";
|
||||
import { PrintDocument } from "../../../../frontend/print/print-document";
|
||||
import {
|
||||
buildPreliminaryBoardHierarchy,
|
||||
|
|
@ -30,7 +37,7 @@ import {
|
|||
import {
|
||||
documentLogoMimeTypes,
|
||||
maxDocumentLogoBytes,
|
||||
} from "../../../../shared/validation/document-branding.schemas";
|
||||
} from "../../../../shared/constants/document-branding";
|
||||
|
||||
type PlanFormat = "a4" | "a3";
|
||||
|
||||
|
|
@ -61,6 +68,14 @@ export default function ProjectPrintPage() {
|
|||
const projectId = params.projectId;
|
||||
const [data, setData] = useState<PrintData | null>(null);
|
||||
const [logo, setLogo] = useState<DocumentLogoMetadataDto | null>(null);
|
||||
const [office, setOffice] = useState<OfficeProfileDto>({
|
||||
officeName: null,
|
||||
officeAddress: null,
|
||||
});
|
||||
const [officeDraft, setOfficeDraft] = useState<{
|
||||
officeName: string;
|
||||
officeAddress: string;
|
||||
} | null>(null);
|
||||
const [format, setFormat] = useState<PlanFormat>("a4");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
|
@ -116,6 +131,7 @@ export default function ProjectPrintPage() {
|
|||
.sort((left, right) => left.createdAtIso.localeCompare(right.createdAtIso)),
|
||||
});
|
||||
setLogo(branding.logo);
|
||||
setOffice(branding.office);
|
||||
document.title = `Leistungsbilanz – ${project.name}`;
|
||||
}
|
||||
load().catch((err: unknown) => {
|
||||
|
|
@ -172,6 +188,28 @@ export default function ProjectPrintPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleOfficeSave(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!officeDraft) {
|
||||
return;
|
||||
}
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateOfficeProfile({
|
||||
officeName: officeDraft.officeName.trim() || null,
|
||||
officeAddress: officeDraft.officeAddress.trim() || null,
|
||||
});
|
||||
setOffice(result.office);
|
||||
setOfficeDraft(null);
|
||||
setMessage("Büroangaben gespeichert. Sie gelten für alle Projekte.");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Die Büroangaben konnten nicht gespeichert werden.");
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogoDelete() {
|
||||
if (!window.confirm("Hinterlegtes Logo für alle Projekte entfernen?")) {
|
||||
return;
|
||||
|
|
@ -213,6 +251,19 @@ export default function ProjectPrintPage() {
|
|||
<option value="a3">A3 quer</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
setOfficeDraft({
|
||||
officeName: office.officeName ?? "",
|
||||
officeAddress: office.officeAddress ?? "",
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Büroangaben
|
||||
</button>
|
||||
<label className={`btn btn-sm btn-outline-primary mb-0${isBusy ? " disabled" : ""}`}>
|
||||
{logo ? "Logo ersetzen" : "Logo hinterlegen"}
|
||||
<input
|
||||
|
|
@ -245,11 +296,56 @@ export default function ProjectPrintPage() {
|
|||
Zum Projekt
|
||||
</Link>
|
||||
</div>
|
||||
{officeDraft ? (
|
||||
<FormModal
|
||||
description="Planverfasser auf dem Deckblatt. Gilt für alle Projekte."
|
||||
isSaving={isBusy}
|
||||
onClose={() => setOfficeDraft(null)}
|
||||
onSubmit={handleOfficeSave}
|
||||
submitLabel="Büroangaben speichern"
|
||||
title="Büroangaben"
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="office-name">
|
||||
{officeProfileFieldLabels.officeName}
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="office-name"
|
||||
maxLength={officeProfileFieldMaxLengths.officeName}
|
||||
onChange={(event) =>
|
||||
setOfficeDraft({ ...officeDraft, officeName: event.target.value })
|
||||
}
|
||||
value={officeDraft.officeName}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="office-address">
|
||||
{officeProfileFieldLabels.officeAddress}
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="office-address"
|
||||
maxLength={officeProfileFieldMaxLengths.officeAddress}
|
||||
onChange={(event) =>
|
||||
setOfficeDraft({ ...officeDraft, officeAddress: event.target.value })
|
||||
}
|
||||
placeholder={"Straße Nr.\nPLZ Ort"}
|
||||
rows={3}
|
||||
value={officeDraft.officeAddress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
) : null}
|
||||
{data ? (
|
||||
<PrintDocument
|
||||
entries={entries}
|
||||
logoSrc={logoSrc}
|
||||
namedSnapshots={data.namedSnapshots}
|
||||
office={office}
|
||||
printedAt={printedAt}
|
||||
project={data.project}
|
||||
/>
|
||||
|
|
|
|||
10
src/db/migrations/0008_project_cover_and_office_profile.sql
Normal file
10
src/db/migrations/0008_project_cover_and_office_profile.sql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE `document_branding_settings` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `site_address` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `work_stage` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `project_lead` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `project_editor` text;
|
||||
2553
src/db/migrations/meta/0008_snapshot.json
Normal file
2553
src/db/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -57,6 +57,13 @@
|
|||
"when": 1790411540872,
|
||||
"tag": "0007_board_info_and_document_logo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1790411560953,
|
||||
"tag": "0008_project_cover_and_office_profile",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
13
src/db/schema/document-branding-settings.ts
Normal file
13
src/db/schema/document-branding-settings.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// Office-wide text settings for printed documents (e.g. office name and
|
||||
// address on the cover page). Global application data like
|
||||
// document_branding_assets: outside project revisions, snapshots and transfer.
|
||||
export const documentBrandingSettings = sqliteTable("document_branding_settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
});
|
||||
|
|
@ -21,5 +21,9 @@ export const projects = sqliteTable("projects", {
|
|||
.$type<DistributionBoardSupplyType[]>()
|
||||
.notNull()
|
||||
.default(sql`'["AV","SV","EV","USV","MSR","SiBe"]'`),
|
||||
siteAddress: text("site_address"),
|
||||
workStage: text("work_stage"),
|
||||
projectLead: text("project_lead"),
|
||||
projectEditor: text("project_editor"),
|
||||
currentRevision: integer("current_revision").notNull().default(0),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,13 +3,23 @@ import {
|
|||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import {
|
||||
projectCoverFieldMaxLengths,
|
||||
projectCoverFields,
|
||||
type ProjectCoverField,
|
||||
} from "../../shared/constants/project-cover.js";
|
||||
|
||||
export const projectSettingsUpdateCommandType =
|
||||
"project.update-settings" as const;
|
||||
export const projectSettingsUpdateCommandSchemaVersion = 2 as const;
|
||||
const previousProjectSettingsUpdateCommandSchemaVersion = 1 as const;
|
||||
// Version 3 adds the print cover fields. Versions 2 (without cover fields)
|
||||
// and 1 (additionally without the public-building flag) stay executable;
|
||||
// their missing values keep the current project values.
|
||||
export const projectSettingsUpdateCommandSchemaVersion = 3 as const;
|
||||
export const withoutCoverProjectSettingsUpdateCommandSchemaVersion = 2 as const;
|
||||
export const baselineProjectSettingsUpdateCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface ProjectSettingsValues {
|
||||
export interface ProjectSettingsValues
|
||||
extends Record<ProjectCoverField, string | null> {
|
||||
name: string;
|
||||
internalProjectNumber: string | null;
|
||||
externalProjectNumber: string | null;
|
||||
|
|
@ -21,8 +31,13 @@ export interface ProjectSettingsValues {
|
|||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||
}
|
||||
|
||||
type PreviousProjectSettingsValues = Omit<
|
||||
export type WithoutCoverProjectSettingsValues = Omit<
|
||||
ProjectSettingsValues,
|
||||
ProjectCoverField
|
||||
>;
|
||||
|
||||
export type BaselineProjectSettingsValues = Omit<
|
||||
WithoutCoverProjectSettingsValues,
|
||||
"isPublicBuilding"
|
||||
>;
|
||||
|
||||
|
|
@ -32,15 +47,22 @@ export interface CurrentProjectSettingsUpdateProjectCommand
|
|||
type: typeof projectSettingsUpdateCommandType;
|
||||
}
|
||||
|
||||
interface PreviousProjectSettingsUpdateProjectCommand
|
||||
extends SerializedProjectCommand<PreviousProjectSettingsValues> {
|
||||
schemaVersion: typeof previousProjectSettingsUpdateCommandSchemaVersion;
|
||||
export interface WithoutCoverProjectSettingsUpdateProjectCommand
|
||||
extends SerializedProjectCommand<WithoutCoverProjectSettingsValues> {
|
||||
schemaVersion: typeof withoutCoverProjectSettingsUpdateCommandSchemaVersion;
|
||||
type: typeof projectSettingsUpdateCommandType;
|
||||
}
|
||||
|
||||
export interface BaselineProjectSettingsUpdateProjectCommand
|
||||
extends SerializedProjectCommand<BaselineProjectSettingsValues> {
|
||||
schemaVersion: typeof baselineProjectSettingsUpdateCommandSchemaVersion;
|
||||
type: typeof projectSettingsUpdateCommandType;
|
||||
}
|
||||
|
||||
export type ProjectSettingsUpdateProjectCommand =
|
||||
| CurrentProjectSettingsUpdateProjectCommand
|
||||
| PreviousProjectSettingsUpdateProjectCommand;
|
||||
| WithoutCoverProjectSettingsUpdateProjectCommand
|
||||
| BaselineProjectSettingsUpdateProjectCommand;
|
||||
|
||||
export function createProjectSettingsUpdateProjectCommand(
|
||||
values: ProjectSettingsValues
|
||||
|
|
@ -60,7 +82,10 @@ export function assertProjectSettingsUpdateProjectCommand(
|
|||
if (
|
||||
command.type !== projectSettingsUpdateCommandType ||
|
||||
(command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion &&
|
||||
command.schemaVersion !== previousProjectSettingsUpdateCommandSchemaVersion)
|
||||
command.schemaVersion !==
|
||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion &&
|
||||
command.schemaVersion !==
|
||||
baselineProjectSettingsUpdateCommandSchemaVersion)
|
||||
) {
|
||||
throw new Error("Unsupported project settings update command.");
|
||||
}
|
||||
|
|
@ -91,13 +116,23 @@ export function assertProjectSettingsUpdateProjectCommand(
|
|||
"Project settings update command contains invalid supply types."
|
||||
);
|
||||
}
|
||||
const isPreviousSchema =
|
||||
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion;
|
||||
const payload = command.payload;
|
||||
const expectedKeyCount =
|
||||
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
|
||||
? 8
|
||||
: command.schemaVersion ===
|
||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion
|
||||
? 9
|
||||
: 9 + projectCoverFields.length;
|
||||
if (
|
||||
(isPreviousSchema && Object.keys(command.payload).length !== 8) ||
|
||||
(!isPreviousSchema &&
|
||||
(typeof command.payload.isPublicBuilding !== "boolean" ||
|
||||
Object.keys(command.payload).length !== 9))
|
||||
Object.keys(payload).length !== expectedKeyCount ||
|
||||
(command.schemaVersion !==
|
||||
baselineProjectSettingsUpdateCommandSchemaVersion &&
|
||||
typeof payload.isPublicBuilding !== "boolean") ||
|
||||
(command.schemaVersion === projectSettingsUpdateCommandSchemaVersion &&
|
||||
!projectCoverFields.every((field) =>
|
||||
isNullableTrimmedString(payload[field], projectCoverFieldMaxLengths[field])
|
||||
))
|
||||
) {
|
||||
throw new Error(
|
||||
"Project settings update command contains invalid values."
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ import {
|
|||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardInfoField,
|
||||
} from "../../shared/constants/distribution-board.js";
|
||||
import {
|
||||
projectCoverFieldMaxLengths,
|
||||
type ProjectCoverField,
|
||||
} from "../../shared/constants/project-cover.js";
|
||||
import {
|
||||
circuitGroupCategories,
|
||||
} from "../../shared/constants/circuit-group.js";
|
||||
|
|
@ -63,10 +67,30 @@ const previousProjectSchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const projectSchema = previousProjectSchema
|
||||
const publicBuildingProjectSchema = previousProjectSchema
|
||||
.extend({ isPublicBuilding: z.boolean() })
|
||||
.strict();
|
||||
|
||||
const projectCoverValueSchema = (field: ProjectCoverField) =>
|
||||
z.string().trim().min(1).max(projectCoverFieldMaxLengths[field]).nullable();
|
||||
|
||||
// Schema version 6 adds the print cover fields.
|
||||
const projectSchema = publicBuildingProjectSchema
|
||||
.extend({
|
||||
siteAddress: projectCoverValueSchema("siteAddress"),
|
||||
workStage: projectCoverValueSchema("workStage"),
|
||||
projectLead: projectCoverValueSchema("projectLead"),
|
||||
projectEditor: projectCoverValueSchema("projectEditor"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const emptyProjectCoverValues = {
|
||||
siteAddress: null,
|
||||
workStage: null,
|
||||
projectLead: null,
|
||||
projectEditor: null,
|
||||
};
|
||||
|
||||
const legacyDistributionBoardSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
|
|
@ -494,7 +518,7 @@ export const projectStateSnapshotSchema = z
|
|||
const boardInfoProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(boardInfoProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
project: publicBuildingProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
externalModel: externalModelStateSchema,
|
||||
|
|
@ -505,7 +529,7 @@ const boardInfoProjectStateSnapshotSchema = z
|
|||
const previousProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
project: publicBuildingProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
externalModel: externalModelStateSchema,
|
||||
|
|
@ -516,7 +540,7 @@ const previousProjectStateSnapshotSchema = z
|
|||
const externalModelProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
project: publicBuildingProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||
...projectStateSnapshotContents,
|
||||
|
|
@ -526,7 +550,7 @@ const externalModelProjectStateSnapshotSchema = z
|
|||
const legacyProjectStateSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
||||
project: projectSchema,
|
||||
project: publicBuildingProjectSchema,
|
||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||
...projectStateSnapshotContents,
|
||||
})
|
||||
|
|
@ -581,12 +605,14 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
}
|
||||
const schemaVersion = (value as { schemaVersion: number }).schemaVersion;
|
||||
// Every supported version before 6 lacks the distribution-board info
|
||||
// fields; they are upgraded as empty (null) values.
|
||||
// fields and the project cover fields; they are upgraded as empty (null)
|
||||
// values.
|
||||
if (schemaVersion === boardInfoProjectStateSnapshotSchemaVersion) {
|
||||
const previous = boardInfoProjectStateSnapshotSchema.parse(value);
|
||||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
previous.distributionBoards
|
||||
),
|
||||
|
|
@ -597,6 +623,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
previous.distributionBoards
|
||||
),
|
||||
|
|
@ -607,6 +634,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
return {
|
||||
...previous,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
previous.distributionBoards
|
||||
),
|
||||
|
|
@ -618,6 +646,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
return {
|
||||
...legacy,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
project: { ...legacy.project, ...emptyProjectCoverValues },
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
legacy.distributionBoards
|
||||
),
|
||||
|
|
@ -630,7 +659,11 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
|||
return {
|
||||
...baseline,
|
||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||
project: { ...baseline.project, isPublicBuilding: false },
|
||||
project: {
|
||||
...baseline.project,
|
||||
isPublicBuilding: false,
|
||||
...emptyProjectCoverValues,
|
||||
},
|
||||
distributionBoards: withEmptyDistributionBoardInfo(
|
||||
baseline.distributionBoards
|
||||
),
|
||||
|
|
|
|||
|
|
@ -7,9 +7,16 @@ import {
|
|||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardSupplyType,
|
||||
} from "../../shared/constants/distribution-board";
|
||||
import {
|
||||
projectCoverFieldLabels,
|
||||
projectCoverFieldMaxLengths,
|
||||
projectCoverFields,
|
||||
type ProjectCoverField,
|
||||
} from "../../shared/constants/project-cover";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
export interface ProjectSettingsInput {
|
||||
export interface ProjectSettingsInput
|
||||
extends Record<ProjectCoverField, string | null> {
|
||||
name: string;
|
||||
internalProjectNumber: string | null;
|
||||
externalProjectNumber: string | null;
|
||||
|
|
@ -21,6 +28,13 @@ export interface ProjectSettingsInput {
|
|||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||
}
|
||||
|
||||
const coverFieldPlaceholders: Record<ProjectCoverField, string> = {
|
||||
siteAddress: "Straße Nr.\nPLZ Ort",
|
||||
workStage: "z. B. LPH 5 – Ausführungsplanung",
|
||||
projectLead: "",
|
||||
projectEditor: "",
|
||||
};
|
||||
|
||||
interface ProjectSettingsModalProps {
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -71,6 +85,14 @@ export function ProjectSettingsModal({
|
|||
] = useState<DistributionBoardSupplyType[]>(
|
||||
project.enabledDistributionBoardSupplyTypes
|
||||
);
|
||||
const [coverValues, setCoverValues] = useState<
|
||||
Record<ProjectCoverField, string>
|
||||
>({
|
||||
siteAddress: project.siteAddress ?? "",
|
||||
workStage: project.workStage ?? "",
|
||||
projectLead: project.projectLead ?? "",
|
||||
projectEditor: project.projectEditor ?? "",
|
||||
});
|
||||
const [transfer, setTransfer] = useState<unknown>(null);
|
||||
const [transferFilename, setTransferFilename] = useState("");
|
||||
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
|
||||
|
|
@ -91,6 +113,10 @@ export function ProjectSettingsModal({
|
|||
singlePhaseVoltageV: Number(singlePhaseVoltageV),
|
||||
threePhaseVoltageV: Number(threePhaseVoltageV),
|
||||
enabledDistributionBoardSupplyTypes,
|
||||
siteAddress: toNullableString(coverValues.siteAddress),
|
||||
workStage: toNullableString(coverValues.workStage),
|
||||
projectLead: toNullableString(coverValues.projectLead),
|
||||
projectEditor: toNullableString(coverValues.projectEditor),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -309,6 +335,57 @@ export function ProjectSettingsModal({
|
|||
value={threePhaseVoltageV}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<hr className="my-2" />
|
||||
<h3 className="h6">Deckblatt</h3>
|
||||
<p className="text-secondary small">
|
||||
Angaben für das Deckblatt der Seitenansicht. Büroname und
|
||||
Büroanschrift werden projektübergreifend in der Seitenansicht
|
||||
gepflegt.
|
||||
</p>
|
||||
<div className="row g-3">
|
||||
{projectCoverFields.map((field) => (
|
||||
<div
|
||||
className={field === "siteAddress" ? "col-12" : "col-12 col-md-6"}
|
||||
key={field}
|
||||
>
|
||||
<label className="form-label" htmlFor={`project-cover-${field}`}>
|
||||
{projectCoverFieldLabels[field]}
|
||||
</label>
|
||||
{field === "siteAddress" ? (
|
||||
<textarea
|
||||
className="form-control"
|
||||
id={`project-cover-${field}`}
|
||||
maxLength={projectCoverFieldMaxLengths[field]}
|
||||
onChange={(event) =>
|
||||
setCoverValues((current) => ({
|
||||
...current,
|
||||
[field]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={coverFieldPlaceholders[field]}
|
||||
rows={2}
|
||||
value={coverValues[field]}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="form-control"
|
||||
id={`project-cover-${field}`}
|
||||
maxLength={projectCoverFieldMaxLengths[field]}
|
||||
onChange={(event) =>
|
||||
setCoverValues((current) => ({
|
||||
...current,
|
||||
[field]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={coverFieldPlaceholders[field]}
|
||||
value={coverValues[field]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<hr className="my-2" />
|
||||
<h3 className="h6">Projekt importieren oder exportieren</h3>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* Print view (Seitenansicht) in the provisional kbp corporate design.
|
||||
Colors are derived from the kbp logo and presentation material and will be
|
||||
replaced by the official CD specification; change them only here.
|
||||
/* Print view (Seitenansicht) in the kbp corporate design. Colors are
|
||||
derived from the kbp logo and presentation material; change them only
|
||||
here.
|
||||
All selectors are scoped to .print-view. The @page rules (format, margins,
|
||||
page numbers) are injected by the print page because they need project
|
||||
data. */
|
||||
|
|
@ -113,6 +113,9 @@
|
|||
color: var(--pd-accent);
|
||||
font-weight: bold;
|
||||
}
|
||||
.pd-multiline {
|
||||
white-space: pre-line;
|
||||
}
|
||||
.pd-logo {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
CircuitTreeCircuitDto,
|
||||
CircuitTreeComponentDto,
|
||||
CircuitTreeDeviceRowDto,
|
||||
OfficeProfileDto,
|
||||
ProjectDto,
|
||||
ProjectSnapshotMetadataDto,
|
||||
} from "../types";
|
||||
|
|
@ -18,17 +19,23 @@ import {
|
|||
type PrintBoardHierarchyEntry,
|
||||
} from "./print-document-model";
|
||||
|
||||
// Provisional office data until the project/cover fields are added.
|
||||
export const printOfficeName = "kbp köhler beraten + planen GmbH";
|
||||
|
||||
export interface PrintDocumentProps {
|
||||
project: ProjectDto;
|
||||
office: OfficeProfileDto;
|
||||
entries: PrintBoardHierarchyEntry[];
|
||||
namedSnapshots: ProjectSnapshotMetadataDto[];
|
||||
logoSrc: string | null;
|
||||
printedAt: Date;
|
||||
}
|
||||
|
||||
/** Shows a value or the orange "[ergänzen]" marker for missing data. */
|
||||
function Value({ value, multiline = false }: { value: string | null; multiline?: boolean }) {
|
||||
if (!value) {
|
||||
return <Placeholder />;
|
||||
}
|
||||
return multiline ? <span className="pd-multiline">{value}</span> : <>{value}</>;
|
||||
}
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
|
|
@ -129,6 +136,7 @@ function boardLabel(entry: PrintBoardHierarchyEntry) {
|
|||
|
||||
function CoverSheet({
|
||||
project,
|
||||
office,
|
||||
entries,
|
||||
namedSnapshots,
|
||||
logoSrc,
|
||||
|
|
@ -149,11 +157,11 @@ function CoverSheet({
|
|||
<caption>Projekt</caption>
|
||||
<tbody>
|
||||
<tr><th>Bauvorhaben</th><td>{project.name}</td></tr>
|
||||
<tr><th>Anschrift</th><td><Placeholder /></td></tr>
|
||||
<tr><th>Bauherr</th><td>{project.buildingOwner ?? <Placeholder />}</td></tr>
|
||||
<tr><th>Projektnummer intern</th><td>{project.internalProjectNumber ?? <Placeholder />}</td></tr>
|
||||
<tr><th>Projektnummer extern</th><td>{project.externalProjectNumber ?? <Placeholder />}</td></tr>
|
||||
<tr><th>Leistungsphase</th><td><Placeholder /></td></tr>
|
||||
<tr><th>Anschrift</th><td><Value multiline value={project.siteAddress} /></td></tr>
|
||||
<tr><th>Bauherr</th><td><Value value={project.buildingOwner} /></td></tr>
|
||||
<tr><th>Projektnummer intern</th><td><Value value={project.internalProjectNumber} /></td></tr>
|
||||
<tr><th>Projektnummer extern</th><td><Value value={project.externalProjectNumber} /></td></tr>
|
||||
<tr><th>Leistungsphase</th><td><Value value={project.workStage} /></td></tr>
|
||||
<tr><th>Gewerk</th><td>KG 440 – Starkstromanlagen</td></tr>
|
||||
<tr><th>Beschreibung</th><td>{project.description ?? "–"}</td></tr>
|
||||
</tbody>
|
||||
|
|
@ -163,10 +171,10 @@ function CoverSheet({
|
|||
<table className="pd-kv">
|
||||
<caption>Planverfasser</caption>
|
||||
<tbody>
|
||||
<tr><th>Büro</th><td>{printOfficeName}</td></tr>
|
||||
<tr><th>Anschrift</th><td><Placeholder /></td></tr>
|
||||
<tr><th>Projektleitung</th><td><Placeholder /></td></tr>
|
||||
<tr><th>Bearbeitung</th><td><Placeholder /></td></tr>
|
||||
<tr><th>Büro</th><td><Value value={office.officeName} /></td></tr>
|
||||
<tr><th>Anschrift</th><td><Value multiline value={office.officeAddress} /></td></tr>
|
||||
<tr><th>Projektleitung</th><td><Value value={project.projectLead} /></td></tr>
|
||||
<tr><th>Bearbeitung</th><td><Value value={project.projectEditor} /></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table className="pd-kv">
|
||||
|
|
@ -192,7 +200,9 @@ function CoverSheet({
|
|||
<td>{String.fromCharCode(97 + (index % 26))}</td>
|
||||
<td>{dateFormat.format(new Date(snapshot.createdAtIso))}</td>
|
||||
<td>{snapshot.name}</td>
|
||||
<td><Placeholder /></td>
|
||||
{/* Snapshots do not record an editor yet; the project's
|
||||
current editor is shown for every entry. */}
|
||||
<td><Value value={project.projectEditor} /></td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
|
|
@ -582,6 +592,7 @@ function SectionRows({
|
|||
|
||||
export function PrintDocument({
|
||||
project,
|
||||
office,
|
||||
entries,
|
||||
namedSnapshots,
|
||||
logoSrc,
|
||||
|
|
@ -594,6 +605,7 @@ export function PrintDocument({
|
|||
entries={entries}
|
||||
logoSrc={logoSrc}
|
||||
namedSnapshots={namedSnapshots}
|
||||
office={office}
|
||||
project={project}
|
||||
stand={stand}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -93,9 +93,18 @@ export interface ProjectDto {
|
|||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||
siteAddress: string | null;
|
||||
workStage: string | null;
|
||||
projectLead: string | null;
|
||||
projectEditor: string | null;
|
||||
currentRevision: number;
|
||||
}
|
||||
|
||||
export interface OfficeProfileDto {
|
||||
officeName: string | null;
|
||||
officeAddress: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectHistoryStateDto {
|
||||
projectId: string;
|
||||
currentRevision: number;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
DistributionBoardInfoDto,
|
||||
DocumentLogoMetadataDto,
|
||||
FloorDto,
|
||||
OfficeProfileDto,
|
||||
GlobalDeviceDto,
|
||||
ProjectCommandDto,
|
||||
ProjectCommandResultDto,
|
||||
|
|
@ -258,7 +259,7 @@ export function updateProjectSettings(
|
|||
singlePhaseVoltageV: number;
|
||||
threePhaseVoltageV: number;
|
||||
enabledDistributionBoardSupplyTypes: ProjectDto["enabledDistributionBoardSupplyTypes"];
|
||||
}
|
||||
} & Partial<Pick<ProjectDto, "siteAddress" | "workStage" | "projectLead" | "projectEditor">>
|
||||
) {
|
||||
return request<ProjectSettingsCommandResultDto>(
|
||||
`/api/projects/${projectId}`,
|
||||
|
|
@ -346,9 +347,17 @@ export function updateDistributionBoard(
|
|||
export const documentLogoUrl = "/api/document-branding/logo";
|
||||
|
||||
export function getDocumentBranding() {
|
||||
return request<{ logo: DocumentLogoMetadataDto | null }>(
|
||||
"/api/document-branding"
|
||||
);
|
||||
return request<{
|
||||
logo: DocumentLogoMetadataDto | null;
|
||||
office: OfficeProfileDto;
|
||||
}>("/api/document-branding");
|
||||
}
|
||||
|
||||
export function updateOfficeProfile(office: OfficeProfileDto) {
|
||||
return request<{ office: OfficeProfileDto }>("/api/document-branding/office", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(office),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadDocumentLogo(file: File) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import type { Request, Response } from "express";
|
||||
import type { DocumentBrandingAsset } from "../../db/schema/document-branding-assets.js";
|
||||
import { officeProfileFields } from "../../shared/constants/project-cover.js";
|
||||
import {
|
||||
maxDocumentLogoBytes,
|
||||
updateOfficeProfileSchema,
|
||||
uploadDocumentLogoSchema,
|
||||
type DocumentLogoMimeType,
|
||||
} from "../../shared/validation/document-branding.schemas.js";
|
||||
|
|
@ -11,7 +13,21 @@ const logoKey = "logo";
|
|||
|
||||
export function getDocumentBrandingHandler(_req: Request, res: Response) {
|
||||
const logo = documentBrandingRepository.get(logoKey);
|
||||
return res.json({ logo: logo ? toLogoMetadata(logo) : null });
|
||||
return res.json({
|
||||
logo: logo ? toLogoMetadata(logo) : null,
|
||||
office: documentBrandingRepository.getSettings(officeProfileFields),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateOfficeProfileHandler(req: Request, res: Response) {
|
||||
const parsed = updateOfficeProfileSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
documentBrandingRepository.setSettings(parsed.data);
|
||||
return res.json({
|
||||
office: documentBrandingRepository.getSettings(officeProfileFields),
|
||||
});
|
||||
}
|
||||
|
||||
export function getDocumentLogoHandler(_req: Request, res: Response) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ import {
|
|||
updateProjectSettingsSchema,
|
||||
} from "../../shared/validation/project-structure.schemas.js";
|
||||
import { createProjectSettingsUpdateProjectCommand } from "../../domain/models/project-settings-project-command.model.js";
|
||||
import {
|
||||
projectCoverFields,
|
||||
type ProjectCoverField,
|
||||
} from "../../shared/constants/project-cover.js";
|
||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import { projectRepository } from "../composition/application-repositories.js";
|
||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||
|
|
@ -46,12 +50,27 @@ export async function updateProjectSettings(req: Request, res: Response) {
|
|||
}
|
||||
|
||||
const { expectedRevision, ...settings } = parsed.data;
|
||||
const current = await projectRepository.findById(projectId);
|
||||
if (!current) {
|
||||
return res.status(404).json({ error: "Project not found" });
|
||||
}
|
||||
// Cover fields are optional in the request and keep their current value
|
||||
// when omitted. `expectedRevision` rejects a project changed meanwhile.
|
||||
const coverValues = Object.fromEntries(
|
||||
projectCoverFields.map((field) => [
|
||||
field,
|
||||
settings[field] === undefined ? current[field] : settings[field],
|
||||
])
|
||||
) as Record<ProjectCoverField, string | null>;
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
expectedRevision,
|
||||
description: "Projekteinstellungen bearbeiten",
|
||||
command: createProjectSettingsUpdateProjectCommand(settings),
|
||||
command: createProjectSettingsUpdateProjectCommand({
|
||||
...settings,
|
||||
...coverValues,
|
||||
}),
|
||||
});
|
||||
const project = await projectRepository.findById(projectId);
|
||||
if (!project) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import * as documentBrandingController from "../controllers/document-branding.co
|
|||
export const documentBrandingRouter = express.Router();
|
||||
|
||||
documentBrandingRouter.get("/", documentBrandingController.getDocumentBrandingHandler);
|
||||
documentBrandingRouter.put("/office", documentBrandingController.updateOfficeProfileHandler);
|
||||
documentBrandingRouter.get("/logo", documentBrandingController.getDocumentLogoHandler);
|
||||
documentBrandingRouter.put("/logo", documentBrandingController.uploadDocumentLogoHandler);
|
||||
documentBrandingRouter.delete("/logo", documentBrandingController.deleteDocumentLogoHandler);
|
||||
|
|
|
|||
9
src/shared/constants/document-branding.ts
Normal file
9
src/shared/constants/document-branding.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const documentLogoMimeTypes = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/svg+xml",
|
||||
] as const;
|
||||
|
||||
export type DocumentLogoMimeType = (typeof documentLogoMimeTypes)[number];
|
||||
|
||||
export const maxDocumentLogoBytes = 2 * 1024 * 1024;
|
||||
45
src/shared/constants/project-cover.ts
Normal file
45
src/shared/constants/project-cover.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* Project data shown on the print view cover page. They are ordinary project
|
||||
* settings (persisted through `project.update-settings`, part of logical
|
||||
* snapshots and project transfer).
|
||||
*/
|
||||
export const projectCoverFields = [
|
||||
"siteAddress",
|
||||
"workStage",
|
||||
"projectLead",
|
||||
"projectEditor",
|
||||
] as const;
|
||||
|
||||
export type ProjectCoverField = (typeof projectCoverFields)[number];
|
||||
|
||||
export const projectCoverFieldLabels: Record<ProjectCoverField, string> = {
|
||||
siteAddress: "Anschrift Bauvorhaben",
|
||||
workStage: "Leistungsphase",
|
||||
projectLead: "Projektleitung",
|
||||
projectEditor: "Bearbeitung",
|
||||
};
|
||||
|
||||
export const projectCoverFieldMaxLengths: Record<ProjectCoverField, number> = {
|
||||
siteAddress: 500,
|
||||
workStage: 100,
|
||||
projectLead: 200,
|
||||
projectEditor: 200,
|
||||
};
|
||||
|
||||
/**
|
||||
* Office-wide data for the cover page ("Planverfasser"). Global branding like
|
||||
* the document logo, outside project revisions and snapshots.
|
||||
*/
|
||||
export const officeProfileFields = ["officeName", "officeAddress"] as const;
|
||||
|
||||
export type OfficeProfileField = (typeof officeProfileFields)[number];
|
||||
|
||||
export const officeProfileFieldLabels: Record<OfficeProfileField, string> = {
|
||||
officeName: "Büro",
|
||||
officeAddress: "Anschrift Büro",
|
||||
};
|
||||
|
||||
export const officeProfileFieldMaxLengths: Record<OfficeProfileField, number> = {
|
||||
officeName: 200,
|
||||
officeAddress: 500,
|
||||
};
|
||||
|
|
@ -1,14 +1,15 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
documentLogoMimeTypes,
|
||||
maxDocumentLogoBytes,
|
||||
} from "../constants/document-branding.js";
|
||||
import { officeProfileFieldMaxLengths } from "../constants/project-cover.js";
|
||||
|
||||
export const documentLogoMimeTypes = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/svg+xml",
|
||||
] as const;
|
||||
|
||||
export type DocumentLogoMimeType = (typeof documentLogoMimeTypes)[number];
|
||||
|
||||
export const maxDocumentLogoBytes = 2 * 1024 * 1024;
|
||||
export {
|
||||
documentLogoMimeTypes,
|
||||
maxDocumentLogoBytes,
|
||||
type DocumentLogoMimeType,
|
||||
} from "../constants/document-branding.js";
|
||||
|
||||
export const uploadDocumentLogoSchema = z
|
||||
.object({
|
||||
|
|
@ -23,3 +24,19 @@ export const uploadDocumentLogoSchema = z
|
|||
.strict();
|
||||
|
||||
export type UploadDocumentLogoInput = z.infer<typeof uploadDocumentLogoSchema>;
|
||||
|
||||
// Empty text is stored as "not set" (null).
|
||||
const officeValueSchema = (maximumLength: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(maximumLength)
|
||||
.nullable()
|
||||
.transform((value) => (value ? value : null));
|
||||
|
||||
export const updateOfficeProfileSchema = z
|
||||
.object({
|
||||
officeName: officeValueSchema(officeProfileFieldMaxLengths.officeName),
|
||||
officeAddress: officeValueSchema(officeProfileFieldMaxLengths.officeAddress),
|
||||
})
|
||||
.strict();
|
||||
|
|
|
|||
|
|
@ -4,8 +4,22 @@ import {
|
|||
distributionBoardSupplyTypes,
|
||||
type DistributionBoardInfoField,
|
||||
} from "../constants/distribution-board.js";
|
||||
import {
|
||||
projectCoverFieldMaxLengths,
|
||||
type ProjectCoverField,
|
||||
} from "../constants/project-cover.js";
|
||||
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||
|
||||
// Empty text is stored as null so "not entered" has one representation.
|
||||
const optionalCoverValueSchema = (field: ProjectCoverField) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(projectCoverFieldMaxLengths[field])
|
||||
.nullable()
|
||||
.transform((value) => (value ? value : null))
|
||||
.optional();
|
||||
|
||||
export const createProjectSchema = z.object({
|
||||
name: z.string().trim().min(1).max(200),
|
||||
internalProjectNumber: z.string().trim().max(100).optional(),
|
||||
|
|
@ -34,6 +48,11 @@ export const updateProjectSettingsSchema = z
|
|||
.refine((values) => new Set(values).size === values.length, {
|
||||
message: "Netzarten dürfen nicht mehrfach ausgewählt werden.",
|
||||
}),
|
||||
// Print cover fields; omitted fields keep their current value.
|
||||
siteAddress: optionalCoverValueSchema("siteAddress"),
|
||||
workStage: optionalCoverValueSchema("workStage"),
|
||||
projectLead: optionalCoverValueSchema("projectLead"),
|
||||
projectEditor: optionalCoverValueSchema("projectEditor"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue