Add report templates and unify template storage

This commit is contained in:
2026-03-13 19:20:54 +01:00
parent 5fd349dee2
commit e01afb9a10
13 changed files with 833 additions and 57 deletions

View File

@@ -0,0 +1,16 @@
package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
import java.util.List;
@Document("reportTemplates")
public record ReportTemplate(
@Id String userId,
List<InvoiceTemplateElement> elements,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,20 @@
package de.svencarstensen.muh.domain;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.CompoundIndex;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.LocalDateTime;
import java.util.List;
@Document("templates")
@CompoundIndex(name = "user_template_type_unique", def = "{'userId': 1, 'type': 1}", unique = true)
public record Template(
@Id String id,
String userId,
TemplateType type,
List<InvoiceTemplateElement> elements,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
}

View File

@@ -0,0 +1,6 @@
package de.svencarstensen.muh.domain;
public enum TemplateType {
INVOICE,
REPORT
}

View File

@@ -0,0 +1,7 @@
package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.ReportTemplate;
import org.springframework.data.mongodb.repository.MongoRepository;
public interface ReportTemplateRepository extends MongoRepository<ReportTemplate, String> {
}

View File

@@ -0,0 +1,12 @@
package de.svencarstensen.muh.repository;
import de.svencarstensen.muh.domain.Template;
import de.svencarstensen.muh.domain.TemplateType;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.Optional;
public interface TemplateRepository extends MongoRepository<Template, String> {
Optional<Template> findByUserIdAndType(String userId, TemplateType type);
}

View File

@@ -3,8 +3,11 @@ package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AppUser; import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.InvoiceTemplate; import de.svencarstensen.muh.domain.InvoiceTemplate;
import de.svencarstensen.muh.domain.InvoiceTemplateElement; import de.svencarstensen.muh.domain.InvoiceTemplateElement;
import de.svencarstensen.muh.domain.Template;
import de.svencarstensen.muh.domain.TemplateType;
import de.svencarstensen.muh.repository.AppUserRepository; import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.repository.InvoiceTemplateRepository; import de.svencarstensen.muh.repository.InvoiceTemplateRepository;
import de.svencarstensen.muh.repository.TemplateRepository;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -12,40 +15,52 @@ import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Objects; import java.util.Objects;
@Service @Service
public class InvoiceTemplateService { public class InvoiceTemplateService {
private static final TemplateType TEMPLATE_TYPE = TemplateType.INVOICE;
private final AppUserRepository appUserRepository; private final AppUserRepository appUserRepository;
private final TemplateRepository templateRepository;
private final InvoiceTemplateRepository invoiceTemplateRepository; private final InvoiceTemplateRepository invoiceTemplateRepository;
public InvoiceTemplateService( public InvoiceTemplateService(
AppUserRepository appUserRepository, AppUserRepository appUserRepository,
TemplateRepository templateRepository,
InvoiceTemplateRepository invoiceTemplateRepository InvoiceTemplateRepository invoiceTemplateRepository
) { ) {
this.appUserRepository = appUserRepository; this.appUserRepository = appUserRepository;
this.templateRepository = templateRepository;
this.invoiceTemplateRepository = invoiceTemplateRepository; this.invoiceTemplateRepository = invoiceTemplateRepository;
} }
public InvoiceTemplateResponse currentTemplate(String actorId) { public InvoiceTemplateResponse currentTemplate(String actorId) {
String userId = requireActorId(actorId); String userId = requireActorId(actorId);
requireActiveUser(userId); requireActiveUser(userId);
return invoiceTemplateRepository.findById(userId) return templateRepository.findByUserIdAndType(userId, TEMPLATE_TYPE)
.map(this::toResponse) .map(this::toResponse)
.or(() -> invoiceTemplateRepository.findById(userId).map(this::toLegacyResponse))
.orElseGet(() -> new InvoiceTemplateResponse(false, List.of(), null)); .orElseGet(() -> new InvoiceTemplateResponse(false, List.of(), null));
} }
public InvoiceTemplateResponse saveTemplate(String actorId, List<InvoiceTemplateElementPayload> payloadElements) { public InvoiceTemplateResponse saveTemplate(String actorId, List<InvoiceTemplateElementPayload> payloadElements) {
String userId = requireActorId(actorId); String userId = requireActorId(actorId);
requireActiveUser(userId); requireActiveUser(userId);
InvoiceTemplate existing = invoiceTemplateRepository.findById(userId).orElse(null); Template existing = templateRepository.findByUserIdAndType(userId, TEMPLATE_TYPE).orElse(null);
InvoiceTemplate legacyTemplate = existing == null
? invoiceTemplateRepository.findById(userId).orElse(null)
: null;
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
InvoiceTemplate saved = invoiceTemplateRepository.save(new InvoiceTemplate( Template saved = templateRepository.save(new Template(
existing != null ? existing.id() : templateId(userId),
userId, userId,
TEMPLATE_TYPE,
sanitizeElements(payloadElements), sanitizeElements(payloadElements),
existing != null ? existing.createdAt() : now, existing != null ? existing.createdAt() : legacyTemplate != null ? legacyTemplate.createdAt() : now,
now now
)); ));
return toResponse(saved); return toResponse(saved);
@@ -108,11 +123,12 @@ public class InvoiceTemplateService {
); );
} }
private InvoiceTemplateResponse toResponse(InvoiceTemplate template) { private InvoiceTemplateResponse toResponse(Template template) {
List<InvoiceTemplateElementPayload> elements = template.elements() == null return toResponse(template.elements(), template.updatedAt());
? List.of() }
: template.elements().stream().map(this::toPayload).toList();
return new InvoiceTemplateResponse(true, elements, template.updatedAt()); private InvoiceTemplateResponse toLegacyResponse(InvoiceTemplate template) {
return toResponse(template.elements(), template.updatedAt());
} }
private InvoiceTemplateElementPayload toPayload(InvoiceTemplateElement element) { private InvoiceTemplateElementPayload toPayload(InvoiceTemplateElement element) {
@@ -152,6 +168,17 @@ public class InvoiceTemplateService {
return value == null ? "" : value; return value == null ? "" : value;
} }
private InvoiceTemplateResponse toResponse(List<InvoiceTemplateElement> elements, LocalDateTime updatedAt) {
List<InvoiceTemplateElementPayload> payloads = elements == null
? List.of()
: elements.stream().map(this::toPayload).toList();
return new InvoiceTemplateResponse(true, payloads, updatedAt);
}
private String templateId(String userId) {
return userId + ":" + TEMPLATE_TYPE.name().toLowerCase(Locale.ROOT);
}
public record InvoiceTemplateElementPayload( public record InvoiceTemplateElementPayload(
String id, String id,
String paletteId, String paletteId,

View File

@@ -0,0 +1,189 @@
package de.svencarstensen.muh.service;
import de.svencarstensen.muh.domain.AppUser;
import de.svencarstensen.muh.domain.InvoiceTemplateElement;
import de.svencarstensen.muh.domain.ReportTemplate;
import de.svencarstensen.muh.domain.Template;
import de.svencarstensen.muh.domain.TemplateType;
import de.svencarstensen.muh.repository.AppUserRepository;
import de.svencarstensen.muh.repository.ReportTemplateRepository;
import de.svencarstensen.muh.repository.TemplateRepository;
import org.springframework.http.HttpStatus;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
@Service
public class ReportTemplateService {
private static final TemplateType TEMPLATE_TYPE = TemplateType.REPORT;
private final AppUserRepository appUserRepository;
private final TemplateRepository templateRepository;
private final ReportTemplateRepository reportTemplateRepository;
public ReportTemplateService(
AppUserRepository appUserRepository,
TemplateRepository templateRepository,
ReportTemplateRepository reportTemplateRepository
) {
this.appUserRepository = appUserRepository;
this.templateRepository = templateRepository;
this.reportTemplateRepository = reportTemplateRepository;
}
public InvoiceTemplateService.InvoiceTemplateResponse currentTemplate(String actorId) {
String userId = requireActorId(actorId);
requireActiveUser(userId);
return templateRepository.findByUserIdAndType(userId, TEMPLATE_TYPE)
.map(this::toResponse)
.or(() -> reportTemplateRepository.findById(userId).map(this::toLegacyResponse))
.orElseGet(() -> new InvoiceTemplateService.InvoiceTemplateResponse(false, List.of(), null));
}
public InvoiceTemplateService.InvoiceTemplateResponse saveTemplate(
String actorId,
List<InvoiceTemplateService.InvoiceTemplateElementPayload> payloadElements
) {
String userId = requireActorId(actorId);
requireActiveUser(userId);
Template existing = templateRepository.findByUserIdAndType(userId, TEMPLATE_TYPE).orElse(null);
ReportTemplate legacyTemplate = existing == null
? reportTemplateRepository.findById(userId).orElse(null)
: null;
LocalDateTime now = LocalDateTime.now();
Template saved = templateRepository.save(new Template(
existing != null ? existing.id() : templateId(userId),
userId,
TEMPLATE_TYPE,
sanitizeElements(payloadElements),
existing != null ? existing.createdAt() : legacyTemplate != null ? legacyTemplate.createdAt() : now,
now
));
return toResponse(saved);
}
private AppUser requireActiveUser(@NonNull String actorId) {
return appUserRepository.findById(actorId)
.filter(AppUser::active)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt"));
}
private @NonNull String requireActorId(String actorId) {
if (isBlank(actorId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nicht berechtigt");
}
String sanitized = Objects.requireNonNull(actorId).trim();
return Objects.requireNonNull(sanitized);
}
private List<InvoiceTemplateElement> sanitizeElements(
List<InvoiceTemplateService.InvoiceTemplateElementPayload> payloadElements
) {
if (payloadElements == null || payloadElements.isEmpty()) {
return List.of();
}
return payloadElements.stream()
.map(this::sanitizeElement)
.filter(Objects::nonNull)
.toList();
}
private InvoiceTemplateElement sanitizeElement(InvoiceTemplateService.InvoiceTemplateElementPayload payload) {
if (payload == null
|| isBlank(payload.id())
|| isBlank(payload.paletteId())
|| isBlank(payload.kind())
|| payload.x() == null
|| payload.y() == null
|| payload.width() == null
|| payload.fontSize() == null) {
return null;
}
return new InvoiceTemplateElement(
payload.id().trim(),
payload.paletteId().trim(),
payload.kind().trim(),
trimOrEmpty(payload.label()),
nullToEmpty(payload.content()),
payload.x(),
payload.y(),
payload.width(),
payload.height(),
payload.fontSize(),
payload.fontWeight(),
blankToNull(payload.textAlign()),
blankToNull(payload.lineOrientation()),
blankToNull(payload.imageSrc()),
payload.imageNaturalWidth(),
payload.imageNaturalHeight()
);
}
private InvoiceTemplateService.InvoiceTemplateResponse toResponse(Template template) {
return toResponse(template.elements(), template.updatedAt());
}
private InvoiceTemplateService.InvoiceTemplateResponse toLegacyResponse(ReportTemplate template) {
return toResponse(template.elements(), template.updatedAt());
}
private InvoiceTemplateService.InvoiceTemplateElementPayload toPayload(InvoiceTemplateElement element) {
return new InvoiceTemplateService.InvoiceTemplateElementPayload(
element.id(),
element.paletteId(),
element.kind(),
element.label(),
element.content(),
element.x(),
element.y(),
element.width(),
element.height(),
element.fontSize(),
element.fontWeight(),
element.textAlign(),
element.lineOrientation(),
element.imageSrc(),
element.imageNaturalWidth(),
element.imageNaturalHeight()
);
}
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
private String blankToNull(String value) {
return isBlank(value) ? null : value.trim();
}
private String trimOrEmpty(String value) {
return value == null ? "" : value.trim();
}
private String nullToEmpty(String value) {
return value == null ? "" : value;
}
private InvoiceTemplateService.InvoiceTemplateResponse toResponse(
List<InvoiceTemplateElement> elements,
LocalDateTime updatedAt
) {
List<InvoiceTemplateService.InvoiceTemplateElementPayload> payloads = elements == null
? List.of()
: elements.stream().map(this::toPayload).toList();
return new InvoiceTemplateService.InvoiceTemplateResponse(true, payloads, updatedAt);
}
private String templateId(String userId) {
return userId + ":" + TEMPLATE_TYPE.name().toLowerCase(Locale.ROOT);
}
}

View File

@@ -2,6 +2,7 @@ package de.svencarstensen.muh.web;
import de.svencarstensen.muh.service.CatalogService; import de.svencarstensen.muh.service.CatalogService;
import de.svencarstensen.muh.service.InvoiceTemplateService; import de.svencarstensen.muh.service.InvoiceTemplateService;
import de.svencarstensen.muh.service.ReportTemplateService;
import de.svencarstensen.muh.security.SecuritySupport; import de.svencarstensen.muh.security.SecuritySupport;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -19,15 +20,18 @@ public class SessionController {
private final CatalogService catalogService; private final CatalogService catalogService;
private final InvoiceTemplateService invoiceTemplateService; private final InvoiceTemplateService invoiceTemplateService;
private final ReportTemplateService reportTemplateService;
private final SecuritySupport securitySupport; private final SecuritySupport securitySupport;
public SessionController( public SessionController(
CatalogService catalogService, CatalogService catalogService,
InvoiceTemplateService invoiceTemplateService, InvoiceTemplateService invoiceTemplateService,
ReportTemplateService reportTemplateService,
SecuritySupport securitySupport SecuritySupport securitySupport
) { ) {
this.catalogService = catalogService; this.catalogService = catalogService;
this.invoiceTemplateService = invoiceTemplateService; this.invoiceTemplateService = invoiceTemplateService;
this.reportTemplateService = reportTemplateService;
this.securitySupport = securitySupport; this.securitySupport = securitySupport;
} }
@@ -41,6 +45,11 @@ public class SessionController {
return invoiceTemplateService.currentTemplate(securitySupport.currentUser().id()); return invoiceTemplateService.currentTemplate(securitySupport.currentUser().id());
} }
@GetMapping("/report-template")
public InvoiceTemplateService.InvoiceTemplateResponse currentReportTemplate() {
return reportTemplateService.currentTemplate(securitySupport.currentUser().id());
}
@PostMapping("/password-login") @PostMapping("/password-login")
public CatalogService.SessionResponse passwordLogin(@RequestBody PasswordLoginRequest request) { public CatalogService.SessionResponse passwordLogin(@RequestBody PasswordLoginRequest request) {
return catalogService.loginWithPassword(request.email(), request.password()); return catalogService.loginWithPassword(request.email(), request.password());
@@ -62,7 +71,7 @@ public class SessionController {
@PutMapping("/invoice-template") @PutMapping("/invoice-template")
public InvoiceTemplateService.InvoiceTemplateResponse saveInvoiceTemplate( public InvoiceTemplateService.InvoiceTemplateResponse saveInvoiceTemplate(
@RequestBody InvoiceTemplateRequest request @RequestBody TemplateRequest request
) { ) {
return invoiceTemplateService.saveTemplate( return invoiceTemplateService.saveTemplate(
securitySupport.currentUser().id(), securitySupport.currentUser().id(),
@@ -70,6 +79,16 @@ public class SessionController {
); );
} }
@PutMapping("/report-template")
public InvoiceTemplateService.InvoiceTemplateResponse saveReportTemplate(
@RequestBody TemplateRequest request
) {
return reportTemplateService.saveTemplate(
securitySupport.currentUser().id(),
request.elements()
);
}
public record PasswordLoginRequest(@NotBlank String email, @NotBlank String password) { public record PasswordLoginRequest(@NotBlank String email, @NotBlank String password) {
} }
@@ -85,7 +104,7 @@ public class SessionController {
) { ) {
} }
public record InvoiceTemplateRequest( public record TemplateRequest(
List<InvoiceTemplateService.InvoiceTemplateElementPayload> elements List<InvoiceTemplateService.InvoiceTemplateElementPayload> elements
) { ) {
} }

View File

@@ -14,6 +14,7 @@ import SearchFarmerPage from "./pages/SearchFarmerPage";
import SearchCalendarPage from "./pages/SearchCalendarPage"; import SearchCalendarPage from "./pages/SearchCalendarPage";
import UserManagementPage from "./pages/UserManagementPage"; import UserManagementPage from "./pages/UserManagementPage";
import InvoiceTemplatePage from "./pages/InvoiceTemplatePage"; import InvoiceTemplatePage from "./pages/InvoiceTemplatePage";
import ReportTemplatePage from "./pages/ReportTemplatePage";
function ProtectedRoutes() { function ProtectedRoutes() {
const { user, ready } = useSession(); const { user, ready } = useSession();
@@ -37,6 +38,7 @@ function ProtectedRoutes() {
<Route path="/samples/:sampleId/antibiogram" element={<AntibiogramPage />} /> <Route path="/samples/:sampleId/antibiogram" element={<AntibiogramPage />} />
<Route path="/samples/:sampleId/therapy" element={<TherapyPage />} /> <Route path="/samples/:sampleId/therapy" element={<TherapyPage />} />
<Route path="/invoice-template" element={<InvoiceTemplatePage />} /> <Route path="/invoice-template" element={<InvoiceTemplatePage />} />
<Route path="/report-template" element={<ReportTemplatePage />} />
<Route path="/admin" element={<Navigate to={isAdmin ? "/admin/landwirte" : "/admin/benutzer"} replace />} /> <Route path="/admin" element={<Navigate to={isAdmin ? "/admin/landwirte" : "/admin/benutzer"} replace />} />
<Route path="/admin/benutzer" element={<UserManagementPage />} /> <Route path="/admin/benutzer" element={<UserManagementPage />} />
<Route path="/admin/landwirte" element={<AdministrationPage />} /> <Route path="/admin/landwirte" element={<AdministrationPage />} />

View File

@@ -5,7 +5,8 @@ const PAGE_TITLES: Record<string, string> = {
"/home": "Startseite", "/home": "Startseite",
"/samples/new": "Neuanlage einer Probe", "/samples/new": "Neuanlage einer Probe",
"/portal": "MUH-Portal", "/portal": "MUH-Portal",
"/invoice-template": "Rechnungstemplate", "/invoice-template": "Rechnung",
"/report-template": "Bericht",
}; };
function resolvePageTitle(pathname: string) { function resolvePageTitle(pathname: string) {
@@ -80,9 +81,17 @@ export default function AppShell() {
<div className="nav-group"> <div className="nav-group">
<div className="nav-group__label">Verwaltung</div> <div className="nav-group__label">Verwaltung</div>
<div className="nav-subnav"> <div className="nav-subnav">
<div className="nav-subgroup">
<div className="nav-subgroup__label">Vorlagen</div>
<div className="nav-subnav nav-subnav--nested">
<NavLink to="/invoice-template" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}> <NavLink to="/invoice-template" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Rechnungstemplate Rechnung
</NavLink> </NavLink>
<NavLink to="/report-template" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Bericht
</NavLink>
</div>
</div>
<NavLink to="/admin/landwirte" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}> <NavLink to="/admin/landwirte" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
Landwirte Landwirte
</NavLink> </NavLink>

View File

@@ -30,7 +30,7 @@ const MIN_LINE_THICKNESS = 1;
const MIN_LINE_LENGTH = 40; const MIN_LINE_LENGTH = 40;
const MIN_FONT_SIZE = 12; const MIN_FONT_SIZE = 12;
const MAX_FONT_SIZE = 44; const MAX_FONT_SIZE = 44;
const LOCKED_TEXT_PALETTE_IDS = new Set([ const INVOICE_LOCKED_TEXT_PALETTE_IDS = new Set([
"invoice-title", "invoice-title",
"company-name", "company-name",
"contact-person", "contact-person",
@@ -50,12 +50,39 @@ const LOCKED_TEXT_PALETTE_IDS = new Set([
"bic", "bic",
"footer", "footer",
]); ]);
const REPORT_LOCKED_TEXT_PALETTE_IDS = new Set([
"report-title",
"report-farmer",
"report-cow",
"report-clinical-findings",
"report-treatment",
"report-examination-start",
"report-examination-end",
"report-sample-number",
"report-date",
"report-quarter-vl-label",
"report-quarter-vr-label",
"report-quarter-hl-label",
"report-quarter-hr-label",
"report-quarter-vl-result",
"report-quarter-vr-result",
"report-quarter-hl-result",
"report-quarter-hr-result",
"report-antibiogram-heading",
"report-antibiogram-summary",
"report-antibiogram-details",
"report-therapy-heading",
"report-therapy-text",
"report-misc-heading",
"report-misc-note",
"report-lab-note",
]);
type ElementKind = "text" | "line" | "image"; type ElementKind = "text" | "line" | "image";
type LineOrientation = "horizontal" | "vertical"; type LineOrientation = "horizontal" | "vertical";
type TextAlign = "left" | "center" | "right"; type TextAlign = "left" | "center" | "right";
type FontWeight = 400 | 500 | 600 | 700; type FontWeight = 400 | 500 | 600 | 700;
type PaletteCategory = "master-data" | "customer-data" | "free-elements"; type PaletteCategory = string;
interface PaletteItem { interface PaletteItem {
id: string; id: string;
@@ -118,6 +145,17 @@ interface InvoiceTemplateResponse {
updatedAt: string | null; updatedAt: string | null;
} }
interface InvoiceTemplatePageProps {
buildStarterLayout?: (user: UserOption | null, paletteItems: PaletteItem[]) => TemplateElement[];
lockedTextPaletteIds?: ReadonlySet<string>;
paletteGroups?: Array<{ category: PaletteCategory; title: string }>;
paletteItems?: PaletteItem[];
pdfDownloadName?: string;
pdfPreviewTitle?: string;
templateApiPath?: string;
templateTitle?: string;
}
type DragPayload = type DragPayload =
| { | {
kind: "palette"; kind: "palette";
@@ -158,7 +196,7 @@ const CP1252_MAP: Record<number, number> = {
8482: 153, 8482: 153,
}; };
const PALETTE_ITEMS: PaletteItem[] = [ const INVOICE_PALETTE_ITEMS: PaletteItem[] = [
{ {
id: "invoice-title", id: "invoice-title",
category: "master-data", category: "master-data",
@@ -459,12 +497,338 @@ const PALETTE_ITEMS: PaletteItem[] = [
}, },
]; ];
const PALETTE_GROUPS: Array<{ category: PaletteCategory; title: string }> = [ const INVOICE_PALETTE_GROUPS: Array<{ category: PaletteCategory; title: string }> = [
{ category: "master-data", title: "Stammdaten" }, { category: "master-data", title: "Stammdaten" },
{ category: "customer-data", title: "Kundendaten" }, { category: "customer-data", title: "Kundendaten" },
{ category: "free-elements", title: "Freie Elemente" }, { category: "free-elements", title: "Freie Elemente" },
]; ];
const REPORT_PALETTE_ITEMS: PaletteItem[] = [
{
id: "report-title",
category: "report-header",
label: "Berichtstitel",
description: "Titel der Probenauswertung",
width: 330,
fontSize: 28,
fontWeight: 700,
textAlign: "left",
defaultContent: () => "Milchprobenauswertung",
},
{
id: "report-sample-number",
category: "report-header",
label: "Probe",
description: "Probenummer",
width: 120,
fontSize: 16,
fontWeight: 600,
textAlign: "left",
defaultContent: () => "Probe: 10307",
},
{
id: "report-date",
category: "report-header",
label: "Datum",
description: "Berichtsdatum",
width: 120,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
defaultContent: () => `Datum:\n${new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(new Date())}`,
},
{
id: "report-farmer",
category: "report-data",
label: "Landwirt",
description: "Name des Landwirts",
width: 270,
fontSize: 16,
fontWeight: 600,
textAlign: "left",
defaultContent: () => "Landwirt: VoS, Dirk Schwissel",
},
{
id: "report-cow",
category: "report-data",
label: "Kuh",
description: "Kuh- oder Tiernummer",
width: 220,
fontSize: 16,
fontWeight: 600,
textAlign: "left",
defaultContent: () => "Kuh: 36",
},
{
id: "report-clinical-findings",
category: "report-data",
label: "Klinische Untersuchung",
description: "Klinischer Befund",
width: 320,
fontSize: 16,
fontWeight: 600,
textAlign: "left",
defaultContent: () => "Klinische Untersuchung:",
},
{
id: "report-treatment",
category: "report-data",
label: "Vorbehandelt",
description: "Vorbehandlung oder Medikation",
width: 340,
fontSize: 16,
fontWeight: 600,
textAlign: "left",
defaultContent: () => "Vorbehandelt mit: Ubrolexin; Betamox",
},
{
id: "report-examination-start",
category: "report-data",
label: "Untersuchungsbeginn",
description: "Startdatum der Untersuchung",
width: 280,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Untersuchungsbeginn: 11.03.2026",
},
{
id: "report-examination-end",
category: "report-data",
label: "Untersuchungsende",
description: "Enddatum der Untersuchung",
width: 260,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Untersuchungsende: 13.03.2026",
},
{
id: "report-quarter-vl-label",
category: "report-findings",
label: "VL",
description: "Linkes Vorderviertel",
width: 32,
fontSize: 16,
fontWeight: 700,
textAlign: "center",
defaultContent: () => "VL",
},
{
id: "report-quarter-vr-label",
category: "report-findings",
label: "VR",
description: "Rechtes Vorderviertel",
width: 32,
fontSize: 16,
fontWeight: 700,
textAlign: "center",
defaultContent: () => "VR",
},
{
id: "report-quarter-hl-label",
category: "report-findings",
label: "HL",
description: "Linkes Hinterviertel",
width: 32,
fontSize: 16,
fontWeight: 700,
textAlign: "center",
defaultContent: () => "HL",
},
{
id: "report-quarter-hr-label",
category: "report-findings",
label: "HR",
description: "Rechtes Hinterviertel",
width: 32,
fontSize: 16,
fontWeight: 700,
textAlign: "center",
defaultContent: () => "HR",
},
{
id: "report-quarter-vl-result",
category: "report-findings",
label: "Befund VL",
description: "Befundtext fuer VL",
width: 120,
fontSize: 15,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "kein bakt.\nWachstum",
},
{
id: "report-quarter-vr-result",
category: "report-findings",
label: "Befund VR",
description: "Befundtext fuer VR",
width: 120,
fontSize: 15,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "KNS",
},
{
id: "report-quarter-hl-result",
category: "report-findings",
label: "Befund HL",
description: "Befundtext fuer HL",
width: 120,
fontSize: 15,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "kein bakt.\nWachstum",
},
{
id: "report-quarter-hr-result",
category: "report-findings",
label: "Befund HR",
description: "Befundtext fuer HR",
width: 120,
fontSize: 15,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "kein bakt.\nWachstum",
},
{
id: "report-antibiogram-heading",
category: "report-sections",
label: "Antibiogramm Titel",
description: "Ueberschrift des Antibiogramms",
width: 170,
fontSize: 18,
fontWeight: 700,
textAlign: "left",
defaultContent: () => "Antibiogramm 4/4",
},
{
id: "report-antibiogram-summary",
category: "report-sections",
label: "Antibiogramm Ergebnis",
description: "Kurzfassung zum Viertel",
width: 520,
fontSize: 15,
fontWeight: 600,
textAlign: "left",
defaultContent: () => "VR (sensibel): Penicillin, Cefalexin ...",
},
{
id: "report-antibiogram-details",
category: "report-sections",
label: "Antibiogramm Details",
description: "Ausfuehrliche Antibiogramm-Liste",
width: 610,
fontSize: 15,
fontWeight: 400,
textAlign: "left",
defaultContent: () =>
"Cefquinom, Enrofloxacin, Sulfadoxin Trimethoprim, Amoxicillin Clavulansaeure, Oxacillin, Ampicillin, Sulfadoxin Trimethoprim",
},
{
id: "report-therapy-heading",
category: "report-sections",
label: "Empfehlung/Therapie",
description: "Ueberschrift der Therapieempfehlung",
width: 230,
fontSize: 17,
fontWeight: 700,
textAlign: "left",
defaultContent: () => "Empfehlung/Therapie:",
},
{
id: "report-therapy-text",
category: "report-sections",
label: "Therapietext",
description: "Empfehlungstext",
width: 400,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "weiter wie begonnen",
},
{
id: "report-misc-heading",
category: "report-sections",
label: "Sonstiges",
description: "Ueberschrift fuer Hinweise",
width: 140,
fontSize: 18,
fontWeight: 700,
textAlign: "left",
defaultContent: () => "Sonstiges",
},
{
id: "report-misc-note",
category: "report-sections",
label: "Hinweis",
description: "Allgemeiner Hinweistext",
width: 620,
fontSize: 15,
fontWeight: 400,
textAlign: "left",
defaultContent: () =>
"· Bei Rueckfragen setzen Sie sich bitte mit Ihrem behandelnden Tierarzt in Verbindung.",
},
{
id: "report-lab-note",
category: "report-sections",
label: "Laborvermerk",
description: "Interner Vermerk",
width: 220,
fontSize: 15,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Interner Vermerk: Labor",
},
{
id: "free-text",
category: "free-elements",
label: "Freitext",
description: "Beliebig editierbarer Text",
width: 220,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Freitext",
},
{
id: "line",
category: "free-elements",
label: "Linie",
description: "Linie mit umschaltbarer Ausrichtung",
kind: "line",
width: 260,
height: 3,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
lineOrientation: "horizontal",
defaultContent: () => "",
},
{
id: "image",
category: "free-elements",
label: "Bild",
description: "Logo, Kopfgrafik oder Gestaltungselement",
kind: "image",
width: 180,
height: 120,
fontSize: 16,
fontWeight: 400,
textAlign: "left",
defaultContent: () => "Bild",
},
];
const REPORT_PALETTE_GROUPS: Array<{ category: PaletteCategory; title: string }> = [
{ category: "report-header", title: "Kopfbereich" },
{ category: "report-data", title: "Probendaten" },
{ category: "report-findings", title: "Befunde" },
{ category: "report-sections", title: "Abschnitte" },
{ category: "free-elements", title: "Freie Elemente" },
];
function clamp(value: number, minimum: number, maximum: number) { function clamp(value: number, minimum: number, maximum: number) {
return Math.min(Math.max(value, minimum), maximum); return Math.min(Math.max(value, minimum), maximum);
} }
@@ -915,12 +1279,12 @@ function normalizeElement(element: TemplateElement): TemplateElement {
}; };
} }
function findPaletteItem(paletteId: string) { function findPaletteItem(paletteItems: PaletteItem[], paletteId: string) {
return PALETTE_ITEMS.find((entry) => entry.id === paletteId) ?? null; return paletteItems.find((entry) => entry.id === paletteId) ?? null;
} }
function requirePaletteItem(paletteId: string) { function requirePaletteItem(paletteItems: PaletteItem[], paletteId: string) {
const paletteItem = findPaletteItem(paletteId); const paletteItem = findPaletteItem(paletteItems, paletteId);
if (!paletteItem) { if (!paletteItem) {
throw new Error(`Palette item ${paletteId} is not configured.`); throw new Error(`Palette item ${paletteId} is not configured.`);
} }
@@ -937,8 +1301,11 @@ function getPalettePreviewText(paletteItem: PaletteItem, user: UserOption | null
return paletteItem.defaultContent(user); return paletteItem.defaultContent(user);
} }
function isElementContentEditable(element: Pick<TemplateElement, "kind" | "paletteId">) { function isElementContentEditable(
return element.kind === "text" && !LOCKED_TEXT_PALETTE_IDS.has(element.paletteId); element: Pick<TemplateElement, "kind" | "paletteId">,
lockedTextPaletteIds: ReadonlySet<string>,
) {
return element.kind === "text" && !lockedTextPaletteIds.has(element.paletteId);
} }
function createElementFromPalette( function createElementFromPalette(
@@ -966,18 +1333,66 @@ function createElementFromPalette(
}); });
} }
function createStarterLayout(user: UserOption | null) { function createInvoiceStarterLayout(user: UserOption | null, paletteItems: PaletteItem[]) {
return [ return [
createElementFromPalette(requirePaletteItem("company-name"), user, { x: 56, y: 56 }), createElementFromPalette(requirePaletteItem(paletteItems, "company-name"), user, { x: 56, y: 56 }),
createElementFromPalette(requirePaletteItem("street"), user, { x: 56, y: 104 }), createElementFromPalette(requirePaletteItem(paletteItems, "street"), user, { x: 56, y: 104 }),
createElementFromPalette(requirePaletteItem("house-number"), user, { x: 288, y: 104 }), createElementFromPalette(requirePaletteItem(paletteItems, "house-number"), user, { x: 288, y: 104 }),
createElementFromPalette(requirePaletteItem("postal-code"), user, { x: 56, y: 132 }), createElementFromPalette(requirePaletteItem(paletteItems, "postal-code"), user, { x: 56, y: 132 }),
createElementFromPalette(requirePaletteItem("city"), user, { x: 164, y: 132 }), createElementFromPalette(requirePaletteItem(paletteItems, "city"), user, { x: 164, y: 132 }),
createElementFromPalette(requirePaletteItem("invoice-title"), user, { x: 56, y: 238 }), createElementFromPalette(requirePaletteItem(paletteItems, "invoice-title"), user, { x: 56, y: 238 }),
createElementFromPalette(requirePaletteItem("invoice-date"), user, { x: 538, y: 64 }), createElementFromPalette(requirePaletteItem(paletteItems, "invoice-date"), user, { x: 538, y: 64 }),
createElementFromPalette(requirePaletteItem("invoice-number"), user, { x: 538, y: 96 }), createElementFromPalette(requirePaletteItem(paletteItems, "invoice-number"), user, { x: 538, y: 96 }),
createElementFromPalette(requirePaletteItem("amount"), user, { x: 558, y: 432 }), createElementFromPalette(requirePaletteItem(paletteItems, "amount"), user, { x: 558, y: 432 }),
createElementFromPalette(requirePaletteItem("footer"), user, { x: 56, y: 1008 }), createElementFromPalette(requirePaletteItem(paletteItems, "footer"), user, { x: 56, y: 1008 }),
];
}
function createReportStarterLayout(user: UserOption | null, paletteItems: PaletteItem[]) {
const horizontalLine = (x: number, y: number, width: number) =>
normalizeElement({
...createElementFromPalette(requirePaletteItem(paletteItems, "line"), user, { x, y }),
width,
});
const verticalLine = (x: number, y: number, height: number) =>
normalizeElement({
...createElementFromPalette(requirePaletteItem(paletteItems, "line"), user, { x, y }),
height,
lineOrientation: "vertical",
width: 3,
});
return [
createElementFromPalette(requirePaletteItem(paletteItems, "report-title"), user, { x: 56, y: 108 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-farmer"), user, { x: 56, y: 154 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-cow"), user, { x: 56, y: 186 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-clinical-findings"), user, { x: 56, y: 218 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-treatment"), user, { x: 56, y: 250 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-examination-start"), user, { x: 56, y: 282 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-examination-end"), user, { x: 56, y: 314 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-sample-number"), user, { x: 430, y: 108 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-date"), user, { x: 572, y: 108 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-vl-label"), user, { x: 538, y: 206 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-vr-label"), user, { x: 628, y: 206 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-hl-label"), user, { x: 538, y: 236 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-hr-label"), user, { x: 628, y: 236 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-vl-result"), user, { x: 430, y: 168 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-vr-result"), user, { x: 618, y: 184 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-hl-result"), user, { x: 430, y: 272 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-quarter-hr-result"), user, { x: 618, y: 272 }),
horizontalLine(520, 254, 170),
verticalLine(606, 196, 106),
createElementFromPalette(requirePaletteItem(paletteItems, "report-antibiogram-heading"), user, { x: 56, y: 426 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-antibiogram-summary"), user, { x: 56, y: 458 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-antibiogram-details"), user, { x: 56, y: 490 }),
horizontalLine(56, 396, 646),
horizontalLine(56, 548, 646),
createElementFromPalette(requirePaletteItem(paletteItems, "report-therapy-heading"), user, { x: 56, y: 574 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-therapy-text"), user, { x: 292, y: 574 }),
horizontalLine(56, 628, 646),
createElementFromPalette(requirePaletteItem(paletteItems, "report-misc-heading"), user, { x: 56, y: 656 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-misc-note"), user, { x: 56, y: 702 }),
createElementFromPalette(requirePaletteItem(paletteItems, "report-lab-note"), user, { x: 56, y: 754 }),
]; ];
} }
@@ -1130,7 +1545,16 @@ function readDragPayload(event: DragEvent<HTMLElement>): DragPayload | null {
} }
} }
export default function InvoiceTemplatePage() { export default function InvoiceTemplatePage({
buildStarterLayout = createInvoiceStarterLayout,
lockedTextPaletteIds = INVOICE_LOCKED_TEXT_PALETTE_IDS,
paletteGroups = INVOICE_PALETTE_GROUPS,
paletteItems = INVOICE_PALETTE_ITEMS,
pdfDownloadName = "rechnung-template.pdf",
pdfPreviewTitle = "PDF-Vorschau Rechnungsvorlage",
templateApiPath = "/session/invoice-template",
templateTitle = "Rechnungsvorlage",
}: InvoiceTemplatePageProps) {
const { user } = useSession(); const { user } = useSession();
const pageRef = useRef<HTMLDivElement | null>(null); const pageRef = useRef<HTMLDivElement | null>(null);
const cardRef = useRef<HTMLElement | null>(null); const cardRef = useRef<HTMLElement | null>(null);
@@ -1140,7 +1564,9 @@ export default function InvoiceTemplatePage() {
const mouseDragCleanupRef = useRef<(() => void) | null>(null); const mouseDragCleanupRef = useRef<(() => void) | null>(null);
const moveOffsetRef = useRef<{ x: number; y: number } | null>(null); const moveOffsetRef = useRef<{ x: number; y: number } | null>(null);
const [elements, setElements] = useState<TemplateElement[]>(() => createStarterLayout(user)); const [elements, setElements] = useState<TemplateElement[]>(() =>
buildStarterLayout(user, paletteItems),
);
const [selectedElementId, setSelectedElementId] = useState<string | null>(null); const [selectedElementId, setSelectedElementId] = useState<string | null>(null);
const [isCanvasActive, setIsCanvasActive] = useState(false); const [isCanvasActive, setIsCanvasActive] = useState(false);
const [draggingElementId, setDraggingElementId] = useState<string | null>(null); const [draggingElementId, setDraggingElementId] = useState<string | null>(null);
@@ -1163,7 +1589,7 @@ export default function InvoiceTemplatePage() {
const selectedElement = elements.find((entry) => entry.id === selectedElementId) ?? null; const selectedElement = elements.find((entry) => entry.id === selectedElementId) ?? null;
const selectedElementHeight = selectedElement ? getElementHeight(selectedElement) : null; const selectedElementHeight = selectedElement ? getElementHeight(selectedElement) : null;
const selectedElementContentEditable = selectedElement const selectedElementContentEditable = selectedElement
? isElementContentEditable(selectedElement) ? isElementContentEditable(selectedElement, lockedTextPaletteIds)
: false; : false;
const selectedElementMinimumWidth = selectedElement const selectedElementMinimumWidth = selectedElement
? getMinimumWidthForElement(selectedElement.kind, selectedElement.lineOrientation) ? getMinimumWidthForElement(selectedElement.kind, selectedElement.lineOrientation)
@@ -1176,10 +1602,11 @@ export default function InvoiceTemplatePage() {
) )
: MIN_MEDIA_HEIGHT; : MIN_MEDIA_HEIGHT;
const templateTimestampLabel = formatTemplateTimestamp(templateUpdatedAt); const templateTimestampLabel = formatTemplateTimestamp(templateUpdatedAt);
const emptyCanvasMessage = `Ziehen Sie links ein Element auf diese Fläche, um Ihre ${templateTitle} zu starten.`;
useEffect(() => { useEffect(() => {
if (!user) { if (!user) {
const starterLayout = createStarterLayout(null); const starterLayout = buildStarterLayout(null, paletteItems);
setElements(starterLayout); setElements(starterLayout);
setSelectedElementId(starterLayout[0]?.id ?? null); setSelectedElementId(starterLayout[0]?.id ?? null);
setResizingElementId(null); setResizingElementId(null);
@@ -1194,7 +1621,7 @@ export default function InvoiceTemplatePage() {
setTemplateError(null); setTemplateError(null);
setIsTemplateLoading(true); setIsTemplateLoading(true);
void apiGet<InvoiceTemplateResponse>("/session/invoice-template") void apiGet<InvoiceTemplateResponse>(templateApiPath)
.then((response) => { .then((response) => {
if (cancelled) { if (cancelled) {
return; return;
@@ -1203,7 +1630,7 @@ export default function InvoiceTemplatePage() {
setIsTemplateApiAvailable(true); setIsTemplateApiAvailable(true);
setTemplateUpdatedAt(response.updatedAt); setTemplateUpdatedAt(response.updatedAt);
if (!response.stored) { if (!response.stored) {
const starterLayout = createStarterLayout(user); const starterLayout = buildStarterLayout(user, paletteItems);
setElements(starterLayout); setElements(starterLayout);
setSelectedElementId(starterLayout[0]?.id ?? null); setSelectedElementId(starterLayout[0]?.id ?? null);
setResizingElementId(null); setResizingElementId(null);
@@ -1222,7 +1649,7 @@ export default function InvoiceTemplatePage() {
.catch((error) => { .catch((error) => {
if (!cancelled) { if (!cancelled) {
if (error instanceof ApiError && error.status === 404) { if (error instanceof ApiError && error.status === 404) {
const starterLayout = createStarterLayout(user); const starterLayout = buildStarterLayout(user, paletteItems);
setElements(starterLayout); setElements(starterLayout);
setSelectedElementId(starterLayout[0]?.id ?? null); setSelectedElementId(starterLayout[0]?.id ?? null);
setResizingElementId(null); setResizingElementId(null);
@@ -1243,7 +1670,7 @@ export default function InvoiceTemplatePage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [user]); }, [buildStarterLayout, paletteItems, templateApiPath, user]);
useEffect(() => { useEffect(() => {
if (selectedElementId && elements.some((entry) => entry.id === selectedElementId)) { if (selectedElementId && elements.some((entry) => entry.id === selectedElementId)) {
@@ -1390,7 +1817,7 @@ export default function InvoiceTemplatePage() {
entry.id === elementId entry.id === elementId
? normalizeElement({ ? normalizeElement({
...entry, ...entry,
...(!isElementContentEditable(entry) && ...(!isElementContentEditable(entry, lockedTextPaletteIds) &&
Object.prototype.hasOwnProperty.call(patch, "content") Object.prototype.hasOwnProperty.call(patch, "content")
? { ...patch, content: entry.content } ? { ...patch, content: entry.content }
: patch), : patch),
@@ -1408,7 +1835,7 @@ export default function InvoiceTemplatePage() {
} }
function placePaletteElement(paletteId: string, clientX: number, clientY: number) { function placePaletteElement(paletteId: string, clientX: number, clientY: number) {
const paletteItem = findPaletteItem(paletteId); const paletteItem = findPaletteItem(paletteItems, paletteId);
const canvasElement = canvasRef.current; const canvasElement = canvasRef.current;
if (!paletteItem || !canvasElement) { if (!paletteItem || !canvasElement) {
return; return;
@@ -1481,7 +1908,7 @@ export default function InvoiceTemplatePage() {
function handleResetToStarter() { function handleResetToStarter() {
mouseDragCleanupRef.current?.(); mouseDragCleanupRef.current?.();
const starterLayout = createStarterLayout(user); const starterLayout = buildStarterLayout(user, paletteItems);
setElements(starterLayout); setElements(starterLayout);
setSelectedElementId(starterLayout[0]?.id ?? null); setSelectedElementId(starterLayout[0]?.id ?? null);
setResizingElementId(null); setResizingElementId(null);
@@ -1523,7 +1950,7 @@ export default function InvoiceTemplatePage() {
setIsTemplateSaving(true); setIsTemplateSaving(true);
try { try {
const response = await apiPut<InvoiceTemplateResponse>("/session/invoice-template", { const response = await apiPut<InvoiceTemplateResponse>(templateApiPath, {
elements, elements,
}); });
const savedElements = normalizeTemplateElements(response.elements); const savedElements = normalizeTemplateElements(response.elements);
@@ -1808,14 +2235,14 @@ export default function InvoiceTemplatePage() {
</div> </div>
<div className="invoice-template__palette"> <div className="invoice-template__palette">
{PALETTE_GROUPS.map((group) => ( {paletteGroups.map((group) => (
<section key={group.category} className="invoice-template__palette-group"> <section key={group.category} className="invoice-template__palette-group">
<div className="invoice-template__palette-group-header"> <div className="invoice-template__palette-group-header">
<h5>{group.title}</h5> <h5>{group.title}</h5>
</div> </div>
<div className="invoice-template__palette-grid"> <div className="invoice-template__palette-grid">
{PALETTE_ITEMS.filter((item) => item.category === group.category).map((item) => ( {paletteItems.filter((item) => item.category === group.category).map((item) => (
<button <button
key={item.id} key={item.id}
type="button" type="button"
@@ -1844,7 +2271,7 @@ export default function InvoiceTemplatePage() {
<div className="invoice-template__canvas-column"> <div className="invoice-template__canvas-column">
<div className="invoice-template__canvas-header"> <div className="invoice-template__canvas-header">
<div> <div>
<h4>Rechnungsvorlage</h4> <h4>{templateTitle}</h4>
<span className="invoice-template__panel-note"> <span className="invoice-template__panel-note">
{isTemplateLoading {isTemplateLoading
? "Gespeicherte Vorlage wird geladen..." ? "Gespeicherte Vorlage wird geladen..."
@@ -1931,10 +2358,7 @@ export default function InvoiceTemplatePage() {
</button> </button>
)) ))
) : ( ) : (
<div className="invoice-template__canvas-empty"> <div className="invoice-template__canvas-empty">{emptyCanvasMessage}</div>
Ziehen Sie links ein Element auf diese Fläche, um Ihr Rechnungstemplate zu
starten.
</div>
)} )}
</div> </div>
</div> </div>
@@ -2174,7 +2598,7 @@ export default function InvoiceTemplatePage() {
<div className="dialog__actions"> <div className="dialog__actions">
<a <a
href={pdfPreviewUrl} href={pdfPreviewUrl}
download="rechnung-template.pdf" download={pdfDownloadName}
className="secondary-button" className="secondary-button"
> >
PDF herunterladen PDF herunterladen
@@ -2193,7 +2617,7 @@ export default function InvoiceTemplatePage() {
<iframe <iframe
className="dialog__frame" className="dialog__frame"
src={pdfPreviewUrl} src={pdfPreviewUrl}
title="PDF-Vorschau Rechnungstemplate" title={pdfPreviewTitle}
/> />
</div> </div>
</div> </div>
@@ -2202,3 +2626,10 @@ export default function InvoiceTemplatePage() {
</div> </div>
); );
} }
export {
REPORT_LOCKED_TEXT_PALETTE_IDS,
REPORT_PALETTE_GROUPS,
REPORT_PALETTE_ITEMS,
createReportStarterLayout,
};

View File

@@ -0,0 +1,21 @@
import InvoiceTemplatePage, {
REPORT_LOCKED_TEXT_PALETTE_IDS,
REPORT_PALETTE_GROUPS,
REPORT_PALETTE_ITEMS,
createReportStarterLayout,
} from "./InvoiceTemplatePage";
export default function ReportTemplatePage() {
return (
<InvoiceTemplatePage
buildStarterLayout={createReportStarterLayout}
lockedTextPaletteIds={REPORT_LOCKED_TEXT_PALETTE_IDS}
paletteGroups={REPORT_PALETTE_GROUPS}
paletteItems={REPORT_PALETTE_ITEMS}
pdfDownloadName="bericht-template.pdf"
pdfPreviewTitle="PDF-Vorschau Berichtsvorlage"
templateApiPath="/session/report-template"
templateTitle="Berichtsvorlage"
/>
);
}

View File

@@ -137,6 +137,23 @@ a {
padding-left: 14px; padding-left: 14px;
} }
.nav-subgroup {
display: grid;
gap: 6px;
}
.nav-subgroup__label {
padding: 4px 14px 0;
color: rgba(248, 243, 237, 0.56);
font-size: 0.78rem;
font-weight: 600;
}
.nav-subnav--nested {
gap: 6px;
padding-left: 12px;
}
.nav-sublink { .nav-sublink {
padding: 10px 14px; padding: 10px 14px;
border-radius: 14px; border-radius: 14px;