diff --git a/frontend/src/appointments-detail.tsx b/frontend/src/appointments-detail.tsx index beb9a67..ded0b33 100644 --- a/frontend/src/appointments-detail.tsx +++ b/frontend/src/appointments-detail.tsx @@ -54,6 +54,12 @@ export function renderAppointmentsDetail(): string { +
+ + +
diff --git a/frontend/src/client/appointments-detail.ts b/frontend/src/client/appointments-detail.ts index 30ffe88..d4545f9 100644 --- a/frontend/src/client/appointments-detail.ts +++ b/frontend/src/client/appointments-detail.ts @@ -1,6 +1,7 @@ import { initI18n, t, tDyn, getLang } from "./i18n"; import { initSidebar } from "./sidebar"; import { initNotes } from "./notes"; +import { projectIndent } from "./project-indent"; interface Appointment { id: string; @@ -18,10 +19,12 @@ interface Project { id: string; reference?: string | null; title: string; + path?: string; } let appointment: Appointment | null = null; let project: Project | null = null; +let allProjects: Project[] = []; function parseAppointmentID(): string | null { const parts = window.location.pathname.split("/").filter(Boolean); @@ -77,6 +80,32 @@ async function loadProject(id: string) { } } +async function loadAllProjects() { + try { + const resp = await fetch("/api/projects"); + if (resp.ok) allProjects = await resp.json(); + } catch { + /* non-fatal */ + } +} + +function populateProjectPicker() { + const sel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null; + if (!sel) return; + const none = sel.querySelector('option[value=""]'); + sel.innerHTML = ""; + if (none) sel.appendChild(none); + for (const p of allProjects) { + const opt = document.createElement("option"); + opt.value = p.id; + opt.textContent = `${projectIndent(p.path)}${p.reference || ""} — ${p.title}`; + sel.appendChild(opt); + } + if (appointment) { + sel.value = appointment.project_id ?? ""; + } +} + function renderHeader() { if (!appointment) return; document.getElementById("appointment-title-display")!.textContent = appointment.title; @@ -114,6 +143,8 @@ function fillEditForm() { (document.getElementById("appointment-type-edit") as HTMLSelectElement).value = appointment.appointment_type ?? ""; (document.getElementById("appointment-location-edit") as HTMLInputElement).value = appointment.location ?? ""; (document.getElementById("appointment-description-edit") as HTMLTextAreaElement).value = appointment.description ?? ""; + const projectSel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null; + if (projectSel) projectSel.value = appointment.project_id ?? ""; } async function saveEdit(ev: Event) { @@ -129,6 +160,9 @@ async function saveEdit(ev: Event) { const type = (document.getElementById("appointment-type-edit") as HTMLSelectElement).value; const location = (document.getElementById("appointment-location-edit") as HTMLInputElement).value.trim(); const description = (document.getElementById("appointment-description-edit") as HTMLTextAreaElement).value; + const projectSel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null; + const newProjectID = projectSel ? projectSel.value : ""; + const currentProjectID = appointment.project_id ?? ""; const payload: Record = { title, @@ -138,6 +172,13 @@ async function saveEdit(ev: Event) { location, description, }; + if (newProjectID !== currentProjectID) { + if (newProjectID === "") { + payload.clear_project = true; + } else { + payload.project_id = newProjectID; + } + } submitBtn.disabled = true; try { @@ -147,7 +188,13 @@ async function saveEdit(ev: Event) { body: JSON.stringify(payload), }); if (resp.ok) { + const prevProjectID = appointment.project_id ?? ""; appointment = await resp.json(); + const nextProjectID = appointment?.project_id ?? ""; + if (nextProjectID !== prevProjectID) { + project = null; + if (appointment?.project_id) await loadProject(appointment.project_id); + } renderHeader(); msg.textContent = t("appointments.detail.saved"); msg.className = "form-msg form-msg-ok"; @@ -200,10 +247,14 @@ async function main() { notFound.style.display = "block"; return; } - if (appointment.project_id) await loadProject(appointment.project_id); + await Promise.all([ + appointment.project_id ? loadProject(appointment.project_id) : Promise.resolve(), + loadAllProjects(), + ]); loading.style.display = "none"; body.style.display = ""; renderHeader(); + populateProjectPicker(); fillEditForm(); document.getElementById("appointment-edit-form")!.addEventListener("submit", saveEdit); diff --git a/frontend/src/client/deadlines-detail.ts b/frontend/src/client/deadlines-detail.ts index 120db3d..adcf15b 100644 --- a/frontend/src/client/deadlines-detail.ts +++ b/frontend/src/client/deadlines-detail.ts @@ -1,6 +1,7 @@ import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n"; import { initSidebar } from "./sidebar"; import { initNotes } from "./notes"; +import { projectIndent } from "./project-indent"; import { attachEventTypePicker, fetchEventTypes, @@ -32,6 +33,7 @@ interface Project { id: string; reference?: string | null; title: string; + path?: string; } interface DeadlineRule { @@ -51,6 +53,7 @@ let deadline: Deadline | null = null; let project: Project | null = null; let rule: DeadlineRule | null = null; let me: Me | null = null; +let allProjects: Project[] = []; function parseDeadlineID(): string | null { const parts = window.location.pathname.split("/").filter(Boolean); @@ -123,6 +126,30 @@ async function loadProject(projectID: string) { } } +async function loadAllProjects() { + try { + const resp = await fetch("/api/projects"); + if (resp.ok) allProjects = await resp.json(); + } catch { + /* non-fatal */ + } +} + +function populateProjectPicker() { + const sel = document.getElementById("deadline-project-edit") as HTMLSelectElement | null; + if (!sel || !deadline) return; + const opts: string[] = []; + for (const p of allProjects) { + const indent = projectIndent(p.path); + const ref = p.reference || ""; + opts.push( + ``, + ); + } + sel.innerHTML = opts.join(""); + sel.value = deadline.project_id; +} + async function loadRule(ruleID: string) { try { const resp = await fetch(`/api/deadline-rules`); @@ -261,6 +288,8 @@ function initEdit() { const saveBtn = document.getElementById("deadline-save-btn") as HTMLButtonElement; const etDisplay = document.getElementById("deadline-event-types-display"); const etEdit = document.getElementById("deadline-event-types-edit"); + const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement; + const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null; function enterEdit() { titleDisplay.style.display = "none"; @@ -271,6 +300,11 @@ function initEdit() { notesEdit.style.display = ""; if (etDisplay) etDisplay.style.display = "none"; if (etEdit) etEdit.style.display = ""; + if (projectEdit && deadline) { + projectLink.style.display = "none"; + projectEdit.style.display = ""; + projectEdit.value = deadline.project_id; + } saveBtn.style.display = ""; editBtn.style.display = "none"; titleEdit.focus(); @@ -285,6 +319,10 @@ function initEdit() { notesEdit.style.display = "none"; if (etDisplay) etDisplay.style.display = ""; if (etEdit) etEdit.style.display = "none"; + if (projectEdit) { + projectEdit.style.display = "none"; + projectLink.style.display = ""; + } saveBtn.style.display = "none"; editBtn.style.display = ""; } @@ -307,13 +345,20 @@ function initEdit() { if (eventTypePicker) { payload.event_type_ids = eventTypePicker.getIDs(); } + if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) { + payload.project_id = projectEdit.value; + } const resp = await fetch(`/api/deadlines/${deadline.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (resp.ok) { + const prevProjectID = deadline.project_id; deadline = await resp.json(); + if (deadline && deadline.project_id !== prevProjectID) { + await loadProject(deadline.project_id); + } render(); } } finally { @@ -410,7 +455,7 @@ async function main() { notfound.style.display = "block"; return; } - await loadProject(deadline.project_id); + await Promise.all([loadProject(deadline.project_id), loadAllProjects()]); if (deadline.rule_id) await loadRule(deadline.rule_id); // Load event types in parallel; render once ready (the picker re-renders @@ -435,6 +480,7 @@ async function main() { }); } + populateProjectPicker(); render(); initEdit(); initComplete(); diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 917304b..f91e201 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -795,10 +795,12 @@ const translations: Record> = { "dashboard.action.short.deadline_completed": "erledigte Frist", "dashboard.action.short.deadline_reopened": "öffnete Frist wieder", "dashboard.action.short.deadline_deleted": "l\u00f6schte Frist", + "dashboard.action.short.deadline_project_changed": "verschob Frist", "dashboard.action.short.deadlines_imported": "importierte Fristen", "dashboard.action.short.appointment_created": "legte Termin an", "dashboard.action.short.appointment_updated": "\u00e4nderte Termin", "dashboard.action.short.appointment_deleted": "l\u00f6schte Termin", + "dashboard.action.short.appointment_project_changed": "verschob 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", @@ -812,10 +814,12 @@ const translations: Record> = { "event.title.deadline_completed": "Frist erledigt", "event.title.deadline_reopened": "Frist wiederer\u00f6ffnet", "event.title.deadline_deleted": "Frist gel\u00f6scht", + "event.title.deadline_project_changed": "Frist verschoben", "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", + "event.title.appointment_project_changed": "Termin verschoben", "event.title.checklist_created": "Checkliste angelegt", "event.title.checklist_renamed": "Checkliste umbenannt", "event.title.checklist_linked": "Checkliste verkn\u00fcpft", @@ -836,10 +840,12 @@ const translations: Record> = { "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.deadline_project_changed": "Frist \u201e{title}\u201c einer anderen Akte zugeordnet", "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", + "event.description.appointment_project_changed": "Termin \u201e{title}\u201c einer anderen Akte zugeordnet", "dashboard.action.short.checklist_created": "legte Checkliste an", "dashboard.action.short.checklist_renamed": "benannte Checkliste um", "dashboard.action.short.checklist_unlinked": "trennte Checkliste", @@ -2387,10 +2393,12 @@ const translations: Record> = { "dashboard.action.short.deadline_completed": "completed deadline", "dashboard.action.short.deadline_reopened": "reopened deadline", "dashboard.action.short.deadline_deleted": "deleted deadline", + "dashboard.action.short.deadline_project_changed": "moved deadline", "dashboard.action.short.deadlines_imported": "imported deadlines", "dashboard.action.short.appointment_created": "added appointment", "dashboard.action.short.appointment_updated": "updated appointment", "dashboard.action.short.appointment_deleted": "deleted appointment", + "dashboard.action.short.appointment_project_changed": "moved 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", @@ -2404,10 +2412,12 @@ const translations: Record> = { "event.title.deadline_completed": "Deadline completed", "event.title.deadline_reopened": "Deadline reopened", "event.title.deadline_deleted": "Deadline deleted", + "event.title.deadline_project_changed": "Deadline moved", "event.title.deadlines_imported": "Deadlines imported", "event.title.appointment_created": "Appointment created", "event.title.appointment_updated": "Appointment updated", "event.title.appointment_deleted": "Appointment deleted", + "event.title.appointment_project_changed": "Appointment moved", "event.title.checklist_created": "Checklist created", "event.title.checklist_renamed": "Checklist renamed", "event.title.checklist_linked": "Checklist linked", @@ -2428,10 +2438,12 @@ const translations: Record> = { "event.description.deadline_completed": "Deadline “{title}” completed", "event.description.deadline_reopened": "Deadline “{title}” reopened", "event.description.deadline_deleted": "Deadline “{title}” deleted", + "event.description.deadline_project_changed": "Deadline “{title}” moved to another matter", "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", + "event.description.appointment_project_changed": "Appointment “{title}” moved to another matter", "dashboard.action.short.checklist_created": "added checklist", "dashboard.action.short.checklist_renamed": "renamed checklist", "dashboard.action.short.checklist_unlinked": "unlinked checklist", diff --git a/frontend/src/deadlines-detail.tsx b/frontend/src/deadlines-detail.tsx index e7844ff..78a2db9 100644 --- a/frontend/src/deadlines-detail.tsx +++ b/frontend/src/deadlines-detail.tsx @@ -44,6 +44,7 @@ export function renderDeadlinesDetail(): string { +