t-paliad-025 Phase 3 — frontend rename pass:
File renames (git mv, preserving history):
frontend/src/
akten.tsx → projects.tsx
akten-neu.tsx → projects-new.tsx
akten-detail.tsx → projects-detail.tsx
fristen.tsx → deadlines.tsx
fristen-neu.tsx → deadlines-new.tsx
fristen-detail.tsx → deadlines-detail.tsx
fristen-kalender.tsx → deadlines-calendar.tsx
termine.tsx → appointments.tsx
termine-neu.tsx → appointments-new.tsx
termine-detail.tsx → appointments-detail.tsx
termine-kalender.tsx → appointments-calendar.tsx
einstellungen.tsx → settings.tsx
checklisten*.tsx → checklists*.tsx
gerichte.tsx → courts.tsx
glossar.tsx → glossary.tsx
frontend/src/client/ — same renames, plus notizen.ts → notes.ts.
Render exports renamed (renderAkten → renderProjects, renderFristen →
renderDeadlines, …). build.ts rewired to new names.
Client-side changes:
* fetch() API paths: /api/projekte → /api/projects, /api/fristen →
/api/deadlines, /api/termine → /api/appointments, /api/notizen →
/api/notes, /api/gerichte → /api/courts, /api/glossar → /api/glossary,
/api/dezernate → /api/departments, /api/parteien → /api/parties,
/api/checklisten → /api/checklists. Legacy /api/akten aliases removed.
* Navigation href/template strings: /akten → /projects, /fristen →
/deadlines, /termine → /appointments, /einstellungen → /settings,
/notizen → /notes, /checklisten → /checklists, /gerichte → /courts,
/glossar → /glossary. Nested paths /neu → /new, /verlauf → /events,
/kinder → /children, /kalender → /calendar, /dokumente → /documents.
* Interface names in client TS: Frist → Deadline, Termin → Appointment,
Notiz → Note, Partei → Party, Akte → Project, ProjektMini → ProjectMini,
Dezernat → Department, DezernatMitglied → DepartmentMember.
* JSON wire-format keys follow backend: projekt_id → project_id, akte_id
→ project_id, frist_id → deadline_id, termin_id → appointment_id,
akten_event_id → project_event_id, dezernat_id → department_id,
termin_type → appointment_type.
Go handlers (projects_pages.go, deadlines_pages.go, appointments_pages.go,
checklists.go, courts.go, glossary.go) serve the correctly-named HTML
files from dist/.
Kept German (user-facing i18n + product names):
* i18n keys/strings (src/client/i18n.ts) — DE labels and their keys
* Product names: fristenrechner, kostenrechner, gebuehrentabellen
Build verified: go build / vet / test clean; bun run build clean;
dist/ contains all 26 English-named HTML pages.
467 lines
15 KiB
TypeScript
467 lines
15 KiB
TypeScript
// Shared polymorphic notes module. Each detail page (akten / fristen /
|
|
// termine) renders an empty <div id="notes-container" data-parent-type=…
|
|
// data-parent-id=…> and loads this script. initNotes(container) attaches
|
|
// the "Add note" form + list rendering; the API base URL is picked from
|
|
// the parent type.
|
|
//
|
|
// Visibility and edit rights are enforced by the server — the UI only
|
|
// hides the edit/delete controls when they wouldn't work anyway (non-
|
|
// author, non-partner).
|
|
|
|
import { t, getLang } from "./i18n";
|
|
|
|
export type NotizParentType = "project" | "frist" | "termin";
|
|
|
|
export interface Note {
|
|
id: string;
|
|
project_id?: string | null;
|
|
deadline_id?: string | null;
|
|
appointment_id?: string | null;
|
|
project_event_id?: string | null;
|
|
content: string;
|
|
created_by?: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
author_name?: string | null;
|
|
author_email?: string | null;
|
|
}
|
|
|
|
interface Me {
|
|
id: string;
|
|
role: string;
|
|
}
|
|
|
|
interface NotesState {
|
|
parentType: NotizParentType;
|
|
parentId: string;
|
|
me: Me | null;
|
|
notes: Note[];
|
|
listEl: HTMLElement;
|
|
emptyEl: HTMLElement;
|
|
formEl: HTMLFormElement;
|
|
textareaEl: HTMLTextAreaElement;
|
|
submitBtn: HTMLButtonElement;
|
|
msgEl: HTMLElement;
|
|
editingID: string | null;
|
|
}
|
|
|
|
function baseURL(parentType: NotizParentType, parentId: string): string {
|
|
switch (parentType) {
|
|
case "project":
|
|
return `/api/projects/${parentId}/notizen`;
|
|
case "frist":
|
|
return `/api/deadlines/${parentId}/notizen`;
|
|
case "termin":
|
|
return `/api/appointments/${parentId}/notizen`;
|
|
}
|
|
}
|
|
|
|
function esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function authorLabel(n: Note): string {
|
|
return n.author_name || n.author_email || t("notizen.unknown_author");
|
|
}
|
|
|
|
function fmtRelative(iso: string): string {
|
|
const now = Date.now();
|
|
const then = new Date(iso).getTime();
|
|
const diff = Math.max(0, now - then);
|
|
const sec = Math.floor(diff / 1000);
|
|
const min = Math.floor(sec / 60);
|
|
const hr = Math.floor(min / 60);
|
|
const day = Math.floor(hr / 24);
|
|
const lang = getLang();
|
|
|
|
if (sec < 45) return t("notizen.time.just_now");
|
|
if (min < 2) return lang === "de" ? "vor 1 Minute" : "1 minute ago";
|
|
if (min < 60) return lang === "de" ? `vor ${min} Minuten` : `${min} minutes ago`;
|
|
if (hr < 2) return lang === "de" ? "vor 1 Stunde" : "1 hour ago";
|
|
if (hr < 24) return lang === "de" ? `vor ${hr} Stunden` : `${hr} hours ago`;
|
|
if (day < 2) return lang === "de" ? "gestern" : "yesterday";
|
|
if (day < 30) return lang === "de" ? `vor ${day} Tagen` : `${day} days ago`;
|
|
|
|
// > 30 days → absolute date
|
|
const d = new Date(iso);
|
|
return d.toLocaleDateString(lang === "de" ? "de-DE" : "en-GB", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
});
|
|
}
|
|
|
|
function canEdit(state: NotesState, n: Note): boolean {
|
|
if (!state.me) return false;
|
|
return n.created_by === state.me.id;
|
|
}
|
|
|
|
function canDelete(state: NotesState, n: Note): boolean {
|
|
if (!state.me) return false;
|
|
if (n.created_by === state.me.id) return true;
|
|
return state.me.role === "partner" || state.me.role === "admin";
|
|
}
|
|
|
|
function render(state: NotesState) {
|
|
state.listEl.innerHTML = "";
|
|
if (state.notes.length === 0) {
|
|
state.emptyEl.style.display = "";
|
|
return;
|
|
}
|
|
state.emptyEl.style.display = "none";
|
|
for (const n of state.notes) {
|
|
state.listEl.appendChild(noteCard(state, n));
|
|
}
|
|
}
|
|
|
|
function noteCard(state: NotesState, n: Note): HTMLElement {
|
|
const card = document.createElement("li");
|
|
card.className = "notiz-card";
|
|
card.dataset.id = n.id;
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "notiz-card-header";
|
|
|
|
const author = document.createElement("span");
|
|
author.className = "notiz-author";
|
|
author.textContent = authorLabel(n);
|
|
|
|
const time = document.createElement("time");
|
|
time.className = "notiz-time";
|
|
time.dateTime = n.created_at;
|
|
time.textContent = fmtRelative(n.created_at);
|
|
time.title = new Date(n.created_at).toLocaleString(getLang() === "de" ? "de-DE" : "en-GB");
|
|
|
|
header.appendChild(author);
|
|
header.appendChild(time);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "notiz-actions";
|
|
|
|
if (canEdit(state, n)) {
|
|
const editBtn = document.createElement("button");
|
|
editBtn.type = "button";
|
|
editBtn.className = "notiz-action-btn";
|
|
editBtn.setAttribute("aria-label", t("notizen.edit"));
|
|
editBtn.title = t("notizen.edit");
|
|
editBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>`;
|
|
editBtn.addEventListener("click", () => startEdit(state, n));
|
|
actions.appendChild(editBtn);
|
|
}
|
|
if (canDelete(state, n)) {
|
|
const delBtn = document.createElement("button");
|
|
delBtn.type = "button";
|
|
delBtn.className = "notiz-action-btn notiz-action-danger";
|
|
delBtn.setAttribute("aria-label", t("notizen.delete"));
|
|
delBtn.title = t("notizen.delete");
|
|
delBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>`;
|
|
delBtn.addEventListener("click", () => deleteNote(state, n));
|
|
actions.appendChild(delBtn);
|
|
}
|
|
|
|
if (actions.children.length > 0) {
|
|
header.appendChild(actions);
|
|
}
|
|
|
|
const body = document.createElement("div");
|
|
body.className = "notiz-body";
|
|
body.innerHTML = linkify(n.content);
|
|
|
|
card.appendChild(header);
|
|
card.appendChild(body);
|
|
|
|
if (n.updated_at && n.updated_at !== n.created_at) {
|
|
const edited = document.createElement("span");
|
|
edited.className = "notiz-edited";
|
|
edited.textContent = t("notizen.edited");
|
|
edited.title = new Date(n.updated_at).toLocaleString(getLang() === "de" ? "de-DE" : "en-GB");
|
|
card.appendChild(edited);
|
|
}
|
|
|
|
return card;
|
|
}
|
|
|
|
// Plaintext → HTML with URL auto-linking. Escapes first, then linkifies.
|
|
function linkify(content: string): string {
|
|
const escaped = esc(content);
|
|
// Match URLs outside existing markup. Simple pattern — escaped content has
|
|
// no tags, so we only need to avoid breaking & etc.
|
|
return escaped.replace(/(https?:\/\/[^\s<]+)/g, (url) => {
|
|
const clean = url.replace(/[.,;:!?)]+$/, "");
|
|
const trailing = url.slice(clean.length);
|
|
return `<a href="${clean}" target="_blank" rel="noopener noreferrer">${clean}</a>${trailing}`;
|
|
}).replace(/\n/g, "<br>");
|
|
}
|
|
|
|
async function loadMe(): Promise<Me | null> {
|
|
try {
|
|
const resp = await fetch("/api/me");
|
|
if (!resp.ok) return null;
|
|
return (await resp.json()) as Me;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function loadNotes(state: NotesState) {
|
|
try {
|
|
const resp = await fetch(baseURL(state.parentType, state.parentId));
|
|
if (!resp.ok) {
|
|
state.notes = [];
|
|
render(state);
|
|
return;
|
|
}
|
|
const data = (await resp.json()) as Note[];
|
|
state.notes = Array.isArray(data) ? data : [];
|
|
} catch {
|
|
state.notes = [];
|
|
}
|
|
render(state);
|
|
}
|
|
|
|
function showMsg(state: NotesState, kind: "ok" | "error", text: string) {
|
|
state.msgEl.textContent = text;
|
|
state.msgEl.className = `form-msg ${kind === "ok" ? "form-msg-ok" : "form-msg-error"}`;
|
|
}
|
|
|
|
function clearMsg(state: NotesState) {
|
|
state.msgEl.textContent = "";
|
|
state.msgEl.className = "form-msg";
|
|
}
|
|
|
|
async function submitForm(ev: Event, state: NotesState) {
|
|
ev.preventDefault();
|
|
const content = state.textareaEl.value.trim();
|
|
if (!content) {
|
|
showMsg(state, "error", t("notizen.error.empty"));
|
|
return;
|
|
}
|
|
state.submitBtn.disabled = true;
|
|
clearMsg(state);
|
|
|
|
if (state.editingID) {
|
|
await saveEdit(state, state.editingID, content);
|
|
} else {
|
|
await addNote(state, content);
|
|
}
|
|
state.submitBtn.disabled = false;
|
|
}
|
|
|
|
async function addNote(state: NotesState, content: string) {
|
|
// Optimistic add: insert a placeholder, replace with server response on success.
|
|
const tempID = `temp-${Date.now()}`;
|
|
const placeholder: Note = {
|
|
id: tempID,
|
|
content,
|
|
created_by: state.me?.id ?? null,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
author_name: null,
|
|
author_email: null,
|
|
};
|
|
state.notes = [placeholder, ...state.notes];
|
|
render(state);
|
|
state.textareaEl.value = "";
|
|
|
|
try {
|
|
const resp = await fetch(baseURL(state.parentType, state.parentId), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
if (!resp.ok) {
|
|
// Revert: remove placeholder
|
|
state.notes = state.notes.filter((n) => n.id !== tempID);
|
|
render(state);
|
|
state.textareaEl.value = content;
|
|
const data = await resp.json().catch(() => ({}) as { error?: string });
|
|
showMsg(state, "error", data.error || t("notizen.error.generic"));
|
|
return;
|
|
}
|
|
const saved = (await resp.json()) as Note;
|
|
state.notes = state.notes.map((n) => (n.id === tempID ? saved : n));
|
|
render(state);
|
|
} catch {
|
|
state.notes = state.notes.filter((n) => n.id !== tempID);
|
|
render(state);
|
|
state.textareaEl.value = content;
|
|
showMsg(state, "error", t("notizen.error.generic"));
|
|
}
|
|
}
|
|
|
|
function startEdit(state: NotesState, n: Note) {
|
|
state.editingID = n.id;
|
|
state.textareaEl.value = n.content;
|
|
state.textareaEl.focus();
|
|
state.submitBtn.textContent = t("notizen.save");
|
|
state.formEl.classList.add("notiz-form-editing");
|
|
// Add a cancel affordance inline
|
|
let cancel = state.formEl.querySelector<HTMLButtonElement>(".notiz-cancel-btn");
|
|
if (!cancel) {
|
|
cancel = document.createElement("button");
|
|
cancel.type = "button";
|
|
cancel.className = "btn-cancel notiz-cancel-btn";
|
|
cancel.textContent = t("notizen.cancel");
|
|
cancel.addEventListener("click", () => cancelEdit(state));
|
|
state.submitBtn.parentElement!.insertBefore(cancel, state.submitBtn);
|
|
}
|
|
}
|
|
|
|
function cancelEdit(state: NotesState) {
|
|
state.editingID = null;
|
|
state.textareaEl.value = "";
|
|
state.submitBtn.textContent = t("notizen.submit");
|
|
state.formEl.classList.remove("notiz-form-editing");
|
|
const cancel = state.formEl.querySelector<HTMLButtonElement>(".notiz-cancel-btn");
|
|
if (cancel) cancel.remove();
|
|
clearMsg(state);
|
|
}
|
|
|
|
async function saveEdit(state: NotesState, id: string, content: string) {
|
|
const prev = state.notes.find((n) => n.id === id);
|
|
if (!prev) return;
|
|
const prevContent = prev.content;
|
|
// Optimistic
|
|
state.notes = state.notes.map((n) => (n.id === id ? { ...n, content, updated_at: new Date().toISOString() } : n));
|
|
render(state);
|
|
cancelEdit(state);
|
|
|
|
try {
|
|
const resp = await fetch(`/api/notes/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ content }),
|
|
});
|
|
if (!resp.ok) {
|
|
state.notes = state.notes.map((n) => (n.id === id ? { ...n, content: prevContent } : n));
|
|
render(state);
|
|
const data = await resp.json().catch(() => ({}) as { error?: string });
|
|
showMsg(state, "error", data.error || t("notizen.error.generic"));
|
|
return;
|
|
}
|
|
const saved = (await resp.json()) as Note;
|
|
state.notes = state.notes.map((n) => (n.id === id ? saved : n));
|
|
render(state);
|
|
} catch {
|
|
state.notes = state.notes.map((n) => (n.id === id ? { ...n, content: prevContent } : n));
|
|
render(state);
|
|
showMsg(state, "error", t("notizen.error.generic"));
|
|
}
|
|
}
|
|
|
|
async function deleteNote(state: NotesState, n: Note) {
|
|
if (!confirm(t("notizen.delete.confirm"))) return;
|
|
const prev = state.notes;
|
|
state.notes = state.notes.filter((x) => x.id !== n.id);
|
|
render(state);
|
|
try {
|
|
const resp = await fetch(`/api/notes/${n.id}`, { method: "DELETE" });
|
|
if (!resp.ok && resp.status !== 204) {
|
|
state.notes = prev;
|
|
render(state);
|
|
const data = await resp.json().catch(() => ({}) as { error?: string });
|
|
alert(data.error || t("notizen.error.generic"));
|
|
}
|
|
} catch {
|
|
state.notes = prev;
|
|
render(state);
|
|
alert(t("notizen.error.generic"));
|
|
}
|
|
}
|
|
|
|
function buildUI(container: HTMLElement, state: NotesState) {
|
|
container.innerHTML = "";
|
|
|
|
const form = document.createElement("form");
|
|
form.className = "notiz-form";
|
|
form.autocomplete = "off";
|
|
|
|
const textarea = document.createElement("textarea");
|
|
textarea.className = "notiz-textarea";
|
|
textarea.rows = 3;
|
|
textarea.placeholder = t("notizen.placeholder");
|
|
textarea.setAttribute("data-i18n-placeholder", "notizen.placeholder");
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "notiz-form-actions";
|
|
|
|
const submit = document.createElement("button");
|
|
submit.type = "submit";
|
|
submit.className = "btn-primary btn-cta-lime btn-small";
|
|
submit.textContent = t("notizen.submit");
|
|
|
|
const msg = document.createElement("p");
|
|
msg.className = "form-msg";
|
|
|
|
actions.appendChild(submit);
|
|
form.appendChild(textarea);
|
|
form.appendChild(actions);
|
|
form.appendChild(msg);
|
|
|
|
const list = document.createElement("ul");
|
|
list.className = "notiz-list";
|
|
|
|
const empty = document.createElement("p");
|
|
empty.className = "notiz-empty";
|
|
empty.textContent = t("notizen.empty");
|
|
empty.setAttribute("data-i18n", "notizen.empty");
|
|
empty.style.display = "none";
|
|
|
|
container.appendChild(form);
|
|
container.appendChild(list);
|
|
container.appendChild(empty);
|
|
|
|
state.formEl = form;
|
|
state.textareaEl = textarea;
|
|
state.submitBtn = submit;
|
|
state.msgEl = msg;
|
|
state.listEl = list;
|
|
state.emptyEl = empty;
|
|
|
|
form.addEventListener("submit", (ev) => submitForm(ev, state));
|
|
textarea.addEventListener("keydown", (ev) => {
|
|
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
|
|
ev.preventDefault();
|
|
form.requestSubmit();
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function initNotes(
|
|
container: HTMLElement,
|
|
parentType: NotizParentType,
|
|
parentId: string,
|
|
): Promise<void> {
|
|
const state: NotesState = {
|
|
parentType,
|
|
parentId,
|
|
me: null,
|
|
notes: [],
|
|
listEl: document.createElement("ul"),
|
|
emptyEl: document.createElement("p"),
|
|
formEl: document.createElement("form"),
|
|
textareaEl: document.createElement("textarea"),
|
|
submitBtn: document.createElement("button"),
|
|
msgEl: document.createElement("p"),
|
|
editingID: null,
|
|
};
|
|
|
|
buildUI(container, state);
|
|
state.me = await loadMe();
|
|
await loadNotes(state);
|
|
}
|
|
|
|
// Auto-init if a container element with data attributes is present. Any
|
|
// page that embeds a <div id="notes-container" data-parent-type=…
|
|
// data-parent-id=…> gets the component for free — no per-page wiring.
|
|
export function autoInitNotes() {
|
|
const el = document.getElementById("notes-container");
|
|
if (!el) return;
|
|
const parentType = el.getAttribute("data-parent-type") as NotizParentType | null;
|
|
const parentId = el.getAttribute("data-parent-id");
|
|
if (!parentType || !parentId) return;
|
|
void initNotes(el, parentType, parentId);
|
|
}
|