Merge: t-paliad-143 — derived members per-unit role + multi-unit Herkunft (admin UI dropdown + array_agg in DerivationService)

This commit is contained in:
m
2026-05-06 17:17:05 +02:00
7 changed files with 266 additions and 54 deletions

View File

@@ -1,4 +1,4 @@
import { initI18n, onLangChange, t, getLang } from "./i18n"; import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar"; import { initSidebar } from "./sidebar";
interface PartnerUnit { interface PartnerUnit {
@@ -16,8 +16,11 @@ interface Member {
display_name: string; display_name: string;
office: string; office: string;
job_title: string | null; job_title: string | null;
unit_role: string;
} }
const UNIT_ROLES = ["lead", "attorney", "senior_pa", "pa", "paralegal"] as const;
interface PartnerUnitWithMembers extends PartnerUnit { interface PartnerUnitWithMembers extends PartnerUnit {
lead_display_name?: string; lead_display_name?: string;
lead_email?: string; lead_email?: string;
@@ -284,16 +287,54 @@ function renderMemberList(): void {
return; return;
} }
list.innerHTML = u.members list.innerHTML = u.members
.map( .map((m) => {
(m) => `<li class="partner-unit-member-item"> const roleOptions = UNIT_ROLES.map((r) => {
const label = tDyn(`unit_role.${r}`) || r;
const sel = m.unit_role === r ? " selected" : "";
return `<option value="${esc(r)}"${sel}>${esc(label)}</option>`;
}).join("");
return `<li class="partner-unit-member-item">
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span> <span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button> <span class="partner-unit-member-actions">
</li>`, <select class="pu-role-select" data-user="${esc(m.user_id)}" aria-label="${escAttr(tDyn("admin.partner_units.member.role") || "Rolle")}">${roleOptions}</select>
) <button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
</span>
</li>`;
})
.join(""); .join("");
list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) => list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) =>
b.addEventListener("click", () => removeMember(b.dataset.user!)), b.addEventListener("click", () => removeMember(b.dataset.user!)),
); );
list.querySelectorAll<HTMLSelectElement>(".pu-role-select").forEach((s) =>
s.addEventListener("change", () => setMemberRole(s.dataset.user!, s.value, s)),
);
}
async function setMemberRole(userID: string, role: string, sel: HTMLSelectElement): Promise<void> {
if (!activeUnitID) return;
// Snapshot the prior selection so we can roll back on failure.
const u = units.find((x) => x.id === activeUnitID);
const prior = u?.members.find((m) => m.user_id === userID)?.unit_role;
sel.disabled = true;
const resp = await fetch(
`/api/partner-units/${activeUnitID}/members/${userID}/role`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ unit_role: role }),
},
);
sel.disabled = false;
if (!resp.ok) {
if (prior !== undefined) sel.value = prior;
const body = await resp.json().catch(() => ({ error: resp.statusText }));
showFeedback(body.error || "Rolle konnte nicht gespeichert werden.", true);
return;
}
await loadUnits();
renderMemberList();
render();
showFeedback(tDyn("admin.partner_units.feedback.role_updated") || "Rolle aktualisiert.", false);
} }
function wireSuggestions(): void { function wireSuggestions(): void {

View File

@@ -1544,6 +1544,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.partner_units.feedback.created": "Angelegt.", "admin.partner_units.feedback.created": "Angelegt.",
"admin.partner_units.feedback.updated": "Aktualisiert.", "admin.partner_units.feedback.updated": "Aktualisiert.",
"admin.partner_units.feedback.deleted": "Gelöscht.", "admin.partner_units.feedback.deleted": "Gelöscht.",
"admin.partner_units.feedback.role_updated": "Rolle aktualisiert.",
"admin.partner_units.member.heading": "Mitglieder verwalten", "admin.partner_units.member.heading": "Mitglieder verwalten",
"admin.partner_units.member.empty": "Noch keine Mitglieder.", "admin.partner_units.member.empty": "Noch keine Mitglieder.",
"admin.partner_units.member.add": "Mitglied hinzufügen", "admin.partner_units.member.add": "Mitglied hinzufügen",
@@ -1551,6 +1552,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.partner_units.member.remove": "Entfernen", "admin.partner_units.member.remove": "Entfernen",
"admin.partner_units.member.confirm_remove": "Mitglied entfernen?", "admin.partner_units.member.confirm_remove": "Mitglied entfernen?",
"admin.partner_units.member.placeholder": "Name oder E-Mail", "admin.partner_units.member.placeholder": "Name oder E-Mail",
"admin.partner_units.member.role": "Rolle",
"admin.audit.loading": "Lade…", "admin.audit.loading": "Lade…",
"admin.audit.empty": "Keine Ereignisse für die gewählten Filter.", "admin.audit.empty": "Keine Ereignisse für die gewählten Filter.",
"admin.audit.loadmore": "Weitere laden", "admin.audit.loadmore": "Weitere laden",
@@ -3255,6 +3257,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.partner_units.feedback.created": "Created.", "admin.partner_units.feedback.created": "Created.",
"admin.partner_units.feedback.updated": "Updated.", "admin.partner_units.feedback.updated": "Updated.",
"admin.partner_units.feedback.deleted": "Deleted.", "admin.partner_units.feedback.deleted": "Deleted.",
"admin.partner_units.feedback.role_updated": "Role updated.",
"admin.partner_units.member.heading": "Manage members", "admin.partner_units.member.heading": "Manage members",
"admin.partner_units.member.empty": "No members yet.", "admin.partner_units.member.empty": "No members yet.",
"admin.partner_units.member.add": "Add member", "admin.partner_units.member.add": "Add member",
@@ -3262,6 +3265,7 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.partner_units.member.remove": "Remove", "admin.partner_units.member.remove": "Remove",
"admin.partner_units.member.confirm_remove": "Remove member?", "admin.partner_units.member.confirm_remove": "Remove member?",
"admin.partner_units.member.placeholder": "Name or email", "admin.partner_units.member.placeholder": "Name or email",
"admin.partner_units.member.role": "Role",
"admin.audit.loading": "Loading…", "admin.audit.loading": "Loading…",
"admin.audit.empty": "No events match the selected filters.", "admin.audit.empty": "No events match the selected filters.",
"admin.audit.loadmore": "Load more", "admin.audit.loadmore": "Load more",

View File

@@ -47,14 +47,21 @@ interface ProjectTeamMember {
} }
// t-paliad-139 — derived team member from a partner-unit attachment. // t-paliad-139 — derived team member from a partner-unit attachment.
// One DerivedMember per user; users in multiple attached units carry one
// DerivedMembership per (unit, role) pair so the Herkunft column can list
// every source (t-paliad-143).
interface DerivedMembership {
unit_id: string;
unit_name: string;
unit_role: string;
}
interface DerivedMember { interface DerivedMember {
user_id: string; user_id: string;
user_email: string; user_email: string;
user_display_name: string; user_display_name: string;
user_office: string; user_office: string;
unit_role: string; memberships: DerivedMembership[];
unit_id: string;
unit_name: string;
derive_grants_authority: boolean; derive_grants_authority: boolean;
} }
@@ -1736,7 +1743,25 @@ function renderDerivedMembers() {
section.style.display = ""; section.style.display = "";
body.innerHTML = derivedMembers body.innerHTML = derivedMembers
.map((m) => { .map((m) => {
const roleLabel = tDyn(`unit_role.${m.unit_role}`) || m.unit_role; const memberships = m.memberships || [];
// Role column shows distinct unit_role values (usually one — only
// diverges if the user has different roles in different units).
const distinctRoles = Array.from(new Set(memberships.map((x) => x.unit_role)));
const roleLabel = distinctRoles
.map((r) => tDyn(`unit_role.${r}`) || r)
.join(", ");
// Herkunft column lists every (unit, role) pair so multi-unit users
// surface all their sources, not just the closest one (t-paliad-143).
// Multi-unit: bold each unit name and append the role in parentheses.
// Single-unit: bold the one unit name (matches the legacy rendering).
const sourceLabel = memberships
.map((x) => {
const name = `<strong>${esc(x.unit_name)}</strong>`;
if (memberships.length === 1) return name;
const role = esc(tDyn(`unit_role.${x.unit_role}`) || x.unit_role);
return `${name} (${role})`;
})
.join(", ");
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : ""; const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
const authBadge = m.derive_grants_authority const authBadge = m.derive_grants_authority
? `<span class="derived-badge derived-badge--authority" title="${escAttr(t("projects.team.derived.authority.hint") || "Authority granted")}">${esc(t("projects.team.derived.authority") || "Sicht & 4-Augen")}</span>` ? `<span class="derived-badge derived-badge--authority" title="${escAttr(t("projects.team.derived.authority.hint") || "Authority granted")}">${esc(t("projects.team.derived.authority") || "Sicht & 4-Augen")}</span>`
@@ -1745,7 +1770,7 @@ function renderDerivedMembers() {
<td><strong>${esc(m.user_display_name || m.user_email)}</strong> <td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td> <span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td> <td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
<td>${esc(t("projects.team.derived.from") || "über")}: <strong>${esc(m.unit_name)}</strong> ${authBadge}</td> <td>${esc(t("projects.team.derived.from") || "über")}: ${sourceLabel} ${authBadge}</td>
</tr>`; </tr>`;
}) })
.join(""); .join("");

View File

@@ -167,6 +167,7 @@ export type I18nKey =
| "admin.partner_units.error.user_required" | "admin.partner_units.error.user_required"
| "admin.partner_units.feedback.created" | "admin.partner_units.feedback.created"
| "admin.partner_units.feedback.deleted" | "admin.partner_units.feedback.deleted"
| "admin.partner_units.feedback.role_updated"
| "admin.partner_units.feedback.updated" | "admin.partner_units.feedback.updated"
| "admin.partner_units.heading" | "admin.partner_units.heading"
| "admin.partner_units.loading" | "admin.partner_units.loading"
@@ -177,6 +178,7 @@ export type I18nKey =
| "admin.partner_units.member.heading" | "admin.partner_units.member.heading"
| "admin.partner_units.member.placeholder" | "admin.partner_units.member.placeholder"
| "admin.partner_units.member.remove" | "admin.partner_units.member.remove"
| "admin.partner_units.member.role"
| "admin.partner_units.new" | "admin.partner_units.new"
| "admin.partner_units.new.heading" | "admin.partner_units.new.heading"
| "admin.partner_units.subtitle" | "admin.partner_units.subtitle"

View File

@@ -8911,6 +8911,44 @@ dialog.quick-add-sheet::backdrop {
background: var(--color-bg-lime-tint); background: var(--color-bg-lime-tint);
} }
/* /admin/partner-units member modal — list of (display_name, role-select,
remove) rows. The role-select is wired to PATCH …/members/{user}/role
(t-paliad-143). */
.partner-unit-member-list {
list-style: none;
margin: 0 0 1rem 0;
padding: 0;
}
.partner-unit-member-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.4rem 0;
border-bottom: 1px solid var(--color-border);
}
.partner-unit-member-item:last-child {
border-bottom: none;
}
.partner-unit-member-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.pu-role-select {
padding: 0.25rem 0.4rem;
font-size: 0.82rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
}
.admin-team-input { .admin-team-input {
width: 100%; width: 100%;
padding: 0.3rem 0.45rem; padding: 0.3rem 0.45rem;

View File

@@ -0,0 +1,71 @@
package services
import (
"testing"
"github.com/google/uuid"
)
// TestDerivedMembershipListScan covers the sql.Scanner over a Postgres
// jsonb column — the wire format that ListDerivedMembers' jsonb_agg
// returns. Pinned because if a future migration changes the JSON shape
// (e.g. drops a key), the rendered Herkunft column on /projects/{id}
// silently breaks (t-paliad-143).
func TestDerivedMembershipListScan(t *testing.T) {
unitA := uuid.New()
unitB := uuid.New()
cases := []struct {
name string
src any
want []DerivedMembership
}{
{
name: "nil",
src: nil,
want: nil,
},
{
name: "single membership as bytes",
src: []byte(`[{"unit_id":"` + unitA.String() + `","unit_name":"Lehment","unit_role":"attorney"}]`),
want: []DerivedMembership{{UnitID: unitA, UnitName: "Lehment", UnitRole: "attorney"}},
},
{
name: "two memberships as string",
src: `[
{"unit_id":"` + unitA.String() + `","unit_name":"Lehment","unit_role":"attorney"},
{"unit_id":"` + unitB.String() + `","unit_name":"Plassmann","unit_role":"pa"}
]`,
want: []DerivedMembership{
{UnitID: unitA, UnitName: "Lehment", UnitRole: "attorney"},
{UnitID: unitB, UnitName: "Plassmann", UnitRole: "pa"},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got DerivedMembershipList
if err := got.Scan(tc.src); err != nil {
t.Fatalf("Scan: %v", err)
}
if len(got) != len(tc.want) {
t.Fatalf("len: got %d want %d", len(got), len(tc.want))
}
for i := range got {
if got[i] != tc.want[i] {
t.Errorf("row %d: got %+v want %+v", i, got[i], tc.want[i])
}
}
})
}
}
// TestDerivedMembershipListScanRejectsUnknown ensures we don't silently
// accept random column types and produce an empty list (which would mask
// a schema regression).
func TestDerivedMembershipListScanRejectsUnknown(t *testing.T) {
var l DerivedMembershipList
if err := l.Scan(123); err == nil {
t.Fatal("expected error scanning int into DerivedMembershipList, got nil")
}
}

View File

@@ -14,6 +14,7 @@ package services
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -49,18 +50,49 @@ type AttachedUnit struct {
DerivedMemberCount int `db:"derived_member_count" json:"derived_member_count"` DerivedMemberCount int `db:"derived_member_count" json:"derived_member_count"`
} }
// DerivedMember is one user who currently derives onto a project via an // DerivedMembership is one (unit, role) pair through which a user currently
// attached partner unit. Used by the Team tab "Abgeleitet (Partner Unit)" // derives onto a project. A multi-unit user has one DerivedMembership per
// subsection. // unit they belong to that's attached to the project (or one of its
// ancestors) AND whose unit_role is in the attachment's derive_unit_roles.
type DerivedMembership struct {
UnitID uuid.UUID `json:"unit_id"`
UnitName string `json:"unit_name"`
UnitRole string `json:"unit_role"`
}
// DerivedMembershipList is a []DerivedMembership that scans from a Postgres
// jsonb column (the array_agg/jsonb_agg payload in ListDerivedMembers).
type DerivedMembershipList []DerivedMembership
// Scan implements sql.Scanner over a jsonb array.
func (l *DerivedMembershipList) Scan(src any) error {
if src == nil {
*l = nil
return nil
}
var raw []byte
switch v := src.(type) {
case []byte:
raw = v
case string:
raw = []byte(v)
default:
return fmt.Errorf("DerivedMembershipList.Scan: unsupported type %T", src)
}
return json.Unmarshal(raw, (*[]DerivedMembership)(l))
}
// DerivedMember is one user who currently derives onto a project. The user
// may derive via multiple units (e.g. a PA who works with two partners);
// each is one entry in Memberships. DeriveGrantsAuthority is true if any
// of the source attachments have authority enabled.
type DerivedMember struct { type DerivedMember struct {
UserID uuid.UUID `db:"user_id" json:"user_id"` UserID uuid.UUID `db:"user_id" json:"user_id"`
Email string `db:"email" json:"user_email"` Email string `db:"email" json:"user_email"`
DisplayName string `db:"display_name" json:"user_display_name"` DisplayName string `db:"display_name" json:"user_display_name"`
Office string `db:"office" json:"user_office"` Office string `db:"office" json:"user_office"`
UnitRole string `db:"unit_role" json:"unit_role"` Memberships DerivedMembershipList `db:"memberships" json:"memberships"`
UnitID uuid.UUID `db:"unit_id" json:"unit_id"` DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
UnitName string `db:"unit_name" json:"unit_name"`
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
} }
// AttachUnitOptions controls how a unit is attached. Empty values use the // AttachUnitOptions controls how a unit is attached. Empty values use the
@@ -190,9 +222,12 @@ func (s *DerivationService) ListAttachedUnits(ctx context.Context, callerID, pro
// down to descendants — derivation honours the same direction as // down to descendants — derivation honours the same direction as
// can_see_project. // can_see_project.
// //
// Dedupe: if the same user derives via multiple (unit, ancestor) pairs, // One row per user. Multi-unit users (e.g. a PA working across two partner
// the closest-attachment row wins (smallest path-distance). One row per // units, both of which are attached to the project's path) carry every
// user. // (unit, role) pair in Memberships so the Herkunft column can list them
// all (t-paliad-143). DeriveGrantsAuthority is bool_or across the
// underlying attachments — a user with at least one authority-granting
// derivation source qualifies as authority-bearing for approval purposes.
func (s *DerivationService) ListDerivedMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]DerivedMember, error) { func (s *DerivationService) ListDerivedMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]DerivedMember, error) {
project, err := s.projects.GetByID(ctx, callerID, projectID) project, err := s.projects.GetByID(ctx, callerID, projectID)
if err != nil { if err != nil {
@@ -209,44 +244,40 @@ func (s *DerivationService) ListDerivedMembers(ctx context.Context, callerID, pr
SELECT ppu.project_id AS attach_project_id, SELECT ppu.project_id AS attach_project_id,
ppu.partner_unit_id, ppu.partner_unit_id,
ppu.derive_unit_roles, ppu.derive_unit_roles,
ppu.derive_grants_authority, ppu.derive_grants_authority
array_position($1::uuid[], ppu.project_id) AS depth_rank
FROM paliad.project_partner_units ppu FROM paliad.project_partner_units ppu
WHERE ppu.project_id = ANY($1::uuid[]) WHERE ppu.project_id = ANY($1::uuid[])
),
candidate AS (
SELECT pum.user_id, pum.unit_role,
a.partner_unit_id, a.derive_grants_authority, a.depth_rank
FROM attached a
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = a.partner_unit_id
WHERE pum.unit_role = ANY(a.derive_unit_roles)
),
ranked AS (
SELECT c.*,
ROW_NUMBER() OVER (
PARTITION BY c.user_id
-- Closest attachment wins: highest depth_rank in path
-- (path is root→…→self, array_position returns 1-based,
-- so larger = nearer to self).
ORDER BY c.depth_rank DESC NULLS LAST
) AS rn
FROM candidate c
) )
SELECT r.user_id, SELECT pum.user_id,
u.email, u.display_name, u.office, u.email, u.display_name, u.office,
r.unit_role, jsonb_agg(DISTINCT jsonb_build_object(
r.partner_unit_id AS unit_id, 'unit_id', a.partner_unit_id,
pu.name AS unit_name, 'unit_name', pu.name,
r.derive_grants_authority 'unit_role', pum.unit_role
FROM ranked r )) AS memberships,
JOIN paliad.users u ON u.id = r.user_id bool_or(a.derive_grants_authority) AS derive_grants_authority
JOIN paliad.partner_units pu ON pu.id = r.partner_unit_id FROM attached a
WHERE r.rn = 1 JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = a.partner_unit_id
JOIN paliad.users u ON u.id = pum.user_id
JOIN paliad.partner_units pu ON pu.id = a.partner_unit_id
WHERE pum.unit_role = ANY(a.derive_unit_roles)
GROUP BY pum.user_id, u.email, u.display_name, u.office
ORDER BY u.display_name`, ORDER BY u.display_name`,
pq.StringArray(ancestorIDs)) pq.StringArray(ancestorIDs))
if err != nil { if err != nil {
return nil, fmt.Errorf("list derived members: %w", err) return nil, fmt.Errorf("list derived members: %w", err)
} }
// jsonb_agg(DISTINCT …) doesn't support ORDER BY in the same call.
// Sort each member's memberships by unit_name in Go so the Herkunft
// column renders deterministically.
for i := range rows {
ms := rows[i].Memberships
for j := 1; j < len(ms); j++ {
for k := j; k > 0 && ms[k-1].UnitName > ms[k].UnitName; k-- {
ms[k-1], ms[k] = ms[k], ms[k-1]
}
}
}
return rows, nil return rows, nil
} }