feat(t-paliad-056): allow type change in project edit modal with data-loss warning
Enables the type dropdown in /projects/{id} edit modal. Switching to a
new type clears the old type's specific columns server-side and emits a
project_type_changed audit event. The frontend surfaces an inline
warning naming the fields that will be NULL'd before the user saves.
Field map (kept in sync with services.typeSpecificColumns):
client → industry, country, client_number
patent → patent_number, filing_date, grant_date
case → court, case_number, proceeding_type_id
litigation/project → none
Server: PATCH /api/projects/{id} now accepts `type`. ProjectService.Update
collects the obsolete columns up-front and force-NULLs them at the end of
the SET list; per-field appendSet calls for those columns are skipped so
Postgres' "no duplicate column in UPDATE" rule isn't tripped (and the
clear wins regardless of what the client sent). Audit event description
records old → new type slug.
Frontend: openEditModal no longer disables projekt-type. A new
renderTypeChangeWarning() computes the lost-fields list from the loaded
project record and shows it above Save when the selection diverges from
the current type. Empty when nothing would be cleared.
No DB hierarchy CHECK constraint exists on parent/child types, so type
changes don't risk schema violations on existing children. Tree
inheritance rules are not enforced on edit (matching create behaviour).
This commit is contained in:
@@ -886,8 +886,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projekte.field.grant_date": "Erteilungstag",
|
||||
"projekte.field.court": "Gericht",
|
||||
"projekte.field.case_number": "Aktenzeichen (Gericht)",
|
||||
"projekte.field.proceeding_type_id": "Verfahrensart",
|
||||
"projekte.field.status": "Status",
|
||||
"projekte.error.title_required": "Titel erforderlich",
|
||||
"projekte.detail.edit.type_change_warning.title": "Diese Felder werden geleert:",
|
||||
"projekte.detail.title": "Projekt \u2014 Paliad",
|
||||
"projekte.detail.back": "\u2190 Zur\u00fcck zur \u00dcbersicht",
|
||||
"projekte.detail.loading": "L\u00e4dt\u2026",
|
||||
@@ -2123,8 +2125,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projekte.field.grant_date": "Grant date",
|
||||
"projekte.field.court": "Court",
|
||||
"projekte.field.case_number": "Case number (court)",
|
||||
"projekte.field.proceeding_type_id": "Proceeding type",
|
||||
"projekte.field.status": "Status",
|
||||
"projekte.error.title_required": "Title required",
|
||||
"projekte.detail.edit.type_change_warning.title": "These fields will be cleared:",
|
||||
"projekte.detail.title": "Project \u2014 Paliad",
|
||||
"projekte.detail.back": "\u2190 Back to overview",
|
||||
"projekte.detail.loading": "Loading\u2026",
|
||||
|
||||
@@ -769,20 +769,84 @@ function openEditModal() {
|
||||
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.
|
||||
// Re-parenting is out of scope for the edit modal — disable the picker.
|
||||
if (parentInput) parentInput.disabled = true;
|
||||
// Type changes are allowed (t-paliad-056). Wire the warning that lists
|
||||
// which fields will be NULL'd server-side when the user picks a new
|
||||
// type.
|
||||
const typeSel = document.getElementById("projekt-type") as HTMLSelectElement | null;
|
||||
if (typeSel) {
|
||||
typeSel.disabled = false;
|
||||
typeSel.onchange = () => {
|
||||
// Keep the upstream visibility toggle that wireTypeChange installed.
|
||||
renderTypeChangeWarning();
|
||||
};
|
||||
}
|
||||
renderTypeChangeWarning();
|
||||
});
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
// renderTypeChangeWarning compares the type select's current value against
|
||||
// the loaded project's type. When they differ AND the old type has
|
||||
// non-NULL type-specific fields on the project record, it surfaces an
|
||||
// inline warning naming each field that will be cleared on save.
|
||||
//
|
||||
// Source of truth for the field map mirrors the server's
|
||||
// typeSpecificColumns helper. Keep them in sync.
|
||||
const TYPE_SPECIFIC_FIELDS: Record<string, { key: string; i18n: string }[]> = {
|
||||
client: [
|
||||
{ key: "industry", i18n: "projekte.field.industry" },
|
||||
{ key: "country", i18n: "projekte.field.country" },
|
||||
{ key: "client_number", i18n: "projekte.field.client_number" },
|
||||
],
|
||||
patent: [
|
||||
{ key: "patent_number", i18n: "projekte.field.patent_number" },
|
||||
{ key: "filing_date", i18n: "projekte.field.filing_date" },
|
||||
{ key: "grant_date", i18n: "projekte.field.grant_date" },
|
||||
],
|
||||
case: [
|
||||
{ key: "court", i18n: "projekte.field.court" },
|
||||
{ key: "case_number", i18n: "projekte.field.case_number" },
|
||||
{ key: "proceeding_type_id", i18n: "projekte.field.proceeding_type_id" },
|
||||
],
|
||||
};
|
||||
|
||||
function renderTypeChangeWarning() {
|
||||
const wrap = document.getElementById("project-edit-type-warning") as HTMLDivElement | null;
|
||||
const fieldsSpan = document.getElementById("project-edit-type-warning-fields") as HTMLSpanElement | null;
|
||||
const typeSel = document.getElementById("projekt-type") as HTMLSelectElement | null;
|
||||
if (!wrap || !fieldsSpan || !typeSel || !project) return;
|
||||
|
||||
const newType = typeSel.value;
|
||||
if (newType === project.type) {
|
||||
wrap.style.display = "none";
|
||||
fieldsSpan.textContent = "";
|
||||
return;
|
||||
}
|
||||
const obsolete = TYPE_SPECIFIC_FIELDS[project.type] || [];
|
||||
const projectRec = project as unknown as Record<string, unknown>;
|
||||
const lost = obsolete
|
||||
.filter((f) => {
|
||||
const v = projectRec[f.key];
|
||||
return v !== null && v !== undefined && v !== "";
|
||||
})
|
||||
.map((f) => t(f.i18n) || f.key);
|
||||
if (lost.length === 0) {
|
||||
wrap.style.display = "none";
|
||||
fieldsSpan.textContent = "";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
// Translate the static label too (it has data-i18n but the modal may have
|
||||
// opened before the lang change handler re-translated it).
|
||||
const titleEl = wrap.querySelector("strong");
|
||||
if (titleEl) titleEl.textContent = t("projekte.detail.edit.type_change_warning.title") || titleEl.textContent || "";
|
||||
fieldsSpan.textContent = " " + lost.join(", ");
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
const modal = document.getElementById("project-edit-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
|
||||
@@ -347,6 +347,18 @@ export function renderProjectsDetail(): string {
|
||||
<form id="project-edit-form" className="akten-form" autocomplete="off">
|
||||
<ProjectFormFields />
|
||||
|
||||
<div
|
||||
className="form-warn"
|
||||
id="project-edit-type-warning"
|
||||
role="alert"
|
||||
style="display:none"
|
||||
>
|
||||
<strong data-i18n="projekte.detail.edit.type_change_warning.title">
|
||||
Diese Felder werden geleert:
|
||||
</strong>
|
||||
<span id="project-edit-type-warning-fields" />
|
||||
</div>
|
||||
|
||||
<p className="form-msg" id="project-edit-msg" />
|
||||
|
||||
<div className="form-actions">
|
||||
|
||||
@@ -1996,6 +1996,24 @@ input[type="range"]::-moz-range-thumb {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Inline warning shown above Save in the project edit modal when changing
|
||||
the project type clears existing type-specific fields (t-paliad-056). */
|
||||
.form-warn {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid #f59e0b;
|
||||
background: #fffbeb;
|
||||
color: #78350f;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.form-warn strong {
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
/* --- Responsive: General --- */
|
||||
|
||||
/* --- Downloads --- */
|
||||
|
||||
Reference in New Issue
Block a user