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:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" : "";
|
||||
|
||||
@@ -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 "—";
|
||||
}
|
||||
|
||||
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) : "—";
|
||||
const ruleLabel = ruleDisplay(f);
|
||||
const isDone = f.status === "completed";
|
||||
const titleClass = isDone ? "frist-title-done" : "";
|
||||
const reopenLabel = esc(t("fristen.action.reopen"));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function renderDeadlines(): string {
|
||||
<th />
|
||||
<th data-i18n="fristen.col.due">Fä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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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älle — hierarchisch organisiert.
|
||||
Mandanten, Streitsachen, Patente und Verfahren — 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 über „Neues Projekt“ — legen Sie zuerst einen Mandanten an, darunter Streitsachen, Patente und Fälle.
|
||||
Starten Sie über „Neues Projekt“ — 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user