diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 262a9dc..b0c513c 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -886,8 +886,10 @@ const translations: Record> = { "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> = { "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", diff --git a/frontend/src/client/projects-detail.ts b/frontend/src/client/projects-detail.ts index d042b03..c4279ad 100644 --- a/frontend/src/client/projects-detail.ts +++ b/frontend/src/client/projects-detail.ts @@ -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 = { + 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; + 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"; diff --git a/frontend/src/projects-detail.tsx b/frontend/src/projects-detail.tsx index 904a8e6..fc39218 100644 --- a/frontend/src/projects-detail.tsx +++ b/frontend/src/projects-detail.tsx @@ -347,6 +347,18 @@ export function renderProjectsDetail(): string {
+ +

diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 99911f0..40ebfb7 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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 --- */ diff --git a/internal/services/project_service.go b/internal/services/project_service.go index 38330c8..e499f09 100644 --- a/internal/services/project_service.go +++ b/internal/services/project_service.go @@ -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,