Merge: t-paliad-140 — editable project on /deadlines/{id} + /appointments/{id}

This commit is contained in:
m
2026-05-06 15:43:20 +02:00
9 changed files with 298 additions and 23 deletions

View File

@@ -54,6 +54,12 @@ export function renderAppointmentsDetail(): string {
<label htmlFor="appointment-title-edit" data-i18n="appointments.field.title">Titel</label>
<input type="text" id="appointment-title-edit" required />
</div>
<div className="form-field">
<label htmlFor="appointment-project-edit" data-i18n="appointments.field.akte">Akte (optional)</label>
<select id="appointment-project-edit">
<option value="" data-i18n="appointments.field.akte.none">Pers&ouml;nlicher Termin</option>
</select>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="appointment-start-edit" data-i18n="appointments.field.start">Beginn</label>

View File

@@ -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<string, unknown> = {
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);

View File

@@ -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(
`<option value="${esc(p.id)}">${indent}${esc(ref)}${esc(p.title)}</option>`,
);
}
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();

View File

@@ -795,10 +795,12 @@ const translations: Record<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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",

View File

@@ -44,6 +44,7 @@ export function renderDeadlinesDetail(): string {
<span id="deadline-due-chip" className="frist-due-chip" />
<span id="deadline-status-chip" className="entity-status-chip" />
<a id="deadline-project-link" className="entity-ref" href="#" />
<select id="deadline-project-edit" className="entity-ref-select" style="display:none" />
</div>
</div>
<div className="entity-detail-actions">

View File

@@ -460,6 +460,7 @@ export type I18nKey =
| "dashboard.action.short.akte_created"
| "dashboard.action.short.appointment_created"
| "dashboard.action.short.appointment_deleted"
| "dashboard.action.short.appointment_project_changed"
| "dashboard.action.short.appointment_updated"
| "dashboard.action.short.checklist_created"
| "dashboard.action.short.checklist_deleted"
@@ -477,6 +478,7 @@ export type I18nKey =
| "dashboard.action.short.deadline_completed"
| "dashboard.action.short.deadline_created"
| "dashboard.action.short.deadline_deleted"
| "dashboard.action.short.deadline_project_changed"
| "dashboard.action.short.deadline_reopened"
| "dashboard.action.short.deadline_updated"
| "dashboard.action.short.deadlines_imported"
@@ -847,10 +849,12 @@ export type I18nKey =
| "einstellungen.title"
| "event.description.appointment_created"
| "event.description.appointment_deleted"
| "event.description.appointment_project_changed"
| "event.description.appointment_updated"
| "event.description.deadline_completed"
| "event.description.deadline_created"
| "event.description.deadline_deleted"
| "event.description.deadline_project_changed"
| "event.description.deadline_reopened"
| "event.description.deadline_updated"
| "event.description.deadlines_imported"
@@ -860,6 +864,7 @@ export type I18nKey =
| "event.note.parent.project"
| "event.title.appointment_created"
| "event.title.appointment_deleted"
| "event.title.appointment_project_changed"
| "event.title.appointment_updated"
| "event.title.checklist_created"
| "event.title.checklist_deleted"
@@ -870,6 +875,7 @@ export type I18nKey =
| "event.title.deadline_completed"
| "event.title.deadline_created"
| "event.title.deadline_deleted"
| "event.title.deadline_project_changed"
| "event.title.deadline_reopened"
| "event.title.deadline_updated"
| "event.title.deadlines_imported"

View File

@@ -5940,6 +5940,17 @@ input[type="range"]::-moz-range-thumb {
margin-right: 0.25rem;
}
.entity-ref-select {
font-family: var(--font-mono);
font-size: 0.85rem;
padding: 0.2rem 0.4rem;
border: 1px solid var(--color-accent);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
max-width: 100%;
}
.entity-detail-actions {
display: flex;
gap: 0.5rem;