Files
paliad/frontend/src/client/project-form.ts
m 59cf47b5ed 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.
2026-04-27 13:37:56 +02:00

226 lines
8.0 KiB
TypeScript

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);
}