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:
@@ -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",
|
||||
|
||||
225
frontend/src/client/project-form.ts
Normal file
225
frontend/src/client/project-form.ts
Normal 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);
|
||||
}
|
||||
@@ -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 -----------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
|
||||
169
frontend/src/components/ProjectFormFields.tsx
Normal file
169
frontend/src/components/ProjectFormFields.tsx
Normal 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">Übergeordnetes Projekt</label>
|
||||
<input
|
||||
type="text"
|
||||
id="projekt-parent-input"
|
||||
placeholder="Titel eingeben, um ein Ü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ü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
|
||||
(ü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>
|
||||
);
|
||||
}
|
||||
@@ -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ßen">×</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">
|
||||
|
||||
@@ -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">Übergeordnetes Projekt</label>
|
||||
<input
|
||||
type="text"
|
||||
id="projekt-parent-input"
|
||||
placeholder="Titel eingeben, um ein Ü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ü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
|
||||
(ü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" />
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user