fix(t-paliad-067): PR-1 i18n leak sweep + activity narrative (F-04, F-07, F-10, F-12, F-21, F-29, F-35, F-46)

Per docs/audit-polish-2-2026-04-29.md PR-1. Single concern: text rendered
to a German narrative that was still English or raw-keyed.

- F-04 deadlines-new.ts now references the existing fristen.field.akte.*
  keys (the SSR template already used them) instead of the non-existent
  fristen.field.project.* keys, so the picker no longer renders the raw
  i18n key.
- F-07 + F-21 dashboard activity log + project Verlauf:
  • i18n.ts gains the missing dashboard.action.short.project_type_changed
    plus a parallel event.title.* key set (full noun-phrase form for
    Verlauf, complementing the dashboard's verb form) and
    event.description.* templates with {title}/{count}/{parent}
    placeholders.
  • New translateEvent(eventType, title, description) helper localizes a
    stored project_events row for display; parses both new value-only
    descriptions and legacy English+DE-mix shapes ("Deadline „Foo"
    geändert", "Type case → litigation", "Note zu deadline hinzugefügt").
    Wired into dashboard.ts and projects-detail.ts renderers.
  • Go services now write descriptions as value-only payloads (the title,
    the count, the parent slug, or "old → new") so future rows are
    locale-clean. Affected services: deadline_service.go (5 sites),
    appointment_service.go (3 sites), note_service.go (1 site),
    project_service.go (2 sites: status_changed, project_type_changed).
  • Translation covers historical project_events rows too — the
    legacy-format parsers in translateEventDescription strip the English
    "Type"/"Status" prefix and pull the quoted title out of "Deadline
    „Foo" geändert" so DE/EN renders correctly without DB migration.
  • Renamed dashboard.action.short.project_* DE labels from "...Akte" to
    "...Projekt" to match the project-rename direction.
- F-10 deadlines list REGEL column now resolves rule_name/rule_name_en
  via a JOIN-side alias on deadline_service.ListWithProjects (added
  RuleName/RuleNameEN to DeadlineWithProject). New ruleDisplay() helper
  prefers the localized rule name and falls back to em-dash; never
  renders the raw rule_code slug ("inf.rejoin").
- F-12 fristen.col.akte and termine.col.akte DE values flip "Akte" →
  "Projekt"; matching SSR placeholder text on deadlines.tsx and
  appointments.tsx column headers (EN already said "Matter").
- F-29 the checklists empty-state hint on /projects/{id}/checklists is
  split into prefix/link/suffix spans so the <a href="/checklists"> stays
  intact after applyTranslations() runs (the previous single-string i18n
  value collapsed the anchor on first paint).
- F-35 projekte.subtitle DE flips "Fälle" → "Verfahren" (matches the
  actual type taxonomy: Mandant/Streitsache/Patent/Verfahren/Projekt).
  Same fix on projekte.empty.hint. EN keeps "cases" since EN labels the
  case type as "case".
- F-46 dashboard.greeting.prefix EN flips "Good day" → "Hello".

Verified
- go build ./... + go vet ./... + go test ./... all green.
- bun run build clean.
- Dashboard activity widget + project Verlauf renderer verified by
  reading the translated paths; live smoke pending deploy.
This commit is contained in:
m
2026-04-29 14:26:04 +02:00
parent 80fdab0963
commit f583c650a2
14 changed files with 252 additions and 48 deletions

View File

@@ -98,7 +98,7 @@ export function renderAppointments(): string {
<th />
<th data-i18n="termine.col.start">Beginn</th>
<th data-i18n="termine.col.title">Titel</th>
<th data-i18n="termine.col.akte">Akte</th>
<th data-i18n="termine.col.akte">Projekt</th>
<th data-i18n="termine.col.location">Ort</th>
<th data-i18n="termine.col.type">Typ</th>
</tr>

View File

@@ -1,4 +1,4 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initI18n, onLangChange, t, getLang, translateEvent } from "./i18n";
import { initSidebar } from "./sidebar";
interface DashboardUser {
@@ -228,10 +228,12 @@ function renderActivity(items: ActivityEntry[]): void {
const shortAction = hasI18n
? translated
: (e.action || t("dashboard.activity.event"));
// The German sentence on the detail line ("Deadline „ok“ geändert").
// Legacy rows without an i18n match fall back to the static English
// details so the row still says something instead of going blank.
const detail = e.description || (hasI18n ? "" : e.details);
// Localize the muted detail line so it speaks DE in DE and EN in EN —
// historical rows carry English nouns inside DE narrative ("Deadline „ok"
// geändert", "Note zu deadline hinzugefügt"); translateEvent parses both
// legacy and new (value-only) shapes.
const stored = e.description ?? (hasI18n ? "" : e.details);
const { description: detail } = translateEvent(e.action, "", stored);
return `<li class="dashboard-activity-item">
<span class="dashboard-activity-time">${esc(formatDateTime(e.timestamp))}</span>
<div class="dashboard-activity-body">

View File

@@ -38,11 +38,11 @@ async function loadProjects() {
const projects: Project[] = await resp.json();
if (projects.length === 0) {
hint.style.display = "";
hint.innerHTML = `${esc(t("fristen.field.project.empty"))} <a href="/projects/new">${esc(t("fristen.field.project.empty.link"))}</a>`;
hint.innerHTML = `${esc(t("fristen.field.akte.empty"))} <a href="/projects/new">${esc(t("fristen.field.akte.empty.link"))}</a>`;
return;
}
const options: string[] = [
`<option value="" disabled${preselectedProjectID ? "" : " selected"} data-i18n="fristen.field.project.choose">${esc(t("fristen.field.project.choose"))}</option>`,
`<option value="" disabled${preselectedProjectID ? "" : " selected"} data-i18n="fristen.field.akte.choose">${esc(t("fristen.field.akte.choose"))}</option>`,
];
for (const p of projects) {
const isSelected = preselectedProjectID === p.id ? " selected" : "";

View File

@@ -12,6 +12,8 @@ interface Deadline {
project_reference: string;
project_title: string;
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
}
interface Project {
@@ -150,6 +152,16 @@ function esc(s: string): string {
return d.innerHTML;
}
// REGEL cell label. Prefer the localized rule name ("Replik" / "Reply"); fall
// back to em-dash when no rule is attached. We never render the raw machine
// slug ("inf.rejoin") — the audit (F-10) flagged that as implementation leak.
function ruleDisplay(f: Deadline): string {
const lang = getLang();
const localized = lang === "en" ? f.rule_name_en : f.rule_name;
if (localized && localized.trim()) return esc(localized);
return "&mdash;";
}
function render() {
if (!loadedOK) return;
const tbody = document.getElementById("deadlines-body")!;
@@ -179,7 +191,7 @@ function render() {
.map((f) => {
const urgency = urgencyClass(f.due_date, f.status);
const statusLabel = t(`fristen.status.${f.status}`) || f.status;
const ruleLabel = f.rule_code ? esc(f.rule_code) : "&mdash;";
const ruleLabel = ruleDisplay(f);
const isDone = f.status === "completed";
const titleClass = isDone ? "frist-title-done" : "";
const reopenLabel = esc(t("fristen.action.reopen"));

View File

@@ -538,7 +538,7 @@ const translations: Record<Lang, Record<string, string>> = {
"fristen.filter.akte.all": "Alle Projekte",
"fristen.col.due": "F\u00e4llig",
"fristen.col.title": "Titel",
"fristen.col.akte": "Akte",
"fristen.col.akte": "Projekt",
"fristen.col.rule": "Regel",
"fristen.col.status": "Status",
"fristen.empty.title": "Keine Fristen vorhanden",
@@ -688,9 +688,10 @@ const translations: Record<Lang, Record<string, string>> = {
// bold summary line ("Matthias änderte Frist"); the full German
// sentence ("Deadline „ok“ geändert") lives on a second
// muted line via the project_events.description column.
"dashboard.action.short.project_created": "legte Akte an",
"dashboard.action.short.project_archived": "archivierte Akte",
"dashboard.action.short.project_reparented": "ordnete Akte neu zu",
"dashboard.action.short.project_created": "legte Projekt an",
"dashboard.action.short.project_archived": "archivierte Projekt",
"dashboard.action.short.project_reparented": "ordnete Projekt neu zu",
"dashboard.action.short.project_type_changed": "\u00e4nderte Projekt-Typ",
"dashboard.action.short.status_changed": "\u00e4nderte Status",
"dashboard.action.short.visibility_changed": "\u00e4nderte Sichtbarkeit",
"dashboard.action.short.collaborators_updated": "aktualisierte Bearbeiter",
@@ -704,6 +705,41 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.action.short.appointment_created": "legte Termin an",
"dashboard.action.short.appointment_updated": "\u00e4nderte Termin",
"dashboard.action.short.appointment_deleted": "l\u00f6schte Termin",
// Localized event-row title for the project Verlauf tab \u2014 full noun
// phrase ("Frist ge\u00e4ndert") complementing the dashboard's verb form.
"event.title.project_created": "Projekt angelegt",
"event.title.project_archived": "Projekt archiviert",
"event.title.project_reparented": "Projekt umstrukturiert",
"event.title.project_type_changed": "Projekt-Typ ge\u00e4ndert",
"event.title.status_changed": "Status ge\u00e4ndert",
"event.title.note_created": "Notiz hinzugef\u00fcgt",
"event.title.deadline_created": "Frist angelegt",
"event.title.deadline_updated": "Frist ge\u00e4ndert",
"event.title.deadline_completed": "Frist erledigt",
"event.title.deadline_reopened": "Frist wiederer\u00f6ffnet",
"event.title.deadline_deleted": "Frist gel\u00f6scht",
"event.title.deadlines_imported": "Fristen importiert",
"event.title.appointment_created": "Termin angelegt",
"event.title.appointment_updated": "Termin ge\u00e4ndert",
"event.title.appointment_deleted": "Termin gel\u00f6scht",
// Note-parent slugs for note_created descriptions
// ("Notiz zu Frist hinzugef\u00fcgt"). Used by translateEventDescription().
"event.note.parent.project": "Projekt",
"event.note.parent.deadline": "Frist",
"event.note.parent.appointment": "Termin",
"event.note.added_to": "Notiz zu {parent} hinzugef\u00fcgt",
// Localized full-sentence descriptions for the project Verlauf and the
// dashboard activity feed. Stored descriptions carry just the variable
// payload (title/count/slugs); the renderer interpolates here.
"event.description.deadline_created": "Frist \u201e{title}\u201c angelegt",
"event.description.deadline_updated": "Frist \u201e{title}\u201c ge\u00e4ndert",
"event.description.deadline_completed": "Frist \u201e{title}\u201c als erledigt markiert",
"event.description.deadline_reopened": "Frist \u201e{title}\u201c wieder ge\u00f6ffnet",
"event.description.deadline_deleted": "Frist \u201e{title}\u201c gel\u00f6scht",
"event.description.deadlines_imported": "{count} Fristen aus Fristenrechner \u00fcbernommen",
"event.description.appointment_created": "Termin \u201e{title}\u201c angelegt",
"event.description.appointment_updated": "Termin \u201e{title}\u201c ge\u00e4ndert",
"event.description.appointment_deleted": "Termin \u201e{title}\u201c gel\u00f6scht",
"dashboard.action.short.checklist_created": "legte Checkliste an",
"dashboard.action.short.checklist_renamed": "benannte Checkliste um",
"dashboard.action.short.checklist_unlinked": "trennte Checkliste",
@@ -841,7 +877,7 @@ const translations: Record<Lang, Record<string, string>> = {
"dezernat.confirm_remove": "Mitglied entfernen?",
"projekte.title": "Projekte \u2014 Paliad",
"projekte.heading": "Projekte",
"projekte.subtitle": "Mandanten, Streitsachen, Patente und F\u00e4lle \u2014 hierarchisch organisiert.",
"projekte.subtitle": "Mandanten, Streitsachen, Patente und Verfahren \u2014 hierarchisch organisiert.",
"projekte.new": "Neues Projekt",
"projekte.search.placeholder": "Titel, Referenz oder ClientMatter...",
"projekte.filter.type": "Typ",
@@ -862,7 +898,7 @@ const translations: Record<Lang, Record<string, string>> = {
"projekte.col.status": "Status",
"projekte.col.updated": "Zuletzt ge\u00e4ndert",
"projekte.empty.title": "Noch kein Projekt angelegt",
"projekte.empty.hint": "Starten Sie \u00fcber \u201eNeues Projekt\u201c \u2014 legen Sie zuerst einen Mandanten an, darunter Streitsachen, Patente und F\u00e4lle.",
"projekte.empty.hint": "Starten Sie \u00fcber \u201eNeues Projekt\u201c \u2014 legen Sie zuerst einen Mandanten an, darunter Streitsachen, Patente und Verfahren.",
"projekte.empty.filtered": "Keine Treffer f\u00fcr diese Filter.",
"projekte.cancel": "Abbrechen",
"projekte.submit": "Projekt anlegen",
@@ -946,7 +982,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projekte.detail.checklisten.col.name": "Name",
"projekte.detail.checklisten.col.progress": "Fortschritt",
"projekte.detail.checklisten.col.created": "Angelegt",
"projekte.detail.checklisten.hint": "Instanzen werden auf der Vorlagen-Seite unter Checklisten angelegt.",
"projekte.detail.checklisten.hint.prefix": "Instanzen werden auf der Vorlagen-Seite unter ",
"projekte.detail.checklisten.hint.link": "Checklisten",
"projekte.detail.checklisten.hint.suffix": " angelegt.",
"projekte.detail.delete": "Projekt archivieren",
"projekte.detail.delete.confirm.title": "Projekt wirklich archivieren?",
"projekte.detail.delete.confirm.body": "Das Projekt wird archiviert. Es kann nicht direkt wiederhergestellt werden.",
@@ -1040,7 +1078,7 @@ const translations: Record<Lang, Record<string, string>> = {
"termine.filter.to": "Bis",
"termine.col.start": "Beginn",
"termine.col.title": "Titel",
"termine.col.akte": "Akte",
"termine.col.akte": "Projekt",
"termine.col.location": "Ort",
"termine.col.type": "Typ",
"termine.empty.title": "Keine Termine vorhanden",
@@ -1903,7 +1941,7 @@ const translations: Record<Lang, Record<string, string>> = {
// Dashboard (logged-in landing)
"dashboard.title": "Dashboard \u2014 Paliad",
"dashboard.greeting.prefix": "Good day",
"dashboard.greeting.prefix": "Hello",
"dashboard.unavailable": "Dashboard requires the database \u2014 contact an administrator.",
"dashboard.onboarding": "Please complete onboarding before deadlines and matters are shown.",
"dashboard.summary.heading": "Deadlines at a glance",
@@ -1929,9 +1967,10 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.urgency.soon": "Soon",
"dashboard.when.today": "today",
"dashboard.when.tomorrow": "tomorrow",
"dashboard.action.short.project_created": "created matter",
"dashboard.action.short.project_archived": "archived matter",
"dashboard.action.short.project_reparented": "re-parented matter",
"dashboard.action.short.project_created": "created project",
"dashboard.action.short.project_archived": "archived project",
"dashboard.action.short.project_reparented": "re-parented project",
"dashboard.action.short.project_type_changed": "changed project type",
"dashboard.action.short.status_changed": "changed status",
"dashboard.action.short.visibility_changed": "changed visibility",
"dashboard.action.short.collaborators_updated": "updated collaborators",
@@ -1945,6 +1984,41 @@ const translations: Record<Lang, Record<string, string>> = {
"dashboard.action.short.appointment_created": "added appointment",
"dashboard.action.short.appointment_updated": "updated appointment",
"dashboard.action.short.appointment_deleted": "deleted appointment",
// Localized event-row title for the project Verlauf tab — full noun
// phrase ("Deadline updated") complementing the dashboard's verb form.
"event.title.project_created": "Project created",
"event.title.project_archived": "Project archived",
"event.title.project_reparented": "Project re-parented",
"event.title.project_type_changed": "Project type changed",
"event.title.status_changed": "Status changed",
"event.title.note_created": "Note added",
"event.title.deadline_created": "Deadline created",
"event.title.deadline_updated": "Deadline updated",
"event.title.deadline_completed": "Deadline completed",
"event.title.deadline_reopened": "Deadline reopened",
"event.title.deadline_deleted": "Deadline deleted",
"event.title.deadlines_imported": "Deadlines imported",
"event.title.appointment_created": "Appointment created",
"event.title.appointment_updated": "Appointment updated",
"event.title.appointment_deleted": "Appointment deleted",
// Note-parent slugs for note_created descriptions
// ("Note added to deadline"). Used by translateEventDescription().
"event.note.parent.project": "project",
"event.note.parent.deadline": "deadline",
"event.note.parent.appointment": "appointment",
"event.note.added_to": "Note added to {parent}",
// Localized full-sentence descriptions for the project Verlauf and the
// dashboard activity feed. Stored descriptions carry just the variable
// payload (title/count/slugs); the renderer interpolates here.
"event.description.deadline_created": "Deadline “{title}” added",
"event.description.deadline_updated": "Deadline “{title}” updated",
"event.description.deadline_completed": "Deadline “{title}” completed",
"event.description.deadline_reopened": "Deadline “{title}” reopened",
"event.description.deadline_deleted": "Deadline “{title}” deleted",
"event.description.deadlines_imported": "{count} deadlines imported from Fristenrechner",
"event.description.appointment_created": "Appointment “{title}” added",
"event.description.appointment_updated": "Appointment “{title}” updated",
"event.description.appointment_deleted": "Appointment “{title}” deleted",
"dashboard.action.short.checklist_created": "added checklist",
"dashboard.action.short.checklist_renamed": "renamed checklist",
"dashboard.action.short.checklist_unlinked": "unlinked checklist",
@@ -2187,7 +2261,9 @@ const translations: Record<Lang, Record<string, string>> = {
"projekte.detail.checklisten.col.name": "Name",
"projekte.detail.checklisten.col.progress": "Progress",
"projekte.detail.checklisten.col.created": "Created",
"projekte.detail.checklisten.hint": "Instances are created on the template page under Checklists.",
"projekte.detail.checklisten.hint.prefix": "Instances are created on the template page under ",
"projekte.detail.checklisten.hint.link": "Checklists",
"projekte.detail.checklisten.hint.suffix": ".",
"projekte.detail.delete": "Archive project",
"projekte.detail.delete.confirm.title": "Archive project?",
"projekte.detail.delete.confirm.body": "The project will be archived. It cannot be directly restored.",
@@ -2513,6 +2589,95 @@ export function getLang(): Lang {
return currentLang;
}
// translateEvent localizes a stored project_events row for display.
//
// project_events rows are written with stable English event_type slugs
// ("project_type_changed") and English fallback titles ("Project type
// changed"). The description column is freeform but for known event types
// follows a value-only pattern ("case → litigation", "active → archived",
// "deadline" for note_created). Legacy rows from before this PR carry the
// English prefix ("Type case → litigation", "Note zu deadline hinzugefügt"
// — DE/EN-mixed); both legacy and new shapes parse here.
//
// Caller provides whatever the backend returned for title + description;
// receives a localized pair ready for innerHTML/textContent.
export function translateEvent(
eventType: string | null | undefined,
storedTitle: string,
storedDescription: string | null | undefined,
): { title: string; description: string } {
// Title: prefer the per-event-type localized noun phrase.
let title = storedTitle;
if (eventType) {
const v = tOrEmpty(`event.title.${eventType}`);
if (v) title = v;
}
const description = storedDescription
? translateEventDescription(eventType ?? "", storedDescription)
: "";
return { title, description };
}
function translateEventDescription(eventType: string, description: string): string {
const body = description.trim();
if (!body) return "";
if (eventType === "project_type_changed") {
// New format: "case → litigation". Legacy: "Type case → litigation".
return translateArrowSlugs(body.replace(/^Type\s+/, ""), "projekte.type.");
}
if (eventType === "status_changed") {
// New format: "active → archived". Legacy: "Status active → archived".
return translateArrowSlugs(body.replace(/^Status\s+/, ""), "projekte.filter.status.");
}
if (eventType === "note_created") {
// New format: just the parent slug. Legacy: "Note zu <slug> hinzugefügt".
const m = body.match(/^Note zu (project|deadline|appointment) hinzugef[üu]gt$/i);
const slug = (m ? m[1] : body).toLowerCase();
const parent = tOrEmpty(`event.note.parent.${slug}`) || slug;
const phrase = tOrEmpty("event.note.added_to");
return phrase ? phrase.replace("{parent}", parent) : body;
}
if (eventType.startsWith("deadline_") || eventType.startsWith("appointment_")) {
// New format: just the entity title. Legacy: 'Deadline „Foo" geändert' /
// 'Appointment „Foo" angelegt' etc. Strip the noun + verb wrapper, keep
// the quoted title, then re-wrap in the localized template.
const title = extractQuotedTitle(body) ?? body;
return interpolateTemplate(`event.description.${eventType}`, { title }) ?? body;
}
if (eventType === "deadlines_imported") {
// New format: just the count. Legacy: "N Deadlines aus Fristenrechner übernommen".
const m = body.match(/^(\d+)\b/);
const count = m ? m[1] : body;
return interpolateTemplate("event.description.deadlines_imported", { count }) ?? body;
}
return description;
}
// extractQuotedTitle pulls the body out of a legacy DE description like
// `Deadline „Foo" geändert` — German double-low-9 (U+201E) opening quote and
// double-high-6 (U+201C) closing quote, the pair the Go services actually
// write today. Returns null if no quoted run is found, so callers can fall
// back to the raw body.
function extractQuotedTitle(body: string): string | null {
const m = body.match(/„([^“”„]+)[“”]/);
return m ? m[1] : null;
}
function interpolateTemplate(key: string, vars: Record<string, string>): string | null {
const tmpl = tOrEmpty(key);
if (!tmpl) return null;
return tmpl.replace(/\{(\w+)\}/g, (_, name) => vars[name] ?? `{${name}}`);
}
function translateArrowSlugs(body: string, prefix: string): string {
const m = body.match(/^(\S+)\s*[→→]\s*(\S+)$/);
if (!m) return body;
const a = tOrEmpty(`${prefix}${m[1]}`) || m[1];
const b = tOrEmpty(`${prefix}${m[2]}`) || m[2];
return `${a}${b}`;
}
export function onLangChange(cb: () => void) {
changeCallbacks.push(cb);
}

View File

@@ -1,4 +1,4 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initI18n, onLangChange, t, getLang, translateEvent } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
import {
@@ -572,15 +572,16 @@ function renderEvents() {
}
empty.style.display = "none";
list.innerHTML = events
.map(
(e) => `<li class="akten-event">
.map((e) => {
const { title, description } = translateEvent(e.event_type, e.title, e.description ?? null);
return `<li class="akten-event">
<div class="akten-event-date">${fmtDateTime(e.created_at)}</div>
<div class="akten-event-body">
<div class="akten-event-title">${esc(e.title)}</div>
${e.description ? `<div class="akten-event-desc">${esc(e.description)}</div>` : ""}
<div class="akten-event-title">${esc(title)}</div>
${description ? `<div class="akten-event-desc">${esc(description)}</div>` : ""}
</div>
</li>`,
)
</li>`;
})
.join("");
if (moreWrap) moreWrap.style.display = eventsHasMore ? "" : "none";
}

View File

@@ -98,7 +98,7 @@ export function renderDeadlines(): string {
<th />
<th data-i18n="fristen.col.due">F&auml;llig</th>
<th data-i18n="fristen.col.title">Titel</th>
<th data-i18n="fristen.col.akte">Akte</th>
<th data-i18n="fristen.col.akte">Projekt</th>
<th data-i18n="fristen.col.rule">Regel</th>
<th data-i18n="fristen.col.status">Status</th>
</tr>

View File

@@ -325,8 +325,10 @@ export function renderProjectsDetail(): string {
<tbody id="project-checklists-body" />
</table>
</div>
<p className="tool-subtitle akten-checklisten-hint" data-i18n="projekte.detail.checklisten.hint">
Instanzen werden auf der Vorlagen-Seite unter <a href="/checklists">Checklisten</a> angelegt.
<p className="tool-subtitle akten-checklisten-hint">
<span data-i18n="projekte.detail.checklisten.hint.prefix">Instanzen werden auf der Vorlagen-Seite unter </span>
<a href="/checklists" data-i18n="projekte.detail.checklisten.hint.link">Checklisten</a>
<span data-i18n="projekte.detail.checklisten.hint.suffix"> angelegt.</span>
</p>
</section>

View File

@@ -31,7 +31,7 @@ export function renderProjects(): string {
<div>
<h1 data-i18n="projekte.heading">Projekte</h1>
<p className="tool-subtitle" data-i18n="projekte.subtitle">
Mandanten, Streitsachen, Patente und F&auml;lle &mdash; hierarchisch organisiert.
Mandanten, Streitsachen, Patente und Verfahren &mdash; hierarchisch organisiert.
</p>
</div>
<a href="/projects/new" className="btn-primary btn-cta-lime" data-i18n="projekte.new">
@@ -114,7 +114,7 @@ export function renderProjects(): string {
<div className="akten-empty" id="akten-empty" style="display:none">
<h2 data-i18n="projekte.empty.title">Noch kein Projekt angelegt</h2>
<p data-i18n="projekte.empty.hint">
Starten Sie &uuml;ber &bdquo;Neues Projekt&ldquo; &mdash; legen Sie zuerst einen Mandanten an, darunter Streitsachen, Patente und F&auml;lle.
Starten Sie &uuml;ber &bdquo;Neues Projekt&ldquo; &mdash; legen Sie zuerst einen Mandanten an, darunter Streitsachen, Patente und Verfahren.
</p>
<a href="/projects/new" className="btn-primary btn-cta-lime" data-i18n="projekte.new">Neues Projekt</a>
</div>

View File

@@ -185,13 +185,19 @@ type Deadline struct {
}
// DeadlineWithProject enriches a Deadline with parent-Project display fields
// (reference + title) for list views.
// (reference + title) for list views. RuleName/RuleNameEN are the
// human-readable label of the linked deadline-rule (e.g. "Replik" / "Reply"),
// while RuleCode is the machine-readable slug ("inf.rejoin") — list views
// should prefer the localized name and fall back to the code only when no
// rule is attached.
type DeadlineWithProject struct {
Deadline
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
ProjectTitle string `db:"project_title" json:"project_title"`
ProjectType string `db:"project_type" json:"project_type"`
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
RuleName *string `db:"rule_name" json:"rule_name,omitempty"`
RuleNameEN *string `db:"rule_name_en" json:"rule_name_en,omitempty"`
}
// Appointment is one appointment. project_id is nullable: NULL = personal

View File

@@ -254,7 +254,10 @@ func (s *AppointmentService) Create(ctx context.Context, userID uuid.UUID, input
}
if input.ProjectID != nil {
desc := fmt.Sprintf("Appointment \u201E%s\u201C angelegt", title)
// Description carries value-only payload (the appointment title); frontend
// renders via the localized event.description.appointment_* template. Same
// pattern for updated/deleted below.
desc := title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, *input.ProjectID, userID, "appointment_created", "Appointment created", descPtr); err != nil {
return nil, err
@@ -342,7 +345,7 @@ func (s *AppointmentService) Update(ctx context.Context, userID, terminID uuid.U
}
if current.ProjectID != nil {
desc := fmt.Sprintf("Appointment \u201E%s\u201C ge\u00e4ndert", current.Title)
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "appointment_updated", "Appointment updated", descPtr); err != nil {
return nil, err
@@ -386,7 +389,7 @@ func (s *AppointmentService) Delete(ctx context.Context, userID, terminID uuid.U
return fmt.Errorf("delete appointment: %w", err)
}
if current.ProjectID != nil {
desc := fmt.Sprintf("Appointment \u201E%s\u201C gel\u00f6scht", current.Title)
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "appointment_deleted", "Appointment deleted", descPtr); err != nil {
return err

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
"time"
@@ -125,7 +126,9 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
p.reference AS project_reference,
p.title AS project_title,
p.type AS project_type,
r.code AS rule_code
r.code AS rule_code,
r.name AS rule_name,
r.name_en AS rule_name_en
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
LEFT JOIN paliad.deadline_rules r ON r.id = f.rule_id
@@ -215,7 +218,10 @@ func (s *DeadlineService) CreateBulk(ctx context.Context, userID, projektID uuid
ids = append(ids, id)
}
desc := fmt.Sprintf("%d Deadlines aus Fristenrechner übernommen", len(inputs))
// Description carries the value-only payload (the import count); the
// frontend renders via the localized event.description.deadlines_imported
// template.
desc := strconv.Itoa(len(inputs))
descPtr := &desc
if err := insertProjectEvent(ctx, tx, projektID, userID, "deadlines_imported", "Deadlines imported", descPtr); err != nil {
return nil, err
@@ -301,7 +307,10 @@ func (s *DeadlineService) Update(ctx context.Context, userID, fristID uuid.UUID,
return nil, fmt.Errorf("update deadline: %w", err)
}
desc := fmt.Sprintf("Deadline \u201E%s\u201C geändert", current.Title)
// Description carries value-only payload (the deadline title); frontend
// renders via the localized event.description.deadline_updated template.
// Same pattern below for completed/reopened/deleted/created.
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_updated", "Deadline updated", descPtr); err != nil {
return nil, err
@@ -335,7 +344,7 @@ func (s *DeadlineService) Complete(ctx context.Context, userID, fristID uuid.UUI
WHERE id = $2`, now, fristID); err != nil {
return nil, fmt.Errorf("complete deadline: %w", err)
}
desc := fmt.Sprintf("Deadline \u201E%s\u201C als erledigt markiert", current.Title)
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_completed", "Deadline completed", descPtr); err != nil {
return nil, err
@@ -375,7 +384,7 @@ func (s *DeadlineService) Reopen(ctx context.Context, userID, fristID uuid.UUID)
WHERE id = $2`, now, fristID); err != nil {
return nil, fmt.Errorf("reopen deadline: %w", err)
}
desc := fmt.Sprintf("Deadline \u201E%s\u201C wieder ge\u00f6ffnet", current.Title)
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_reopened", "Deadline reopened", descPtr); err != nil {
return nil, err
@@ -451,7 +460,7 @@ func (s *DeadlineService) Delete(ctx context.Context, userID, fristID uuid.UUID)
`DELETE FROM paliad.deadlines WHERE id = $1`, fristID); err != nil {
return fmt.Errorf("delete deadline: %w", err)
}
desc := fmt.Sprintf("Deadline \u201E%s\u201C gelöscht", current.Title)
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_deleted", "Deadline deleted", descPtr); err != nil {
return err
@@ -534,7 +543,7 @@ func (s *DeadlineService) insert(ctx context.Context, userID, projektID uuid.UUI
return uuid.Nil, err
}
desc := fmt.Sprintf("Deadline \u201E%s\u201C angelegt", strings.TrimSpace(input.Title))
desc := strings.TrimSpace(input.Title)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, projektID, userID, "deadline_created", "Deadline created", descPtr); err != nil {
return uuid.Nil, err

View File

@@ -245,8 +245,10 @@ func (s *NoteService) insertWithAudit(ctx context.Context, userID uuid.UUID, con
}
if projektAuditID != nil {
// Description carries the value-only payload (the parent slug); the
// frontend renders it via the localized event.note.added_to template.
title := "Note added"
desc := fmt.Sprintf("Note zu %s hinzugef\u00fcgt", parentLabel)
desc := parentLabel
descPtr := &desc
if err := insertProjectEvent(ctx, tx, *projektAuditID, userID, "note_created", title, descPtr); err != nil {
return uuid.Nil, err

View File

@@ -612,15 +612,17 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
return nil, fmt.Errorf("update project: %w", err)
}
// Descriptions carry the value-only payload (`old → new`); the frontend
// renderer translates both slugs and prepends the localized prefix.
if input.Status != nil && *input.Status != current.Status {
desc := fmt.Sprintf("Status %s → %s", current.Status, *input.Status)
desc := fmt.Sprintf("%s → %s", current.Status, *input.Status)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, id, userID, "status_changed", "Status changed", descPtr); err != nil {
return nil, err
}
}
if typeChanged {
desc := fmt.Sprintf("Type %s → %s", current.Type, *input.Type)
desc := fmt.Sprintf("%s → %s", current.Type, *input.Type)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, id, userID, "project_type_changed", "Project type changed", descPtr); err != nil {
return nil, err