Two-part fix from m's 2026-05-21 finding that the Schriftsätze tab
told users "Bitte zuerst einen Verfahrenstyp setzen" while the
project form had no field to set it. The `proceeding_type_id`
column was already on `paliad.projects` and accepted by the API.
Part 1 — Verfahrenstyp picker on the case-fields block
* frontend/src/components/ProjectFormFields.tsx — new optional
<select id="project-proceeding-type-id"> rendered between
Aktenzeichen and Mandantenrolle inside the type=case block.
First option is "(nicht gesetzt)" / "(unset)".
* frontend/src/client/project-form.ts — shared
loadProceedingTypes() + populateProceedingTypeSelect()
helpers. Options sorted by `code` (de.* → dpma.* → epa.* →
upc.*). readPayload sends `proceeding_type_id` only when the
user picked a value; prefillForm restores the saved id via
dataset.preselect to survive the async populate race.
* frontend/src/client/projects-new.ts — kicks off populate on
DOMContentLoaded.
* frontend/src/client/projects-detail.ts — edit-modal preload
now awaits populate; the local loadProceedingTypes duplicate
(used by the counterclaim modal) is replaced by the shared
helper so both surfaces hit the same cache.
Part 2 — Actionable empty-state on the Schriftsätze tab
* frontend/src/projects-detail.tsx — the static <p> empty-state
becomes a div with a "Projekt bearbeiten" button.
* frontend/src/client/projects-detail.ts — openEditModal now
accepts an optional focusFieldID; the new
#project-submissions-edit-cta click handler calls it with
"project-proceeding-type-id" so the picker is scrolled into
view and focused right after the modal opens.
i18n: new keys projects.field.proceeding_type{,.unset,.hint} and
projects.detail.submissions.empty.no_proceeding.cta; reworded
no_proceeding copy to match the new "edit the project" CTA.
Backend already validates via validateProceedingTypeCategory
(mig 087/088 fristenrechner-category guard). Added
TestProjectService_CaseProceedingTypePicker exercising both the
happy and reject paths through a `case`-typed Create.
Manual test path: open any case project → Edit → the Verfahrenstyp
picker shows below Aktenzeichen → save → the Schriftsätze tab now
lists the submission codes. Clicking the empty-state CTA jumps
straight to the picker.
354 lines
14 KiB
TypeScript
354 lines
14 KiB
TypeScript
import { t, tDyn, getLang } 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 ProceedingTypeRow {
|
|
id: number;
|
|
code: string;
|
|
name: string;
|
|
name_en: string;
|
|
jurisdiction?: string;
|
|
is_active: boolean;
|
|
}
|
|
|
|
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
|
|
|
|
// loadProceedingTypes fetches active fristenrechner-category proceeding
|
|
// types — the only set a project may bind to (mig 087/088 + service
|
|
// validation guard `validateProceedingTypeCategory`). Cached at module
|
|
// level so the page only pays for one fetch even when both the new-
|
|
// project page and the edit modal exercise the picker.
|
|
export async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
|
|
if (proceedingTypesCache) return proceedingTypesCache;
|
|
try {
|
|
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
|
|
if (!resp.ok) return [];
|
|
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
|
|
proceedingTypesCache = rows.filter((r) => r.is_active);
|
|
return proceedingTypesCache;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export interface ProjectMini {
|
|
id: string;
|
|
title: string;
|
|
type: string;
|
|
reference?: string | null;
|
|
// t-paliad-222 / m/paliad#50: auto-derived dotted project code from
|
|
// the ancestor tree. Populated by the service projection on every
|
|
// /api/projects response, so the picker can show the code without an
|
|
// extra fetch.
|
|
code?: string;
|
|
}
|
|
|
|
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;
|
|
ourSide: 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 litigationFields = tryGet("fields-litigation") 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 (litigationFields) litigationFields.style.display = typeSel === "litigation" ? "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) => {
|
|
// Search across title + manual reference + auto-derived code
|
|
// so the user can type "EXMPL" or "INF.CFI" and find the row.
|
|
const hay = (p.title + " " + (p.reference || "") + " " + (p.code || "")).toLowerCase();
|
|
return hay.includes(q);
|
|
})
|
|
.slice(0, 8);
|
|
sugs.innerHTML = matches
|
|
.map((p) => {
|
|
// Render the auto-derived code (if any, and distinct from
|
|
// reference) as a small mono badge on the right so the user
|
|
// can disambiguate two same-titled projects by their tree
|
|
// position. Single template literal kept readable inline.
|
|
const code = p.code && p.code !== (p.reference || "") ? p.code : "";
|
|
const codeBadge = code
|
|
? `<span class="entity-ref entity-ref-code">${esc(code)}</span>`
|
|
: "";
|
|
return `<div class="collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
|
|
<strong>${esc(p.title)}</strong>
|
|
<span class="entity-type-chip entity-type-${esc(p.type)}">${esc(tDyn("projects.type." + p.type) || p.type)}</span>
|
|
${codeBadge}
|
|
</div>`;
|
|
})
|
|
.join("");
|
|
sugs.querySelectorAll<HTMLDivElement>(".collab-suggestion").forEach((el) => {
|
|
el.addEventListener("click", () => {
|
|
hidden.value = el.dataset.id!;
|
|
input.value = el.dataset.title!;
|
|
sugs.innerHTML = "";
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// wireTypeChange wires the <select id="project-type"> change handler and runs
|
|
// the visibility pass once with the current value.
|
|
export function wireTypeChange() {
|
|
const typeSel = $("project-type") as HTMLSelectElement;
|
|
showFieldsForType(typeSel.value);
|
|
typeSel.addEventListener("change", () => showFieldsForType(typeSel.value));
|
|
}
|
|
|
|
// populateProceedingTypeSelect fills #project-proceeding-type-id with one
|
|
// option per fristenrechner-category proceeding type, ordered by `code`
|
|
// (so the user scans `de.*`, `dpma.*`, `epa.*`, `upc.*` in stable
|
|
// jurisdiction-grouped order). The first option is the empty "unset"
|
|
// choice already in the markup; this helper only appends rows below it.
|
|
// Idempotent — clearing rows[1..] on re-call so a re-open of the edit
|
|
// modal doesn't double-render the list.
|
|
export async function populateProceedingTypeSelect(): Promise<void> {
|
|
const sel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
|
|
if (!sel) return;
|
|
const rows = await loadProceedingTypes();
|
|
rows.sort((a, b) => a.code.localeCompare(b.code));
|
|
while (sel.options.length > 1) sel.remove(1);
|
|
const isEN = getLang() === "en";
|
|
for (const row of rows) {
|
|
const opt = document.createElement("option");
|
|
opt.value = String(row.id);
|
|
const label = isEN && row.name_en ? row.name_en : row.name;
|
|
opt.textContent = `${label} (${row.code})`;
|
|
sel.appendChild(opt);
|
|
}
|
|
// Honour a pre-selection value that prefillForm wrote before the
|
|
// option set existed. dataset.preselect is set to "" or the saved id;
|
|
// restoring it here keeps the edit modal's saved value visible.
|
|
const preselect = sel.dataset.preselect;
|
|
if (preselect !== undefined) sel.value = preselect;
|
|
}
|
|
|
|
// readPayload collects the form's current values into a CreateProjectInput /
|
|
// UpdateProjectInput 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 = ($("project-type") as HTMLSelectElement).value;
|
|
const title = ($("project-title") as HTMLInputElement).value.trim();
|
|
if (!title) {
|
|
msg.textContent = t("projects.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 === "litigation") {
|
|
// opponent_code is the litigation-only short slug used as the
|
|
// middle segment when BuildProjectCode auto-derives a project
|
|
// code from the ancestor tree (t-paliad-222 / m/paliad#50).
|
|
// Uppercased on submit so the user can type lowercase comfortably
|
|
// — the DB CHECK enforces the [A-Z0-9-]{1,16} pattern.
|
|
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
|
|
if (ocEl) {
|
|
const v = ocEl.value.trim().toUpperCase();
|
|
if (v) payload.opponent_code = v;
|
|
else if (!opts.omitEmpty) payload.opponent_code = "";
|
|
}
|
|
}
|
|
if (type === "case") {
|
|
stringField("project-court", "court");
|
|
stringField("project-case-number", "case_number");
|
|
|
|
// Proceeding type — optional picker. Per t-paliad-232, an empty
|
|
// pick simply omits the key from the payload (create: column stays
|
|
// NULL; edit: server's `omitempty` skips the SET). Clearing a
|
|
// previously-set value isn't supported in this slice; once bound,
|
|
// a project's proceeding type can be swapped but not unset from
|
|
// the form. The server's validateProceedingTypeCategory backs the
|
|
// selected id with a category check.
|
|
const ptSel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
|
|
if (ptSel) {
|
|
const v = ptSel.value.trim();
|
|
if (v) {
|
|
const n = parseInt(v, 10);
|
|
if (!isNaN(n)) payload.proceeding_type_id = n;
|
|
}
|
|
}
|
|
|
|
// Client Role (DB column: our_side) — case-only after t-paliad-222.
|
|
// The select uses "" for the unset option; the service maps empty
|
|
// string to NULL via nullableOurSide.
|
|
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
|
if (osSel) {
|
|
const v = osSel.value.trim();
|
|
if (v) payload.our_side = v;
|
|
else if (!opts.omitEmpty) payload.our_side = "";
|
|
}
|
|
}
|
|
|
|
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("project-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 ?? "");
|
|
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
|
if (osSel) osSel.value = String(p.our_side ?? "");
|
|
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
|
|
if (ocEl) ocEl.value = String(p.opponent_code ?? "");
|
|
// Proceeding-type picker — populated lazily by populateProceedingTypeSelect.
|
|
// Set the value here even if the options haven't arrived yet; the post-
|
|
// populate render runs ApplyProceedingTypeValue to re-select the saved id
|
|
// once the option exists.
|
|
const ptSel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
|
|
if (ptSel) {
|
|
const v = p.proceeding_type_id == null ? "" : String(p.proceeding_type_id);
|
|
ptSel.dataset.preselect = v;
|
|
ptSel.value = v;
|
|
}
|
|
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);
|
|
}
|