feat(projects): full edit modal + breadcrumb polish + tab toolbar buttons (t-paliad-049)

- Edit pencil on /projects/{id} now opens a modal with the same form as
  /projects/new, pre-filled from the project. Type and parent are
  intentionally read-only — re-typing/reparenting are structural ops not
  exposed via PATCH today.
- Form body extracted into <ProjectFormFields/> + shared
  client/project-form.ts so create and edit share the same fields,
  visibility logic, parent picker, and payload builder.
- Inline title/description edit removed; one edit path is clearer than two.
- Breadcrumb rewritten as pill chips with type icons (matching the project
  tree), chevron separators, hover lime accent, ellipsis truncation, and
  horizontal-scroll fallback on mobile.
- Tab toolbar action buttons standardised — same height, padding, font
  weight across Verlauf/Team/Untergeordnet/Parteien/Fristen/Termine plus
  the "Mehr laden" secondary so they no longer drift visually.
This commit is contained in:
m
2026-04-27 13:37:56 +02:00
parent 94222f790b
commit 59cf47b5ed
8 changed files with 756 additions and 337 deletions

View File

@@ -874,6 +874,7 @@ const translations: Record<Lang, Record<string, string>> = {
"projekte.field.client_number": "Client-Nr. (7 Ziffern)",
"projekte.field.matter_number": "Matter-Nr. (7 Ziffern)",
"projekte.field.clientmatter.hint": "HLC-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt (\u00fcberschreibbar).",
"projekte.field.billing_reference": "Billing-Referenz (optional)",
"projekte.field.netdocuments_url": "netDocuments-URL (optional)",
"projekte.field.industry": "Branche",
"projekte.field.country": "Land (ISO-2)",
@@ -892,6 +893,7 @@ const translations: Record<Lang, Record<string, string>> = {
"projekte.detail.loading": "L\u00e4dt\u2026",
"projekte.detail.notfound": "Projekt nicht gefunden oder keine Berechtigung.",
"projekte.detail.edit": "Bearbeiten",
"projekte.detail.edit.modal.title": "Projekt bearbeiten",
"projekte.detail.save": "Speichern",
"projekte.detail.tab.verlauf": "Verlauf",
"projekte.detail.tab.team": "Team",
@@ -2047,6 +2049,7 @@ const translations: Record<Lang, Record<string, string>> = {
"projekte.field.client_number": "Client no. (7 digits)",
"projekte.field.matter_number": "Matter no. (7 digits)",
"projekte.field.clientmatter.hint": "HLC billing numbers. Format CCCCCCC.MMMMMMM. Client no. is inherited by sub-projects (overridable).",
"projekte.field.billing_reference": "Billing reference (optional)",
"projekte.field.netdocuments_url": "netDocuments URL (optional)",
"projekte.field.industry": "Industry",
"projekte.field.country": "Country (ISO-2)",
@@ -2065,6 +2068,7 @@ const translations: Record<Lang, Record<string, string>> = {
"projekte.detail.loading": "Loading\u2026",
"projekte.detail.notfound": "Project not found or no access.",
"projekte.detail.edit": "Edit",
"projekte.detail.edit.modal.title": "Edit project",
"projekte.detail.save": "Save",
"projekte.detail.tab.verlauf": "Activity",
"projekte.detail.tab.team": "Team",

View File

@@ -0,0 +1,225 @@
import { t } from "./i18n";
// Shared logic for the Project form rendered by ProjectFormFields.tsx.
// Used by /projects/new and the edit modal on /projects/{id}.
export interface ProjectMini {
id: string;
title: string;
type: string;
reference?: string | null;
}
export interface ProjectFormState {
type: string;
parentID: string;
parentTitle: string;
title: string;
reference: string;
description: string;
status: string;
clientNumber: string;
matterNumber: string;
billingReference: string;
netDocumentsURL: string;
industry: string;
country: string;
patentNumber: string;
filingDate: string;
grantDate: string;
court: string;
caseNumber: string;
}
let parentCandidates: ProjectMini[] = [];
function $(id: string): HTMLElement {
const el = document.getElementById(id);
if (!el) throw new Error("missing form element: " + id);
return el;
}
function tryGet(id: string): HTMLElement | null {
return document.getElementById(id);
}
// showFieldsForType toggles parent-picker + type-specific blocks.
export function showFieldsForType(typeSel: string) {
const parentWrap = tryGet("projekt-parent-wrap") as HTMLDivElement | null;
const clientFields = tryGet("fields-client") as HTMLDivElement | null;
const patentFields = tryGet("fields-patent") as HTMLDivElement | null;
const caseFields = tryGet("fields-case") as HTMLDivElement | null;
if (clientFields) clientFields.style.display = typeSel === "client" ? "block" : "none";
if (patentFields) patentFields.style.display = typeSel === "patent" ? "block" : "none";
if (caseFields) caseFields.style.display = typeSel === "case" ? "block" : "none";
if (parentWrap) parentWrap.style.display = typeSel === "client" ? "none" : "block";
}
export async function loadParentCandidates(excludeID?: string) {
try {
const resp = await fetch("/api/projects");
if (!resp.ok) return;
const all = (await resp.json()) as ProjectMini[];
parentCandidates = excludeID ? all.filter((p) => p.id !== excludeID) : all;
} catch {
/* network — leave empty */
}
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
export function initParentPicker() {
const input = tryGet("projekt-parent-input") as HTMLInputElement | null;
const hidden = tryGet("projekt-parent-id") as HTMLInputElement | null;
const sugs = tryGet("projekt-parent-suggestions") as HTMLDivElement | null;
if (!input || !hidden || !sugs) return;
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
hidden.value = "";
if (!q) {
sugs.innerHTML = "";
return;
}
const matches = parentCandidates
.filter((p) => {
const hay = (p.title + " " + (p.reference || "")).toLowerCase();
return hay.includes(q);
})
.slice(0, 8);
sugs.innerHTML = matches
.map(
(p) =>
`<div class="akten-collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
<strong>${esc(p.title)}</strong>
<span class="akten-type-chip akten-type-${esc(p.type)}">${esc(t("projekte.type." + p.type) || p.type)}</span>
</div>`,
)
.join("");
sugs.querySelectorAll<HTMLDivElement>(".akten-collab-suggestion").forEach((el) => {
el.addEventListener("click", () => {
hidden.value = el.dataset.id!;
input.value = el.dataset.title!;
sugs.innerHTML = "";
});
});
});
}
// wireTypeChange wires the <select id="projekt-type"> change handler and runs
// the visibility pass once with the current value.
export function wireTypeChange() {
const typeSel = $("projekt-type") as HTMLSelectElement;
showFieldsForType(typeSel.value);
typeSel.addEventListener("change", () => showFieldsForType(typeSel.value));
}
// readPayload collects the form's current values into a CreateProjektInput /
// UpdateProjektInput compatible JSON payload. Returns null + sets msg when
// title is missing.
//
// `omitEmpty` controls whether empty-string fields are sent. For Create we
// drop them (server treats absent as default). For Update we send them as
// `""` so the server can clear the column — except for ParentID (handled
// specially because client→non-client requires structural changes the user
// shouldn't trigger from an edit form).
export function readPayload(
msg: HTMLElement,
opts: { omitEmpty: boolean; mode: "create" | "edit" },
): Record<string, unknown> | null {
const type = ($("projekt-type") as HTMLSelectElement).value;
const title = ($("project-title") as HTMLInputElement).value.trim();
if (!title) {
msg.textContent = t("projekte.error.title_required") || "Title required";
msg.className = "form-msg form-msg-error";
return null;
}
const payload: Record<string, unknown> = {
type,
title,
status: ($("project-status") as HTMLSelectElement).value,
};
const parentID = ($("projekt-parent-id") as HTMLInputElement).value;
if (type !== "client" && parentID) {
payload.parent_id = parentID;
}
const stringField = (id: string, key: string) => {
const v = ($(id) as HTMLInputElement).value.trim();
if (v) payload[key] = v;
else if (!opts.omitEmpty) payload[key] = "";
};
stringField("project-ref", "reference");
stringField("project-client-number", "client_number");
stringField("project-matter-number", "matter_number");
stringField("project-billing-ref", "billing_reference");
stringField("project-netdocs", "netdocuments_url");
if (type === "client") {
stringField("project-industry", "industry");
stringField("project-country", "country");
}
if (type === "patent") {
stringField("project-patent-number", "patent_number");
const fd = ($("project-filing-date") as HTMLInputElement).value;
if (fd) payload.filing_date = fd + "T00:00:00Z";
const gd = ($("project-grant-date") as HTMLInputElement).value;
if (gd) payload.grant_date = gd + "T00:00:00Z";
}
if (type === "case") {
stringField("project-court", "court");
stringField("project-case-number", "case_number");
}
const desc = ($("project-description") as HTMLTextAreaElement).value.trim();
if (desc) payload.description = desc;
else if (!opts.omitEmpty) payload.description = "";
// The edit modal currently doesn't expose reparenting; keep parent_id off
// the payload unless the user is creating.
if (opts.mode === "edit") {
delete payload.parent_id;
}
return payload;
}
// prefillForm hydrates the form fields from an existing Project record.
export function prefillForm(p: Record<string, unknown>) {
const get = (id: string) => $(id) as HTMLInputElement;
const getSel = (id: string) => $(id) as HTMLSelectElement;
const getTA = (id: string) => $(id) as HTMLTextAreaElement;
const type = String(p.type ?? "project");
getSel("projekt-type").value = type;
showFieldsForType(type);
get("project-title").value = String(p.title ?? "");
get("project-ref").value = String(p.reference ?? "");
get("project-client-number").value = String(p.client_number ?? "");
get("project-matter-number").value = String(p.matter_number ?? "");
get("project-billing-ref").value = String(p.billing_reference ?? "");
get("project-netdocs").value = String(p.netdocuments_url ?? "");
get("project-industry").value = String(p.industry ?? "");
get("project-country").value = String(p.country ?? "");
get("project-patent-number").value = String(p.patent_number ?? "");
get("project-filing-date").value = isoToDate(p.filing_date as string | null | undefined);
get("project-grant-date").value = isoToDate(p.grant_date as string | null | undefined);
get("project-court").value = String(p.court ?? "");
get("project-case-number").value = String(p.case_number ?? "");
getTA("project-description").value = String(p.description ?? "");
getSel("project-status").value = String(p.status ?? "active");
}
function isoToDate(iso: string | null | undefined): string {
if (!iso) return "";
// Accept YYYY-MM-DD or full ISO; slice to date.
return iso.slice(0, 10);
}

View File

@@ -1,6 +1,13 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
import {
loadParentCandidates,
initParentPicker,
wireTypeChange,
prefillForm,
readPayload,
} from "./project-form";
interface Project {
id: string;
@@ -9,10 +16,17 @@ interface Project {
path: string;
title: string;
reference?: string | null;
description?: string | null;
status: string;
client_number?: string | null;
matter_number?: string | null;
billing_reference?: string | null;
netdocuments_url?: string | null;
industry?: string | null;
country?: string | null;
patent_number?: string | null;
filing_date?: string | null;
grant_date?: string | null;
court?: string | null;
case_number?: string | null;
updated_at: string;
@@ -488,19 +502,15 @@ function fmtDateTime(iso: string): string {
function renderHeader() {
if (!project) return;
(document.getElementById("project-title-display") as HTMLElement).textContent = project.title;
(document.getElementById("project-title-edit") as HTMLInputElement).value = project.title;
(document.getElementById("project-ref-display") as HTMLElement).textContent = project.reference || "";
const descDisplay = document.getElementById("project-description-display") as HTMLElement;
const descEdit = document.getElementById("project-description-edit") as HTMLTextAreaElement;
const description = (project as Project & { description?: string | null }).description ?? "";
const description = project.description ?? "";
descDisplay.textContent = description;
descEdit.value = description;
const descWrap = document.querySelector<HTMLElement>(".akten-detail-description");
const descWrap = document.getElementById("project-description-wrap");
if (descWrap) {
// Hide the whole Notizen block when there is nothing to show AND we're
// not in edit mode — toggled by initTitleEdit on edit/save.
descWrap.dataset.empty = description ? "0" : "1";
// Hide the whole Notizen block when there is no description.
descWrap.style.display = description ? "" : "none";
}
const typeChip = document.getElementById("project-type-chip")!;
@@ -721,66 +731,120 @@ function initTabs() {
});
}
function initTitleEdit() {
const display = document.getElementById("project-title-display")!;
const editInput = document.getElementById("project-title-edit") as HTMLInputElement;
const descDisplay = document.getElementById("project-description-display") as HTMLElement;
const descEdit = document.getElementById("project-description-edit") as HTMLTextAreaElement;
const editBtn = document.getElementById("project-edit-btn") as HTMLButtonElement;
const saveBtn = document.getElementById("project-save-btn") as HTMLButtonElement;
// Edit modal — full form, same fields as /projects/new but pre-filled and
// PATCH'd back. The shared client/project-form module handles parent-picker
// suggestions, type-driven field visibility, and payload building.
let editFormPrepared = false;
editBtn.addEventListener("click", () => {
display.style.display = "none";
editInput.style.display = "";
descDisplay.style.display = "none";
descEdit.style.display = "";
saveBtn.style.display = "";
editBtn.style.display = "none";
editInput.focus();
editInput.select();
async function prepareEditForm() {
if (editFormPrepared) return;
editFormPrepared = true;
wireTypeChange();
// Exclude the project itself so users can't accidentally pick themselves
// as the new parent (server would reject anyway).
await loadParentCandidates(project?.id);
initParentPicker();
}
function openEditModal() {
if (!project) return;
const modal = document.getElementById("project-edit-modal");
const msg = document.getElementById("project-edit-msg");
if (!modal || !msg) return;
void prepareEditForm().then(() => {
if (!project) return;
prefillForm(project as unknown as Record<string, unknown>);
// Pre-fill the parent picker label from the immediate parent (if any).
const parentInput = document.getElementById("projekt-parent-input") as HTMLInputElement | null;
const parentHidden = document.getElementById("projekt-parent-id") as HTMLInputElement | null;
if (parentInput && parentHidden) {
if (project.parent_id && ancestors.length > 0) {
const parent = ancestors[ancestors.length - 1];
parentHidden.value = parent.id;
parentInput.value = parent.title;
} else {
parentHidden.value = "";
parentInput.value = "";
}
}
// Type changes are a structural operation the server doesn't support
// via PATCH — disable the dropdown so the UI doesn't promise more than
// it can deliver. The select still drives the conditional field
// visibility from its current value.
const typeSel = document.getElementById("projekt-type") as HTMLSelectElement | null;
if (typeSel) typeSel.disabled = true;
// Re-parenting is also out of scope for the edit modal.
if (parentInput) parentInput.disabled = true;
});
msg.textContent = "";
msg.className = "form-msg";
modal.style.display = "flex";
}
function closeEditModal() {
const modal = document.getElementById("project-edit-modal");
if (modal) modal.style.display = "none";
}
function initEditModal() {
const editBtn = document.getElementById("project-edit-btn") as HTMLButtonElement | null;
const modal = document.getElementById("project-edit-modal");
const closeBtn = document.getElementById("project-edit-modal-close");
const cancelBtn = document.getElementById("project-edit-cancel");
const form = document.getElementById("project-edit-form") as HTMLFormElement | null;
const msg = document.getElementById("project-edit-msg") as HTMLParagraphElement | null;
if (!editBtn || !modal || !closeBtn || !cancelBtn || !form || !msg) return;
editBtn.addEventListener("click", openEditModal);
closeBtn.addEventListener("click", closeEditModal);
cancelBtn.addEventListener("click", closeEditModal);
modal.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeEditModal();
});
saveBtn.addEventListener("click", async () => {
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!project) return;
const newTitle = editInput.value.trim();
const newDesc = descEdit.value.trim();
const oldDesc = (project as Project & { description?: string | null }).description ?? "";
const titleUnchanged = !newTitle || newTitle === project.title;
const descUnchanged = newDesc === oldDesc;
if (titleUnchanged && descUnchanged) {
cancelEdit();
return;
}
saveBtn.disabled = true;
msg.textContent = "";
msg.className = "form-msg";
const payload = readPayload(msg, { omitEmpty: false, mode: "edit" });
if (!payload) return;
// Type changes from the edit form are an unusual structural action —
// the server allows it but we're explicit about not sending `type` when
// unchanged so the backend doesn't run avoidable validation.
if (payload.type === project.type) delete payload.type;
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
try {
const body: Record<string, unknown> = {};
if (!titleUnchanged) body.title = newTitle;
if (!descUnchanged) body.description = newDesc;
const resp = await fetch(`/api/projects/${project.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
body: JSON.stringify(payload),
});
if (resp.ok) {
project = await resp.json();
if (!resp.ok) {
const errBody = await resp.json().catch(() => ({ error: "unknown" }));
msg.textContent = errBody.error || t("akten.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
project = await resp.json();
closeEditModal();
if (project) {
await Promise.all([loadAncestors(project.id), loadEvents(project.id)]);
renderHeader();
if (project) await loadEvents(project.id);
renderBreadcrumb();
renderEvents();
}
} catch (err) {
msg.textContent = t("akten.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
saveBtn.disabled = false;
cancelEdit();
submitBtn.disabled = false;
}
});
function cancelEdit() {
display.style.display = "";
editInput.style.display = "none";
descDisplay.style.display = "";
descEdit.style.display = "none";
saveBtn.style.display = "none";
editBtn.style.display = "";
}
}
function initPartiesForm() {
@@ -926,7 +990,7 @@ async function main() {
initDeadlineAddLink();
initChildAddLink();
initTabs();
initTitleEdit();
initEditModal();
initPartiesForm();
initProjectAppointmentForm();
initTeamForm(id);
@@ -957,16 +1021,74 @@ async function loadAncestors(id: string) {
}
}
// Lucide-style 24x24 icons matched to the project tree's icon set so the
// visual language stays consistent across the app.
const TYPE_ICONS: Record<string, string> = {
client:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<rect x="2" y="7" width="20" height="14" rx="2"/>` +
`<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>` +
`</svg>`,
litigation:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<path d="M16 16l3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/>` +
`<path d="M2 16l3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/>` +
`<path d="M7 21h10"/>` +
`<path d="M12 3v18"/>` +
`<path d="M3 7h18"/>` +
`</svg>`,
patent:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7"/>` +
`<path d="M3 7v12a2 2 0 0 0 2 2h0"/>` +
`<path d="M21 3a2 2 0 0 0-2 2v14"/>` +
`<line x1="9" y1="9" x2="15" y2="9"/>` +
`<line x1="9" y1="13" x2="15" y2="13"/>` +
`<line x1="9" y1="17" x2="13" y2="17"/>` +
`</svg>`,
case:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<path d="M14 13l-7 7-3-3 7-7"/>` +
`<path d="M11.5 7.5l5 5"/>` +
`<path d="M16 3l5 5-3 3-5-5z"/>` +
`<path d="M5 21h6"/>` +
`</svg>`,
project:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>` +
`</svg>`,
};
const BREADCRUMB_CHEVRON =
`<svg class="projekt-breadcrumb-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<polyline points="9 18 15 12 9 6"/>` +
`</svg>`;
function typeIcon(type: string): string {
return TYPE_ICONS[type] || TYPE_ICONS.project;
}
function renderBreadcrumb() {
if (!project) return;
const el = document.getElementById("project-breadcrumb");
if (!el) return;
const parts: string[] = ancestors.map(
(a) =>
`<a href="/projects/${esc(a.id)}" class="projekt-crumb">${esc(a.title)}</a>`,
const crumbs: string[] = ancestors.map((a) => {
const label = t(`projekte.type.${a.type}`) || a.type;
return (
`<a href="/projects/${esc(a.id)}" class="projekt-crumb projekt-crumb-link projekt-crumb-${esc(a.type)}" title="${escAttr(label)}: ${escAttr(a.title)}">` +
`<span class="projekt-crumb-icon">${typeIcon(a.type)}</span>` +
`<span class="projekt-crumb-title">${esc(a.title)}</span>` +
`</a>`
);
});
const currentLabel = t(`projekte.type.${project.type}`) || project.type;
crumbs.push(
`<span class="projekt-crumb projekt-crumb-current projekt-crumb-${esc(project.type)}" title="${escAttr(currentLabel)}: ${escAttr(project.title)}">` +
`<span class="projekt-crumb-icon">${typeIcon(project.type)}</span>` +
`<span class="projekt-crumb-title">${esc(project.title)}</span>` +
`</span>`,
);
parts.push(`<span class="projekt-crumb projekt-crumb-current">${esc(project.title)}</span>`);
el.innerHTML = parts.join(`<span class="projekt-crumb-sep"></span>`);
el.innerHTML = crumbs.join(BREADCRUMB_CHEVRON);
}
// ----- Children -----------------------------------------------------------

View File

@@ -1,17 +1,15 @@
import { initI18n, t } from "./i18n";
import { initI18n } from "./i18n";
import { initSidebar } from "./sidebar";
import {
loadParentCandidates,
initParentPicker,
wireTypeChange,
showFieldsForType,
readPayload,
} from "./project-form";
// /projekte/neu client. Posts v2 CreateProjektInput shape.
// Fields shown depend on type selection; parent picker shown for non-client types.
interface ProjectMini {
id: string;
title: string;
type: string;
reference?: string | null;
}
let parentCandidates: ProjectMini[] = [];
// /projects/new client. Posts v2 CreateProjektInput shape using the shared
// project-form helpers.
function $(id: string): HTMLElement {
const el = document.getElementById(id);
@@ -19,120 +17,16 @@ function $(id: string): HTMLElement {
return el;
}
function showFieldsForType(typeSel: string) {
const parentWrap = $("projekt-parent-wrap") as HTMLDivElement;
const clientFields = $("fields-client") as HTMLDivElement;
const patentFields = $("fields-patent") as HTMLDivElement;
const caseFields = $("fields-case") as HTMLDivElement;
clientFields.style.display = typeSel === "client" ? "block" : "none";
patentFields.style.display = typeSel === "patent" ? "block" : "none";
caseFields.style.display = typeSel === "case" ? "block" : "none";
parentWrap.style.display = typeSel === "client" ? "none" : "block";
}
async function loadParentCandidates() {
try {
const resp = await fetch("/api/projects");
if (!resp.ok) return;
parentCandidates = (await resp.json()) as ProjectMini[];
} catch {
// ignore
}
}
function initParentPicker() {
const input = $("projekt-parent-input") as HTMLInputElement;
const hidden = $("projekt-parent-id") as HTMLInputElement;
const sugs = $("projekt-parent-suggestions") as HTMLDivElement;
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
hidden.value = "";
if (!q) {
sugs.innerHTML = "";
return;
}
const matches = parentCandidates
.filter((p) => {
const hay = (p.title + " " + (p.reference || "")).toLowerCase();
return hay.includes(q);
})
.slice(0, 8);
sugs.innerHTML = matches
.map(
(p) =>
`<div class="akten-collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
<strong>${esc(p.title)}</strong>
<span class="akten-type-chip akten-type-${esc(p.type)}">${esc(t("projekte.type." + p.type) || p.type)}</span>
</div>`,
)
.join("");
sugs.querySelectorAll<HTMLDivElement>(".akten-collab-suggestion").forEach((el) => {
el.addEventListener("click", () => {
hidden.value = el.dataset.id!;
input.value = el.dataset.title!;
sugs.innerHTML = "";
});
});
});
}
function submitForm() {
const form = $("akten-neu-form") as HTMLFormElement;
const msg = $("akten-neu-msg") as HTMLParagraphElement;
form.addEventListener("submit", async (e) => {
e.preventDefault();
msg.textContent = "";
msg.className = "form-msg";
const type = ($("projekt-type") as HTMLSelectElement).value;
const title = ($("project-title") as HTMLInputElement).value.trim();
if (!title) {
msg.textContent = t("projekte.error.title_required") || "Title required";
return;
}
const payload: Record<string, unknown> = {
type,
title,
status: ($("project-status") as HTMLSelectElement).value,
};
const parentID = ($("projekt-parent-id") as HTMLInputElement).value;
if (type !== "client" && parentID) payload.parent_id = parentID;
const ref = ($("project-ref") as HTMLInputElement).value.trim();
if (ref) payload.reference = ref;
const clientNumber = ($("project-client-number") as HTMLInputElement).value.trim();
if (clientNumber) payload.client_number = clientNumber;
const matterNumber = ($("project-matter-number") as HTMLInputElement).value.trim();
if (matterNumber) payload.matter_number = matterNumber;
const netdocs = ($("project-netdocs") as HTMLInputElement).value.trim();
if (netdocs) payload.netdocuments_url = netdocs;
if (type === "client") {
const ind = ($("project-industry") as HTMLInputElement).value.trim();
if (ind) payload.industry = ind;
const cty = ($("project-country") as HTMLInputElement).value.trim();
if (cty) payload.country = cty;
}
const desc = ($("project-description") as HTMLTextAreaElement).value.trim();
if (desc) payload.description = desc;
if (type === "patent") {
const pat = ($("project-patent-number") as HTMLInputElement).value.trim();
if (pat) payload.patent_number = pat;
const fd = ($("project-filing-date") as HTMLInputElement).value;
if (fd) payload.filing_date = fd + "T00:00:00Z";
const gd = ($("project-grant-date") as HTMLInputElement).value;
if (gd) payload.grant_date = gd + "T00:00:00Z";
}
if (type === "case") {
const court = ($("project-court") as HTMLInputElement).value.trim();
if (court) payload.court = court;
const cn = ($("project-case-number") as HTMLInputElement).value.trim();
if (cn) payload.case_number = cn;
}
const payload = readPayload(msg, { omitEmpty: true, mode: "create" });
if (!payload) return;
try {
const resp = await fetch("/api/projects", {
@@ -143,22 +37,18 @@ function submitForm() {
if (!resp.ok) {
const errBody = await resp.json().catch(() => ({ error: "unknown" }));
msg.textContent = errBody.error || "Fehler beim Anlegen";
msg.className = "form-msg form-msg-error";
return;
}
const p = (await resp.json()) as { id: string };
window.location.href = `/projects/${p.id}`;
} catch (e) {
msg.textContent = String(e);
msg.className = "form-msg form-msg-error";
}
});
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
async function applyParentFromQueryString() {
const qs = new URLSearchParams(window.location.search);
const parentID = qs.get("parent");
@@ -166,7 +56,7 @@ async function applyParentFromQueryString() {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(parentID)}`);
if (!resp.ok) return;
const p = (await resp.json()) as ProjectMini;
const p = (await resp.json()) as { id: string; title: string };
($("projekt-parent-id") as HTMLInputElement).value = p.id;
($("projekt-parent-input") as HTMLInputElement).value = p.title;
// Default to 'case' under a non-root parent; user can override.
@@ -183,9 +73,7 @@ async function applyParentFromQueryString() {
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
const typeSel = $("projekt-type") as HTMLSelectElement;
showFieldsForType(typeSel.value);
typeSel.addEventListener("change", () => showFieldsForType(typeSel.value));
wireTypeChange();
await loadParentCandidates();
initParentPicker();
await applyParentFromQueryString();

View File

@@ -0,0 +1,169 @@
import { h } from "../jsx";
// Reusable Project form body. Renders the field grid only — the surrounding
// <form>, submit/cancel buttons and the form-msg paragraph belong to the
// caller because /projects/new and the edit modal want different button
// labels and submit behaviour.
//
// Field IDs are intentionally identical to the ones used historically on
// /projects/new so the shared client module client/project-form.ts can read
// them via getElementById on either page (the two forms never coexist on a
// single page).
export function ProjectFormFields(): string {
return (
<div className="project-form-fields">
<div className="form-field">
<label htmlFor="projekt-type" data-i18n="projekte.field.type">Typ</label>
<select id="projekt-type" required>
<option value="client" data-i18n="projekte.type.client">Mandant (Wurzel)</option>
<option value="litigation" data-i18n="projekte.type.litigation">Streitsache</option>
<option value="patent" data-i18n="projekte.type.patent">Patent</option>
<option value="case" data-i18n="projekte.type.case">Verfahren</option>
<option value="project" data-i18n="projekte.type.project">Projekt (generisch)</option>
</select>
</div>
<div className="form-field" id="projekt-parent-wrap" style="display:none">
<label htmlFor="projekt-parent-input" data-i18n="projekte.field.parent">&Uuml;bergeordnetes Projekt</label>
<input
type="text"
id="projekt-parent-input"
placeholder="Titel eingeben, um ein &Uuml;berprojekt zu suchen..."
data-i18n-placeholder="projekte.field.parent.placeholder"
autocomplete="off"
/>
<input type="hidden" id="projekt-parent-id" />
<div id="projekt-parent-suggestions" className="akten-collab-suggestions" />
<p className="form-hint" data-i18n="projekte.field.parent.hint">
Leer lassen f&uuml;r ein Wurzel-Projekt (typisch: Mandant).
</p>
</div>
<div className="form-field">
<label htmlFor="project-title" data-i18n="projekte.field.title">Titel</label>
<input
type="text"
id="project-title"
required
placeholder="z.B. Siemens AG | Siemens v. Huawei | EP 1 234 567"
data-i18n-placeholder="projekte.field.title.placeholder"
/>
</div>
<div className="form-field">
<label htmlFor="project-ref" data-i18n="projekte.field.reference">Interne Referenz (optional)</label>
<input
type="text"
id="project-ref"
placeholder="z.B. HL-2026-0042"
data-i18n-placeholder="projekte.field.reference.placeholder"
/>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="project-client-number" data-i18n="projekte.field.client_number">Client-Nr. (7 Ziffern)</label>
<input
type="text"
id="project-client-number"
pattern="[0-9]{7}"
maxLength={7}
placeholder="0001234"
/>
</div>
<div className="form-field">
<label htmlFor="project-matter-number" data-i18n="projekte.field.matter_number">Matter-Nr. (7 Ziffern)</label>
<input
type="text"
id="project-matter-number"
pattern="[0-9]{7}"
maxLength={7}
placeholder="0000567"
/>
</div>
</div>
<p className="form-hint" data-i18n="projekte.field.clientmatter.hint">
HLC-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt
(&uuml;berschreibbar).
</p>
<div className="form-field">
<label htmlFor="project-billing-ref" data-i18n="projekte.field.billing_reference">Billing-Referenz (optional)</label>
<input
type="text"
id="project-billing-ref"
placeholder="z.B. PO-2026-0815"
/>
</div>
<div className="form-field">
<label htmlFor="project-netdocs" data-i18n="projekte.field.netdocuments_url">netDocuments-URL (optional)</label>
<input
type="url"
id="project-netdocs"
placeholder="https://netdocs.hoganlovells.com/..."
/>
</div>
{/* Client-specific */}
<div className="projekt-fields projekt-fields-client" id="fields-client" style="display:none">
<div className="form-field-row">
<div className="form-field">
<label htmlFor="project-industry" data-i18n="projekte.field.industry">Branche</label>
<input type="text" id="project-industry" placeholder="z.B. industrial" />
</div>
<div className="form-field">
<label htmlFor="project-country" data-i18n="projekte.field.country">Land (ISO-2)</label>
<input type="text" id="project-country" maxLength={2} placeholder="DE" />
</div>
</div>
</div>
{/* Patent-specific */}
<div className="projekt-fields projekt-fields-patent" id="fields-patent" style="display:none">
<div className="form-field">
<label htmlFor="project-patent-number" data-i18n="projekte.field.patent_number">Patentnummer</label>
<input type="text" id="project-patent-number" placeholder="EP 1 234 567" />
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="project-filing-date" data-i18n="projekte.field.filing_date">Anmeldetag</label>
<input type="date" id="project-filing-date" />
</div>
<div className="form-field">
<label htmlFor="project-grant-date" data-i18n="projekte.field.grant_date">Erteilungstag</label>
<input type="date" id="project-grant-date" />
</div>
</div>
</div>
{/* Case-specific */}
<div className="projekt-fields projekt-fields-case" id="fields-case" style="display:none">
<div className="form-field-row">
<div className="form-field">
<label htmlFor="project-court" data-i18n="projekte.field.court">Gericht</label>
<input type="text" id="project-court" placeholder="UPC_CFI_Munich" />
</div>
<div className="form-field">
<label htmlFor="project-case-number" data-i18n="projekte.field.case_number">Aktenzeichen (Gericht)</label>
<input type="text" id="project-case-number" placeholder="UPC_CFI_123/2026" />
</div>
</div>
</div>
<div className="form-field">
<label htmlFor="project-description" data-i18n="projekte.field.description">Notizen</label>
<textarea id="project-description" rows={4} placeholder="Kurznotizen zum Projekt (optional)..." data-i18n-placeholder="projekte.field.description.placeholder" />
</div>
<div className="form-field">
<label htmlFor="project-status" data-i18n="projekte.field.status">Status</label>
<select id="project-status">
<option value="active" data-i18n="projekte.filter.status.active">Aktiv</option>
<option value="closed" data-i18n="projekte.filter.status.closed">Abgeschlossen</option>
<option value="archived" data-i18n="projekte.filter.status.archived">Archiviert</option>
</select>
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
import { ProjectFormFields } from "./components/ProjectFormFields";
// Project detail shell (v2). DOM IDs use the English `project-*` /
// `parties-*` / `deadlines-*` / `appointments-*` / `notes-*` / `checklists-*`
@@ -45,7 +46,6 @@ export function renderProjectsDetail(): string {
<div className="akten-detail-title-row">
<div className="akten-detail-title-col">
<h1 id="project-title-display" />
<input type="text" id="project-title-edit" className="akten-title-input" style="display:none" />
<div className="akten-detail-meta">
<span id="project-type-chip" className="akten-type-chip" />
<span className="akten-ref" id="project-ref-display" />
@@ -61,15 +61,13 @@ export function renderProjectsDetail(): string {
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button id="project-save-btn" className="btn-primary btn-cta-lime" type="button" style="display:none" data-i18n="projekte.detail.save">Speichern</button>
</div>
</div>
</header>
<div className="akten-detail-description">
<div className="akten-detail-description" id="project-description-wrap">
<h3 data-i18n="projekte.detail.description.heading">Notizen</h3>
<p id="project-description-display" className="akten-detail-description-text" />
<textarea id="project-description-edit" className="akten-detail-description-input" rows={4} style="display:none" />
</div>
<nav className="akten-tabs" id="project-tabs">
@@ -339,6 +337,26 @@ export function renderProjectsDetail(): string {
</div>
</div>
{/* Full edit modal — same form as /projects/new, pre-filled. */}
<div className="modal-overlay" id="project-edit-modal" style="display:none">
<div className="modal-card modal-card-wide">
<div className="modal-header">
<h2 data-i18n="projekte.detail.edit.modal.title">Projekt bearbeiten</h2>
<button className="modal-close" id="project-edit-modal-close" type="button" aria-label="Schlie&szlig;en">&times;</button>
</div>
<form id="project-edit-form" className="akten-form" autocomplete="off">
<ProjectFormFields />
<p className="form-msg" id="project-edit-msg" />
<div className="form-actions">
<button type="button" className="btn-cancel" id="project-edit-cancel" data-i18n="projekte.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projekte.detail.save">Speichern</button>
</div>
</form>
</div>
</div>
{/* Delete confirmation modal */}
<div className="modal-overlay" id="delete-modal" style="display:none">
<div className="modal-card">

View File

@@ -3,9 +3,11 @@ import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
import { ProjectFormFields } from "./components/ProjectFormFields";
// "Neues Projekt" form (v2). Rendered at /projekte/neu. Supports five types;
// fields show/hide based on the selected type via client TS.
// "Neues Projekt" form (v2). Rendered at /projects/new. The form body is the
// shared ProjectFormFields component, also used by the edit modal on
// /projects/{id}.
export function renderProjectsNew(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
@@ -36,149 +38,7 @@ export function renderProjectsNew(): string {
</div>
<form id="akten-neu-form" className="akten-form" autocomplete="off">
<div className="form-field">
<label htmlFor="projekt-type" data-i18n="projekte.field.type">Typ</label>
<select id="projekt-type" required>
<option value="client" data-i18n="projekte.type.client">Mandant (Wurzel)</option>
<option value="litigation" data-i18n="projekte.type.litigation">Streitsache</option>
<option value="patent" data-i18n="projekte.type.patent">Patent</option>
<option value="case" data-i18n="projekte.type.case">Verfahren</option>
<option value="project" data-i18n="projekte.type.project">Projekt (generisch)</option>
</select>
</div>
<div className="form-field" id="projekt-parent-wrap" style="display:none">
<label htmlFor="projekt-parent-input" data-i18n="projekte.field.parent">&Uuml;bergeordnetes Projekt</label>
<input
type="text"
id="projekt-parent-input"
placeholder="Titel eingeben, um ein &Uuml;berprojekt zu suchen..."
data-i18n-placeholder="projekte.field.parent.placeholder"
autocomplete="off"
/>
<input type="hidden" id="projekt-parent-id" />
<div id="projekt-parent-suggestions" className="akten-collab-suggestions" />
<p className="form-hint" data-i18n="projekte.field.parent.hint">
Leer lassen f&uuml;r ein Wurzel-Projekt (typisch: Mandant).
</p>
</div>
<div className="form-field">
<label htmlFor="project-title" data-i18n="projekte.field.title">Titel</label>
<input
type="text"
id="project-title"
required
placeholder="z.B. Siemens AG | Siemens v. Huawei | EP 1 234 567"
data-i18n-placeholder="projekte.field.title.placeholder"
/>
</div>
<div className="form-field">
<label htmlFor="project-ref" data-i18n="projekte.field.reference">Interne Referenz (optional)</label>
<input
type="text"
id="project-ref"
placeholder="z.B. HL-2026-0042"
data-i18n-placeholder="projekte.field.reference.placeholder"
/>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="project-client-number" data-i18n="projekte.field.client_number">Client-Nr. (7 Ziffern)</label>
<input
type="text"
id="project-client-number"
pattern="[0-9]{7}"
maxLength={7}
placeholder="0001234"
/>
</div>
<div className="form-field">
<label htmlFor="project-matter-number" data-i18n="projekte.field.matter_number">Matter-Nr. (7 Ziffern)</label>
<input
type="text"
id="project-matter-number"
pattern="[0-9]{7}"
maxLength={7}
placeholder="0000567"
/>
</div>
</div>
<p className="form-hint" data-i18n="projekte.field.clientmatter.hint">
HLC-Billing-Nummern. Format CCCCCCC.MMMMMMM. Client-Nr. wird an Unterprojekte vererbt
(&uuml;berschreibbar).
</p>
<div className="form-field">
<label htmlFor="project-netdocs" data-i18n="projekte.field.netdocuments_url">netDocuments-URL (optional)</label>
<input
type="url"
id="project-netdocs"
placeholder="https://netdocs.hoganlovells.com/..."
/>
</div>
{/* Client-specific */}
<div className="projekt-fields projekt-fields-client" id="fields-client" style="display:none">
<div className="form-field-row">
<div className="form-field">
<label htmlFor="project-industry" data-i18n="projekte.field.industry">Branche</label>
<input type="text" id="project-industry" placeholder="z.B. industrial" />
</div>
<div className="form-field">
<label htmlFor="project-country" data-i18n="projekte.field.country">Land (ISO-2)</label>
<input type="text" id="project-country" maxLength={2} placeholder="DE" />
</div>
</div>
</div>
{/* Patent-specific */}
<div className="projekt-fields projekt-fields-patent" id="fields-patent" style="display:none">
<div className="form-field">
<label htmlFor="project-patent-number" data-i18n="projekte.field.patent_number">Patentnummer</label>
<input type="text" id="project-patent-number" placeholder="EP 1 234 567" />
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="project-filing-date" data-i18n="projekte.field.filing_date">Anmeldetag</label>
<input type="date" id="project-filing-date" />
</div>
<div className="form-field">
<label htmlFor="project-grant-date" data-i18n="projekte.field.grant_date">Erteilungstag</label>
<input type="date" id="project-grant-date" />
</div>
</div>
</div>
{/* Case-specific */}
<div className="projekt-fields projekt-fields-case" id="fields-case" style="display:none">
<div className="form-field-row">
<div className="form-field">
<label htmlFor="project-court" data-i18n="projekte.field.court">Gericht</label>
<input type="text" id="project-court" placeholder="UPC_CFI_Munich" />
</div>
<div className="form-field">
<label htmlFor="project-case-number" data-i18n="projekte.field.case_number">Aktenzeichen (Gericht)</label>
<input type="text" id="project-case-number" placeholder="UPC_CFI_123/2026" />
</div>
</div>
</div>
<div className="form-field">
<label htmlFor="project-description" data-i18n="projekte.field.description">Notizen</label>
<textarea id="project-description" rows={4} placeholder="Kurznotizen zum Projekt (optional)..." data-i18n-placeholder="projekte.field.description.placeholder" />
</div>
<div className="form-field">
<label htmlFor="project-status" data-i18n="projekte.field.status">Status</label>
<select id="project-status">
<option value="active" data-i18n="projekte.filter.status.active">Aktiv</option>
<option value="closed" data-i18n="projekte.filter.status.closed">Abgeschlossen</option>
<option value="archived" data-i18n="projekte.filter.status.archived">Archiviert</option>
</select>
</div>
<ProjectFormFields />
<p className="form-msg" id="akten-neu-msg" />

View File

@@ -6831,3 +6831,136 @@ dialog.quick-add-sheet::backdrop {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* --- Project breadcrumb (t-paliad-049) ---------------------------------
Pill-style breadcrumbs with type icons, chevron separators and a hover
lime accent. Horizontal-scroll fallback on narrow screens; the trailing
current crumb stays bolder/non-link. */
.projekt-breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem 0.35rem;
margin: 0.25rem 0 1rem;
font-size: 0.82rem;
line-height: 1.2;
}
.projekt-crumb {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.28rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-surface, #fff);
color: var(--color-text-muted);
text-decoration: none;
font-weight: 500;
max-width: 16rem;
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.projekt-crumb-link:hover,
.projekt-crumb-link:focus-visible {
border-color: var(--color-accent, #c6f41c);
background: rgba(198, 244, 28, 0.12);
color: var(--color-text, #1a1a2e);
outline: none;
}
.projekt-crumb-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex-shrink: 0;
color: var(--color-text-muted);
}
.projekt-crumb-link:hover .projekt-crumb-icon,
.projekt-crumb-link:focus-visible .projekt-crumb-icon {
color: var(--color-text, #1a1a2e);
}
.projekt-crumb-icon svg {
width: 100%;
height: 100%;
display: block;
}
.projekt-crumb-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.projekt-crumb-current {
color: var(--color-text, #1a1a2e);
font-weight: 700;
background: #fafafa;
border-color: #d4d4d8;
}
.projekt-breadcrumb-chevron {
width: 12px;
height: 12px;
flex-shrink: 0;
color: var(--color-text-muted);
opacity: 0.55;
}
@media (max-width: 640px) {
.projekt-breadcrumb {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 0.25rem;
scrollbar-width: thin;
}
.projekt-crumb {
max-width: 10rem;
flex-shrink: 0;
}
}
/* --- Wide modal variant for the project edit form ------------------- */
.modal-card.modal-card-wide {
max-width: 720px;
padding: 1.5rem 1.75rem;
}
.modal-card-wide .akten-form {
margin-top: 0.5rem;
}
/* --- Standardised tab toolbar action buttons (t-paliad-049) ----------
The .akten-parteien-controls toolbar above each project sub-tab table
used a mix of <a class="btn-cta-lime btn-small"> and <button>; pin them
to a single shape so heights, paddings and hover states match across
tabs. Mehr laden's btn-secondary gets the same compact size. */
.akten-parteien-controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.akten-parteien-controls .btn-primary,
.akten-parteien-controls .btn-secondary,
.akten-parteien-controls .btn-cta-lime,
.akten-parteien-controls a.btn-primary,
.akten-parteien-controls a.btn-cta-lime,
.akten-events-loadmore .btn-secondary {
display: inline-flex;
align-items: center;
height: 2.1rem;
padding: 0 0.95rem;
font-size: 0.82rem;
font-weight: 600;
border-radius: var(--radius);
line-height: 1;
text-decoration: none;
}