Merge: edit-project type change with data-loss warning (t-paliad-056)
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 --- */
|
||||
|
||||
@@ -115,6 +115,7 @@ type CreateProjektInput struct {
|
||||
|
||||
// UpdateProjektInput is the partial-update payload.
|
||||
type UpdateProjektInput struct {
|
||||
Type *string `json:"type,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Reference *string `json:"reference,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
@@ -500,6 +501,23 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
}
|
||||
}
|
||||
|
||||
// Type change: validate up-front and collect the columns that were
|
||||
// specific to the old type. Those get force-NULL'd at the end of the SET
|
||||
// list and the per-field appendSet calls below skip them — Postgres
|
||||
// rejects duplicate column assignments in a single UPDATE, and the
|
||||
// type-change clear has to win regardless of what the client sent.
|
||||
typeChanged := false
|
||||
clearOnTypeChange := map[string]bool{}
|
||||
if input.Type != nil && *input.Type != current.Type {
|
||||
if !isValidProjectType(*input.Type) {
|
||||
return nil, fmt.Errorf("%w: invalid type %q", ErrInvalidInput, *input.Type)
|
||||
}
|
||||
for _, col := range typeSpecificColumns(current.Type) {
|
||||
clearOnTypeChange[col] = true
|
||||
}
|
||||
typeChanged = true
|
||||
}
|
||||
|
||||
sets := []string{}
|
||||
args := []any{}
|
||||
next := 1
|
||||
@@ -508,7 +526,18 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
args = append(args, val)
|
||||
next++
|
||||
}
|
||||
// appendSetSkippable is for per-field user input that must yield to the
|
||||
// type-change clear when the column is being forced to NULL.
|
||||
appendSetSkippable := func(col string, val any) {
|
||||
if clearOnTypeChange[col] {
|
||||
return
|
||||
}
|
||||
appendSet(col, val)
|
||||
}
|
||||
|
||||
if typeChanged {
|
||||
appendSet("type", *input.Type)
|
||||
}
|
||||
if input.Title != nil {
|
||||
t := strings.TrimSpace(*input.Title)
|
||||
if t == "" {
|
||||
@@ -532,16 +561,16 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
appendSet("parent_id", *input.ParentID)
|
||||
}
|
||||
if input.Industry != nil {
|
||||
appendSet("industry", *input.Industry)
|
||||
appendSetSkippable("industry", *input.Industry)
|
||||
}
|
||||
if input.Country != nil {
|
||||
appendSet("country", *input.Country)
|
||||
appendSetSkippable("country", *input.Country)
|
||||
}
|
||||
if input.BillingReference != nil {
|
||||
appendSet("billing_reference", *input.BillingReference)
|
||||
}
|
||||
if input.ClientNumber != nil {
|
||||
appendSet("client_number", *input.ClientNumber)
|
||||
appendSetSkippable("client_number", *input.ClientNumber)
|
||||
}
|
||||
if input.MatterNumber != nil {
|
||||
appendSet("matter_number", *input.MatterNumber)
|
||||
@@ -550,22 +579,27 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
appendSet("netdocuments_url", *input.NetDocumentsURL)
|
||||
}
|
||||
if input.PatentNumber != nil {
|
||||
appendSet("patent_number", *input.PatentNumber)
|
||||
appendSetSkippable("patent_number", *input.PatentNumber)
|
||||
}
|
||||
if input.FilingDate != nil {
|
||||
appendSet("filing_date", *input.FilingDate)
|
||||
appendSetSkippable("filing_date", *input.FilingDate)
|
||||
}
|
||||
if input.GrantDate != nil {
|
||||
appendSet("grant_date", *input.GrantDate)
|
||||
appendSetSkippable("grant_date", *input.GrantDate)
|
||||
}
|
||||
if input.Court != nil {
|
||||
appendSet("court", *input.Court)
|
||||
appendSetSkippable("court", *input.Court)
|
||||
}
|
||||
if input.CaseNumber != nil {
|
||||
appendSet("case_number", *input.CaseNumber)
|
||||
appendSetSkippable("case_number", *input.CaseNumber)
|
||||
}
|
||||
if input.ProceedingTypeID != nil {
|
||||
appendSet("proceeding_type_id", *input.ProceedingTypeID)
|
||||
appendSetSkippable("proceeding_type_id", *input.ProceedingTypeID)
|
||||
}
|
||||
if typeChanged {
|
||||
for _, col := range typeSpecificColumns(current.Type) {
|
||||
appendSet(col, nil)
|
||||
}
|
||||
}
|
||||
if len(sets) == 0 {
|
||||
return current, nil
|
||||
@@ -593,6 +627,13 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if typeChanged {
|
||||
desc := fmt.Sprintf("Type %s → %s", current.Type, *input.Type)
|
||||
descPtr := &desc
|
||||
if err := insertProjectEvent(ctx, tx, id, userID, "project_type_changed", "Project type changed", descPtr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if input.ParentID != nil {
|
||||
if err := insertProjectEvent(ctx, tx, id, userID, "project_reparented", "Project re-parented", nil); err != nil {
|
||||
return nil, err
|
||||
@@ -770,6 +811,22 @@ func insertProjectEvent(ctx context.Context, tx *sqlx.Tx, projektID, userID uuid
|
||||
return nil
|
||||
}
|
||||
|
||||
// typeSpecificColumns returns the DB columns that only make sense for the
|
||||
// given project type. When a project's type changes away from `t`, callers
|
||||
// NULL these columns so the row doesn't carry stale data from the old type.
|
||||
// Litigation/project have no specific columns.
|
||||
func typeSpecificColumns(t string) []string {
|
||||
switch t {
|
||||
case ProjectTypeClient:
|
||||
return []string{"industry", "country", "client_number"}
|
||||
case ProjectTypePatent:
|
||||
return []string{"patent_number", "filing_date", "grant_date"}
|
||||
case ProjectTypeCase:
|
||||
return []string{"court", "case_number", "proceeding_type_id"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidProjectType(t string) bool {
|
||||
switch t {
|
||||
case ProjectTypeClient, ProjectTypeLitigation, ProjectTypePatent,
|
||||
|
||||
Reference in New Issue
Block a user