diff --git a/cmd/server/main.go b/cmd/server/main.go index 7e8343c..68079e4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -157,7 +157,14 @@ func main() { EmailTemplate: emailTemplateSvc, Link: services.NewLinkService(pool), Event: services.NewEventService(pool, deadlineSvc, appointmentSvc), + Approval: services.NewApprovalService(pool, users), } + // Wire ApprovalService into the entity services so Create / Update / + // Complete / Delete consult paliad.approval_policies (t-paliad-138). + // Without this wiring, the policies and request tables exist but no + // mutation path consults them — paliad behaves as before. + deadlineSvc.SetApprovalService(svcBundle.Approval) + appointmentSvc.SetApprovalService(svcBundle.Approval) // v3 (t-paliad-133): wire EventCategoryService and cross-link // it into DeadlineSearchService so ?event_category_slug= can // resolve to a concept-id allow-list during search. diff --git a/docs/design-approvals-2026-05-06.md b/docs/design-approvals-2026-05-06.md new file mode 100644 index 0000000..25ab1ea --- /dev/null +++ b/docs/design-approvals-2026-05-06.md @@ -0,0 +1,828 @@ +# Design — Dual-control approvals (4-Augen-Prüfung) for Deadlines + Appointments + +**Author:** cronus (inventor) +**Date:** 2026-05-06 +**Task:** t-paliad-138 (Gitea m/paliad#3) +**Branch:** `mai/cronus/inventor-dual-control` +**Status:** DESIGN READY FOR REVIEW. Awaiting m go/no-go before any coder shift. + +--- + +## 0. TL;DR + +Add a 4-eye principle to `paliad.deadlines` and `paliad.appointments`. Every state-changing action (create / update-of-date-fields / complete / delete) submitted by one team member must be signed off by a qualified second team member from the same project before the change is "approved". + +Six locked design decisions from m (2026-05-06): + +| # | Question | Locked answer | +|---|---|---| +| Q1 | Where does the qualification level live? | **Reuse `project_teams.role` per-project** (no new firm-wide column). New value `senior_pa` added to the role enum. | +| Q1+ | Strict-ladder default? | **Default approval-eligible = {lead, associate}**. Per-project / per-event setting can extend to `senior_pa` or `pa` (so PAs can approve other PAs in some projects). | +| Q2 | Hierarchy semantics | **Strict ladder.** Higher level always satisfies lower. | +| Q3 | Policy granularity | **Per-(project, entity_type, lifecycle_event)** \— up to 8 settable rows per project. | +| Q4 | Edit-trigger fields | **Only date-changing fields.** Deadline: `due_date`, `original_due_date`, `warning_date`. Appointment: `start_at`, `end_at`. All other field changes bypass approval. | +| Q5 | Pending-state architecture | **Write-then-approve.** Field changes apply immediately; the entity carries `approval_status='pending'` until an approver flips it. (Delete is the one exception — see §5.4.) | +| Q6 | Inbox surface | **Bell icon (sidebar header) + dedicated `/inbox` page** with two tabs: "Zur Genehmigung" / "Meine Anfragen". | +| Q7 | Revocation | **Pending-only revoke.** After approval, only path back is a new request. | +| Q8 | Single-qualified-approver deadlock | **Refuse + global_admin override.** UI refuses with "Kein qualifizierter Approver verfügbar"; global_admin can manually approve as override (audit-marked). | +| Q9 | Audit / chronology | **Both** \— operational `paliad.approval_requests` table + new event types in `paliad.project_events`. Both creator and approver names persist on the entity row. | +| Q10 | RLS | **Visible to project team, action gated by service.** Same `can_see_project()` predicate; service layer checks "caller has required role tier AND caller_id != requested_by". | +| Q11 | Migration of existing rows | **Mark legacy + skip backfill.** All existing rows get `approval_status='legacy'`. New lifecycle events on legacy rows trigger normal approval flow. | + +Plus m's explicit interjection: **pending state must be visualised everywhere the entity normally surfaces** — list views, agenda, dashboard traffic-light, project detail, CalDAV-synced calendars, and email reminders. Silence on a pending change creates more risk than visible-but-flagged-pending. + +Out of scope for v1: notes, parties, documents, checklists; cross-app generalisation; multi-step n-of-m chains; email/WhatsApp/Telegram approvals (in-app only). + +--- + +## 1. Context — what's already in the code + +What this design slots into: + +- **Three-axis principle (m, t-paliad-051, sacrosanct).** "Firm roles ≠ project roles ≠ tool roles." + - `paliad.users.job_title` — free-text display. Never gates anything. + - `paliad.users.global_role` — `standard` | `global_admin`. Tool-admin gate only. + - `paliad.project_teams.role` — `lead | associate | pa | of_counsel | local_counsel | expert | observer`. Per-project membership role. +- **Visibility:** `paliad.can_see_project()` SQL function (migration 023) + Go mirror `services.visibilityPredicate()` — global_admin OR any team membership on the project's path. Service-role connection bypasses RLS, so the Go mirror is load-bearing; RLS is defense-in-depth. +- **Audit:** `paliad.project_events` (created in migration 005 as `akten_events`, renamed in 018). Every mutation on every project-scoped entity emits one row via `services.insertProjectEventWithMeta()` inside the same tx. Carries `event_type`, `title`, `description`, `metadata jsonb`, `created_by`, `event_date`. Read by `services.AuditService` and by the Verlauf card on each project / deadline / appointment detail page (t-paliad-097, t-paliad-102). +- **Entity tables:** `paliad.deadlines` and `paliad.appointments`. Both already carry `created_by uuid REFERENCES auth.users(id)`. Deadlines have `status text CHECK IN ('pending','completed','cancelled','waived')`. Appointments have no status column. +- **Service layer:** `DeadlineService.{Create,Update,Complete,Reopen,Delete}`, `AppointmentService.{Create,Update,Delete}`. Each goes through `ProjectService.GetByID(ctx, userID, projectID)` for visibility before mutating. Each emits its `*_created` / `*_updated` / `*_completed` / `*_deleted` event in the same tx. +- **Existing patterns this design reuses:** + - `paliad.partner_unit_events` audit table (migration 027) — proves the side-table-with-RLS shape works alongside `project_events`. + - `paliad.event_types` + `paliad.deadline_event_types` (migration 030) — the picker / multi-select / chip UI pattern is reusable for the "required role" select on the policy authoring page. + - `services.visibilityPredicate(alias)` — same shape for the new `approvalEligibleInProject(userID, projectID, requiredRole)` helper. + +This design adds **no new auth/permission axis**. It reuses `project_teams.role` for the qualification gate, per m's Q1 decision. The 3-axis principle holds because the gate uses the existing project axis, not a new firm-wide one. + +--- + +## 2. Approval ladder + +### 2.1 Strict ladder over `project_teams.role` + +``` +level | role | approval-eligible by default? +------+------------------+------------------------------- + 5 | lead | yes — partner-tier on this project + 4 | of_counsel | yes — senior tier + 3 | associate | yes ← default required level + 2 | senior_pa (new) | only if project policy lowers required to 'senior_pa' or below + 1 | pa | only if project policy lowers required to 'pa' + 0 | local_counsel | ineligible — external attorney, not in approval scope + 0 | expert | ineligible — technical witness role + 0 | observer | ineligible — read-only audit role +``` + +`senior_pa` is added to the `paliad.project_teams.role` CHECK constraint via migration 054 (see §6.1). It currently has no value in the enum. + +**Strict-ladder rule:** a user with project_teams.role `R` can approve any request whose `required_role` is at level ≤ `level(R)`. So: + +- Default `required_role = 'associate'` (level 3) → eligible approvers: lead, of_counsel, associate. +- Override to `required_role = 'senior_pa'` (level 2) → eligible: lead, of_counsel, associate, senior_pa. +- Override to `required_role = 'pa'` (level 1) → eligible: lead, of_counsel, associate, senior_pa, pa. This is the "PAs approve other PAs" mode m called for. +- Override to `required_role = 'lead'` → only the project lead can approve. + +**Hard rules:** + +1. **Self-approval is hard-blocked.** `caller_id = requested_by` always returns 403, regardless of role. This is enforced at the Go service layer (the only place that mutates approval state) and by a CHECK constraint on the row at decision time (`approved_by != requested_by`). +2. **Eligible level 0 = ineligible.** A user with role=local_counsel/expert/observer **cannot** approve any request, even if they're the only team member. They appear in the inbox with "Sie sind nicht qualifiziert" instead of the approve button. +3. **`global_admin` is an explicit override path** (§4.2) — not a normal approver. global_admin sign-off is allowed regardless of project_teams.role and audit-marked as `decision_kind='admin_override'`. + +### 2.2 Why not introduce a firm-wide qualification column? + +The issue listed candidates `partner / senior_attorney / attorney / senior_pa / pa / paralegal` and asked whether roles should be global, per-team, or per-project. m chose **per-project** (Q1 = "Reuse project_teams.role"). Rationale (mine, before m chose; reproduced for the record): + +A firm-wide rank column would have: +- Cleanly separated from `job_title` (display) and `global_role` (tool admin). +- Made authoring rules trivial — one column on `users`, one int compare. +- Worked even before a project's team was fully populated. + +But it would have: +- Added a 4th identity-axis to maintain (firm rank), violating the spirit of the three-axis principle even if the letter holds. +- Forced a firm-wide ladder onto a project context where seniority is already encoded — `lead` on a project IS the partner-tier on that project. +- Introduced the question "what if firm rank disagrees with project role" (a senior partner staffed as `observer` on a small case) without a clean answer. + +m's per-project choice is consistent with how the rest of paliad treats authority: the `lead` role on `project_teams` is the source of truth for "who is the partner running this case", and approvals naturally cluster around that. + +### 2.3 What about local_counsel / expert / observer? + +Default: ineligible to approve. Rationale: + +- **local_counsel** is an external attorney (Mitanwalt) — not always a firm employee, often outside the firm's approval chain. +- **expert** is a technical / scientific consultant role — not legally qualified to sign off on procedural deadlines. +- **observer** is explicitly a read-only role. + +**Escape hatch:** if a project genuinely wants its local_counsel to approve, the team admin can re-add them with `role='associate'` (or whatever tier is intended). The role on `project_teams` is a per-project assignment; the same human can be `local_counsel` on Project A and `associate` on Project B if that's the correct authority on each. + +**Out of scope (follow-up if needed):** a per-project list of "additional approval-eligible roles" that promotes local_counsel/expert into the eligible set without changing their primary project role. Probably not worth the complexity for the few cases where it'd matter. + +--- + +## 3. Policy grammar — `paliad.approval_policies` + +### 3.1 Schema + +```sql +CREATE TABLE paliad.approval_policies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE, + entity_type text NOT NULL CHECK (entity_type IN ('deadline','appointment')), + lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create','update','complete','delete')), + required_role text NOT NULL CHECK (required_role IN ('lead','of_counsel','associate','senior_pa','pa')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL, + UNIQUE (project_id, entity_type, lifecycle_event) +); + +CREATE INDEX approval_policies_project_idx ON paliad.approval_policies (project_id); +``` + +Design choices: + +- **Up to 8 rows per project.** `(deadline,create), (deadline,update), (deadline,complete), (deadline,delete), (appointment,create), (appointment,update), (appointment,complete), (appointment,delete)`. UNIQUE composite key enforces this. +- **No row = no approval needed for that event.** A project with zero policy rows is in the same operational state as today — no 4-eye anywhere. +- **`required_role` is a single value**, not a min-level int. Stored as text matching `project_teams.role` values; the strict ladder is applied in code (see `levelOf(role)` in §3.4). Storing the enum value (rather than an int level) keeps the row readable in `psql` and survives any future ladder reordering. +- **Appointment lifecycle includes `complete`**. Today appointments don't have a `completed_at` column or status field. We add one via migration 054 to give `appointment:complete` somewhere to land — see §6.4. (m may choose to defer this; if so, the policy CHECK can drop `complete` for `appointment` and the migration becomes lighter.) + +### 3.2 Inheritance + +**No automatic inheritance from parent project.** A child project (e.g. a single Verfahren under a Litigation parent) does NOT auto-inherit its parent's policy. Reasons: + +- Inheriting would silently change behaviour when projects are reparented (t-paliad-018 already has reparent semantics). +- Policy authoring per-Verfahren is the right default — different stages of a litigation may legitimately need different scrutiny. +- The path-walking logic for "find the closest ancestor with policy" adds complexity for marginal value. + +**UI affordance:** project detail → Settings → Approvals tab → "Aus Eltern-Projekt übernehmen" button copies the parent's 8 rows into this project. One-shot copy, no live link. Documented as a productivity shortcut. + +### 3.3 Authoring permission + +**v1: global_admin only.** Consistent with the existing /admin/team and /admin/partner-units pattern. Per-project leads cannot edit policy on their own projects in v1. + +**Reasoning:** approval policy is firm-governance-grade — getting it wrong loosens compliance. Concentrating in global_admin is safer for v1. Lifting to "project lead can edit policy on their project" is a one-line gate change. + +**Out of scope follow-up:** lead-can-edit-own-project-policy. File as t-paliad-139 if needed once the v1 ships. + +### 3.4 Service-layer helpers + +```go +// internal/services/approval_levels.go + +// levelOf maps a project_teams.role value to the strict-ladder level used +// for approval gating. Returns 0 (ineligible) for roles outside the +// approval ladder (local_counsel, expert, observer). +func levelOf(role string) int { + switch role { + case "lead": return 5 + case "of_counsel": return 4 + case "associate": return 3 + case "senior_pa": return 2 + case "pa": return 1 + default: return 0 // local_counsel, expert, observer, anything new + } +} + +// canApprove returns true iff: +// - caller is not the requester (self-approval blocked) +// - caller's project_teams.role on this project has level >= required level +// OR caller is global_admin (which is always allowed and audit-marked separately). +func (s *ApprovalService) canApprove(ctx, callerID, projectID, requiredRole string, requesterID uuid.UUID) (bool, kind string, err error) { + if callerID == requesterID { + return false, "", ErrSelfApprovalBlocked + } + user, err := s.users.GetByID(ctx, callerID) + if err != nil { return false, "", err } + if user.GlobalRole == "global_admin" { + return true, "admin_override", nil + } + membership, err := s.projects.MembershipFor(ctx, callerID, projectID) + if err != nil || membership == nil { + return false, "", nil // not on team, cannot approve + } + if levelOf(membership.Role) >= levelOf(requiredRole) { + return true, "peer", nil + } + return false, "", nil +} +``` + +`decision_kind` values: `peer` (normal in-team sign-off), `admin_override` (global_admin used override path). Stored on `approval_requests.decision_kind`. + +--- + +## 4. Lifecycle flow (write-then-approve) + +### 4.1 The four lifecycle events + +For each entity (deadline, appointment), four lifecycle events trigger an approval check: + +1. **create** — new row submitted by user. +2. **update** — change to one or more date-bearing fields (allowlist in §4.5). +3. **complete** — flip status from `pending` to `completed` on a deadline; flip new `completed_at` (see §6.4) on appointment. +4. **delete** — request to remove the row. + +### 4.2 Submission + +User clicks Save / Complete / Delete on the entity. The service layer: + +1. Looks up `paliad.approval_policies(project_id, entity_type, event)`. +2. **No row found:** apply mutation immediately (today's behaviour). `approval_status` defaults to `'approved'`. No request row written. Done. +3. **Row found:** apply mutation **except for delete** (see §4.3) and additionally: + - Set `approval_status = 'pending'` and `pending_request_id = ` on the entity row. + - Insert one `paliad.approval_requests` row with `lifecycle_event`, `pre_image jsonb` (a snapshot of the now-overwritten field values, used for revert on rejection — see §4.4), `payload jsonb` (echo of what was submitted, for audit), `requested_by = caller`, `requested_at = now()`, `required_role = policy.required_role`, `status = 'pending'`. + - Emit `paliad.project_events` row with `event_type = 'deadline_approval_requested'` (or `appointment_approval_requested`) carrying `metadata.approval_request_id = `. The Verlauf shows the lifecycle inline. + - All four writes happen in **one transaction** (entity update + request insert + event emit). +4. **Single-qualified-approver deadlock check.** Before committing, the service runs a count: how many users on this project's team have `levelOf(project_teams.role) >= levelOf(required_role) AND user_id != caller`? If 0, the submission **fails with HTTP 409** and a structured error: `{ "error": "no_qualified_approver", "required_role": "associate", "hint": "add_team_member_or_contact_admin" }`. Frontend translates to a user-facing dialog with two action buttons: "Mehr Team-Mitglieder hinzufügen" (jumps to project team page) and "Admin kontaktieren" (mailto link to global_admin emails). global_admin override is the escape hatch (§4.7). + +### 4.3 Delete is special — stage-then-write + +m's chosen architecture is write-then-approve, but delete cannot be applied immediately and reverted: a hard-delete is irrecoverable. + +**Resolution:** for `lifecycle_event = 'delete'`, the entity row stays in place. We set `approval_status = 'pending'` and link to an `approval_requests` row carrying `lifecycle_event = 'delete'`. The UI marks the row "Zur Löschung beantragt" (see §5.3). On approve: hard-delete the row in a tx (cascades clean up the FK from `approval_requests`). On reject: clear `approval_status` back to `'approved'` and `pending_request_id` to NULL. The deletion never happened. + +This is the one departure from pure write-then-approve. It's a write-then-approve from the user's perspective (they "submit a delete" and the entity behaves as if it's about to disappear) but at the data-layer it's stage-then-write for delete. Documented explicitly to avoid surprise. + +### 4.4 Approval / rejection + +Approver opens `/inbox`, picks a request, clicks Approve (or Reject with optional reason). + +**Approve:** + +1. Service-layer `canApprove(caller, project, request)` check (see §3.4). +2. If `decision_kind = 'peer'` or `'admin_override'`, set `approval_requests.status = 'approved'`, `decided_by = caller`, `decided_at = now()`, `decision_kind = …`. +3. Update entity row: `approval_status = 'approved'`, clear `pending_request_id`. Set `approved_by = caller`, `approved_at = now()`. +4. For `delete`: hard-delete the entity (cascade clears the request FK). +5. Emit `paliad.project_events` row with `event_type = 'deadline_approval_approved'` (or `appointment_approval_approved`) carrying `metadata.approval_request_id`, `metadata.decision_kind`. Verlauf line: "Frist X — Genehmigung erteilt von Bert · 2026-05-06". +6. Tx commits. + +**Reject:** + +1. Same `canApprove` check. +2. Set `approval_requests.status = 'rejected'`, `decided_by`, `decided_at`, `decision_note` (optional reason text from approver). +3. **Revert entity** — restore from `pre_image`: + - `create`: hard-delete the entity (it never should have been live). + - `update`: write `pre_image` field values back over the row. + - `complete`: revert deadline `status` to `'pending'`, NULL `completed_at`. Revert appointment `completed_at` to NULL (only meaningful once §6.4 lands). + - `delete`: clear `pending_request_id` and `approval_status`. Entity stays live as before. +4. Emit `paliad.project_events` row `event_type = 'deadline_approval_rejected'` (or appointment_) with `metadata.approval_request_id`, `metadata.decision_note`. Verlauf line: "Frist X — Genehmigung abgelehnt von Bert · 2026-05-06 — Grund: Datum noch nicht best." +5. Tx commits. + +### 4.5 Edit-trigger field allowlist (per Q4) + +The service layer only enters the approval-request flow when an `update` touches the date-bearing fields. All other edits apply immediately as `approval_status='approved'` writes — no request row, no pending state. + +**Deadlines — date-bearing (gates approval):** +- `due_date` +- `original_due_date` +- `warning_date` + +**Deadlines — bypass (no approval):** +- `title`, `description`, `notes` +- `rule_id`, `rule_code` (legal-basis citation — m chose to bypass; see Q4 trade-off below) +- `event_type_ids` (Typ tags via `paliad.deadline_event_types` junction) +- `status` other than via the `complete` lifecycle (e.g. cancel, waive — these are out of approval scope per the issue's "all four lifecycle events" framing, which lists complete but not cancel/waive) + +**Appointments — date-bearing (gates approval):** +- `start_at` +- `end_at` + +**Appointments — bypass (no approval):** +- `title`, `description` +- `location` (m's Q4 choice excludes location; documented trade-off below) +- `appointment_type` + +**Trade-off (m's call):** the looser allowlist means a wrongful change to `rule_code` (legal basis) or `location` (wrong courthouse) won't trigger 4-eye. m's reasoning is implicit but consistent: dates are the highest-stakes mistake category (missed deadline = malpractice exposure), and gating every metadata edit creates approval fatigue that makes approvers rubber-stamp. + +If the team finds this allowlist too loose in practice, the constants in `internal/services/approval_fields.go` (proposed location) are a one-PR widening — no schema change. + +### 4.6 Optimistic-concurrency / superseded requests + +Race scenario: User A submits an `update` request with `pre_image = {due_date: 2026-05-10}`. Before it's approved, user B submits another `update` with their own pre-image. Now there are two pending requests on the same row. + +**Rule:** a row can have at most one pending request at a time. The submission service-layer does: + +```sql +UPDATE paliad.deadlines + SET ...new field values..., approval_status = 'pending', pending_request_id = $newRequestID + WHERE id = $entityID + AND approval_status = 'approved' -- only mutate if currently clean +RETURNING id; +``` + +If the UPDATE returns 0 rows (because `approval_status != 'approved'`), the submission fails with HTTP 409 `{ "error": "concurrent_pending", "hint": "wait_for_existing_approval_or_revoke" }`. Frontend shows "Es liegt bereits eine Genehmigungsanfrage auf dieser Frist vor." + +Submitter has options: revoke their own pending (if they own it) and resubmit; or wait for the existing request to settle. + +### 4.7 Single-qualified-approver deadlock — global_admin override path + +Per Q8, the default behaviour is **refuse to submit** when no qualified approver other than the requester exists on the team. Submission is blocked at the API layer. + +**Override mechanism:** any `global_admin` (regardless of project membership) has the approval right. So if the user's team has nobody else qualified, the user can submit anyway IF the project has at least one global_admin who can approve. The submission service runs the deadlock check as: + +``` +SELECT COUNT(*) FROM paliad.project_teams pt + WHERE pt.project_id = $proj + AND pt.user_id <> $caller + AND pt.role IN (eligible roles for required_role) ++ +SELECT COUNT(*) FROM paliad.users u + WHERE u.global_role = 'global_admin' + AND u.id <> $caller +``` + +If sum > 0, submission is allowed. If sum = 0, the 409 fires. In practice, paliad currently has 2 global_admins so sum is rarely 0 — but the design contemplates the case. + +When global_admin signs off, the `decision_kind` on the approval_request row is `'admin_override'` (vs `'peer'`). Verlauf chronology renders this distinctly: "Admin-Sign-off von m · 2026-05-06" rather than "Genehmigt von Bert · 2026-05-06". The audit log timeline filters can pivot on `decision_kind`. + +### 4.8 Revocation (per Q7) + +- **Requester revokes:** while `request.status = 'pending'`, the requester can DELETE their own request. Service-layer reverts the entity from pre_image (same code path as Reject), but instead of marking the request `'rejected'`, marks it `'revoked'`. New `paliad.project_events` event_type `'deadline_approval_revoked'`. +- **Approver revokes after approval:** **not supported** per Q7. Once approved, the only path back is a new request — e.g. an over-eager Complete is reversed by a fresh "Reopen" lifecycle event, which itself flows through the approval gate. + +--- + +## 5. UI surfaces + +### 5.1 The pending pill — visible everywhere + +Per m's interjection, pending state must surface in every view that shows the entity. Visual treatment: + +- **Pending CREATE** — striped/dashed border on the row, ⚠ icon, label "Erstellung wartet auf Genehmigung von +". Counted toward traffic-light buckets (the deadline IS real, just unverified) but rendered with a "tentative" CSS class. +- **Pending UPDATE** — solid border, but a yellow chip in the date column saying "Datum geändert — wartet auf Genehmigung". Tooltip on the chip shows the diff: "vorher: 2026-05-10 → 2026-05-12". +- **Pending COMPLETE** — solid border, status badge "Erledigt (wartet auf Genehmigung)" with strike-through-pending styling. The traffic-light treats the row as completed (the action-taker thinks they're done) but with the same striped class as create-pending so an approver can see the queue at a glance. +- **Pending DELETE** — dashed-red border, label "Zur Löschung beantragt". Date / details still visible but strike-through. Click → details + approval request. + +CSS classes (proposed, in `frontend/src/styles/global.css`): + +```css +.entity-row--pending-create { border-style: dashed; border-color: var(--frist-amber); } +.entity-row--pending-update { /* solid border, chip handles the signal */ } +.entity-row--pending-complete { background: linear-gradient(...striped...); } +.entity-row--pending-delete { border-style: dashed; border-color: var(--frist-red); text-decoration: line-through; } + +.approval-pill { display: inline-flex; align-items: center; gap: 4px; + padding: 2px 8px; border-radius: 9999px; + background: var(--bg-warn-soft); color: var(--fg-warn); + font-size: 12px; } +.approval-pill::before { content: "⚠ "; } +``` + +i18n keys (DE primary, EN secondary): + +- `approvals.pending_create.label` — "Erstellung wartet auf Genehmigung" / "Awaits approval (creation)" +- `approvals.pending_update.label` — "Änderung wartet auf Genehmigung" / "Awaits approval (change)" +- `approvals.pending_complete.label` — "Erledigung wartet auf Genehmigung" / "Awaits approval (completion)" +- `approvals.pending_delete.label` — "Zur Löschung beantragt" / "Awaits approval (deletion)" +- `approvals.required_role.` — "Lead", "Of Counsel", "Associate", "Senior PA", "PA" +- `approvals.requested_by` — "Eingereicht von {name}" / "Submitted by {name}" +- `approvals.no_approver_dialog.*` — full deadlock dialog strings +- `approvals.approve.button` — "Genehmigen" / "Approve" +- `approvals.reject.button` — "Ablehnen" / "Reject" +- `approvals.revoke.button` — "Zurückziehen" / "Revoke" +- `approvals.decision_kind.peer` — "Genehmigt von {name}" / "Approved by {name}" +- `approvals.decision_kind.admin_override` — "Admin-Sign-off von {name}" / "Admin sign-off by {name}" + +Surfaces that show the pending pill: + +- `/deadlines` and `/appointments` table rows (one pill per row). +- `/agenda` timeline (per-row pill). +- `/dashboard` traffic-light card-list previews. +- `/projects/{id}` details — Fristen + Termine sections. +- `/deadlines/{id}` and `/appointments/{id}` detail pages — full diff display. +- CalDAV: pending entries sync to the user's external calendar with title prefix `[PENDING] ` (e.g. `[PENDING] Frist Erwiderung`). Approved entries sync clean. +- Email reminders (`internal/services/reminder_service.go`): pending entries get a banner in the mail body and a `[PENDING] ` subject prefix. + +### 5.2 Bell + `/inbox` page (per Q6) + +**Bell** in the sidebar header (next to the user-menu). Shows count of "open requests where I am a qualified approver and not the requester". Click → `/inbox`. Refreshes via the existing dashboard-polling pattern (60s interval; `Last-Modified` ETag if cheap to add). + +**`/inbox` page**, two tabs: + +1. **"Zur Genehmigung"** (`?tab=pending-mine`): list of `approval_requests` where: + - `status = 'pending'` + - `requested_by != me` + - I have eligible role on the project (or I'm global_admin) + Sorted by `requested_at` ASC (oldest first — stale requests demand attention). Each item shows: project title, entity title, lifecycle event, requester name, age ("vor 4h"), required-role badge. Inline Approve / Reject buttons, expand-row reveals the diff (for update / complete / delete) or full payload (for create). + +2. **"Meine Anfragen"** (`?tab=mine`): list of `approval_requests` where `requested_by = me`. Status filter pills: pending / approved / rejected / revoked. For pending items, a Revoke button. + +URL structure: `/inbox?tab=pending-mine|mine&status=pending|...&project_id=...`. Back-button friendly. + +Why distinct from email reminder flow: email reminders are outbound notifications (digest of upcoming deadlines). The inbox is a workflow surface — actions are taken there. Sharing infra would conflate two purposes. + +### 5.3 Policy authoring — `/projects/{id}/settings/approvals` + +Tab on the project detail page, gated to global_admin. Rendered as a 2×4 table: + +``` + CREATE UPDATE (date) COMPLETE DELETE +Frist [select] [select] [select] [select] +Termin [select] [select] [select] [select] +``` + +Each ` +
+ + +
diff --git a/frontend/src/client/agenda.ts b/frontend/src/client/agenda.ts index 180244e..c733302 100644 --- a/frontend/src/client/agenda.ts +++ b/frontend/src/client/agenda.ts @@ -23,6 +23,8 @@ interface AgendaItem { project_title?: string | null; project_type?: string | null; // client | litigation | patent | case | project project_reference?: string | null; + // Approval workflow (t-paliad-138). "pending" → render the warning pill. + approval_status?: "approved" | "pending" | "legacy" | null; } interface AgendaPayload { @@ -271,11 +273,15 @@ function expectedUrgency(day: Date): Urgency { function renderItem(it: AgendaItem, bucketUrgency: Urgency): string { const urgencyClass = `agenda-item-${it.urgency}`; const typeClass = `agenda-item-type-${it.type}`; + const pendingClass = it.approval_status === "pending" ? " entity-row--pending-update" : ""; const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon(); const detailHref = itemDetailHref(it); const project = it.project_id ? `${esc(formatProjectLabel(it))}` : ""; + const pendingPill = it.approval_status === "pending" + ? `${esc(tDyn("approvals.pending_update.label"))}` + : ""; const timePart = it.type === "appointment" ? `${esc(formatAppointmentTime(it))}` @@ -291,13 +297,14 @@ function renderItem(it: AgendaItem, bucketUrgency: Urgency): string { : (it.appointment_type ? `agenda.appointment_type.${it.appointment_type}` : "agenda.label.appointment"); const typeLabel = tDyn(typeLabelKey); - return `
  • + return `
  • ${esc(typeLabel)}: ${esc(it.title)} + ${pendingPill} ${project} diff --git a/frontend/src/client/appointments-detail.ts b/frontend/src/client/appointments-detail.ts index 30ffe88..d4545f9 100644 --- a/frontend/src/client/appointments-detail.ts +++ b/frontend/src/client/appointments-detail.ts @@ -1,6 +1,7 @@ import { initI18n, t, tDyn, getLang } from "./i18n"; import { initSidebar } from "./sidebar"; import { initNotes } from "./notes"; +import { projectIndent } from "./project-indent"; interface Appointment { id: string; @@ -18,10 +19,12 @@ interface Project { id: string; reference?: string | null; title: string; + path?: string; } let appointment: Appointment | null = null; let project: Project | null = null; +let allProjects: Project[] = []; function parseAppointmentID(): string | null { const parts = window.location.pathname.split("/").filter(Boolean); @@ -77,6 +80,32 @@ async function loadProject(id: string) { } } +async function loadAllProjects() { + try { + const resp = await fetch("/api/projects"); + if (resp.ok) allProjects = await resp.json(); + } catch { + /* non-fatal */ + } +} + +function populateProjectPicker() { + const sel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null; + if (!sel) return; + const none = sel.querySelector('option[value=""]'); + sel.innerHTML = ""; + if (none) sel.appendChild(none); + for (const p of allProjects) { + const opt = document.createElement("option"); + opt.value = p.id; + opt.textContent = `${projectIndent(p.path)}${p.reference || ""} — ${p.title}`; + sel.appendChild(opt); + } + if (appointment) { + sel.value = appointment.project_id ?? ""; + } +} + function renderHeader() { if (!appointment) return; document.getElementById("appointment-title-display")!.textContent = appointment.title; @@ -114,6 +143,8 @@ function fillEditForm() { (document.getElementById("appointment-type-edit") as HTMLSelectElement).value = appointment.appointment_type ?? ""; (document.getElementById("appointment-location-edit") as HTMLInputElement).value = appointment.location ?? ""; (document.getElementById("appointment-description-edit") as HTMLTextAreaElement).value = appointment.description ?? ""; + const projectSel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null; + if (projectSel) projectSel.value = appointment.project_id ?? ""; } async function saveEdit(ev: Event) { @@ -129,6 +160,9 @@ async function saveEdit(ev: Event) { const type = (document.getElementById("appointment-type-edit") as HTMLSelectElement).value; const location = (document.getElementById("appointment-location-edit") as HTMLInputElement).value.trim(); const description = (document.getElementById("appointment-description-edit") as HTMLTextAreaElement).value; + const projectSel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null; + const newProjectID = projectSel ? projectSel.value : ""; + const currentProjectID = appointment.project_id ?? ""; const payload: Record = { title, @@ -138,6 +172,13 @@ async function saveEdit(ev: Event) { location, description, }; + if (newProjectID !== currentProjectID) { + if (newProjectID === "") { + payload.clear_project = true; + } else { + payload.project_id = newProjectID; + } + } submitBtn.disabled = true; try { @@ -147,7 +188,13 @@ async function saveEdit(ev: Event) { body: JSON.stringify(payload), }); if (resp.ok) { + const prevProjectID = appointment.project_id ?? ""; appointment = await resp.json(); + const nextProjectID = appointment?.project_id ?? ""; + if (nextProjectID !== prevProjectID) { + project = null; + if (appointment?.project_id) await loadProject(appointment.project_id); + } renderHeader(); msg.textContent = t("appointments.detail.saved"); msg.className = "form-msg form-msg-ok"; @@ -200,10 +247,14 @@ async function main() { notFound.style.display = "block"; return; } - if (appointment.project_id) await loadProject(appointment.project_id); + await Promise.all([ + appointment.project_id ? loadProject(appointment.project_id) : Promise.resolve(), + loadAllProjects(), + ]); loading.style.display = "none"; body.style.display = ""; renderHeader(); + populateProjectPicker(); fillEditForm(); document.getElementById("appointment-edit-form")!.addEventListener("submit", saveEdit); diff --git a/frontend/src/client/deadlines-detail.ts b/frontend/src/client/deadlines-detail.ts index 120db3d..adcf15b 100644 --- a/frontend/src/client/deadlines-detail.ts +++ b/frontend/src/client/deadlines-detail.ts @@ -1,6 +1,7 @@ import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n"; import { initSidebar } from "./sidebar"; import { initNotes } from "./notes"; +import { projectIndent } from "./project-indent"; import { attachEventTypePicker, fetchEventTypes, @@ -32,6 +33,7 @@ interface Project { id: string; reference?: string | null; title: string; + path?: string; } interface DeadlineRule { @@ -51,6 +53,7 @@ let deadline: Deadline | null = null; let project: Project | null = null; let rule: DeadlineRule | null = null; let me: Me | null = null; +let allProjects: Project[] = []; function parseDeadlineID(): string | null { const parts = window.location.pathname.split("/").filter(Boolean); @@ -123,6 +126,30 @@ async function loadProject(projectID: string) { } } +async function loadAllProjects() { + try { + const resp = await fetch("/api/projects"); + if (resp.ok) allProjects = await resp.json(); + } catch { + /* non-fatal */ + } +} + +function populateProjectPicker() { + const sel = document.getElementById("deadline-project-edit") as HTMLSelectElement | null; + if (!sel || !deadline) return; + const opts: string[] = []; + for (const p of allProjects) { + const indent = projectIndent(p.path); + const ref = p.reference || ""; + opts.push( + ``, + ); + } + sel.innerHTML = opts.join(""); + sel.value = deadline.project_id; +} + async function loadRule(ruleID: string) { try { const resp = await fetch(`/api/deadline-rules`); @@ -261,6 +288,8 @@ function initEdit() { const saveBtn = document.getElementById("deadline-save-btn") as HTMLButtonElement; const etDisplay = document.getElementById("deadline-event-types-display"); const etEdit = document.getElementById("deadline-event-types-edit"); + const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement; + const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null; function enterEdit() { titleDisplay.style.display = "none"; @@ -271,6 +300,11 @@ function initEdit() { notesEdit.style.display = ""; if (etDisplay) etDisplay.style.display = "none"; if (etEdit) etEdit.style.display = ""; + if (projectEdit && deadline) { + projectLink.style.display = "none"; + projectEdit.style.display = ""; + projectEdit.value = deadline.project_id; + } saveBtn.style.display = ""; editBtn.style.display = "none"; titleEdit.focus(); @@ -285,6 +319,10 @@ function initEdit() { notesEdit.style.display = "none"; if (etDisplay) etDisplay.style.display = ""; if (etEdit) etEdit.style.display = "none"; + if (projectEdit) { + projectEdit.style.display = "none"; + projectLink.style.display = ""; + } saveBtn.style.display = "none"; editBtn.style.display = ""; } @@ -307,13 +345,20 @@ function initEdit() { if (eventTypePicker) { payload.event_type_ids = eventTypePicker.getIDs(); } + if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) { + payload.project_id = projectEdit.value; + } const resp = await fetch(`/api/deadlines/${deadline.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (resp.ok) { + const prevProjectID = deadline.project_id; deadline = await resp.json(); + if (deadline && deadline.project_id !== prevProjectID) { + await loadProject(deadline.project_id); + } render(); } } finally { @@ -410,7 +455,7 @@ async function main() { notfound.style.display = "block"; return; } - await loadProject(deadline.project_id); + await Promise.all([loadProject(deadline.project_id), loadAllProjects()]); if (deadline.rule_id) await loadRule(deadline.rule_id); // Load event types in parallel; render once ready (the picker re-renders @@ -435,6 +480,7 @@ async function main() { }); } + populateProjectPicker(); render(); initEdit(); initComplete(); diff --git a/frontend/src/client/events.ts b/frontend/src/client/events.ts index 47fd57e..5df58c5 100644 --- a/frontend/src/client/events.ts +++ b/frontend/src/client/events.ts @@ -38,6 +38,9 @@ interface EventListItem { project_title?: string; project_type?: string; + // Approval workflow (t-paliad-138). "pending" → render the warning pill. + approval_status?: "approved" | "pending" | "legacy"; + // deadline-only due_date?: string; status?: string; @@ -504,11 +507,19 @@ function renderRow(item: EventListItem, showReopen: boolean): string { ? `${esc(tDyn(`appointments.type.${item.appointment_type}`) || item.appointment_type)}` : "—"; - return ` + // Approval pending pill (t-paliad-138). Soft-tint the row + insert a + // ⚠ chip next to the title. Generic "pending approval" — the inbox + // shows the lifecycle detail. + const pendingClass = item.approval_status === "pending" ? " entity-row--pending-update" : ""; + const pendingPill = item.approval_status === "pending" + ? `${esc(t("approvals.pending_update.label"))}` + : ""; + + return ` ${checkCell} ${rowTypeChip(item)} ${esc(dateLabel)} - ${esc(item.title)} + ${esc(item.title)}${pendingPill ? " " + pendingPill : ""} ${projectCell} ${ruleLabel || "—"} ${eventTypeCell || "—"} diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 8ef4d49..c362f67 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -34,6 +34,7 @@ const translations: Record> = { "nav.termine": "Termine", "nav.dashboard": "Dashboard", "nav.agenda": "Agenda", + "nav.inbox": "Genehmigungen", "nav.team": "Team", "nav.group.uebersicht": "\u00dcbersicht", "nav.group.arbeit": "Arbeit", @@ -305,7 +306,7 @@ const translations: Record> = { "deadlines.card.calc.add_to_project": "Zu Akte hinzufügen", "deadlines.card.calc.add_to_project.disabled": "Gerichtsbestimmt — manuell anlegen", "deadlines.pathway.fork.heading": "Was möchten Sie tun?", - "deadlines.pathway.a.title": "Verfahrensablauf informieren", + "deadlines.pathway.a.title": "Verfahrensablauf", "deadlines.pathway.a.desc": "Verfahrenstyp wählen und alle dazugehörigen Fristen auf einer Zeitleiste sehen.", "deadlines.pathway.b.title": "Frist eintragen aufgrund Ereignis", "deadlines.pathway.b.desc": "Ein Ereignis ist eingetreten — ich brauche die richtige Frist für meine Akte.", @@ -800,10 +801,12 @@ const translations: Record> = { "dashboard.action.short.deadline_completed": "erledigte Frist", "dashboard.action.short.deadline_reopened": "öffnete Frist wieder", "dashboard.action.short.deadline_deleted": "l\u00f6schte Frist", + "dashboard.action.short.deadline_project_changed": "verschob Frist", "dashboard.action.short.deadlines_imported": "importierte Fristen", "dashboard.action.short.appointment_created": "legte Termin an", "dashboard.action.short.appointment_updated": "\u00e4nderte Termin", "dashboard.action.short.appointment_deleted": "l\u00f6schte Termin", + "dashboard.action.short.appointment_project_changed": "verschob Termin", // Localized event-row title for the project Verlauf tab \u2014 full noun // phrase ("Frist ge\u00e4ndert") complementing the dashboard's verb form. "event.title.project_created": "Projekt angelegt", @@ -817,10 +820,39 @@ const translations: Record> = { "event.title.deadline_completed": "Frist erledigt", "event.title.deadline_reopened": "Frist wiederer\u00f6ffnet", "event.title.deadline_deleted": "Frist gel\u00f6scht", + "event.title.deadline_project_changed": "Frist verschoben", "event.title.deadlines_imported": "Fristen importiert", "event.title.appointment_created": "Termin angelegt", "event.title.appointment_updated": "Termin ge\u00e4ndert", "event.title.appointment_deleted": "Termin gel\u00f6scht", + "event.title.appointment_project_changed": "Termin verschoben", + // 4-eye approval lifecycle (t-paliad-138). Verlauf renders these as + // a paired card with the original lifecycle event (e.g. + // "Frist angelegt" + "Genehmigung erteilt von Bert"). + "event.title.deadline_approval_requested": "Genehmigung beantragt", + "event.title.deadline_approval_approved": "Genehmigung erteilt", + "event.title.deadline_approval_rejected": "Genehmigung abgelehnt", + "event.title.deadline_approval_revoked": "Anfrage zurückgezogen", + "event.title.appointment_approval_requested": "Genehmigung beantragt", + "event.title.appointment_approval_approved": "Genehmigung erteilt", + "event.title.appointment_approval_rejected": "Genehmigung abgelehnt", + "event.title.appointment_approval_revoked": "Anfrage zurückgezogen", + "event.description.deadline_approval_requested": "4-Augen-Genehmigung für Frist beantragt", + "event.description.deadline_approval_approved": "Genehmigung für Frist erteilt", + "event.description.deadline_approval_rejected": "Genehmigung für Frist abgelehnt", + "event.description.deadline_approval_revoked": "Genehmigungsanfrage für Frist zurückgezogen", + "event.description.appointment_approval_requested": "4-Augen-Genehmigung für Termin beantragt", + "event.description.appointment_approval_approved": "Genehmigung für Termin erteilt", + "event.description.appointment_approval_rejected": "Genehmigung für Termin abgelehnt", + "event.description.appointment_approval_revoked": "Genehmigungsanfrage für Termin zurückgezogen", + "dashboard.action.short.deadline_approval_requested": "beantragte Genehmigung", + "dashboard.action.short.deadline_approval_approved": "genehmigte Frist", + "dashboard.action.short.deadline_approval_rejected": "lehnte Frist ab", + "dashboard.action.short.deadline_approval_revoked": "zog Anfrage zurück", + "dashboard.action.short.appointment_approval_requested": "beantragte Genehmigung", + "dashboard.action.short.appointment_approval_approved": "genehmigte Termin", + "dashboard.action.short.appointment_approval_rejected": "lehnte Termin ab", + "dashboard.action.short.appointment_approval_revoked": "zog Anfrage zurück", "event.title.checklist_created": "Checkliste angelegt", "event.title.checklist_renamed": "Checkliste umbenannt", "event.title.checklist_linked": "Checkliste verkn\u00fcpft", @@ -841,10 +873,12 @@ const translations: Record> = { "event.description.deadline_completed": "Frist \u201e{title}\u201c als erledigt markiert", "event.description.deadline_reopened": "Frist \u201e{title}\u201c wieder ge\u00f6ffnet", "event.description.deadline_deleted": "Frist \u201e{title}\u201c gel\u00f6scht", + "event.description.deadline_project_changed": "Frist \u201e{title}\u201c einer anderen Akte zugeordnet", "event.description.deadlines_imported": "{count} Fristen aus Fristenrechner \u00fcbernommen", "event.description.appointment_created": "Termin \u201e{title}\u201c angelegt", "event.description.appointment_updated": "Termin \u201e{title}\u201c ge\u00e4ndert", "event.description.appointment_deleted": "Termin \u201e{title}\u201c gel\u00f6scht", + "event.description.appointment_project_changed": "Termin \u201e{title}\u201c einer anderen Akte zugeordnet", "dashboard.action.short.checklist_created": "legte Checkliste an", "dashboard.action.short.checklist_renamed": "benannte Checkliste um", "dashboard.action.short.checklist_unlinked": "trennte Checkliste", @@ -1087,6 +1121,9 @@ const translations: Record> = { "projects.detail.team.confirm_remove": "Mitglied entfernen?", "projects.detail.team.empty": "Noch keine Teammitglieder.", "projects.detail.team.error.user_required": "Benutzer ausw\u00e4hlen", + "projects.detail.team.invite.hint": "Benutzer nicht gefunden?", + "projects.detail.team.invite.hint_email": "Niemand mit dieser E-Mail.", + "projects.detail.team.invite.cta": "Einladen", "projects.view.tree": "Baumansicht", "projects.tree.toggle": "Aufklappen / Zuklappen", "projects.tree.loading": "Baum wird geladen\u2026", @@ -1618,6 +1655,58 @@ const translations: Record> = { "admin.event_types.merge.title": "Typen zusammenführen", "admin.event_types.merge.body": "Wählen Sie den Gewinner-Typ. Die Junction-Einträge der Verlierer werden auf den Gewinner umgeleitet, anschließend werden die Verlierer archiviert.", "admin.event_types.merge.submit": "Zusammenführen", + + // Approval workflow (t-paliad-138). + "approvals.title": "Genehmigungen", + "approvals.heading": "Genehmigungen", + "approvals.subtitle": "4-Augen-Prüfung für Fristen und Termine.", + "approvals.tab.pending_mine": "Zur Genehmigung", + "approvals.tab.mine": "Meine Anfragen", + "approvals.empty.pending_mine": "Aktuell nichts zu genehmigen.", + "approvals.empty.mine": "Sie haben keine offenen Anfragen.", + "approvals.lifecycle.create": "Erstellung", + "approvals.lifecycle.update": "Änderung", + "approvals.lifecycle.complete": "Erledigung", + "approvals.lifecycle.delete": "Löschung", + "approvals.entity.deadline": "Frist", + "approvals.entity.appointment": "Termin", + "approvals.required_role.lead": "Lead", + "approvals.required_role.of_counsel": "Of Counsel", + "approvals.required_role.associate": "Associate", + "approvals.required_role.senior_pa": "Senior PA", + "approvals.required_role.pa": "PA", + "approvals.status.pending": "Offen", + "approvals.status.approved": "Genehmigt", + "approvals.status.rejected": "Abgelehnt", + "approvals.status.revoked": "Zurückgezogen", + "approvals.status.superseded": "Ersetzt", + "approvals.action.approve": "Genehmigen", + "approvals.action.reject": "Ablehnen", + "approvals.action.revoke": "Zurückziehen", + "approvals.note.placeholder": "Optionale Begründung...", + "approvals.requested_by": "Eingereicht von", + "approvals.decided_by": "Entschieden von", + "approvals.decision_kind.peer": "Genehmigt durch Teammitglied", + "approvals.decision_kind.admin_override": "Admin-Sign-off", + "approvals.error.self_approval": "Eigengenehmigung nicht zulässig.", + "approvals.error.not_authorized": "Sie haben nicht die erforderliche Rolle.", + "approvals.error.no_qualified_approver": "Kein qualifizierter Approver verfügbar — bitte einen Approver ins Projekt-Team aufnehmen oder Admin kontaktieren.", + "approvals.error.concurrent_pending": "Es liegt bereits eine Genehmigungsanfrage auf diesem Eintrag vor.", + "approvals.error.request_not_pending": "Diese Anfrage ist nicht mehr offen.", + "approvals.pending_create.label": "Erstellung wartet auf Genehmigung", + "approvals.pending_update.label": "Änderung wartet auf Genehmigung", + "approvals.pending_complete.label": "Erledigung wartet auf Genehmigung", + "approvals.pending_delete.label": "Zur Löschung beantragt", + "approvals.diff.before": "Vorher", + "approvals.diff.after": "Nachher", + "approvals.policies.title": "Genehmigungsrichtlinien", + "approvals.policies.subtitle": "Welche Lebenszyklus-Schritte benötigen 4-Augen-Prüfung in diesem Projekt?", + "approvals.policies.column.event": "Ereignis", + "approvals.policies.column.deadline": "Frist", + "approvals.policies.column.appointment": "Termin", + "approvals.policies.no_approval": "Keine Genehmigung erforderlich", + "approvals.policies.copy_parent": "Aus Eltern-Projekt übernehmen", + "approvals.policies.set_all_associate": "Alle auf Associate setzen", }, en: { @@ -1638,6 +1727,7 @@ const translations: Record> = { "nav.termine": "Appointments", "nav.dashboard": "Dashboard", "nav.agenda": "Agenda", + "nav.inbox": "Approvals", "nav.team": "Team", "nav.group.uebersicht": "Overview", "nav.group.arbeit": "Work", @@ -2397,10 +2487,12 @@ const translations: Record> = { "dashboard.action.short.deadline_completed": "completed deadline", "dashboard.action.short.deadline_reopened": "reopened deadline", "dashboard.action.short.deadline_deleted": "deleted deadline", + "dashboard.action.short.deadline_project_changed": "moved deadline", "dashboard.action.short.deadlines_imported": "imported deadlines", "dashboard.action.short.appointment_created": "added appointment", "dashboard.action.short.appointment_updated": "updated appointment", "dashboard.action.short.appointment_deleted": "deleted appointment", + "dashboard.action.short.appointment_project_changed": "moved appointment", // Localized event-row title for the project Verlauf tab — full noun // phrase ("Deadline updated") complementing the dashboard's verb form. "event.title.project_created": "Project created", @@ -2414,10 +2506,37 @@ const translations: Record> = { "event.title.deadline_completed": "Deadline completed", "event.title.deadline_reopened": "Deadline reopened", "event.title.deadline_deleted": "Deadline deleted", + "event.title.deadline_project_changed": "Deadline moved", "event.title.deadlines_imported": "Deadlines imported", "event.title.appointment_created": "Appointment created", "event.title.appointment_updated": "Appointment updated", "event.title.appointment_deleted": "Appointment deleted", + "event.title.appointment_project_changed": "Appointment moved", + // 4-eye approval lifecycle (t-paliad-138). + "event.title.deadline_approval_requested": "Approval requested", + "event.title.deadline_approval_approved": "Approval granted", + "event.title.deadline_approval_rejected": "Approval rejected", + "event.title.deadline_approval_revoked": "Request revoked", + "event.title.appointment_approval_requested": "Approval requested", + "event.title.appointment_approval_approved": "Approval granted", + "event.title.appointment_approval_rejected": "Approval rejected", + "event.title.appointment_approval_revoked": "Request revoked", + "event.description.deadline_approval_requested": "Four-eyes approval requested for deadline", + "event.description.deadline_approval_approved": "Deadline approval granted", + "event.description.deadline_approval_rejected": "Deadline approval rejected", + "event.description.deadline_approval_revoked": "Deadline approval request revoked", + "event.description.appointment_approval_requested": "Four-eyes approval requested for appointment", + "event.description.appointment_approval_approved": "Appointment approval granted", + "event.description.appointment_approval_rejected": "Appointment approval rejected", + "event.description.appointment_approval_revoked": "Appointment approval request revoked", + "dashboard.action.short.deadline_approval_requested": "requested approval", + "dashboard.action.short.deadline_approval_approved": "approved deadline", + "dashboard.action.short.deadline_approval_rejected": "rejected deadline", + "dashboard.action.short.deadline_approval_revoked": "revoked request", + "dashboard.action.short.appointment_approval_requested": "requested approval", + "dashboard.action.short.appointment_approval_approved": "approved appointment", + "dashboard.action.short.appointment_approval_rejected": "rejected appointment", + "dashboard.action.short.appointment_approval_revoked": "revoked request", "event.title.checklist_created": "Checklist created", "event.title.checklist_renamed": "Checklist renamed", "event.title.checklist_linked": "Checklist linked", @@ -2438,10 +2557,12 @@ const translations: Record> = { "event.description.deadline_completed": "Deadline “{title}” completed", "event.description.deadline_reopened": "Deadline “{title}” reopened", "event.description.deadline_deleted": "Deadline “{title}” deleted", + "event.description.deadline_project_changed": "Deadline “{title}” moved to another matter", "event.description.deadlines_imported": "{count} deadlines imported from Fristenrechner", "event.description.appointment_created": "Appointment “{title}” added", "event.description.appointment_updated": "Appointment “{title}” updated", "event.description.appointment_deleted": "Appointment “{title}” deleted", + "event.description.appointment_project_changed": "Appointment “{title}” moved to another matter", "dashboard.action.short.checklist_created": "added checklist", "dashboard.action.short.checklist_renamed": "renamed checklist", "dashboard.action.short.checklist_unlinked": "unlinked checklist", @@ -2684,6 +2805,9 @@ const translations: Record> = { "projects.detail.team.confirm_remove": "Remove member?", "projects.detail.team.empty": "No team members yet.", "projects.detail.team.error.user_required": "Select a user", + "projects.detail.team.invite.hint": "User not found?", + "projects.detail.team.invite.hint_email": "No one with that email.", + "projects.detail.team.invite.cta": "Invite", "projects.view.tree": "Tree view", "projects.tree.toggle": "Expand / collapse", "projects.tree.loading": "Loading tree…", @@ -3212,6 +3336,58 @@ const translations: Record> = { "admin.event_types.merge.title": "Merge types", "admin.event_types.merge.body": "Pick the winner. Loser junction rows get redirected to the winner, then the losers are archived.", "admin.event_types.merge.submit": "Merge", + + // Approval workflow (t-paliad-138). + "approvals.title": "Approvals", + "approvals.heading": "Approvals", + "approvals.subtitle": "Four-eyes review for deadlines and appointments.", + "approvals.tab.pending_mine": "Awaiting approval", + "approvals.tab.mine": "My requests", + "approvals.empty.pending_mine": "Nothing awaits your approval.", + "approvals.empty.mine": "You have no open requests.", + "approvals.lifecycle.create": "Creation", + "approvals.lifecycle.update": "Change", + "approvals.lifecycle.complete": "Completion", + "approvals.lifecycle.delete": "Deletion", + "approvals.entity.deadline": "Deadline", + "approvals.entity.appointment": "Appointment", + "approvals.required_role.lead": "Lead", + "approvals.required_role.of_counsel": "Of Counsel", + "approvals.required_role.associate": "Associate", + "approvals.required_role.senior_pa": "Senior PA", + "approvals.required_role.pa": "PA", + "approvals.status.pending": "Open", + "approvals.status.approved": "Approved", + "approvals.status.rejected": "Rejected", + "approvals.status.revoked": "Revoked", + "approvals.status.superseded": "Superseded", + "approvals.action.approve": "Approve", + "approvals.action.reject": "Reject", + "approvals.action.revoke": "Revoke", + "approvals.note.placeholder": "Optional note...", + "approvals.requested_by": "Submitted by", + "approvals.decided_by": "Decided by", + "approvals.decision_kind.peer": "Peer approval", + "approvals.decision_kind.admin_override": "Admin override", + "approvals.error.self_approval": "You cannot approve your own request.", + "approvals.error.not_authorized": "You don't have the required role.", + "approvals.error.no_qualified_approver": "No qualified approver available — please add an approver to the project team or contact an admin.", + "approvals.error.concurrent_pending": "Another approval request is already in flight on this entity.", + "approvals.error.request_not_pending": "This request is no longer open.", + "approvals.pending_create.label": "Awaits approval (creation)", + "approvals.pending_update.label": "Awaits approval (change)", + "approvals.pending_complete.label": "Awaits approval (completion)", + "approvals.pending_delete.label": "Awaits approval (deletion)", + "approvals.diff.before": "Before", + "approvals.diff.after": "After", + "approvals.policies.title": "Approval policies", + "approvals.policies.subtitle": "Which lifecycle events need four-eyes review on this project?", + "approvals.policies.column.event": "Event", + "approvals.policies.column.deadline": "Deadline", + "approvals.policies.column.appointment": "Appointment", + "approvals.policies.no_approval": "No approval needed", + "approvals.policies.copy_parent": "Copy from parent project", + "approvals.policies.set_all_associate": "Set all to Associate", }, }; diff --git a/frontend/src/client/inbox.ts b/frontend/src/client/inbox.ts new file mode 100644 index 0000000..6562c70 --- /dev/null +++ b/frontend/src/client/inbox.ts @@ -0,0 +1,277 @@ +import { initI18n, t, getLang, type I18nKey } from "./i18n"; + +// /inbox client. Two tabs (pending-mine / mine), action buttons (approve / +// reject / revoke), and a small inline diff for update / complete / delete +// lifecycle events. +// +// State is URL-driven via ?tab= so back/forward buttons work and the bell +// badge can deep-link to either tab. The badge in the sidebar (id +// sidebar-inbox-badge) is updated by the shared global polling loop in +// sidebar.ts; this module just keeps the page content in sync. + +type Lifecycle = "create" | "update" | "complete" | "delete"; +type RequestStatus = "pending" | "approved" | "rejected" | "revoked" | "superseded"; +type DecisionKind = "peer" | "admin_override"; + +interface ApprovalRequestView { + id: string; + project_id: string; + project_title: string; + entity_type: "deadline" | "appointment"; + entity_id: string; + entity_title?: string; + lifecycle_event: Lifecycle; + pre_image?: Record | null; + payload?: Record | null; + required_role: string; + status: RequestStatus; + requested_at: string; + requested_by: string; + requester_name: string; + decided_at?: string; + decided_by?: string; + decider_name?: string; + decision_kind?: DecisionKind; + decision_note?: string; +} + +type Tab = "pending-mine" | "mine"; + +let currentTab: Tab = "pending-mine"; + +initI18n(); + +document.addEventListener("DOMContentLoaded", () => { + const url = new URL(window.location.href); + const t = url.searchParams.get("tab"); + if (t === "mine") currentTab = "mine"; + bindTabs(); + refresh(); +}); + +function bindTabs() { + document.querySelectorAll("#inbox-tab-row [data-tab]").forEach((btn) => { + btn.addEventListener("click", () => { + const tab = (btn.dataset.tab as Tab) || "pending-mine"; + if (tab === currentTab) return; + currentTab = tab; + const url = new URL(window.location.href); + url.searchParams.set("tab", tab); + history.replaceState({}, "", url.toString()); + document.querySelectorAll("#inbox-tab-row [data-tab]").forEach((b) => { + b.classList.toggle("active", b.dataset.tab === tab); + }); + refresh(); + }); + }); +} + +async function refresh() { + const loading = document.getElementById("inbox-loading") as HTMLElement | null; + const empty = document.getElementById("inbox-empty") as HTMLElement | null; + const list = document.getElementById("inbox-list") as HTMLUListElement | null; + if (!loading || !empty || !list) return; + loading.style.display = ""; + empty.style.display = "none"; + list.innerHTML = ""; + const path = currentTab === "pending-mine" ? "/api/inbox/pending-mine" : "/api/inbox/mine"; + let rows: ApprovalRequestView[] = []; + try { + const r = await fetch(path, { credentials: "include" }); + if (r.ok) rows = (await r.json()) as ApprovalRequestView[]; + } catch (_e) { + // Network errors fall through to empty render. + } + loading.style.display = "none"; + if (rows.length === 0) { + empty.textContent = t( + currentTab === "pending-mine" + ? "approvals.empty.pending_mine" + : "approvals.empty.mine" + ); + empty.style.display = ""; + return; + } + for (const row of rows) list.appendChild(renderRow(row)); +} + +function renderRow(row: ApprovalRequestView): HTMLLIElement { + const li = document.createElement("li"); + li.className = "inbox-row"; + + // Header: project / entity / lifecycle / required-role + const head = document.createElement("div"); + head.className = "inbox-row-head"; + const title = document.createElement("div"); + title.className = "inbox-row-title"; + const entityLabel = t(("approvals.entity." + row.entity_type) as I18nKey); + const lifecycleLabel = t(("approvals.lifecycle." + row.lifecycle_event) as I18nKey); + const entityTitle = row.entity_title || "—"; + title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`; + head.appendChild(title); + + const meta = document.createElement("div"); + meta.className = "inbox-row-meta"; + const reqByLabel = t("approvals.requested_by"); + const roleLabel = t(("approvals.required_role." + row.required_role) as I18nKey); + meta.textContent = `${row.project_title} · ${reqByLabel} ${row.requester_name} · ${roleLabel}+ · ${formatRelativeTime(row.requested_at)}`; + head.appendChild(meta); + li.appendChild(head); + + // Diff for update / complete (date-bearing fields) + const diff = renderDiff(row); + if (diff) li.appendChild(diff); + + // Decision note if any + if (row.decision_note) { + const note = document.createElement("div"); + note.className = "inbox-row-note"; + note.textContent = row.decision_note; + li.appendChild(note); + } + + // Action row + const actions = document.createElement("div"); + actions.className = "inbox-row-actions"; + + if (row.status === "pending" && currentTab === "pending-mine") { + actions.appendChild(actionButton("approve", row.id, () => doDecision(row.id, "approve"))); + actions.appendChild(actionButton("reject", row.id, () => doDecision(row.id, "reject"))); + } else if (row.status === "pending" && currentTab === "mine") { + actions.appendChild(actionButton("revoke", row.id, () => doDecision(row.id, "revoke"))); + } else { + // historic — show status pill + const pill = document.createElement("span"); + pill.className = "approval-pill approval-pill--historic"; + pill.textContent = t(("approvals.status." + row.status) as I18nKey); + if (row.decider_name && row.status !== "revoked") { + const decided = document.createElement("span"); + decided.className = "inbox-row-decided"; + decided.textContent = ` · ${t("approvals.decided_by")} ${row.decider_name}`; + pill.appendChild(decided); + } + actions.appendChild(pill); + } + li.appendChild(actions); + + return li; +} + +function renderDiff(row: ApprovalRequestView): HTMLElement | null { + const before = (row.pre_image || {}) as Record; + const after = (row.payload || {}) as Record; + const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)])); + if (keys.length === 0) return null; + const wrap = document.createElement("div"); + wrap.className = "inbox-row-diff"; + for (const k of keys) { + const line = document.createElement("div"); + line.className = "inbox-row-diff-line"; + const label = document.createElement("span"); + label.className = "inbox-row-diff-key"; + label.textContent = k; + line.appendChild(label); + const span = document.createElement("span"); + span.className = "inbox-row-diff-values"; + const fmt = (v: unknown) => + v === null || v === undefined ? "—" : String(v); + if (k in before && k in after) { + span.textContent = `${fmt(before[k])} → ${fmt(after[k])}`; + } else if (k in before) { + span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`; + } else { + span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`; + } + line.appendChild(span); + wrap.appendChild(line); + } + return wrap; +} + +function actionButton(action: "approve" | "reject" | "revoke", _requestID: string, onClick: () => void): HTMLButtonElement { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = `btn btn-${action === "approve" ? "primary" : action === "reject" ? "danger" : "secondary"} inbox-row-action`; + btn.textContent = t(("approvals.action." + action) as I18nKey); + btn.addEventListener("click", onClick); + return btn; +} + +async function doDecision(requestID: string, action: "approve" | "reject" | "revoke") { + let note = ""; + if (action === "reject") { + note = window.prompt(t("approvals.note.placeholder")) || ""; + } + let r: Response; + try { + r = await fetch(`/api/approval-requests/${requestID}/${action}`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ note }), + }); + } catch (_e) { + alert("Network error"); + return; + } + if (!r.ok) { + const body = await r.json().catch(() => ({})); + const errKey = (body && body.error) || "internal"; + const msg = mapApprovalError(errKey); + alert(msg); + return; + } + refresh(); + // Update sidebar bell count. + refreshInboxBadge(); +} + +function mapApprovalError(key: string): string { + switch (key) { + case "self_approval_blocked": + return t("approvals.error.self_approval"); + case "no_qualified_approver": + return t("approvals.error.no_qualified_approver"); + case "concurrent_pending": + return t("approvals.error.concurrent_pending"); + case "not_authorized": + return t("approvals.error.not_authorized"); + case "request_not_pending": + return t("approvals.error.request_not_pending"); + default: + return key; + } +} + +function formatRelativeTime(iso: string): string { + const t0 = Date.parse(iso); + if (isNaN(t0)) return iso; + const diffMs = Date.now() - t0; + const sec = Math.floor(diffMs / 1000); + if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`; + const day = Math.floor(hr / 24); + return getLang() === "de" ? `vor ${day}d` : `${day}d ago`; +} + +// Update the sidebar inbox badge (shared with sidebar.ts polling). +async function refreshInboxBadge() { + const badge = document.getElementById("sidebar-inbox-badge"); + if (!badge) return; + try { + const r = await fetch("/api/inbox/count", { credentials: "include" }); + if (!r.ok) return; + const data = (await r.json()) as { count: number }; + if (data.count > 0) { + badge.textContent = String(data.count); + badge.style.display = ""; + } else { + badge.style.display = "none"; + } + } catch (_e) { + /* noop */ + } +} diff --git a/frontend/src/client/projects-detail.ts b/frontend/src/client/projects-detail.ts index 315d8c1..4454bd0 100644 --- a/frontend/src/client/projects-detail.ts +++ b/frontend/src/client/projects-detail.ts @@ -1540,8 +1540,24 @@ function initTeamForm(id: string) { const sugs = document.getElementById("team-user-suggestions") as HTMLDivElement | null; const msg = document.getElementById("team-msg") as HTMLParagraphElement | null; const role = document.getElementById("team-role") as HTMLSelectElement | null; + const inviteHint = document.getElementById("team-user-invite-hint") as HTMLDivElement | null; + const inviteHintText = document.getElementById("team-user-invite-hint-text") as HTMLSpanElement | null; + const inviteBtn = document.getElementById("team-user-invite-btn") as HTMLButtonElement | null; if (!addBtn || !form || !cancel || !input || !hidden || !sugs || !msg || !role) return; + const hideInviteHint = () => { + if (inviteHint) inviteHint.style.display = "none"; + }; + const showInviteHint = (q: string) => { + if (!inviteHint || !inviteHintText) return; + const looksLikeEmail = /@/.test(q) && /\./.test(q.split("@")[1] || ""); + inviteHintText.textContent = looksLikeEmail + ? t("projects.detail.team.invite.hint_email") || "Niemand mit dieser E-Mail." + : t("projects.detail.team.invite.hint") || "Benutzer nicht gefunden?"; + inviteHint.dataset.email = looksLikeEmail ? q : ""; + inviteHint.style.display = ""; + }; + addBtn.addEventListener("click", () => { form.style.display = ""; addBtn.style.display = "none"; @@ -1553,18 +1569,21 @@ function initTeamForm(id: string) { input.value = ""; hidden.value = ""; sugs.innerHTML = ""; + hideInviteHint(); msg.textContent = ""; }); input.addEventListener("input", () => { - const q = input.value.trim().toLowerCase(); + const q = input.value.trim(); + const lc = q.toLowerCase(); hidden.value = ""; if (!q) { sugs.innerHTML = ""; + hideInviteHint(); return; } const matches = userOptions - .filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q)) + .filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(lc)) .slice(0, 8); sugs.innerHTML = matches .map( @@ -1579,8 +1598,29 @@ function initTeamForm(id: string) { hidden.value = el.dataset.id!; input.value = el.dataset.label!; sugs.innerHTML = ""; + hideInviteHint(); }); }); + + if (matches.length === 0) { + showInviteHint(q); + } else { + hideInviteHint(); + } + }); + + inviteBtn?.addEventListener("click", () => { + const sidebarBtn = document.getElementById("sidebar-invite-btn") as HTMLButtonElement | null; + if (!sidebarBtn) return; + sidebarBtn.click(); + const prefill = inviteHint?.dataset.email || ""; + if (prefill) { + const inviteEmail = document.getElementById("invite-email") as HTMLInputElement | null; + if (inviteEmail) { + inviteEmail.value = prefill; + inviteEmail.dispatchEvent(new Event("input", { bubbles: true })); + } + } }); form.addEventListener("submit", async (e) => { @@ -1603,6 +1643,7 @@ function initTeamForm(id: string) { input.value = ""; hidden.value = ""; sugs.innerHTML = ""; + hideInviteHint(); form.style.display = "none"; addBtn.style.display = ""; await loadTeam(id); diff --git a/frontend/src/client/sidebar.ts b/frontend/src/client/sidebar.ts index eb27d9c..036b854 100644 --- a/frontend/src/client/sidebar.ts +++ b/frontend/src/client/sidebar.ts @@ -70,6 +70,7 @@ export function initSidebar() { initInviteModal(); initGlobalSearch(); initChangelogBadge(); + initInboxBadge(); initAdminGroup(); initThemeToggle(); const sidebar = document.querySelector(".sidebar"); @@ -314,6 +315,33 @@ function initChangelogBadge(): void { }); } +// Inbox badge (t-paliad-138) — count of approval requests where the +// current user is qualified to approve. Polls every 60s while the page +// is open. Silently swallows errors (badge is optional). +function initInboxBadge(): void { + const badge = document.getElementById("sidebar-inbox-badge") as HTMLElement | null; + if (!badge) return; + + const refresh = () => { + fetch("/api/inbox/count", { credentials: "same-origin" }) + .then((r) => (r.ok ? r.json() : null)) + .then((data: { count?: number } | null) => { + if (!data || typeof data.count !== "number" || data.count <= 0) { + badge.style.display = "none"; + return; + } + badge.textContent = data.count > 9 ? "9+" : String(data.count); + badge.style.display = ""; + }) + .catch(() => { + /* silent */ + }); + }; + + refresh(); + setInterval(refresh, 60_000); +} + // initThemeToggle wires the sun/moon button at the bottom of the sidebar // (m/paliad#2). The pre-paint inline script in PWAHead.tsx already set // the data-theme attribute on ; this function only owns the post- diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 2a2f417..3755d20 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -25,6 +25,8 @@ const ICON_SPARKLE = ' {label} + {badgeID ? ); } @@ -112,6 +115,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st {group("nav.group.uebersicht", "\u00DCbersicht", navItem("/dashboard", ICON_GAUGE, "nav.dashboard", "Dashboard", currentPath) + navItem("/agenda", ICON_AGENDA, "nav.agenda", "Agenda", currentPath) + + navItem("/inbox", ICON_BELL, "nav.inbox", "Inbox", currentPath, "sidebar-inbox-badge") + navItem("/team", ICON_USERS, "nav.team", "Team", currentPath), )} diff --git a/frontend/src/deadlines-detail.tsx b/frontend/src/deadlines-detail.tsx index e7844ff..78a2db9 100644 --- a/frontend/src/deadlines-detail.tsx +++ b/frontend/src/deadlines-detail.tsx @@ -44,6 +44,7 @@ export function renderDeadlinesDetail(): string { +