Compare commits
6 Commits
mai/pasteu
...
mai/hertz/
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fe0272d6d | |||
| 455af36bc8 | |||
| 1bf64213ae | |||
| abab97ea33 | |||
| d438da2c39 | |||
| e505126e8d |
@@ -1,3 +1,6 @@
|
||||
# Project-specific mai configuration
|
||||
# Auto-generated by 'mai init' — run 'mai setup' to customize
|
||||
|
||||
provider: claude
|
||||
providers:
|
||||
claude:
|
||||
@@ -44,13 +47,21 @@ worker:
|
||||
name_scheme: role
|
||||
default_level: standard
|
||||
auto_discard: false
|
||||
max_workers: 7
|
||||
max_workers: 5
|
||||
persistent: true
|
||||
head:
|
||||
name: paliadin
|
||||
name: "paliadin"
|
||||
max_loops: 50
|
||||
infinity_mode: false
|
||||
max_idle_duration: 2h0m0s
|
||||
backoff_intervals:
|
||||
- 5
|
||||
- 10
|
||||
- 15
|
||||
- 30
|
||||
capacity:
|
||||
global:
|
||||
max_workers: 7
|
||||
max_workers: 5
|
||||
max_heads: 3
|
||||
per_worker:
|
||||
max_tasks_lifetime: 0
|
||||
|
||||
@@ -117,9 +117,7 @@ func main() {
|
||||
}
|
||||
|
||||
appointmentSvc := services.NewAppointmentService(pool, projectSvc)
|
||||
bindingSvc := services.NewCalendarBindingService(pool)
|
||||
targetSvc := services.NewAppointmentTargetService(pool)
|
||||
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc, bindingSvc, targetSvc)
|
||||
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc)
|
||||
// Wire the push hook so user-driven mutations sync to the external
|
||||
// calendar without waiting for the next 60-second tick.
|
||||
appointmentSvc.SetCalDAVPusher(caldavSvc)
|
||||
@@ -128,20 +126,6 @@ func main() {
|
||||
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
|
||||
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
|
||||
|
||||
// t-paliad-223 Slice B (#49) — Supabase Admin API client for the
|
||||
// new "Konto direkt anlegen" path on /admin/team. The key is
|
||||
// optional: when unset the client still wires (so dependents
|
||||
// don't panic) but every call short-circuits with
|
||||
// ErrSupabaseAdminUnavailable so the rest of the server stays
|
||||
// runnable.
|
||||
supabaseAdminClient := services.LoadSupabaseAdminClient()
|
||||
if supabaseAdminClient.Enabled() {
|
||||
log.Println("supabase admin API configured — /admin/team Add-User path active")
|
||||
} else {
|
||||
log.Println("SUPABASE_SERVICE_ROLE_KEY not set — /admin/team Add-User path will return 503")
|
||||
}
|
||||
users.SetAddUserDeps(supabaseAdminClient, mailSvc, baseURL)
|
||||
|
||||
// Wire EmailTemplateService onto the MailService so DB-backed admin
|
||||
// edits propagate without a process restart. The constructor is split
|
||||
// from MailService creation because the DB pool isn't available yet
|
||||
@@ -151,11 +135,6 @@ func main() {
|
||||
|
||||
eventTypeSvc := services.NewEventTypeService(pool, users)
|
||||
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
sysAuditSvc := services.NewSystemAuditLogService(pool)
|
||||
checklistTemplateSvc := services.NewChecklistTemplateService(pool, checklistCatalogSvc, sysAuditSvc, users)
|
||||
svcBundle = &handlers.Services{
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
@@ -164,7 +143,6 @@ func main() {
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
CalDAVBindings: bindingSvc,
|
||||
Rules: rules,
|
||||
Calculator: services.NewDeadlineCalculator(holidays),
|
||||
Users: users,
|
||||
@@ -184,11 +162,7 @@ func main() {
|
||||
EventType: eventTypeSvc,
|
||||
Dashboard: services.NewDashboardService(pool, users),
|
||||
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
|
||||
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc, checklistCatalogSvc),
|
||||
ChecklistCatalog: checklistCatalogSvc,
|
||||
ChecklistTemplate: checklistTemplateSvc,
|
||||
ChecklistShare: services.NewChecklistShareService(pool, checklistTemplateSvc, sysAuditSvc, users),
|
||||
ChecklistPromotion: services.NewChecklistPromotionService(pool, checklistTemplateSvc, sysAuditSvc, users),
|
||||
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
|
||||
Mail: mailSvc,
|
||||
Invite: inviteSvc,
|
||||
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
|
||||
@@ -201,34 +175,14 @@ func main() {
|
||||
UserView: services.NewUserViewService(pool),
|
||||
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
|
||||
Pin: services.NewPinService(pool, projectSvc),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
DashboardLayout: services.NewDashboardLayoutService(pool),
|
||||
FirmDashboardDefault: services.NewFirmDashboardDefaultService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
// t-paliad-214 Slice 1 — personal-scope data export. firm name
|
||||
// is captured into __meta of every export and printed in the
|
||||
// embedded README.
|
||||
Export: services.NewExportService(pool, branding.Name),
|
||||
}
|
||||
|
||||
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
|
||||
// for the inbox-approvals widget. Done post-construction to avoid
|
||||
// a circular constructor dependency (ApprovalService doesn't need
|
||||
// the dashboard, and DashboardService can render its other widgets
|
||||
// without approvals — so keeping this a setter keeps both
|
||||
// constructors simple).
|
||||
svcBundle.Dashboard.SetApprovalService(svcBundle.Approval)
|
||||
// Slice C wires PinService into DashboardService for the
|
||||
// pinned-projects widget. Pin pre-dates t-paliad-219; no new
|
||||
// schema, no circular dependency (Pin doesn't know about the
|
||||
// dashboard).
|
||||
svcBundle.Dashboard.SetPinService(svcBundle.Pin)
|
||||
// Slice C wires the firm-wide dashboard default into the
|
||||
// per-user layout service so GetOrSeed/ResetToDefault prefer
|
||||
// the admin-set firm default over the code-resident factory.
|
||||
// Nil-safe: empty firm row falls back to the factory layout.
|
||||
svcBundle.DashboardLayout.SetFirmDefaultService(svcBundle.FirmDashboardDefault)
|
||||
|
||||
// t-paliad-215 Slice 1 — submission generator. Three services
|
||||
// stitched together by handlers/submissions.go: registry pulls
|
||||
// templates from Gitea (reuses GITEA_TOKEN env), vars builds
|
||||
|
||||
@@ -3,23 +3,20 @@
|
||||
// Three checks against TEST_DATABASE_URL:
|
||||
//
|
||||
// 1. db.ApplyMigrations does not panic and returns nil.
|
||||
// 2. paliad.applied_migrations covers every on-disk *.up.sql — no
|
||||
// migration was silently skipped, no version is missing. The set
|
||||
// contract is stronger than the old single-counter check: applied
|
||||
// set must EQUAL on-disk set, not just reach the max version.
|
||||
// 2. The migration tracker (public.paliad_schema_migrations) advances to
|
||||
// the highest *.up.sql version on disk — no migrations were silently
|
||||
// skipped, no "dirty=true" stragglers left behind.
|
||||
// 3. The handler mux (with /healthz mounted) responds 200 to GET /healthz.
|
||||
//
|
||||
// This is the lightweight cousin of the migration dry-run gate
|
||||
// (internal/db/migrate_test.go): the dry-run catches per-migration syntax
|
||||
// errors before merge; this smoke confirms the apply+bind path the
|
||||
// container actually runs at boot. Together they cover the mig-098 /
|
||||
// mig-099 class of crash-loops end-to-end, plus the mig-103 parallel-merge
|
||||
// skip-hole that t-paliad-218 closed (m/paliad#44).
|
||||
// mig-099 class of crash-loops end-to-end.
|
||||
//
|
||||
// Skipped without TEST_DATABASE_URL — matches the rest of the live-DB tests.
|
||||
//
|
||||
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1 and
|
||||
// docs/design-migration-runner-applied-set-2026-05-20.md §6.
|
||||
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1.
|
||||
|
||||
package main
|
||||
|
||||
@@ -54,23 +51,19 @@ func TestBootSmoke(t *testing.T) {
|
||||
t.Fatalf("db.ApplyMigrations: %v", err)
|
||||
}
|
||||
|
||||
// (2) Assert the applied set equals the on-disk set. The new runner
|
||||
// tracks applied state per-migration; a silently-skipped version
|
||||
// would surface as a row missing from paliad.applied_migrations even
|
||||
// though max(version) matches. Comparing sets — not just max —
|
||||
// catches the failure mode the t-paliad-218 post-mortem documented.
|
||||
onDisk := embeddedMigrationVersions(t)
|
||||
applied := appliedMigrationVersions(t, url)
|
||||
|
||||
if missing := setDiff(onDisk, applied); len(missing) > 0 {
|
||||
t.Errorf("paliad.applied_migrations missing %d on-disk versions: %v "+
|
||||
"(a migration was skipped — investigate before deploying)",
|
||||
len(missing), missing)
|
||||
// (2) Assert the tracker advanced to the highest *.up.sql version we
|
||||
// embed. If a migration was silently skipped or the tracker is dirty,
|
||||
// the prod container would crash-loop — this turns that into a test
|
||||
// failure with a precise reason.
|
||||
expected := highestEmbeddedMigrationVersion(t)
|
||||
got, dirty := readTrackerVersion(t, url)
|
||||
if dirty {
|
||||
t.Errorf("tracker reports dirty=true at version %d — investigate before deploying", got)
|
||||
}
|
||||
if extra := setDiff(applied, onDisk); len(extra) > 0 {
|
||||
t.Errorf("paliad.applied_migrations has %d versions with no on-disk file: %v "+
|
||||
"(orphan rows — either restore the file or DELETE the row)",
|
||||
len(extra), extra)
|
||||
if got != expected {
|
||||
t.Errorf("tracker at version %d; expected %d (highest *.up.sql on disk). "+
|
||||
"A migration was skipped or applied out of order.",
|
||||
got, expected)
|
||||
}
|
||||
|
||||
// (3) Mount the public handlers (the same Register call main() makes,
|
||||
@@ -100,16 +93,11 @@ func TestBootSmoke(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// embeddedMigrationVersions returns every N where N_*.up.sql exists in
|
||||
// internal/db/migrations/ on disk. The boot smoke compares this set
|
||||
// against paliad.applied_migrations to detect skipped or orphan
|
||||
// migrations.
|
||||
//
|
||||
// Read from disk (not the embed.FS inside the db package — it's unexported)
|
||||
// since the test runs from the repo. The two views must agree for the
|
||||
// build to be self-consistent; if they diverge, the smoke test is the
|
||||
// wrong place to learn about it (the build is). We trust them to match.
|
||||
func embeddedMigrationVersions(t *testing.T) []int {
|
||||
// highestEmbeddedMigrationVersion finds max(N) over every NNN_*.up.sql
|
||||
// file in internal/db/migrations/ on disk. Used as the expected tracker
|
||||
// version after a clean apply. We read from disk (not the embed.FS in
|
||||
// the db package — it's unexported) since the test runs from the repo.
|
||||
func highestEmbeddedMigrationVersion(t *testing.T) int {
|
||||
t.Helper()
|
||||
root, err := repoRoot()
|
||||
if err != nil {
|
||||
@@ -141,52 +129,24 @@ func embeddedMigrationVersions(t *testing.T) []int {
|
||||
t.Fatalf("no *.up.sql files found in %s", dir)
|
||||
}
|
||||
sort.Ints(versions)
|
||||
return versions
|
||||
return versions[len(versions)-1]
|
||||
}
|
||||
|
||||
// appliedMigrationVersions reads paliad.applied_migrations and returns
|
||||
// the sorted list of versions. Fails the test if the table doesn't exist —
|
||||
// db.ApplyMigrations is supposed to have created it by this point.
|
||||
func appliedMigrationVersions(t *testing.T, url string) []int {
|
||||
// readTrackerVersion fetches the lone row from the tracker. golang-migrate
|
||||
// keeps exactly one row; if we ever see zero or more, that's the dirty-state
|
||||
// the test is designed to flag.
|
||||
func readTrackerVersion(t *testing.T, url string) (version int, dirty bool) {
|
||||
t.Helper()
|
||||
conn, err := sql.Open("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations ORDER BY version`)
|
||||
if err != nil {
|
||||
t.Fatalf("read applied_migrations: %v", err)
|
||||
row := conn.QueryRow(`SELECT version, dirty FROM public.paliad_schema_migrations LIMIT 1`)
|
||||
if err := row.Scan(&version, &dirty); err != nil {
|
||||
t.Fatalf("read tracker: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int
|
||||
for rows.Next() {
|
||||
var v int
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("rows: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// setDiff returns the elements of a that are not in b. Inputs are sorted
|
||||
// ascending; output preserves that ordering.
|
||||
func setDiff(a, b []int) []int {
|
||||
bset := make(map[int]bool, len(b))
|
||||
for _, v := range b {
|
||||
bset[v] = true
|
||||
}
|
||||
var out []int
|
||||
for _, v := range a {
|
||||
if !bset[v] {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
return version, dirty
|
||||
}
|
||||
|
||||
// repoRoot walks upward from the test binary's working directory until it
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
# Design: Align calendar-view rendering between Events/Termine and Custom Views
|
||||
|
||||
**Task:** t-paliad-224 — m/paliad#55
|
||||
**Author:** bohr (inventor)
|
||||
**Date:** 2026-05-20
|
||||
**Status:** ACCEPTED — all 8 (R) defaults confirmed by head 2026-05-20 (msg #2087); coder shift authorised on same branch.
|
||||
**Branch:** `mai/bohr/calendar-view-align`
|
||||
|
||||
---
|
||||
|
||||
## 0. Premise check (verified against live source 2026-05-20)
|
||||
|
||||
m's brief mentions two surfaces ("Events/Termine" and "Custom Views' calendar view type"). The live codebase has **three** distinct calendar implementations, not two:
|
||||
|
||||
| | A — Events tab | B — Standalone | C — Custom Views |
|
||||
|---|---|---|---|
|
||||
| URL | `/events?type=…&` calendar tab | `/deadlines/calendar`, `/appointments/calendar` | `/views/{slug}` with `render_spec.shape="calendar"` |
|
||||
| Shell TSX | `frontend/src/events.tsx:239-269` (inline `events-calendar-wrap` block) | `frontend/src/deadlines-calendar.tsx`, `frontend/src/appointments-calendar.tsx` | `frontend/src/views.tsx:104` (`views-shape-calendar` host) |
|
||||
| Renderer | `frontend/src/client/events.ts:589-656` (`renderCalendar()`) | `frontend/src/client/deadlines-calendar.ts`, `frontend/src/client/appointments-calendar.ts` | `frontend/src/client/views/shape-calendar.ts` (525 lines, mounted from `client/views.ts:227`) |
|
||||
| Build entry | `events.html` (one bundle) | `deadlines-calendar.html` + `appointments-calendar.html` (two extra bundles) — `frontend/build.ts:258,261,387,390` | none (mounted into the views host at runtime) |
|
||||
| Handler | `handleEventsPage` | `handleDeadlinesCalendarPage`, `handleAppointmentsCalendarPage` — `internal/handlers/handlers.go:470,476`; impls in `internal/handlers/deadlines_pages.go:26`, `internal/handlers/appointments_pages.go:27` | `handleViewsBySlug` |
|
||||
|
||||
**Reachability of B (standalone calendars).** `grep` for the URL strings inside `frontend/` finds only `paliadin-context.ts:96,100` (which decode the URL when the user is **already** on the page). The current Sidebar (`frontend/src/components/Sidebar.tsx:162-163`) routes to `/events?type=deadline` and `/events?type=appointment` — the calendar tab inside `/events` is the only UI-reachable calendar today. Routes B exist but are orphaned in navigation; they live for bookmarks / external links / paliadin context.
|
||||
|
||||
The brief's choice of canonical renderer ("likely the Custom Views renderer if it's the more recent / general one") is the right one — verified below in §3.
|
||||
|
||||
---
|
||||
|
||||
## 1. m's intent (as I read it)
|
||||
|
||||
> "the calendar views in Events / Termine are different than in the custom views calendar view type. That should be aligned!"
|
||||
|
||||
The literal statement is about visual + behavioural parity. Read alongside the brief's "drop the duplicate code path" and the explicit naming of `shape-calendar.ts` / `appointments-calendar.tsx` / `client/appointments-calendar.ts`, the intent is:
|
||||
|
||||
1. **One calendar component**, mounted from both the events-page surface and the custom-views surface.
|
||||
2. **Identical visual output** when the same items land in either surface.
|
||||
3. **No duplicate code path** — orphaned standalone calendar TSX + client + dist pages go.
|
||||
4. **Alignment first, not new features** — drag-to-create / week-resize / etc. are explicitly out of scope per the issue body.
|
||||
|
||||
The smallest-diff path that delivers that intent is "canonicalise on shape-calendar.ts and fold A in" — see §3.
|
||||
|
||||
---
|
||||
|
||||
## 2. What actually diverges today
|
||||
|
||||
Side-by-side after reading all three implementations (cited line numbers above):
|
||||
|
||||
| Dimension | A (`/events` tab) | B (`/deadlines/calendar`, `/appointments/calendar`) | C (Custom Views) |
|
||||
|---|---|---|---|
|
||||
| Views offered | month only | month only | month + week + day |
|
||||
| URL deep-link state | none (calendar month is in-memory, lost on refresh) | none | yes — `?cal_view=…&cal_date=YYYY-MM-DD` |
|
||||
| Cell content | day-num + max 4 dots + "+N" | day-num + max 4 dots + "+N" | day-num + max 3 text **pills** + "+N" |
|
||||
| Dot/pill colour key | urgency for deadlines (`frist-urgency-overdue/soon/later/done`) + single appointment colour (`events-cal-dot-appointment`) — mixed semantics | (deadlines page) urgency only; (appointments page) appointment-type colours via `termin-type-hearing/meeting/consultation/deadline_hearing` + legend strip | **kind-coded** — `views-calendar-pill--{deadline|appointment|project_event|approval_request}` |
|
||||
| Today indicator | accent circle on day-number (`frist-cal-today .frist-cal-day` → coloured pill) | identical to A | border + inset box-shadow ring on entire cell (`views-calendar-cell--today`) |
|
||||
| Click cell | opens modal popup (`#events-cal-popup`) listing the day's items | opens modal popup (`#cal-popup`) | drills into **day view** (changes URL via `?cal_view=day&cal_date=…`), no modal |
|
||||
| "+N" overflow | rendered as static `.frist-cal-more` span (not clickable) | identical | rendered as a button — opens the day view (same drill as the day-num button) |
|
||||
| Empty state | per-month "Keine Einträge im ausgewählten Zeitraum." | per-month "Keine Fristen…" / "Keine Termine…" | per-day in week/day views ("Keine Einträge."), no per-month empty in month view |
|
||||
| Toolbar | inline ‹ month-label › + Heute button | identical | view-switcher chips (M/W/D) + ‹ range-label › + (in day/week) "Zurück zum Monat" link |
|
||||
| Weekday header | 7 static `.frist-cal-weekday` divs hard-coded in TSX | identical | rendered inline in the JS grid (single grid spans weekday row + day cells) |
|
||||
| Mobile fallback | `@media (max-width: 700px)` shrinks cell min-height to 64px (CSS-only) | identical | `<600px` → adds a notice + uses cards-style stack; CSS-only no special media query (notice is data-driven) |
|
||||
| Data source | `/api/events` (one fetch, all items unfiltered by date) | `/api/deadlines` or `/api/appointments` separately | `/api/views/{slug}/run` (filter-spec backed, ViewRow[] discriminated by `kind`) |
|
||||
| Item shape | `EventListItem` (discriminator field `type`) | `Deadline` or `Appointment` (typed) | `ViewRow` (discriminator field `kind`) |
|
||||
| Detail link | `/deadlines/{id}` or `/appointments/{id}` from popup row | identical | direct anchor on the pill/row, no popup |
|
||||
| Lang / i18n | `cal.day.*`, `events.calendar.empty` | `cal.day.*`, `appointments.kalender.empty`, `deadlines.kalender.empty`, `appointments.type.*` (legend) | `cal.day.*`, `cal.view.*`, `cal.month.{prev,next}`, `cal.week.*`, `cal.day.no_entries`, `views.calendar.mobile_fallback` |
|
||||
|
||||
The two A/B implementations are near-clones of each other — Slice C alignment alone wouldn't fix the bigger "two of these are the same code with a coat of paint" problem.
|
||||
|
||||
CSS surface: `.frist-cal-*` is consumed **only** by A + B (verified by grep across `frontend/` + `internal/` — no third party). After the refactor, the entire `.frist-cal-calendar`, `.frist-cal-grid`, `.frist-cal-cell{,-empty,-has}`, `.frist-cal-day`, `.frist-cal-today`, `.frist-cal-dot{*}`, `.frist-cal-more`, `.frist-cal-popup-*`, `.frist-cal-weekday`, `.termin-cal-legend{,-item}`, `.termin-cal-dot`, `.events-cal-dot-appointment` block in `frontend/src/styles/global.css:7464-7620` and `:8019-8023` and `:8680-8700` and `:11519-11533` is deletable. About **180 lines of CSS** go away.
|
||||
|
||||
---
|
||||
|
||||
## 3. Recommended design (TL;DR)
|
||||
|
||||
| Area | Recommendation | Smallest-diff alternative considered & rejected |
|
||||
|---|---|---|
|
||||
| **Canonical renderer** | `shape-calendar.ts` is the canonical renderer. Extract its mount API behind a small `mountCalendar(host, items, opts)` boundary so both /events and /views call it. | Two-way merge (cherry-pick best of both into a third component) — strictly more code, no clean canon to point coders at later. |
|
||||
| **/events calendar tab** | Replaces inline month grid + popup with a `mountCalendar(host, items, { urlState: true, defaultView: "month" })` call. Drops `renderCalendar()`, `openCalPopup()`, `wireCalNav()`, and the entire `events-cal-*` TSX subtree. Gains month/week/day views, drill-down, URL state — for free. | Keep A as-is, only converge B with C: leaves the headline divergence (the one m sees in the UI today) unresolved. |
|
||||
| **/deadlines/calendar + /appointments/calendar** | Routes redirect 301 to `/events?type=deadline&view=calendar` and `/events?type=appointment&view=calendar`. TSX + client + dist artefacts deleted. `paliadin-context.ts` entries for the old paths kept (the redirect target carries through to the same context label). | Delete routes outright: breaks bookmarks. A 301 is one line per route. |
|
||||
| **Data adapter** | `client/events.ts` already loads `EventListItem[]` from `/api/events`. Adapter is a one-liner field rename (`type` → `kind`) — the rest of the shape is identical to `ViewRow`. Existing API endpoints unchanged. | Migrate /events tab to `/api/views/{slug}/run` with an ad-hoc filter spec: pulls a lot of substrate (filter spec assembly, view caching) into the events flow for zero gain when the existing API already returns the right shape. |
|
||||
| **Per-shape config** | Reuse `CalendarConfig` (`default_view`, `show_weekends`). `/events` calendar tab passes `default_view: "month"` so it stays month-first; future surfaces can pass `"week"` if needed. | Hard-code "month" inside mountCalendar — closes the door on /events week/day tabs we may want later. |
|
||||
| **Subtype dot colouring** | Drop the per-appointment-type colour legend (deadline-only colouring was urgency-based and mixed semantics with subtype anyway). Pills are kind-coded only — same as `/views/{slug}` with `shape=calendar` does today. Subtype colouring can be added later as a `CalendarConfig.subtype_colors: bool` flag if a user asks. | Preserve the type-colour legend on the events page: only the orphaned /appointments/calendar page exposes it today, and bringing it into /events means designing the legend at the events-page level (events can be deadlines OR appointments OR both per current chip filter). Easier to defer until requested. |
|
||||
| **CSS** | Delete the `.frist-cal-*` block entirely (~180 lines). The single source of truth becomes `.views-calendar-*`. Same lime-green accent (`var(--color-accent)`), same surface tokens — colour parity is automatic. | Keep both blocks: leaves a CSS minefield where future devs are unsure which class to use. |
|
||||
| **i18n** | New keys land under the existing `cal.*` namespace (`cal.view.month/week/day`, `cal.day.back_to_month`, `cal.day.open_day`, `cal.day.no_entries`, `views.calendar.mobile_fallback`). These already exist for Custom Views — no new strings needed. Delete the `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend-only) keys, plus `events.calendar.empty` (replaced by `cal.day.no_entries` at the day-view level). | Keep DE/EN strings as-is for compatibility: just delete-and-go. The keys aren't part of any user-saved data. |
|
||||
|
||||
**Net code change (estimated by file):**
|
||||
|
||||
- **Delete:** `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts` — together ~560 lines.
|
||||
- **Trim:** ~80 lines from `events.tsx` (calendar subtree), ~140 lines from `client/events.ts` (`renderCalendar`/`openCalPopup`/nav handlers/calendar state).
|
||||
- **Trim:** ~180 lines from `global.css` (`.frist-cal-*` block).
|
||||
- **Add:** `frontend/src/client/calendar/mount-calendar.ts` — the extracted public API (~60 lines incl. types).
|
||||
- **Refactor:** `frontend/src/client/views/shape-calendar.ts` becomes a 30-line wrapper that calls `mountCalendar` with `urlState: true` and the spec's calendar config. Most of the existing 525 lines move into `mount-calendar.ts` verbatim.
|
||||
- **Backend:** 4 lines total — turn the two standalone-calendar handlers into 301 redirects (one line each, plus matching delete of the standalone HTML file write in `frontend/build.ts:387,390`).
|
||||
|
||||
Net: **~700 LOC removed, ~100 LOC added, zero new endpoints, zero schema changes, zero new dependencies.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture sketch
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ frontend/src/client/ │
|
||||
│ calendar/ │
|
||||
│ mount-calendar.ts ★ │ ← new shared module
|
||||
│ types.ts (CalendarItem)│
|
||||
└──────────────┬──────────────┘
|
||||
│
|
||||
┌────────────────────────┼─────────────────────────┐
|
||||
│ │ │
|
||||
client/events.ts (Kalender tab) client/views/ │
|
||||
│ shape-calendar.ts │
|
||||
│ (thin wrapper) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ client/views.ts │
|
||||
│ paintRows(…, "calendar") │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────┘
|
||||
|
||||
Data flows:
|
||||
A: /events → fetch /api/events?type=…&status=… → EventListItem[]
|
||||
→ toCalendarItem(items) → CalendarItem[]
|
||||
→ mountCalendar(host, items, opts)
|
||||
|
||||
C: /views/{slug} → fetch /api/views/{slug}/run → ViewRow[]
|
||||
→ toCalendarItem(rows) (noop-ish: rename ‘type’→‘kind’ already done)
|
||||
→ renderCalendarShape() → mountCalendar(host, items, opts)
|
||||
```
|
||||
|
||||
### 4.1 The shared module (`mount-calendar.ts`)
|
||||
|
||||
```ts
|
||||
// frontend/src/client/calendar/mount-calendar.ts
|
||||
import { t, tDyn, getLang, type I18nKey } from "../i18n";
|
||||
|
||||
export type CalendarKind =
|
||||
| "deadline" | "appointment" | "project_event" | "approval_request";
|
||||
|
||||
export interface CalendarItem {
|
||||
kind: CalendarKind;
|
||||
id: string;
|
||||
title: string;
|
||||
event_date: string; // ISO-8601; first 10 chars are yyyy-mm-dd
|
||||
project_id?: string;
|
||||
project_title?: string;
|
||||
project_reference?: string;
|
||||
}
|
||||
|
||||
export interface CalendarOpts {
|
||||
defaultView?: "month" | "week" | "day";
|
||||
/** If true, calendar reads/writes ?cal_view + ?cal_date (or the prefixed
|
||||
* equivalents); if false, state is in-memory only (use for embedded
|
||||
* calendars where URL state belongs to the host page). */
|
||||
urlState?: boolean;
|
||||
/** Optional prefix for URL params (default: empty). Set if more than
|
||||
* one calendar might live on the same URL. */
|
||||
urlPrefix?: string;
|
||||
/** Optional override: how to render a row's href. Default uses the
|
||||
* kind→/deadlines|/appointments|/inbox|/projects routing the existing
|
||||
* shape-calendar.ts ships with. */
|
||||
hrefFor?: (item: CalendarItem) => string;
|
||||
}
|
||||
|
||||
export interface CalendarHandle {
|
||||
/** Re-render with a new item set (e.g. after a filter change in /events). */
|
||||
update(items: CalendarItem[]): void;
|
||||
/** Tear down listeners + clear host. */
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function mountCalendar(
|
||||
host: HTMLElement,
|
||||
items: CalendarItem[],
|
||||
opts?: CalendarOpts,
|
||||
): CalendarHandle;
|
||||
```
|
||||
|
||||
Internals lifted verbatim from `shape-calendar.ts` (toolbar, renderMonth/Week/Day, renderPill, renderRowAnchor, bucketByDate, filterByDay, startOfWeek, shift, isToday, isoDate, formatRangeLabel, formatWeekHeader, readView/Anchor, writeURL). Two tweaks:
|
||||
|
||||
- `readView`/`readAnchor`/`writeURL` accept the `urlPrefix` so embedded calendars on `/events?…&` don't clobber other pages' `?cal_view`.
|
||||
- `urlState: false` skips the URL read/write entirely — initial state comes from `opts.defaultView` and "today".
|
||||
|
||||
### 4.2 `shape-calendar.ts` (after refactor)
|
||||
|
||||
```ts
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
|
||||
|
||||
export function renderCalendarShape(
|
||||
host: HTMLElement, rows: ViewRow[], render: RenderSpec,
|
||||
): void {
|
||||
const items: CalendarItem[] = rows.map(r => ({
|
||||
kind: r.kind,
|
||||
id: r.id, title: r.title,
|
||||
event_date: r.event_date,
|
||||
project_id: r.project_id,
|
||||
project_title: r.project_title,
|
||||
project_reference: r.project_reference,
|
||||
}));
|
||||
mountCalendar(host, items, {
|
||||
defaultView: render.calendar?.default_view ?? "month",
|
||||
urlState: true,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 `client/events.ts` (calendar arm only)
|
||||
|
||||
```ts
|
||||
// near the top
|
||||
import { mountCalendar, type CalendarItem, type CalendarHandle } from "./calendar/mount-calendar";
|
||||
|
||||
// state
|
||||
let calendar: CalendarHandle | null = null;
|
||||
|
||||
// inside applyView() when switching to calendar view:
|
||||
function ensureCalendarMounted(host: HTMLElement, items: CalendarItem[]) {
|
||||
if (calendar) { calendar.update(items); return; }
|
||||
calendar = mountCalendar(host, items, { urlState: false, defaultView: "month" });
|
||||
}
|
||||
|
||||
// inside applyView() when switching AWAY from calendar:
|
||||
function teardownCalendar() {
|
||||
if (calendar) { calendar.destroy(); calendar = null; }
|
||||
}
|
||||
|
||||
function toCalendarItem(it: EventListItem): CalendarItem {
|
||||
return {
|
||||
kind: it.type as CalendarKind, // type "deadline" | "appointment"
|
||||
id: it.id, title: it.title,
|
||||
event_date: itemDateISO(it) + "T00:00:00",
|
||||
project_id: it.project_id,
|
||||
project_title: it.project_title,
|
||||
project_reference: it.project_reference,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`urlState: false` for /events because the page already owns its own URL contract (`?type=`, `?status=`, etc.) and a second calendar deep-link param set would compete with future events-page state. (See §11 Q3 — this is a defaultable preference, not a hard constraint.)
|
||||
|
||||
### 4.4 Standalone calendar redirects
|
||||
|
||||
```go
|
||||
// internal/handlers/deadlines_pages.go
|
||||
func handleDeadlinesCalendarPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/events?type=deadline&view=calendar", http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
// internal/handlers/appointments_pages.go
|
||||
func handleAppointmentsCalendarPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/events?type=appointment&view=calendar", http.StatusMovedPermanently)
|
||||
}
|
||||
```
|
||||
|
||||
The `view=calendar` query string is a **new** events-page URL contract — needs a one-line addition to `client/events.ts:readURLState()` (which already reads `type`, `status`) to honour `view`. Today the view is in-memory only; pinning it to URL is a free side-benefit of this refactor (and lets the redirects land users on the calendar, not on the cards view).
|
||||
|
||||
Build pipeline: delete entries `frontend/build.ts:258`, `261`, `387`, `390` (the two standalone calendar bundles + HTML writes). `paliadin-context.ts:96,100` keep their URL matches — the 301 fires server-side, so the client only ever sees `/events?type=…&view=calendar` (which already maps to a paliadin context).
|
||||
|
||||
---
|
||||
|
||||
## 5. Visual + interaction parity audit
|
||||
|
||||
Walking m's brief checklist against the proposed end-state (assuming the user is on /events Kalender tab after this refactor):
|
||||
|
||||
| Brief item | Today (A) | After refactor | Matches /views? |
|
||||
|---|---|---|---|
|
||||
| Event tile shape | dot | **pill with text** | ✓ |
|
||||
| Color | mixed (urgency + single appointment colour) | **kind-coded** (deadline / appointment / project_event / approval_request) | ✓ |
|
||||
| Click behaviour (navigate to detail) | modal popup → anchor | **direct anchor on pill** (no modal) | ✓ |
|
||||
| Today highlight | accent circle on day-num | **border ring on entire cell + box-shadow** | ✓ |
|
||||
| Weekday header | static TSX divs | **rendered inline in the JS grid** | ✓ |
|
||||
| Date-range / project / type filter shape | same `EventListItem[]` post-adapter | identical adapter feeds same `CalendarItem[]` shape | ✓ shared loader contract |
|
||||
|
||||
Two surfaces still differ after the refactor — and that's by design:
|
||||
|
||||
1. **/events** still has its three view chips above the calendar (Karten / Liste / Kalender) because the events page is multi-shape at the outer level. /views also has its outer shape chips (Liste / Karten / Kalender / Timeline). Both surfaces' shape chips look identical (`agenda-chip-row`).
|
||||
2. **/events** keeps the events-page-level filters (type chip, status select, project select, event-type/appointment-type filters) above the calendar; /views shows its filter-bar (filter-spec-driven axes) instead. Both surfaces' filter chrome is governed by the page, not the calendar — the calendar component itself is the same DOM either way.
|
||||
|
||||
---
|
||||
|
||||
## 6. Mobile parity
|
||||
|
||||
`shape-calendar.ts` today does a mobile fallback at <600px (`mountCalendar` would carry this behaviour over). The fallback appends a single `<p>` notice — "Auf schmalen Bildschirmen empfehlen wir die Listenansicht" or equivalent (i18n key `views.calendar.mobile_fallback`). Cells still render and are responsive (the existing CSS uses CSS-grid + 1fr columns).
|
||||
|
||||
After this refactor:
|
||||
|
||||
- /events Kalender tab: gets the **same** notice + a contextual hint suggesting "Wechsle zu Karten oder Liste" (the events-page shape chips). One new i18n key, OR reuse the existing `views.calendar.mobile_fallback` and accept that it mentions "Listenansicht" generically.
|
||||
- /views Kalender shape: behaviour unchanged from today.
|
||||
|
||||
Mobile audit boxes ticked:
|
||||
|
||||
| | Today A | Today B | Today C | After |
|
||||
|---|---|---|---|---|
|
||||
| Cell shrinks on narrow viewport | ✓ (min-height 64px) | ✓ | partial (cells stay 80px) | ✓ (carry the C behaviour, plus the @media min-height shrink ported) |
|
||||
| Touch target size on pills | n/a (dots, not tappable) | n/a | OK (8px+ at 1x) — but verify on a real phone during coder smoke | OK |
|
||||
| Modal vs drill-down | modal (small viewports lose layout) | modal | drill-down (changes URL — natural back button) | drill-down across both surfaces |
|
||||
| Sidebar collision | sidebar collapses to bottom nav under 768px (existing behaviour) | identical | identical | identical |
|
||||
|
||||
One coder-time TODO: verify the drill-down day-view is comfortable on mobile (it's a vertical list, should be fine, but worth one Playwright screenshot during smoke).
|
||||
|
||||
---
|
||||
|
||||
## 7. Tests + smoke
|
||||
|
||||
Existing test coverage relevant to this refactor:
|
||||
|
||||
- `frontend/src/client/views/shape-timeline-cv.test.ts` — sibling of shape-calendar, no calendar-specific tests today. Add `frontend/src/client/calendar/mount-calendar.test.ts` for the extracted module.
|
||||
- No Go tests touch handler dispatch for `/deadlines/calendar` or `/appointments/calendar` specifically (verified by grep).
|
||||
- `internal/services/render_spec_test.go` covers `CalendarConfig.validate()` — unchanged.
|
||||
|
||||
New test plan:
|
||||
|
||||
1. **`mount-calendar.test.ts` (new)** — table-driven:
|
||||
- Empty `items[]` → month view renders 7-column grid + no pills + (for /views) per-day "no entries" only in day/week views.
|
||||
- `items[]` with mixed kinds → pills get the correct `views-calendar-pill--{kind}` class.
|
||||
- `?cal_view=week` → week column grid renders.
|
||||
- Today bucket flagged with `--today` class on the correct cell.
|
||||
- `+N` overflow renders when items per day > MAX_PILLS_PER_MONTH_CELL (3).
|
||||
- `update(items)` after first mount swaps content without leaking listeners (assert no double-fire on month-nav click).
|
||||
2. **`client/events.ts`** — light test (existing pattern): after refactor, switching to Kalender chip mounts the calendar, switching away calls `destroy()`. No test exists for events.ts today (it's mostly DOM glue), so this is a new test or skip with a comment.
|
||||
3. **Smoke (manual, with `bun run build` + dev server)**:
|
||||
- /events Kalender tab loads, shows pills, click pill navigates to detail.
|
||||
- Day-num click → day view (URL changes if urlState is on for /events per Q3).
|
||||
- /views/{slug} with `render_spec.shape=calendar` (need a saved view or temporary system view to exercise) still loads identical pills + drill-down.
|
||||
- /deadlines/calendar → 301 → /events?type=deadline&view=calendar lands on Kalender tab.
|
||||
- /appointments/calendar → 301 → /events?type=appointment&view=calendar lands on Kalender tab.
|
||||
- DE + EN language toggle on both surfaces.
|
||||
- Light + dark theme on both.
|
||||
4. **Build gate**: `go build ./... && go test ./internal/... && cd frontend && bun run build` must all be clean (per task brief).
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks + mitigations
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|---|---|---|
|
||||
| Custom Views users have saved views with `shape=calendar` and rely on the current week/day behaviour | low (shape-calendar is the canonical, only behaviour I'm changing about it is making `urlState` opt-in) | The refactor is structural — same toolbar, same drill-down, same URL params for /views. `urlState=true` stays the default for that surface. |
|
||||
| `paliadin-context.ts` keys (`deadlines.calendar`, `appointments.calendar`) become unreachable after redirects | low | The 301 fires before the client sees the URL; new URL maps to existing `events` context. If we want to preserve the labels, add `events?type=…&view=calendar` matchers in paliadin-context (one if-branch each) — recommend doing this in the same coder PR for tidiness. |
|
||||
| Subtype colouring loss is a feature regression for someone who used /appointments/calendar's legend | low | The page is unreachable from the UI; nobody reaches it without a bookmark. Q4 below confirms with m. |
|
||||
| Events-page calendar `urlState: false` means refresh loses the Kalender chip selection | medium (today: same — calendar is in-memory either way) | Either accept (status quo) or extend events.ts URL state to include `view` (~3 lines). Q3 below. |
|
||||
| /events fetch is unfiltered by date (loads everything); on a busy team Kalender may load slow | medium (existing behaviour) | Not addressed by this refactor. Filed as follow-up in §10. Filter spec / /api/views path solves it but is out of scope here. |
|
||||
| The 301 redirect to `/events?type=…&view=calendar` requires events.ts to honour `view=calendar` from the URL | hard requirement | Must include this in the coder PR. ~3 lines in `readURLState()`. |
|
||||
|
||||
---
|
||||
|
||||
## 9. What stays "out of scope" (consistent with the issue body)
|
||||
|
||||
- New calendar UX: drag-to-create, week-resize, hover-preview, multi-day event spans.
|
||||
- Performance: switching `/events` to a date-window-bounded fetch (today it loads everything and filters client-side).
|
||||
- A unified events↔views landing (e.g. /events as a Saved View). Discussed in `design-events-unification-2026-05-04.md` and `design-data-display-model-2026-05-06.md`; deliberately not folded in here.
|
||||
- /agenda surface. It's a timeline-grouped feed, not a calendar grid — separate conversation if m wants to converge it.
|
||||
- Subtype dot colouring (deferred per §3 trade-off row).
|
||||
|
||||
---
|
||||
|
||||
## 10. Follow-ups (file as separate issues after this lands)
|
||||
|
||||
1. **Date-windowed loading for /events Kalender.** Pass `?from=…&to=…` to `/api/events` matched to the visible month so a 5-year-old project history doesn't ship to the client on every Kalender open. Backend already accepts `from`/`to` per `internal/handlers/events.go`. Small.
|
||||
2. **Per-shape config: subtype colouring.** Add `CalendarConfig.subtype_colors` (bool, default false). Surface a `--subtype-{value}` modifier on the pill so the appointment-type colour key can come back per-view, if a user asks.
|
||||
3. **Multi-day event spans.** Most events are single-day; deadlines are point-in-time. But appointments have `end_at`. Today neither A nor C surfaces span-rendering. Defer until requested.
|
||||
4. **/agenda convergence.** /agenda is a different visual (day-grouped feed), but the data shape is the same `EventListItem`. If m wants /agenda to disappear (it's a sibling overview entry today per `design-events-unification-2026-05-04.md`), consider folding it into /events as a fourth shape ("feed" / "agenda"). Out of this design's scope.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions for head (NO AskUserQuestion — answered via mai instruct)
|
||||
|
||||
> The role brief disables `AskUserQuestion` for this task. Each question below has a defaulted answer marked **(R)**; head/m can confirm or override via `mai instruct head`. After head replies, decisions land in §12.
|
||||
|
||||
**Q1 — Canonical renderer.** Adopt `shape-calendar.ts` as the canon, fold A into it (§3 sketch), and retire the two standalone routes B as 301-redirects to `/events?type=…&view=calendar`?
|
||||
- **(R) Yes** — covers m's intent ("pick the canonical one — likely the Custom Views renderer"). Net code goes down, no schema changes.
|
||||
- Alternative: keep the standalone routes as standalone pages but make them call `mountCalendar` internally — adds nothing for users (page is unreachable), wastes a build target each.
|
||||
- *(answer: yes / keep-standalone / something-else)*
|
||||
|
||||
**Q2 — Events-page Kalender tab: drill-down vs modal-popup.** Today /events Kalender opens a modal listing the day's items. After the refactor, clicking a day-num drills into the day view (changes view chip, same URL component, but the page swaps to a day-list). Drop the modal entirely?
|
||||
- **(R) Drop the modal** — matches /views behaviour, gives a real day-view (not just a list of links), and removes one popup-management code path.
|
||||
- Alternative: keep the modal on /events only (parity break — defeats the point of the issue).
|
||||
- *(answer: drop / keep)*
|
||||
|
||||
**Q3 — URL state for the /events calendar.** Should the /events Kalender persist its view (month/week/day) and date in the URL via `?cal_view=…&cal_date=…` (matching /views)?
|
||||
- **(R) Yes, persist** — refresh-stable, shareable, ~3 lines in `readURLState()`. /views does it. Cost is owning the param contract on /events.
|
||||
- Alternative: in-memory only — today's behaviour. Keeps /events URL surface minimal.
|
||||
- *(answer: persist / in-memory)*
|
||||
|
||||
**Q4 — Subtype dot colouring on appointments.** The orphaned /appointments/calendar today colours dots by appointment_type (Verhandlung / Besprechung / Beratung / Fristverhandlung) with a legend strip. After the refactor pills are kind-coded only (deadline vs appointment vs …). Drop subtype colouring?
|
||||
- **(R) Drop now, file as follow-up** (§10.2) — page is UI-unreachable today; nobody will notice; can come back as a `CalendarConfig.subtype_colors` flag if/when requested.
|
||||
- Alternative: preserve subtype colouring on /events Kalender tab as well, with a fresh legend matching the new pill colours.
|
||||
- *(answer: drop / preserve)*
|
||||
|
||||
**Q5 — Mobile fallback text.** /views Kalender shows a notice "Auf schmalen Bildschirmen empfehlen wir die Listenansicht" (key `views.calendar.mobile_fallback`). Reuse the same key on /events, or add an /events-specific key recommending the events-page "Karten" or "Liste" shape?
|
||||
- **(R) Reuse the existing key** — generic phrasing covers both surfaces; both have Karten/Liste alternatives.
|
||||
- Alternative: dedicated key per surface — clearer copy but more strings to maintain.
|
||||
- *(answer: reuse / dedicated)*
|
||||
|
||||
**Q6 — Test approach for the extracted module.** Add `mount-calendar.test.ts` with the seven listed cases (§7.1), OR also add a Playwright smoke that drives the new flow end-to-end through both surfaces?
|
||||
- **(R) Unit tests + manual smoke gauntlet** — matches the codebase's existing test layout (most client/* tests are unit-level; Playwright is reserved for fewer flows). Manual smoke per §7.3 is the brief's bar.
|
||||
- Alternative: unit + Playwright.
|
||||
- *(answer: unit-only / unit-plus-playwright)*
|
||||
|
||||
**Q7 — Sequencing across PRs.** One PR (extract + adopt + retire + CSS prune) or three (extract, then adopt+retire, then CSS prune)?
|
||||
- **(R) One PR** — refactors that don't bisect well are worse split (each intermediate state has unused exports / dead code paths / orphaned CSS classes for a few hours). The diff is reviewable in one read because it's mostly moves + deletes.
|
||||
- Alternative: three PRs — easier rollback at each step, but you'd have to land #2 before m sees any UI alignment, which loses the point.
|
||||
- *(answer: one-pr / three-pr)*
|
||||
|
||||
**Q8 — When (if at all) to delete /events `events.calendar.empty` i18n key.** Replaced by `cal.day.no_entries` in the new flow. Drop now or leave as a dead key in `i18n-keys.ts` for one release?
|
||||
- **(R) Drop now** — i18n-keys.ts is the source of truth; dead keys aren't enforced at compile time but they're a slow-rotting maintenance tax. /events' new calendar surface doesn't render an "empty month" message any more (per-day "no entries" is the only empty state, matching /views).
|
||||
- Alternative: leave for one release as a soft-deprecate.
|
||||
- *(answer: drop / leave)*
|
||||
|
||||
---
|
||||
|
||||
## 12. m's decisions (2026-05-20, via head msg #2087)
|
||||
|
||||
Head accepted all 8 (R) defaults in one round-trip ("Design accepted in
|
||||
full — all 8 (R) defaults stand"). Recorded verbatim below; each entry
|
||||
is the (R) pick from §11.
|
||||
|
||||
- **Q1 — Canonical renderer:** Yes. Canonicalise on `shape-calendar.ts`; fold A into it via extracted `mountCalendar()`; retire B as 301 redirects to `/events?type=…&view=calendar`.
|
||||
- **Q2 — Drill-down vs modal:** Drop the modal on /events. Day-num/+N click drills into the day view, matching /views.
|
||||
- **Q3 — URL state on /events:** Persist. /events Kalender reads/writes `?cal_view=…&cal_date=…` like /views does. Adds `view=calendar` to `client/events.ts:readURLState()` so refreshes/redirects land on the Kalender tab.
|
||||
- **Q4 — Subtype dot colouring:** Drop now. Filed as follow-up §10.2. Pills are kind-coded only after the refactor (deadline / appointment / project_event / approval_request).
|
||||
- **Q5 — Mobile fallback text:** Reuse the existing `views.calendar.mobile_fallback` key on /events as well — generic phrasing covers both surfaces.
|
||||
- **Q6 — Test approach:** Unit tests (`mount-calendar.test.ts`) + manual smoke gauntlet (§7.3). No Playwright on this refactor.
|
||||
- **Q7 — Sequencing:** One PR. Extract + adopt + retire + CSS prune land together on `mai/bohr/calendar-view-align`.
|
||||
- **Q8 — Empty-state i18n key:** Drop dead keys now (`events.calendar.empty`, `appointments.kalender.*`, `deadlines.kalender.*`, appointment-type legend keys not used elsewhere).
|
||||
|
||||
---
|
||||
|
||||
## 13. Coder hand-off (after m's go on §11)
|
||||
|
||||
Once §12 is filled in, the coder shift can proceed in this order:
|
||||
|
||||
1. Create `frontend/src/client/calendar/mount-calendar.ts` + `frontend/src/client/calendar/mount-calendar.test.ts`. Lift the shape-calendar internals; add `update`/`destroy` to the returned handle; pipe `urlState` + `urlPrefix` through.
|
||||
2. Update `frontend/src/client/views/shape-calendar.ts` to delegate to `mountCalendar` (≈30 lines after the lift).
|
||||
3. Update `frontend/src/client/events.ts`: import `mountCalendar`, replace `renderCalendar`/`openCalPopup` and nav handlers with a `mountCalendar(host, items, { urlState: <per Q3>, defaultView: "month" })` call inside the existing `applyView()` branch. Add the `view=calendar` URL state handling per Q3.
|
||||
4. Update `frontend/src/events.tsx`: strip the `events-calendar-wrap` inline DOM (toolbar + grid + modal). The empty container `<div id="events-shape-calendar" />` plus a wrapper class is enough — `mountCalendar` builds the DOM.
|
||||
5. Delete `frontend/src/appointments-calendar.tsx`, `frontend/src/deadlines-calendar.tsx`, `frontend/src/client/appointments-calendar.ts`, `frontend/src/client/deadlines-calendar.ts`.
|
||||
6. Update `frontend/build.ts`: remove the `*-calendar.ts` entry-point lines (≈250s) and the `*-calendar.html` writes (≈387s).
|
||||
7. Update `internal/handlers/deadlines_pages.go` + `internal/handlers/appointments_pages.go`: turn the two calendar handlers into 301 redirects to `/events?type=…&view=calendar`.
|
||||
8. Update `frontend/src/styles/global.css`: delete `.frist-cal-*`, `.termin-cal-*`, `.events-cal-dot-appointment`, the 700px-media tweak (lines ~7464-7620, ~8019-8023, ~8680-8700, ~11519-11533). Sanity-check no other consumer (already verified via grep — none).
|
||||
9. Update i18n: drop `appointments.kalender.*`, `deadlines.kalender.*`, `appointments.type.*` (legend keys only — keep type values used elsewhere), `events.calendar.empty` per Q8. Make sure `cal.view.*`, `cal.day.no_entries`, `cal.day.back_to_month`, `cal.day.open_day`, `views.calendar.mobile_fallback` (or a new events-specific key per Q5) all exist DE + EN — most already do.
|
||||
10. `paliadin-context.ts`: optional one-line addition to map `events?view=calendar` to the new context label.
|
||||
11. Run `go build ./... && go test ./internal/... && cd frontend && bun run build`.
|
||||
12. Manual smoke per §7.3.
|
||||
13. Commit. `mai report completed` with SHA per task brief.
|
||||
|
||||
Estimated coder shift: one PR per Q7 (R).
|
||||
|
||||
---
|
||||
415
docs/design-modal-pattern-2026-05-20.md
Normal file
415
docs/design-modal-pattern-2026-05-20.md
Normal file
@@ -0,0 +1,415 @@
|
||||
# Design — Unified modal pattern + suggest-changes rework
|
||||
|
||||
**Author:** hertz (inventor)
|
||||
**Date:** 2026-05-20
|
||||
**Task:** t-paliad-217 (m/paliad#45)
|
||||
**Branch:** `mai/hertz/inventor-unified-modal`
|
||||
**Status:** DESIGN — open questions await m before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
m's feedback on the suggest-changes modal shipped in t-paliad-216 Slice B:
|
||||
|
||||
> I dont like the "suggest correction" modal — it should basically be a modal for all deadline information with an additional "comment" field — currently the layout isnt nice either, font size etc. We dont have too many modals yet but I think we should have a unified modal approach.
|
||||
|
||||
Two asks bundled. **Immediate:** rework the suggest-changes modal so it shows all deadline (or appointment) information — not just the date-allowlist subset — with an additional comment field; polish typography + spacing. **Longer-term:** add a unified modal primitive (CSS frame + TS function) so future modals stop reinventing the same shell six different ways.
|
||||
|
||||
Scope of this design is the **frame** (modal infra: overlay, header/body/footer, ESC, focus trap, scroll-lock, ARIA, mobile UX) and the **suggest-changes rework**. Migrating every existing modal is **out of scope** — only suggest-changes ships on the new primitive in this PR; the other modals migrate one-at-a-time in follow-up PRs as they need touching.
|
||||
|
||||
---
|
||||
|
||||
## 0a. m's decisions (2026-05-20)
|
||||
|
||||
| # | Header | m picked | Reasoning note (when different from recommendation) |
|
||||
|---|---|---|---|
|
||||
| Q1 | Edit scope | **(b) Full-edit — every field editable.** | Differs from (a). Inventor recommended Reading B (full-view, partial-edit) to preserve the t-paliad-138 "approval triggered only by date changes" lock-in. m's pick loosens that: non-date counter-edits now flow through 4-Augen. Concretely this needs a backend expansion of `buildRevertSetClauses` + `counter_payload` schema to accept title / description / notes / rule_code / event_type_ids (deadline) and title / description / location / appointment_type (appointment). See §7 Slice C below for the additional backend slice. |
|
||||
| Q2 | API shape | **(a) Function returning a Promise.** | As recommended. |
|
||||
| Q3 | Substrate | **(a) Native `<dialog>` with `.showModal()`.** | As recommended. |
|
||||
| Q4 | Migration scope | **(b) Suggest-changes + broadcast.ts.** | Differs from (a). Inventor recommended suggest-changes only as the smallest reviewable PR. m wants broadcast.ts retrofitted too — it's the largest existing modal and demonstrates the primitive's generality. Two migrated call sites in this PR. |
|
||||
| Q5 | Mobile UX | **(a + constraints) Full-screen takeover, ABOVE the PWA bottom controls, with a close button, with browser back-button closing the modal.** | Refines (a). Additional constraints captured: the full-screen modal must NOT cover the bottom-nav (so `max-height` accounts for `--bottom-nav-height`); a close button (the existing `.modal__close` X) is mandatory; back-button closes via `history.pushState` on open + `popstate` listener. |
|
||||
| Q6 | Typography | **(a) Match `/deadlines/new` + views editor form-field shapes.** | As recommended. |
|
||||
|
||||
The decisions above lock the design. §7 implementation sketch is updated to reflect them, including the new Slice C backend extension that Reading A on Q1 requires.
|
||||
|
||||
---
|
||||
|
||||
## 1. Current state — modal landscape audit (2026-05-20)
|
||||
|
||||
### CSS (frontend/src/styles/global.css)
|
||||
|
||||
The CSS already has the bones, but it's accidentally forked. Two declarations of `.modal-overlay` exist (lines 3887 and 4452) — the second one (z-index 1000) shadows the first (z-index 100). Three container classes (`.modal-card`, `.modal-content`, `.modal`) overlap heavily but differ in padding + shadow. Each child surface piles its own modifiers (`.modal-broadcast`, `.event-type-add-modal`, `.event-type-browse-modal`, `.modal-card-wide`, `.invite-modal-body`, `.smart-timeline-modal-card`, `#suggest-form`) on top.
|
||||
|
||||
That's the underlying problem the unified primitive needs to clean up.
|
||||
|
||||
### TS call sites
|
||||
|
||||
| File | What it opens | API shape | ESC | Focus trap | Scroll lock | Backdrop click | Notes |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `components/approval-edit-modal.ts` | Suggest-changes editor | Function (Promise-returning) | yes | no | no | yes | This task's main subject. Hard-coded date-allowlist fields. |
|
||||
| `broadcast.ts` | Compose email to selected users | DOM-imperative | yes (close button + ESC handler) | no | no | yes | The richest existing modal — template picker + recipients list + markdown body. |
|
||||
| `event-types.ts` (add + browse, 2 modals) | Add a new event_type / browse all | DOM-imperative | yes | yes (browse modal only) | no | yes | Browse modal is the only one with a real focus trap (lines 793–815). |
|
||||
| `fristenrechner.ts` | Save calculated date as a deadline | DOM-imperative (renders into existing HTML host) | unclear (host-driven) | no | no | unclear | Two save modals + an inline edit modal. |
|
||||
| `filter-bar/save-modal.ts` | Save current filter bar state as a view | Function | no (form-submit only) | no | no | no | Smallest; close-via-cancel-button only. |
|
||||
| `admin-rules-edit.ts` | Reason modal for save-draft / publish | DOM-imperative (server-rendered host) | yes (form-submit) | no | no | unclear | Reason ≥10 char validated client-side. |
|
||||
| `projects-detail.ts` | Smart-timeline modal | DOM-imperative | yes | no | no | unclear | Renders timeline overlay. |
|
||||
| `sidebar.ts` | Invite teammate modal | DOM-imperative | yes | no | no | yes | Lives in static HTML; client just opens/closes. |
|
||||
|
||||
Total: ~9 distinct modal surfaces, ~5 different opening idioms. None use the native `<dialog>` element. Body-scroll-lock is implemented only for the sidebar, not for any modal. ARIA `role="dialog" aria-modal="true"` is present on most but not consistent.
|
||||
|
||||
### Build / language baseline
|
||||
|
||||
- `frontend/tsconfig.json`: `jsxFactory: "h"`, `jsxFragmentFactory: "Fragment"` — paliad has its own custom JSX renderer (not React). Most client code is plain `.ts` with `innerHTML` strings + event wiring. A handful of TSX exists but the modal surfaces are uniformly `.ts` + `innerHTML`.
|
||||
- Browser baseline: modern evergreen (Chrome / Edge / Safari / Firefox, current versions). `<dialog>` element + `:modal` pseudo + `dialog.showModal()` + native focus-trap + backdrop are universally supported in this baseline.
|
||||
|
||||
---
|
||||
|
||||
## 2. Design — the suggest-changes rework
|
||||
|
||||
This is the immediate user-visible change. m wants the modal to show "all deadline information" with an additional comment field. Two readings of "all information":
|
||||
|
||||
- **Reading A (full edit):** every field on the Deadline / Appointment model is editable in the modal. The server's `buildRevertSetClauses` (in `internal/services/approval_service.go`) and the `counter_payload` schema both need to expand to cover title, description, notes, rule_code, event_type_ids (deadline) and title, description, location, appointment_type (appointment). This is a real backend change.
|
||||
- **Reading B (full view, partial edit):** the modal shows every field — but only the existing allowlist (dates) is editable; the rest renders as read-only context so the approver understands what they're suggesting changes to. No backend change.
|
||||
|
||||
Q1 below picks between them. Inventor recommends **B** for v1: the suggest-changes flow is about modifying *what changes* the requester proposed (dates), not about authoring a completely different deadline. Read-only context is honest about the system's actual mutation surface — and aligns with the approval-policy "only date-changing fields trigger 4-eye" lock-in from t-paliad-138 §Q4.
|
||||
|
||||
Either way, the modal structure is the same. With m's pick:
|
||||
|
||||
### Layout (Reading B, recommended)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Änderungen vorschlagen — Frist · Klageerwiderung [×] │ ← header
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ ◯ Bearbeitbare Felder │ ← visually emphasized
|
||||
│ │
|
||||
│ Fälligkeitsdatum [ 2026-06-15 ] │
|
||||
│ Ursprüngliches [ 2026-06-01 ] │
|
||||
│ Warndatum [ 2026-06-10 ] │
|
||||
│ │
|
||||
│ ◯ Kontext (nur Anzeige) │ ← visually de-emphasised
|
||||
│ │
|
||||
│ Titel Klageerwiderung einreichen │
|
||||
│ Beschreibung Frist für die Antragserwiderung im … │
|
||||
│ Notizen Bitte mit Mandant abstimmen. │
|
||||
│ Regel RoP.029 │
|
||||
│ Ereignistyp(en) Replik │
|
||||
│ Akte 12345 — Müller ./. Schmidt │
|
||||
│ Eingereicht von Anna Müller · 20.05.2026 │
|
||||
│ │
|
||||
│ ◯ Kommentar zum Vorschlag │ ← always present
|
||||
│ [textarea — Warum sollen die Daten angepasst werden? ] │
|
||||
│ │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ [Abbrechen] [Vorschlag einreichen] │ ← footer
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Three sections in the body, separated by light visual rules:
|
||||
- **Bearbeitbare Felder** — the actual editable inputs (date allowlist for deadlines; datetime allowlist for appointments).
|
||||
- **Kontext** — every other field rendered as `<label> + <span class="value">` pairs. Non-editable. Visually muted so the eye lands on the editable section first.
|
||||
- **Kommentar zum Vorschlag** — free-text textarea, always present.
|
||||
|
||||
Footer has Cancel + Submit. Submit stays disabled until at least one editable field is dirty OR the comment textarea has non-whitespace content (mirrors server `ErrSuggestionRequiresChange`).
|
||||
|
||||
### Appointment variant
|
||||
|
||||
Same shape. Editable section: `start_at`, `end_at`. Context section: title, description, location, appointment_type, project. The lifecycle restriction still applies — suggest-changes only fires for `lifecycle=update` (shape-list.ts gates this), so the modal never opens on create / complete / delete.
|
||||
|
||||
### Typography / spacing
|
||||
|
||||
Adopt the form-field shapes already used by `/deadlines/new` and the views editor (label above input, 1rem field gap, label size `0.9rem`, value font `1rem`, modal padding `1.5rem`, body max-height `min(90vh, 40rem)` with scroll). The current modal uses inline labels (`<label class="suggest-field">`) — switch to block labels for room and consistency.
|
||||
|
||||
---
|
||||
|
||||
## 3. Design — unified modal primitive
|
||||
|
||||
### Choice of substrate: native `<dialog>` vs. div-based overlay
|
||||
|
||||
Native `<dialog>` element with `.showModal()` is the recommended substrate. The browser handles:
|
||||
- ESC to dismiss (via `dialog.cancel` event)
|
||||
- Backdrop styling via `::backdrop` pseudo-element
|
||||
- Focus trap (modern browsers; auto-focuses first focusable on open)
|
||||
- ARIA `role="dialog" aria-modal="true"` implicit
|
||||
- Stacking context above all page content (top layer)
|
||||
|
||||
Trade-off: `<dialog>` can't be transition-animated through `display: none` (the top-layer rules conflict with display-toggle transitions); we accept that — modals open/close abruptly today anyway, and the no-animation cost is invisible compared to the focus + ESC + a11y wins.
|
||||
|
||||
Q3 lets m flip the recommendation to a div-based overlay if there's a reason to.
|
||||
|
||||
### API shape (function call returning a Promise)
|
||||
|
||||
```ts
|
||||
// frontend/src/client/components/modal.ts
|
||||
|
||||
interface ModalConfig<T> {
|
||||
title: string; // header text
|
||||
body: HTMLElement | string; // body content (string is HTML — caller's responsibility to escape)
|
||||
primary: { label: string; handler: (close: (result: T) => void) => void };
|
||||
secondary?: { label: string }; // defaults to "Abbrechen"
|
||||
size?: "sm" | "md" | "lg" | "full"; // surface width preset
|
||||
onClose?: () => void; // fired on cancel (ESC, backdrop, secondary)
|
||||
classNames?: string; // extra classes on the .modal-card
|
||||
}
|
||||
|
||||
export function openModal<T = void>(config: ModalConfig<T>): Promise<T | null>;
|
||||
```
|
||||
|
||||
The handler receives the `close` callback so the primary action can validate, fetch, then resolve. Resolving with `null` from any path (cancel, ESC, backdrop) keeps caller branching consistent.
|
||||
|
||||
Suggest-changes becomes:
|
||||
|
||||
```ts
|
||||
const result = await openModal<{counterPayload, note}>({
|
||||
title: t("approvals.suggest.modal_title") + " — " + entityLabel,
|
||||
body: buildSuggestChangesBody(entityType, payload, preImage),
|
||||
primary: {
|
||||
label: t("approvals.suggest.submit"),
|
||||
handler: (close) => {
|
||||
const result = readForm(body);
|
||||
if (!result.dirty && !result.note) return; // server-side enforced too
|
||||
close(result);
|
||||
},
|
||||
},
|
||||
size: "md",
|
||||
});
|
||||
```
|
||||
|
||||
The function-call API is recommended over a class-API (no need for a per-instance handle; modals are short-lived) and over a component-API (would require wider TSX adoption, which paliad's frontend isn't on today). Q2 lets m flip.
|
||||
|
||||
### CSS — what the primitive nails down
|
||||
|
||||
A canonical `<dialog>`-backed CSS block:
|
||||
|
||||
```css
|
||||
dialog.modal {
|
||||
border: none;
|
||||
border-radius: calc(var(--radius) * 1.5);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: 0;
|
||||
max-width: min(90vw, var(--modal-max-w, 480px));
|
||||
max-height: min(90vh, 40rem);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
dialog.modal::backdrop {
|
||||
background: var(--color-overlay-modal);
|
||||
}
|
||||
dialog.modal[data-size="lg"] { --modal-max-w: 640px; }
|
||||
dialog.modal[data-size="sm"] { --modal-max-w: 380px; }
|
||||
dialog.modal[data-size="full"] {
|
||||
--modal-max-w: 100vw;
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.modal__header { padding: 1.25rem 1.5rem 0.75rem; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--color-border); }
|
||||
.modal__title { font-size: 1.15rem; font-weight: 700; margin: 0; }
|
||||
.modal__close { background: none; border: none; cursor: pointer; font-size: 1.5rem; color: var(--color-text-muted); }
|
||||
.modal__body { padding: 1.25rem 1.5rem; overflow-y: auto; }
|
||||
.modal__footer { padding: 0.75rem 1.5rem 1.25rem; display: flex; gap: 0.75rem; justify-content: flex-end; border-top: 1px solid var(--color-border); }
|
||||
```
|
||||
|
||||
The existing forks (`.modal-overlay` at lines 3887 + 4452, `.modal-card`, `.modal-content`, `.modal`) stay in place during the migration window — they're loaded by the not-yet-migrated modals. The unified primitive uses BEM-style `.modal__*` to avoid colliding with the legacy class hierarchy. Migration-by-migration each old modal flips to the new substrate, and once all sites are migrated we delete the duplicates.
|
||||
|
||||
### Mobile
|
||||
|
||||
Phone-width breakpoint (`max-width: 32rem` ≈ 512px in the existing CSS) switches `dialog.modal` to `data-size="full"` automatically via a media-query override. Full-screen takeover on mobile is the standard pattern; the alternative (centered card on phone) wastes horizontal real estate. Q5 lets m flip to sheet-from-bottom if preferred.
|
||||
|
||||
### Body-scroll lock
|
||||
|
||||
`<dialog>.showModal()` handles this natively (page behind a top-layer dialog can scroll only if the dialog's CSS allows). For the div-based fallback (if Q3 picks it), we add `document.body.classList.add("no-scroll")` on open + remove on close — same class the sidebar already uses.
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration scope (this PR)
|
||||
|
||||
In this slice:
|
||||
- **components/modal.ts** — the new primitive (open / close / focus / ESC / backdrop / sizes).
|
||||
- **components/approval-edit-modal.ts** — rewritten on top of `openModal()`. New full-info layout per §2. Drops the per-modal ESC + focus management since the primitive handles them.
|
||||
- **CSS** — `dialog.modal` + `.modal__*` block in `global.css`. Legacy `.modal-overlay` / `.modal-card` / `.modal-content` / `.modal` stay in place until each call site migrates.
|
||||
|
||||
Out of scope this PR (each is its own follow-up):
|
||||
- broadcast.ts → openModal()
|
||||
- event-types.ts add + browse → openModal()
|
||||
- fristenrechner.ts save modal → openModal()
|
||||
- filter-bar/save-modal.ts → openModal()
|
||||
- admin-rules-edit.ts reason modal → openModal()
|
||||
- sidebar.ts invite modal → openModal()
|
||||
- projects-detail.ts smart-timeline modal → openModal()
|
||||
|
||||
That gives one PR per legacy modal — small, individually reviewable, and each one shrinks the duplicated CSS by one chunk. Once all are migrated the legacy classes get deleted in a final cleanup PR.
|
||||
|
||||
Q4 lets m bundle one or two of these into the current task if he wants more momentum.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open questions (the historical record)
|
||||
|
||||
### Q1 — Field editability scope in the suggest-changes modal
|
||||
|
||||
The header "modal for all deadline information" can mean two different things in the implementation:
|
||||
|
||||
- **(a) Reading B: full-view, partial-edit (Recommended).** Modal shows every Deadline / Appointment field. Dates (the existing allowlist) are editable inputs; everything else is rendered read-only as context. No backend change required — current `counter_payload` allowlist + `buildRevertSetClauses` already accept exactly the editable fields.
|
||||
- **(b) Reading A: full-edit.** Modal shows every field as an editable input. Server-side `buildRevertSetClauses` + `counter_payload` schema expand to accept title / description / notes / rule_code / event_type_ids (deadline) and title / description / location / appointment_type (appointment). Backend change. This loosens the t-paliad-138 lock-in that approval was triggered only by date changes — non-date counter-edits would now flow through 4-Augen too.
|
||||
- (c) Hybrid: dates editable + a small editable subset (title / notes) + the rest read-only. Compromise; expand allowlist conservatively.
|
||||
|
||||
### Q2 — Modal primitive API shape
|
||||
|
||||
- **(a) Function-call returning a Promise (Recommended).** `openModal({title, body, primary, secondary, size}): Promise<T | null>`. Caller awaits, no per-instance state to manage. Matches the existing approval-edit-modal pattern.
|
||||
- (b) Class with `.open()` / `.close()` / event listeners. More familiar to OO codebases but paliad's frontend is functional.
|
||||
- (c) Component-API in TSX (`<Modal isOpen={…}>`). Would require wider TSX adoption in client code, which paliad isn't on today.
|
||||
|
||||
### Q3 — Substrate: native `<dialog>` vs div-based overlay
|
||||
|
||||
- **(a) Native `<dialog>` element (Recommended).** Browser handles ESC + focus + ARIA + top-layer stacking. Less code, fewer bugs, better a11y out-of-the-box.
|
||||
- (b) Div-based overlay with manual focus trap + ESC + body-scroll lock. More code; full control over animation / transitions; doesn't depend on browser quirks. Trade-off: a11y bugs are easy to ship.
|
||||
|
||||
### Q4 — Migration scope in this PR
|
||||
|
||||
- **(a) Suggest-changes only (Recommended).** Primitive + one migrated call site. Smallest reviewable PR. Other modals migrate one-PR-per-modal as follow-ups when they need touching anyway.
|
||||
- (b) Suggest-changes + broadcast.ts (the largest existing modal, biggest cleanup win). Doubles PR scope but demonstrates the primitive's generality.
|
||||
- (c) Suggest-changes + all six other modals in one PR. High review cost, high regression risk; "rip the bandaid" approach.
|
||||
|
||||
### Q5 — Mobile UX
|
||||
|
||||
- **(a) Full-screen takeover at phone width (Recommended).** Modal expands to 100vw × 100vh below the 32rem breakpoint. Standard pattern; what most apps do.
|
||||
- (b) Bottom-sheet (slides up from bottom, can be dragged down to close). Native-app vibe but a transition library is needed and dragging adds complexity.
|
||||
- (c) Center-stage even on phone. Wastes horizontal space; rejected.
|
||||
|
||||
### Q6 — Typography baseline to match
|
||||
|
||||
- **(a) Match `/deadlines/new` + views editor form-field shapes (Recommended).** Existing form-field rules (label above input, 1rem gap, label `0.9rem`, value `1rem`). Already what most of the app uses.
|
||||
- (b) New typographic scale just for modals. More work, divergent from the rest of the app.
|
||||
|
||||
---
|
||||
|
||||
## 6. m's decisions (filled in after AskUserQuestion)
|
||||
|
||||
_To be appended at §0a after the chip-picker calls return._
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation sketch (decisions-locked)
|
||||
|
||||
Five reviewable slices, one PR.
|
||||
|
||||
### Slice A — components/modal.ts + CSS block
|
||||
|
||||
The unified primitive. Standalone module; nothing else depends on it yet.
|
||||
|
||||
```ts
|
||||
// frontend/src/client/components/modal.ts
|
||||
export interface ModalConfig<T> {
|
||||
title: string;
|
||||
body: HTMLElement | string; // string = HTML, caller must pre-escape
|
||||
primary: { label: string; handler: (close: (result: T) => void) => void };
|
||||
secondary?: { label: string }; // defaults to "Abbrechen"
|
||||
size?: "sm" | "md" | "lg" | "full"; // 380 / 480 / 640 / phone-takeover
|
||||
onClose?: () => void;
|
||||
classNames?: string;
|
||||
}
|
||||
export function openModal<T = void>(config: ModalConfig<T>): Promise<T | null>;
|
||||
```
|
||||
|
||||
Internals:
|
||||
- Builds a `<dialog class="modal" data-size="md">` with `__header`, `__body`, `__footer`.
|
||||
- Calls `dialog.showModal()` — the browser activates top-layer, ESC dismissal, focus trap.
|
||||
- Records the previously-focused element on open; restores it on close. (Native `<dialog>` doesn't do this automatically.)
|
||||
- **History integration (Q5 constraint):** on open, `history.pushState({modal: true}, "")`. Attach a `popstate` listener that calls the close path (resolves null). On programmatic close (primary handler resolves, or secondary clicked), `history.back()` to pop the state. The listener is removed in the close path to avoid double-close.
|
||||
- Backdrop click closes via the dialog's own click event (`if (e.target === dialog) close(null)`).
|
||||
- ESC closes via the dialog's `cancel` event.
|
||||
|
||||
CSS block:
|
||||
|
||||
```css
|
||||
dialog.modal {
|
||||
border: none;
|
||||
border-radius: calc(var(--radius) * 1.5);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: 0;
|
||||
background: var(--color-surface);
|
||||
max-width: min(90vw, var(--modal-max-w, 480px));
|
||||
max-height: min(90vh, 40rem);
|
||||
}
|
||||
dialog.modal::backdrop { background: var(--color-overlay-modal); }
|
||||
dialog.modal[data-size="sm"] { --modal-max-w: 380px; }
|
||||
dialog.modal[data-size="lg"] { --modal-max-w: 640px; }
|
||||
dialog.modal[data-size="full"] { --modal-max-w: 100vw; max-height: 100vh; border-radius: 0; }
|
||||
|
||||
@media (max-width: 32rem) {
|
||||
dialog.modal { --modal-max-w: 100vw; border-radius: 0; }
|
||||
/* Q5: full-screen modal must not cover the PWA bottom-nav. */
|
||||
dialog.modal { max-height: calc(100vh - var(--bottom-nav-height, 3.5rem)); margin-bottom: var(--bottom-nav-height, 3.5rem); }
|
||||
}
|
||||
|
||||
.modal__header { padding: 1.25rem 1.5rem 0.75rem; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--color-border); }
|
||||
.modal__title { font-size: 1.15rem; font-weight: 700; margin: 0; }
|
||||
.modal__close { background: none; border: none; cursor: pointer; font-size: 1.5rem; color: var(--color-text-muted); padding: 0.25rem 0.5rem; }
|
||||
.modal__body { padding: 1.25rem 1.5rem; overflow-y: auto; }
|
||||
.modal__footer { padding: 0.75rem 1.5rem 1.25rem; display: flex; gap: 0.75rem; justify-content: flex-end; border-top: 1px solid var(--color-border); }
|
||||
```
|
||||
|
||||
Close button is mandatory per Q5 — always rendered in the header.
|
||||
|
||||
### Slice B — backend expansion for full-edit counter_payload (Q1 Reading A)
|
||||
|
||||
m picked full-edit on Q1 — the server must accept counter_payload fields beyond the date allowlist. Three changes in `internal/services/approval_service.go`:
|
||||
|
||||
1. **Rename `buildRevertSetClauses` → `buildEntityFieldSetClauses`** and expand the per-entity-type allowlist:
|
||||
- Deadline: existing `due_date`, `original_due_date`, `warning_date`, `status`, `completed_at` PLUS `title`, `description`, `notes`, `rule_code`, `event_type_ids` (the last is a junction table — needs separate handling).
|
||||
- Appointment: existing `start_at`, `end_at`, `completed_at` PLUS `title`, `description`, `location`, `appointment_type`.
|
||||
2. **Separate the "revert allowlist" from the "counter allowlist."** Reject's `applyRevert` and SuggestChanges's `applyEntityUpdate` should call slightly different helpers — Reject restores ONLY what's in the pre_image (defence-in-depth: a malformed pre_image must not be able to write arbitrary fields), while SuggestChanges writes WHATEVER the approver supplied in the counter (subject to the new expanded allowlist). Both share the per-entity-type list of "what's a real column."
|
||||
3. **event_type_ids handling.** Junction-table column — needs `DELETE FROM paliad.deadline_event_types WHERE deadline_id=$1; INSERT ... FOR each new id` inside the same tx. Adds a few lines but no schema change. Skip if m wants to defer event_type editing in the modal (small extra Q at implementation time).
|
||||
|
||||
**The `applyEntityUpdate` call in SuggestChanges already exists from t-paliad-216 Slice A.** The expansion is a SQL-allowlist widening + a junction-table write for event_type_ids — no new approval_requests columns needed (the existing `counter_payload jsonb` already holds the wider shape).
|
||||
|
||||
Update the test suite: extend `TestApprovalService_SuggestChanges_HappyPath` and add new tests for title-only counter and notes-only counter to lock in the expanded allowlist.
|
||||
|
||||
### Slice C — approval-edit-modal.ts rewrite
|
||||
|
||||
Drops every per-modal handler (ESC, focus, backdrop) — the primitive owns them. Renders via `openModal()`. Body has the three-section layout from §2 with EVERY field editable per Q1:
|
||||
|
||||
- **Deadline editable fields:** title, description, due_date, original_due_date, warning_date, notes, rule_code, event_type_ids. Status + completed_at stay read-only (lifecycle-dependent).
|
||||
- **Appointment editable fields:** title, description, start_at, end_at, location, appointment_type. completed_at stays read-only.
|
||||
- **Read-only context (both):** project (link), created_at, requester (with timestamp), approval status pill ("offen / Genehmigung beantragt von X"), event-type chips for deadlines (if not editable here).
|
||||
- **Always:** Vorschlagskommentar textarea (the existing `note`).
|
||||
|
||||
Layout uses block labels matching `/deadlines/new` (Q6). Submit disabled until dirty OR note has content (server-enforced `ErrSuggestionRequiresChange`).
|
||||
|
||||
### Slice D — broadcast.ts retrofit
|
||||
|
||||
Per Q4. The richest existing modal; demonstrates the primitive's generality. Replace its bespoke DOM construction with `openModal({title, body, primary, secondary, size: "lg"})`. The body keeps its existing layout — only the shell, ESC, close-button, backdrop wiring delegates to the primitive. Drop `.modal-broadcast` CSS overrides that the new primitive handles for free.
|
||||
|
||||
Verify the existing markdown-preview + template-picker + recipient-list features still work after the migration. Smoke: open it, fill a template, send.
|
||||
|
||||
### Slice E — i18n + CSS cleanup
|
||||
|
||||
Fill any i18n gaps in `deadlines.field.*` / `appointments.field.*` for the new read-only labels (description, location, appointment_type, etc. — most already exist). Add `modal.close.label` ("Schließen" / "Close"). Don't delete the legacy `.modal-overlay` / `.modal-card` / `.modal-content` / `.modal` CSS yet — the other 6 unmigrated modals still depend on them.
|
||||
|
||||
### Total scope
|
||||
|
||||
Five slices, one PR. Backend expansion (Slice B) is the only schema-adjacent piece and stays SQL-only (no migration needed — `counter_payload jsonb` already accepts arbitrary shape; the change is in the column-allowlist regex/switch on read).
|
||||
|
||||
Coder shift gating per project CLAUDE.md.
|
||||
|
||||
---
|
||||
|
||||
## 8. Out of scope
|
||||
|
||||
- Generic form-builder framework — the modal is the **frame**, not the body content.
|
||||
- Visual redesign of any non-modal surface.
|
||||
- Migrating the other 7 modals — each becomes its own PR after this one lands.
|
||||
- Adding new modals to surfaces that don't currently have one.
|
||||
- Animation / transition library — modals stay non-animated for v1 (the dialog API has its own animation story for a follow-up).
|
||||
|
||||
---
|
||||
|
||||
## 9. Risks / open considerations
|
||||
|
||||
- **Native `<dialog>` quirks.** Older Safari versions (≤ 15.3) had bugs with `::backdrop` and top-layer focus management. paliad's browser baseline is current evergreen so this should be fine, but the first PR includes a CSS smoke (verify backdrop renders + ESC closes + focus lands on first input) on Chrome / Firefox / Safari.
|
||||
- **CSS legacy debt.** The unified primitive co-exists with `.modal-overlay` / `.modal-card` / `.modal-content` / `.modal` during the migration window. Final cleanup is a separate PR after the last legacy modal flips. Don't delete the legacy classes early — that would break unmigrated surfaces.
|
||||
- **Focus restoration on close.** `<dialog>.close()` does NOT restore focus to the previously-focused element by default — we manually record + restore on open / close. Easy to forget.
|
||||
- **Form-element name collisions.** When multiple modals could in principle stack, ID collisions on `<input id="..."` matter. The primitive should scope IDs per-instance (UUID prefix or counter) or use data-attrs only. The existing approval-edit-modal uses `data-suggest-field` already; the primitive should make that idiomatic.
|
||||
- **i18n coverage for context-section labels.** Most fields have `deadlines.field.*` / `appointments.field.*` keys; a few may not (e.g. created_at). Gap-fill in this PR rather than punting to the coder.
|
||||
- **m's "all information" reading.** If m picks Reading A (full-edit) on Q1, this becomes a t-paliad-138 policy change — non-date counter-edits would now flow through 4-Augen. That's a real product decision, not just a UI choice. Flag it explicitly when posing the question.
|
||||
@@ -1,686 +0,0 @@
|
||||
# Project metadata rework — Client Role + auto-derived project codes
|
||||
|
||||
Status: design, ready for head review (2026-05-20)
|
||||
Task: t-paliad-222
|
||||
Issues: m/paliad#47 (Client Role) + m/paliad#50 (project codes)
|
||||
Branch: `mai/kepler/inventorcoder-project`
|
||||
|
||||
Pairs two related changes because both touch `paliad.projects` schema, the
|
||||
project form, and downstream consumers (Fristenrechner Determinator,
|
||||
submission templates, Verlauf, picker / breadcrumb surfaces). One design,
|
||||
two migrations, one coder shift.
|
||||
|
||||
---
|
||||
|
||||
## §1 Scope & non-goals
|
||||
|
||||
In scope:
|
||||
|
||||
- Drop "Wir vertreten" entirely on `type='client'`, `'litigation'`, `'patent'`.
|
||||
- Rename to "Client Role" / "Mandantenrolle" on `type='case'` with new
|
||||
option set (Active / Reactive / Third Party / Other).
|
||||
- Widen `paliad.projects.our_side` CHECK to the new sub-role values; drop
|
||||
`'court'` and `'both'`; backfill existing rows to NULL.
|
||||
- Add `paliad.projects.opponent_code text` on `type='litigation'` rows
|
||||
(segment source for project codes).
|
||||
- New Go helper `services.BuildProjectCode(ctx, projectID) (string, error)`
|
||||
that walks the ancestor chain via the existing ltree `path` and assembles
|
||||
the dotted code. Custom `paliad.projects.reference` on the project itself
|
||||
wins.
|
||||
- Wire the helper into project header, breadcrumb, picker labels, the
|
||||
submission-template variable bag (`{{project.code}}`), and the Excel
|
||||
export `__meta` sheet.
|
||||
|
||||
Out of scope (handled separately or dropped):
|
||||
|
||||
- Reshaping `paliad.parties` (per-party role rows are unchanged).
|
||||
- New analytics / reports breaking out sub-roles.
|
||||
- Bulk-renaming user-facing copy that says "Klägerseite" /
|
||||
"Beklagtenseite" outside the project form.
|
||||
- Reverse lookup (project by code) — already works via `reference`.
|
||||
- Audit-history for who changed an override and when — not requested.
|
||||
- Bulk regeneration of existing `reference` strings — manual entries stay
|
||||
intact; auto-derive only fills empty slots.
|
||||
- Renaming the `our_side` DB column — see §2.2 / Q1.
|
||||
|
||||
---
|
||||
|
||||
## §2 Issue #47 — Client Role rework
|
||||
|
||||
### §2.1 Current state (verified 2026-05-20)
|
||||
|
||||
- Column: `paliad.projects.our_side text`, CHECK constraint
|
||||
`projects_our_side_check` allows `('claimant','defendant','court','both',NULL)`
|
||||
(mig 072).
|
||||
- Live data audit (`SELECT our_side, count(*) FROM paliad.projects
|
||||
GROUP BY our_side`): **all 12 rows are NULL**. Zero rows on
|
||||
`'court'` or `'both'` — backfill is a no-op. The migration is risk-free
|
||||
on the current dataset.
|
||||
- Form: rendered for every project type by
|
||||
`frontend/src/components/ProjectFormFields.tsx:156-168` (one
|
||||
`<select id="project-our-side">` with five static `<option>`s, no
|
||||
conditional render).
|
||||
- Downstream consumers (verified by grep on `our_side` /
|
||||
`OurSide` in `internal/` and `frontend/src/`):
|
||||
- `frontend/src/client/fristenrechner.ts:2187,2734,3754-3776` —
|
||||
Determinator Slice 3c, `ourSideToPerspective()` maps
|
||||
`claimant → claimant`, `defendant → defendant`, anything else
|
||||
(incl. `'court'`, `'both'`, NULL) → `null` (chip free-pick).
|
||||
- `internal/services/submission_vars.go:276-278,390-418` —
|
||||
`{{project.our_side_de}}` / `_en` legal-prose forms. `ourSideDE` /
|
||||
`ourSideEN` switch on the 4 enum values.
|
||||
- `internal/services/project_service.go:1083-1104` —
|
||||
`our_side_changed` project-event row on writes.
|
||||
- `internal/services/project_service.go:1228,1372,1955-` — CCR
|
||||
counterclaim child default-inverts `our_side`; `nullableOurSide()`
|
||||
and `isValidOurSide()` (`project_service.go:1915`) gate writes.
|
||||
|
||||
### §2.2 Decisions
|
||||
|
||||
**Q1 — Rename column `our_side → client_role`?**
|
||||
**Pick: NO. Keep `our_side`.** Renaming forces churn in eleven Go files,
|
||||
the Determinator client bundle (`fristenrechner.ts` type literal +
|
||||
`ourSideToPerspective`), all submission-template tests
|
||||
(`submission_render_test.go:275`), the project-event title key
|
||||
(`event.title.our_side_changed`), and every `{{project.our_side*}}` template
|
||||
that exists in the wild on user systems. The label is purely UI; the column
|
||||
name is internal. Future grep stays clean because the new label
|
||||
("Client Role") and the column (`our_side`) describe the same concept from
|
||||
different perspectives ("which side the firm represents" =
|
||||
"what role the client plays"). Keeping the column avoids a 200-line
|
||||
mechanical rename with non-trivial risk for zero functional gain. The
|
||||
i18n keys *do* rename (`projects.field.our_side` → `projects.field.client_role`)
|
||||
so user-facing copy stays clean.
|
||||
|
||||
**Q2 — Sub-role granularity (7 distinct values vs 3 groups)?**
|
||||
**Pick: 7 sub-roles** — `claimant, defendant, applicant, appellant,
|
||||
respondent, third_party, other`. Lawyers care about the specific
|
||||
procedural posture; Applicant ≠ Claimant in some UPC contexts (e.g. PI
|
||||
applications use "Applicant"). Group-level aggregation is trivial at
|
||||
display time (`switch role { case claimant, applicant, appellant:
|
||||
return "Active" }`). Storing the group only would be a lossy choice we
|
||||
cannot reconstruct from.
|
||||
|
||||
**Q3 — Project types where the field is visible?**
|
||||
**Pick: ONLY `type='case'`.** m's wording is unambiguous ("only plays a
|
||||
role in case projects — and even there the question should be 'Client
|
||||
Role'"). Hide on `client`, `litigation`, `patent`, and the generic
|
||||
`project` type. The client-level "industry / country" block stays as is
|
||||
(those are client-attributes, not procedural roles). The form already
|
||||
has `projekt-fields-case` conditional render (`ProjectFormFields.tsx:143`)
|
||||
— moving the role select into that block is a 4-line change.
|
||||
|
||||
**Q4 — Existing `'court'` / `'both'` row backfill?**
|
||||
**Pick: backfill to NULL** in the same migration that widens the CHECK.
|
||||
Zero rows in production (verified 2026-05-20), so the backfill is a
|
||||
no-op today; it's there for safety if any test fixture or
|
||||
not-yet-deployed instance has them. No audit-event emission for the
|
||||
backfill (it's schema cleanup, not user action).
|
||||
|
||||
**Q5 — Determinator perspective mapping for new sub-roles?**
|
||||
**Pick: Active group → `claimant`, Reactive group → `defendant`, Third
|
||||
Party / Other → `null` (chip free-pick).** Concretely:
|
||||
|
||||
- `claimant`, `applicant`, `appellant` → perspective `'claimant'`
|
||||
- `defendant`, `respondent` → perspective `'defendant'`
|
||||
- `third_party`, `other`, NULL → perspective `null`
|
||||
|
||||
This keeps the Determinator's existing claimant-rule / defendant-rule
|
||||
filter logic unchanged; only `ourSideToPerspective()`'s switch widens.
|
||||
|
||||
**Q6 — Submission template `_de` / `_en` prose for new sub-roles?**
|
||||
|
||||
| value | `_de` (Nominativ) | `_en` |
|
||||
|---------------|-------------------------------|---------------|
|
||||
| `claimant` | Klägerin | Claimant |
|
||||
| `defendant` | Beklagte | Defendant |
|
||||
| `applicant` | Antragstellerin | Applicant |
|
||||
| `appellant` | Berufungsklägerin | Appellant |
|
||||
| `respondent` | Antragsgegnerin | Respondent |
|
||||
| `third_party` | Streithelferin | Third Party |
|
||||
| `other` | sonstige Verfahrensbeteiligte | other party |
|
||||
|
||||
Existing `'court'`/`'both'` switch arms get deleted (no live rows; if a
|
||||
stale `our_side='court'` slipped through somehow, the function returns
|
||||
`""` — same fallback as today for unknown values).
|
||||
|
||||
### §2.3 Migration `112_client_role_rework`
|
||||
|
||||
```sql
|
||||
-- 112_client_role_rework.up.sql (renumbered 2026-05-20 — mig 110 was claimed by m/paliad#51, mig 111 by m/paliad#48)
|
||||
-- t-paliad-222 / m/paliad#47.
|
||||
-- Widens projects.our_side CHECK to seven sub-role values and drops
|
||||
-- the legacy 'court' / 'both' entries. Backfill is a no-op on the
|
||||
-- current dataset (verified 2026-05-20: all 12 rows are NULL), but
|
||||
-- runs defensively in case any test fixture / staging instance still
|
||||
-- carries the old values.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Backfill any 'court' / 'both' rows to NULL. Idempotent.
|
||||
UPDATE paliad.projects
|
||||
SET our_side = NULL
|
||||
WHERE our_side IN ('court', 'both');
|
||||
|
||||
-- 2. Drop the old CHECK, add the widened one. Both are idempotent
|
||||
-- against partially-applied state.
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT IF EXISTS projects_our_side_check;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_our_side_check
|
||||
CHECK (our_side IS NULL OR our_side IN (
|
||||
'claimant', 'defendant',
|
||||
'applicant', 'appellant',
|
||||
'respondent',
|
||||
'third_party', 'other'
|
||||
));
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.our_side IS
|
||||
'Which side the firm represents on this case project (renamed in '
|
||||
'the UI to "Client Role" — t-paliad-222 / m/paliad#47). Allowed '
|
||||
'sub-roles, grouped at display time: Active (claimant, applicant, '
|
||||
'appellant); Reactive (defendant, respondent); Third Party / Other '
|
||||
'(third_party, other). NULL = unknown. Hidden in the form on '
|
||||
'non-case project types. Drives the Fristenrechner Determinator '
|
||||
'perspective chip (Active→claimant, Reactive→defendant, else null).';
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
The down migration restores the original 4-value CHECK and, for
|
||||
defensive symmetry, backfills any new sub-role values to NULL (so the
|
||||
schema is internally consistent when stepped down).
|
||||
|
||||
### §2.4 Frontend changes
|
||||
|
||||
`frontend/src/components/ProjectFormFields.tsx`:
|
||||
|
||||
1. Move the `<div className="form-field">` containing
|
||||
`#project-our-side` from the always-visible block (line 156) into
|
||||
the `projekt-fields-case` block (after the court / case-number
|
||||
row).
|
||||
2. Rename label `data-i18n="projects.field.our_side"` →
|
||||
`projects.field.client_role`.
|
||||
3. Replace the five flat `<option>`s with three `<optgroup>`s + the
|
||||
seven new options + an "Unbekannt" empty option.
|
||||
4. Update the hint text to mention the Determinator group mapping
|
||||
(Active/Reactive).
|
||||
|
||||
`frontend/src/client/i18n.ts` — add new keys (DE + EN):
|
||||
|
||||
```
|
||||
projects.field.client_role → "Mandantenrolle" / "Client Role"
|
||||
projects.field.client_role.hint → "..."
|
||||
projects.field.client_role.unset → "Unbekannt" / "Unknown"
|
||||
projects.field.client_role.group.active → "Aktiv (wir greifen an)" / "Active (we initiate)"
|
||||
projects.field.client_role.group.reactive → "Reaktiv (wir verteidigen)" / "Reactive (we defend)"
|
||||
projects.field.client_role.group.other → "Dritte / Sonstige" / "Third Party / Other"
|
||||
projects.field.client_role.claimant → "Klägerseite" / "Claimant"
|
||||
projects.field.client_role.applicant → "Antragsteller" / "Applicant"
|
||||
projects.field.client_role.appellant → "Berufungsführer" / "Appellant"
|
||||
projects.field.client_role.defendant → "Beklagtenseite" / "Defendant"
|
||||
projects.field.client_role.respondent → "Antragsgegner" / "Respondent"
|
||||
projects.field.client_role.third_party → "Streithelfer / Dritter" / "Third Party"
|
||||
projects.field.client_role.other → "Sonstige Beteiligte" / "Other party"
|
||||
```
|
||||
|
||||
The legacy `projects.field.our_side.*` keys stay deprecated-but-present
|
||||
for one release so any cached browser bundle keeps rendering. They get
|
||||
deleted in a follow-up housekeeping shift once the rollout is confirmed.
|
||||
|
||||
`frontend/src/client/project-form.ts:182-230` — adjust the payload
|
||||
read/write to only include `our_side` when the field is in the DOM
|
||||
(non-case forms no longer emit it). The current code does
|
||||
`if (v) payload.our_side = v` which already handles the "field absent"
|
||||
case gracefully (osSel becomes `null`, no payload key set).
|
||||
|
||||
`frontend/src/client/fristenrechner.ts:3754-3776` —
|
||||
`ourSideToPerspective` switch widens:
|
||||
|
||||
```ts
|
||||
function ourSideToPerspective(os: string | null | undefined): Perspective {
|
||||
switch (os) {
|
||||
case "claimant":
|
||||
case "applicant":
|
||||
case "appellant":
|
||||
return "claimant";
|
||||
case "defendant":
|
||||
case "respondent":
|
||||
return "defendant";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`frontend/src/projects-detail.tsx` Verlauf — the `our_side_changed`
|
||||
event description currently renders the raw enum. Update the renderer
|
||||
to use a label lookup so "Mandant: Beklagte → Antragsteller" reads
|
||||
correctly. Same `event.title.our_side_changed` key stays (the *title*
|
||||
is "Vertretene Seite geändert" / "Represented side changed", which is
|
||||
still accurate semantically).
|
||||
|
||||
### §2.5 Backend changes
|
||||
|
||||
`internal/services/project_service.go:1915` — `isValidOurSide()` widens
|
||||
its allowlist:
|
||||
|
||||
```go
|
||||
case "", "claimant", "defendant",
|
||||
"applicant", "appellant",
|
||||
"respondent",
|
||||
"third_party", "other":
|
||||
return nil
|
||||
```
|
||||
|
||||
`internal/services/project_service.go:1372` —
|
||||
`derivedCounterclaimOurSide()` (CCR flip logic): widen the flip map to
|
||||
mirror the Determinator grouping:
|
||||
|
||||
- claimant ↔ defendant (current behaviour)
|
||||
- applicant ↔ respondent
|
||||
- appellant → defendant (CCR against an appellant is rare; pick
|
||||
the most-likely procedural posture; can be overridden by
|
||||
explicit `flip_our_side=false`)
|
||||
- third_party / other / NULL → keep as-is (no flip)
|
||||
|
||||
`internal/services/submission_vars.go:391-418` — `ourSideDE` /
|
||||
`ourSideEN` switch arms add the five new values per the table in
|
||||
§2.2 Q6. `'court'` and `'both'` arms get deleted.
|
||||
|
||||
`internal/services/project_service.go:1083-1104` — `our_side_changed`
|
||||
audit emission unchanged (it just records old → new on the column).
|
||||
|
||||
`frontend/build.ts` — no change; bundling already picks up
|
||||
`projects.field.client_role.*` i18n keys via `i18n-keys.ts` regeneration.
|
||||
|
||||
`frontend/src/i18n-keys.ts` — regenerate via existing scripted path
|
||||
(adds the new keys, keeps the legacy ones as deprecated entries until
|
||||
the housekeeping pass).
|
||||
|
||||
### §2.6 Tests
|
||||
|
||||
- `internal/services/submission_render_test.go:275` —
|
||||
`TestOurSideTranslations` widens the table to cover the 7 new values
|
||||
in both DE and EN.
|
||||
- `internal/services/projection_service_unit_test.go:319` —
|
||||
`TestDerivedCounterclaimOurSide` widens to cover the new flip map.
|
||||
- New: `TestProjectFormHidesOurSideForNonCase` — unit test on the
|
||||
project-form payload reader confirms `our_side` is silently dropped
|
||||
when the form renders for a non-case project type.
|
||||
|
||||
### §2.7 Acceptance (issue #47)
|
||||
|
||||
- [x] Creating a project of `type='client'`, `'litigation'`, `'patent'`,
|
||||
`'project'` does **not** show the field.
|
||||
- [x] Creating a project of `type='case'` shows the field labelled
|
||||
"Mandantenrolle" (DE) / "Client Role" (EN) with three optgroups
|
||||
and seven options.
|
||||
- [x] Existing `'court'` / `'both'` rows (none in prod, but defensive)
|
||||
are migrated to NULL.
|
||||
- [x] Submission templates referencing `{{project.our_side_de}}` /
|
||||
`_en` render coherent prose for the five new values.
|
||||
- [x] Determinator perspective chip pre-fills correctly from each
|
||||
sub-role (Active→claimant, Reactive→defendant, Other→null).
|
||||
- [x] CCR counterclaim flip yields a sensible child role for the new
|
||||
sub-roles.
|
||||
- [x] `go build && go test ./internal/... && cd frontend && bun run
|
||||
build` clean.
|
||||
|
||||
---
|
||||
|
||||
## §3 Issue #50 — Auto-derived project codes
|
||||
|
||||
### §3.1 Current state (verified 2026-05-20)
|
||||
|
||||
- `paliad.projects.reference text` exists and is informally used (live
|
||||
values: `EXMPL` on a client, `L-2026-001` on a litigation, `C-UPC-0001`
|
||||
on a case, `P-EP1111222` on a patent). No format enforcement.
|
||||
- `paliad.projects.path ltree` is maintained by a Postgres trigger
|
||||
(`projects.path` joined UUIDs root-to-self). Walking ancestors in Go
|
||||
is straightforward: `SELECT * FROM paliad.projects WHERE path @>
|
||||
$1::ltree ORDER BY nlevel(path)`.
|
||||
- No `opponent` field exists anywhere. Opponent text lives only inside
|
||||
the litigation `title` (e.g. "Siemens AG ./. Huawei Technologies").
|
||||
- `paliad.proceeding_types.code` is dot-separated:
|
||||
`upc.inf.cfi`, `upc.rev.cfi`, `de.inf.lg`, `upc.apl.merits`, etc.
|
||||
Splitting on `.` and upper-casing yields `INF`, `REV`, `LG`,
|
||||
`APL.MERITS`. Suitable as the case segment.
|
||||
- `paliad.projects.court text` is free-text on cases (live values:
|
||||
`UPC`, `UPC CoA`, `LG München I`). Not normalised; use the
|
||||
proceeding_type code instead — it carries the same info structurally.
|
||||
|
||||
### §3.2 Decisions
|
||||
|
||||
**Q1 — Litigation opponent source: new column or regex on title?**
|
||||
**Pick: new column `paliad.projects.opponent_code text` on litigation
|
||||
rows.** Regex on title is brittle ("./.", "v.", "vs", "—", varying
|
||||
order) and the user already knows the short code at creation time. New
|
||||
field with explicit validation (slug-cased, max 16 chars) is clean and
|
||||
takes one form field + one migration. Title stays as the human-readable
|
||||
caption; `opponent_code` is the machine-readable segment source.
|
||||
NULL → segment skipped silently.
|
||||
|
||||
**Q2 — Patent segment: always last 3, or last-N variable?**
|
||||
**Pick: last 3 digits when the digit-stream is ≥ 4 digits long; full
|
||||
digit-stream when shorter.** m's example (`EP3456789 → 789`) is 7
|
||||
digits last-3 = 789 ✓. UPC publication numbers (10+ digits) collapse to
|
||||
their last 3 just fine — uniqueness inside the same litigation tree is
|
||||
near-certain because the same litigation tree won't hold two patents
|
||||
sharing the same last-3. If it ever does, the user can set a custom
|
||||
`reference` (Q5). No need for last-4 / last-N logic.
|
||||
|
||||
The patent-number regex extracts the digit-stream from any common
|
||||
format (`EP1234567`, `EP 1 234 567`, `EP1234567A1`, `WO2020/123456A1`):
|
||||
strip non-digits, take last 3 (or whole if shorter), upper-cased.
|
||||
|
||||
**Q3 — Case segment from `proceeding_types.code`?**
|
||||
**Pick: take `proceeding_types.code` (e.g. `upc.inf.cfi`), split on `.`,
|
||||
drop the leading jurisdiction segment, uppercase the rest, join with
|
||||
`.`.** Examples:
|
||||
|
||||
- `upc.inf.cfi` → `INF.CFI`
|
||||
- `upc.rev.cfi` → `REV.CFI`
|
||||
- `upc.pi.cfi` → `PI.CFI`
|
||||
- `upc.apl.merits` → `APL.MERITS`
|
||||
- `de.inf.lg` → `INF.LG`
|
||||
- `de.inf.olg` → `INF.OLG` (appeal instance → segment already
|
||||
encodes "OLG", so we get the appeal level for free; no separate
|
||||
instance segment needed)
|
||||
|
||||
The jurisdiction is dropped because the parent client/patent already
|
||||
implies the jurisdiction context. If the user wants explicit
|
||||
jurisdiction in the code, custom `reference` wins.
|
||||
|
||||
If `proceeding_type_id` is NULL on the case, segment is omitted
|
||||
silently. No fallback to `court` text — that's free-text and noisy.
|
||||
|
||||
**Q4 — Override semantics: wholesale or per-segment?**
|
||||
**Pick: wholesale.** When `paliad.projects.reference` is non-empty on
|
||||
the project the helper is asked about, that string is returned
|
||||
verbatim — no auto-derivation, no string-concatenation, no merging.
|
||||
Per-segment override doubles the implementation complexity for a UX
|
||||
nobody asked for. Users who want partial overrides set the
|
||||
`reference` on the relevant ancestor and let the rest auto-derive
|
||||
naturally.
|
||||
|
||||
**Q5 — Where the user types the override?**
|
||||
**Pick: existing `paliad.projects.reference` field.** Already there,
|
||||
already labelled "Interne Referenz (optional)", already used by users.
|
||||
Adding a second "project_code_override" alongside `reference` would
|
||||
confuse the form. The hint text gets a small addendum: "Leer lassen
|
||||
für automatischen Code aus dem Projekt-Baum."
|
||||
|
||||
**Q6 — Collision handling (two cases derive to the same code)?**
|
||||
**Pick: advisory in v1; no disambiguator.** Codes are display-only
|
||||
(not a primary key, not a unique constraint). Real-world collisions
|
||||
inside the same litigation tree are vanishingly rare; if they happen,
|
||||
the user notices in the picker and sets a custom `reference` on one.
|
||||
Adding `-N` suffixes silently would mask a data issue the user should
|
||||
see. A future surface could flag duplicates as a project-detail warning,
|
||||
but it's not in v1.
|
||||
|
||||
**Q7 (new) — Helper signature and call site?**
|
||||
**Pick: `ProjectService.BuildProjectCode(ctx context.Context, projectID
|
||||
uuid.UUID) (string, error)`.** Lives on the existing ProjectService
|
||||
(it needs DB access for the ancestor walk). Internally builds segments
|
||||
with a small `projectCodeSegment(p Project) string` pure function per
|
||||
type that's table-test-friendly. The helper is called from the
|
||||
projection layer when a project gets serialised for the API
|
||||
(adds a `code` field to the JSON), so every surface — header,
|
||||
breadcrumb, picker, dashboard tile, Excel export — gets the code for
|
||||
free without each surface re-walking the tree. Pricier than a
|
||||
display-time call but eliminates N+1 walks in list views.
|
||||
|
||||
**Q8 (new) — Cache strategy?**
|
||||
**Pick: no cache in v1.** Each ancestor walk is one indexed lookup
|
||||
on `paliad.projects(path)`. With 12 projects in prod and order-of-100s
|
||||
in any plausible firm-scale future, this is microsecond-cheap. If
|
||||
profiling later shows it as a hotspot in list views (which fetch many
|
||||
projects), introduce a materialised view
|
||||
`paliad.projects_derived_codes(project_id, derived_code)` refreshed by
|
||||
trigger on `projects` writes. Don't pre-optimise.
|
||||
|
||||
### §3.3 Migration `113_projects_opponent_code`
|
||||
|
||||
```sql
|
||||
-- 113_projects_opponent_code.up.sql (renumbered 2026-05-20)
|
||||
-- t-paliad-222 / m/paliad#50.
|
||||
-- Add an opponent-code field on litigation projects. Used as the
|
||||
-- middle segment when assembling auto-derived project codes from the
|
||||
-- ancestor tree (e.g. EXMPL.OPNT.567.INF.CFI). NULL = segment is
|
||||
-- skipped silently. No backfill — existing litigation rows simply
|
||||
-- yield codes without an opponent segment until the user sets one.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS opponent_code text;
|
||||
|
||||
-- Slug-shape gate: uppercase letters, digits, dashes, max 16 chars.
|
||||
-- Matches the style of m's example "OPNT". Keeps the auto-code clean.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'projects_opponent_code_check'
|
||||
AND conrelid = 'paliad.projects'::regclass
|
||||
) THEN
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_opponent_code_check
|
||||
CHECK (opponent_code IS NULL
|
||||
OR (opponent_code ~ '^[A-Z0-9-]{1,16}$'
|
||||
AND type = 'litigation'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.opponent_code IS
|
||||
'Short slug for the opposing party on a litigation project '
|
||||
'(uppercase letters, digits, dashes, max 16 chars). Used as the '
|
||||
'middle segment when BuildProjectCode walks the ancestor tree to '
|
||||
'assemble a dotted project code (t-paliad-222 / m/paliad#50). '
|
||||
'NULL = segment skipped silently.';
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
The down migration drops the constraint then the column.
|
||||
|
||||
### §3.4 Go helper
|
||||
|
||||
New file `internal/services/project_code.go`:
|
||||
|
||||
```go
|
||||
// Package-level function (not a method) so it can be called from any
|
||||
// service that already has a *sqlx.DB. ProjectService has a thin
|
||||
// wrapper that calls into this.
|
||||
//
|
||||
// BuildProjectCode assembles the dotted ancestor code for projectID
|
||||
// from the existing paliad.projects.path ltree. If the target row's
|
||||
// reference column is non-empty, it wins outright (no derivation).
|
||||
// Missing ancestor segments are skipped silently — there is no
|
||||
// "unknown" placeholder.
|
||||
func BuildProjectCode(ctx context.Context, db sqlx.QueryerContext, projectID uuid.UUID) (string, error)
|
||||
|
||||
// projectCodeSegment is the per-type segment derivation. Pure, table-
|
||||
// test friendly, never touches the DB.
|
||||
//
|
||||
// client → opts.PreferShortReference (reference if set, else slug(title))
|
||||
// litigation → opts.PreferShortReference (opponent_code if set, else "")
|
||||
// patent → last 3 digits of patent_number (full digits if <4)
|
||||
// case → uppercase tail of proceeding_types.code (jurisdiction segment dropped)
|
||||
// project → "" (generic projects don't contribute a segment)
|
||||
//
|
||||
// proceedingCode is only needed for case rows; the caller resolves
|
||||
// it via a single join (or a cached small lookup) before calling.
|
||||
func projectCodeSegment(p models.Project, proceedingCode string) string
|
||||
```
|
||||
|
||||
Sanitisation helpers live alongside as unexported funcs:
|
||||
|
||||
- `sanitizeClientShort(s string) string` — uppercase, strip diacritics
|
||||
via `golang.org/x/text/unicode/norm` + filter, replace non-alnum
|
||||
with `-`, trim, cap at 8 chars. Already similar to what
|
||||
`internal/util/slug` does for the global slug helper.
|
||||
- `patentLast3(s string) string` — strip non-digits, take last 3
|
||||
characters (or the whole digit-stream when shorter); uppercase.
|
||||
Empty → "".
|
||||
- `proceedingTail(code string) string` — split on `.`, drop element 0
|
||||
(jurisdiction), uppercase + join the rest. `""` → `""`.
|
||||
|
||||
`BuildProjectCode` SQL is a single round-trip:
|
||||
|
||||
```sql
|
||||
SELECT p.id, p.type, p.title, p.reference, p.opponent_code,
|
||||
p.patent_number, p.proceeding_type_id,
|
||||
pt.code AS proceeding_code
|
||||
FROM paliad.projects p
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
|
||||
WHERE p.path @> (SELECT path FROM paliad.projects WHERE id = $1)
|
||||
ORDER BY nlevel(p.path);
|
||||
```
|
||||
|
||||
It returns the chain root-to-target. The function:
|
||||
|
||||
1. If the last row (the target) has non-empty `reference` → return it
|
||||
verbatim. Done.
|
||||
2. Otherwise walk the chain top-to-bottom, call `projectCodeSegment`
|
||||
on each row, skip empty segments, join with `.`, return.
|
||||
|
||||
### §3.5 Wiring into surfaces
|
||||
|
||||
- `internal/services/project_service.go` projection — add a `Code`
|
||||
string field to the read-side struct and populate it in the single
|
||||
fetch path. For list endpoints, do **one** ancestor-chain query per
|
||||
page (CTE that groups by target id) rather than N+1.
|
||||
- `internal/services/submission_vars.go:277` — add
|
||||
`bag["project.code"] = derefString(p.Code)` so submission templates
|
||||
can reference `{{project.code}}`.
|
||||
- `frontend/src/components/ProjectHeader.tsx` (current header
|
||||
component on `/projects/{id}`) — render `code` next to the title
|
||||
(small monospace badge) if non-empty.
|
||||
- `frontend/src/components/Breadcrumb*.tsx` — when rendering the
|
||||
trail, use `project.code` as the trailing badge per segment if the
|
||||
caller asks for it (opt-in to avoid breaking other consumers).
|
||||
- `frontend/src/client/project-form.ts` and any project-picker
|
||||
typeahead — show `code · title` in the dropdown labels when `code`
|
||||
is non-empty.
|
||||
- Excel `__meta` sheet — add a `Project Code` row (already enumerates
|
||||
project metadata).
|
||||
|
||||
The "copy reference" affordance in the header gets a second line: if
|
||||
both `reference` (user override) and the auto-derived code differ, both
|
||||
are visible (override above, derived below, smaller).
|
||||
|
||||
### §3.6 Tests
|
||||
|
||||
- `TestProjectCodeSegment` (table) — every project type × multiple
|
||||
shapes (with/without reference, NULL ancestors, patent_number
|
||||
formats, proceeding codes with 1/2/3 segments).
|
||||
- `TestBuildProjectCodeFullChain` — fixture tree
|
||||
Client → Litigation → Patent → Case yields `EXMPL.OPNT.567.INF.CFI`.
|
||||
- `TestBuildProjectCodeRespectsOverride` — non-empty `reference` wins
|
||||
outright.
|
||||
- `TestBuildProjectCodeMissingAncestors` — case directly under client
|
||||
(no litigation, no patent) yields `EXMPL.INF.CFI`.
|
||||
- `TestBuildProjectCodeCollisionDoesNotDisambiguate` — two sibling
|
||||
cases with identical derived codes both return the same string (v1
|
||||
contract per Q6).
|
||||
- Migration sanity test (existing harness in
|
||||
`internal/db/migrations_test.go` if present) — up → down → up.
|
||||
|
||||
### §3.7 Acceptance (issue #50)
|
||||
|
||||
- [x] `BuildProjectCode` returns `EXMPL.OPNT.567.INF.CFI` for the
|
||||
reference tree (Client EXMPL → Litigation OPNT → Patent
|
||||
EP1234567 → Case `upc.inf.cfi`).
|
||||
- [x] Setting `projects.reference = 'CUSTOM-CODE'` on the case
|
||||
returns `CUSTOM-CODE` verbatim.
|
||||
- [x] Missing ancestor segments are skipped silently
|
||||
(no `..` collapses, no "?" placeholder).
|
||||
- [x] `{{project.code}}` resolves in submission templates.
|
||||
- [x] Project header, breadcrumb, picker, Excel `__meta` all show the
|
||||
code when set/derived.
|
||||
- [x] Litigation form has a new "Opponent Code" field (DE:
|
||||
"Gegner-Kürzel") with the slug pattern validation. Hidden on
|
||||
non-litigation types.
|
||||
- [x] `go build && go test ./internal/... && cd frontend && bun run
|
||||
build` clean.
|
||||
|
||||
---
|
||||
|
||||
## §4 Open questions for the head
|
||||
|
||||
(Head: default to the §2.2 / §3.2 "Pick" recommendations unless something
|
||||
material pushes back. Coder shift only after head signs off.)
|
||||
|
||||
1. **§2.2 Q1** — Keep column name `our_side`? (Recommend YES; rename
|
||||
touches 11+ Go files + bundled-template wire format for zero gain.)
|
||||
2. **§2.2 Q2** — Store 7 sub-roles? (Recommend YES; group-only is
|
||||
lossy.)
|
||||
3. **§2.2 Q3** — Hide the field on `litigation` and `patent` too, not
|
||||
just on `client`? (Recommend YES per m's "only on case projects".)
|
||||
4. **§2.2 Q6** — German prose forms use feminine grammatical gender
|
||||
(Klägerin, Beklagte) per the existing translation table? Or
|
||||
masculine / neutral? (Recommend feminine to match existing
|
||||
`ourSideDE` — keeps consistency with already-rendered templates.)
|
||||
5. **§3.2 Q1** — Add a dedicated `opponent_code` column on
|
||||
litigations? (Recommend YES; regex-on-title is brittle.)
|
||||
6. **§3.2 Q2** — Patent segment = last 3 digits (variable for
|
||||
<4-digit numbers)? (Recommend YES, matches m's example.)
|
||||
7. **§3.2 Q3** — Case segment drops the jurisdiction prefix from
|
||||
`proceeding_types.code` (so `upc.inf.cfi` → `INF.CFI`, not
|
||||
`UPC.INF.CFI`)? (Recommend YES — jurisdiction is implied by the
|
||||
ancestor client/patent context.)
|
||||
8. **§3.2 Q7** — `BuildProjectCode` populates a `code` field on every
|
||||
projected Project JSON (not lazy per-render)? (Recommend YES;
|
||||
simpler consumers, one DB round-trip per list page.)
|
||||
9. **§3.2 Q8** — No cache / materialised view in v1? (Recommend YES;
|
||||
profile later if list views get slow.)
|
||||
|
||||
---
|
||||
|
||||
## §5 Implementation order (coder phase)
|
||||
|
||||
1. **Mig 112** (client role widen + backfill) → mig 113 (opponent_code).
|
||||
*Renumbered twice on 2026-05-20 — mig 110 claimed by m/paliad#51 project_type_other; mig 111 claimed by m/paliad#48 project_admin_and_select; boltzmann's gap-tolerant runner hard-fails on collisions so this is a strict rebump.*
|
||||
Run `ls internal/db/migrations/ | tail` first to verify slot
|
||||
availability (boltzmann's gap-tolerant runner means 110 is fine
|
||||
even if 109 was the last applied).
|
||||
2. **Backend** — `isValidOurSide`, `ourSideDE/EN`,
|
||||
`derivedCounterclaimOurSide`, new `project_code.go` package
|
||||
+ ProjectService wiring + projection `Code` field.
|
||||
3. **Frontend** — `ProjectFormFields.tsx` (conditional render + new
|
||||
options + opponent_code field on litigation block), `i18n.ts` keys,
|
||||
`fristenrechner.ts` `ourSideToPerspective` widen, header /
|
||||
breadcrumb / picker code-badge wiring.
|
||||
4. **Tests** — pinning tests above; `go test ./internal/...` clean.
|
||||
5. **Build verification** — `go build && cd frontend && bun run build`
|
||||
clean.
|
||||
6. **Commit per slice** — three commits (migration + backend, frontend,
|
||||
tests) keep review tractable.
|
||||
|
||||
---
|
||||
|
||||
## §6 Risks & rollback
|
||||
|
||||
- **Submission templates in the wild.** Users may have downloaded /
|
||||
customised submission templates that still reference
|
||||
`{{project.our_side_de}}` for `our_side='court'` or `'both'`. After
|
||||
this change those values are unreachable, so the template arm
|
||||
returns `""`. Already the fallback behaviour for unknown values;
|
||||
no breakage, just an empty render. Mention in release notes.
|
||||
- **Browser cache.** Users with a stale bundle still see the old
|
||||
"Wir vertreten" form for one cache-bust cycle. The legacy i18n keys
|
||||
stay until housekeeping (§2.4), so labels still resolve.
|
||||
- **Migration down path.** Stepping down from 110 restores the old
|
||||
4-value CHECK; new sub-role rows would violate it. The down
|
||||
migration backfills new sub-roles → NULL to stay consistent.
|
||||
- **Per-tree opponent_code uniqueness.** Two litigations under the
|
||||
same client with the same `opponent_code` would derive identical
|
||||
case codes. Per Q6 we accept this; users see it in the picker and
|
||||
customise `reference` if it bothers them.
|
||||
- **No new env vars, no Dokploy compose change** — both changes are
|
||||
pure code + schema; deploy is the existing main-push → webhook →
|
||||
Dokploy auto-redeploy path.
|
||||
@@ -1,918 +0,0 @@
|
||||
# User-authored checklists: authoring, sharing, admin-promotion
|
||||
|
||||
**Task:** t-paliad-225 — Gitea m/paliad#61
|
||||
**Inventor:** dirac, 2026-05-20
|
||||
**Branch:** `mai/dirac/user-checklists`
|
||||
**Status:** DESIGN READY FOR REVIEW
|
||||
|
||||
## 1. Problem statement
|
||||
|
||||
Paliad ships a curated catalog of UPC / DE / EPA checklists today
|
||||
(`internal/checklists/templates.go`, 6 templates). Users instantiate them
|
||||
on Akten and check items off; per-instance state lives in
|
||||
`paliad.checklist_instances` and is gated by the parent project's
|
||||
team-based visibility.
|
||||
|
||||
m wants three new capabilities (m 2026-05-20 14:14):
|
||||
|
||||
1. **User-authored templates** — any non-`global_admin` can create a
|
||||
checklist template they own (title, sections, items, references).
|
||||
2. **Sharing** — author shares with specific colleagues, an Office, a
|
||||
Dezernat (partner-unit), a project team, or the whole firm.
|
||||
3. **Admin promotion to global** — `global_admin` promotes an authored
|
||||
template into the firm-wide catalog so it appears alongside the
|
||||
curated UPC/DE/EPA templates for every user.
|
||||
|
||||
This design covers all three across three sequential slices.
|
||||
|
||||
## 2. Premises verified live (load-bearing findings)
|
||||
|
||||
The Gitea issue body says "Add `owner_id uuid NULL` to
|
||||
`paliad.checklists`". That table **does not exist**. Verifying against
|
||||
the live DB and the code corrected several premises:
|
||||
|
||||
- **`paliad.checklists` does NOT exist as a DB table.** Templates today
|
||||
are pure Go data in `internal/checklists/templates.go` (6 entries,
|
||||
~310 lines), served by `internal/handlers/checklists.go` via
|
||||
`checklists.Summaries()` and `checklists.Find(slug)`. The DB has
|
||||
`paliad.checklist_instances` (per-user state) and
|
||||
`paliad.checklist_feedback` (a thumbs-up/down sink). That's it. The
|
||||
design has to introduce `paliad.checklists` from scratch.
|
||||
|
||||
- **`paliad.checklist_instances.template_slug` is `text` with no FK** —
|
||||
validity is enforced in `ChecklistInstanceService.Create` against the
|
||||
static Go registry. This is what lets the design keep the static
|
||||
catalog as one source of truth and add the DB catalog as a parallel
|
||||
source: instance creation just resolves the slug against the merged
|
||||
view and snapshots the template body.
|
||||
|
||||
- **Migration tracker live = 106; on-disk head = 111.** Five unapplied
|
||||
on-disk migrations (107 caldav-binding-id, 108 mkcalendar-capability,
|
||||
109 user_dashboard_layouts, 110 project_type_other, 111
|
||||
project_admin_and_select — gauss's t-paliad-223 Slice A, m-locked
|
||||
today). At inventor time the next free slot is **112**. The coder
|
||||
MUST re-verify with `ls internal/db/migrations/ | tail` at shift
|
||||
start — the slot can drift if other branches merge first.
|
||||
|
||||
- **`paliad.effective_project_admin(_user_id, _project_id)` lands with
|
||||
migration 111** (gauss, today). Mirrors `can_see_project`'s shape:
|
||||
STABLE SECURITY DEFINER, ltree ancestor walk against `projects.path`,
|
||||
branches on global_admin shortcut + project_teams responsibility =
|
||||
'admin'. **Used by this design** to gate the "Make global" button (we
|
||||
reuse the global_admin shortcut, not the project-admin branch — see
|
||||
§4.4) and as the precedent for any new STABLE SECURITY DEFINER
|
||||
predicates we add.
|
||||
|
||||
- **`paliad.system_audit_log` (mig 102) is the org-scope audit sink.**
|
||||
Columns: `event_type` (free-text), `actor_id`, `actor_email`,
|
||||
`scope` ∈ {org, project, personal}, `scope_root uuid`,
|
||||
`metadata jsonb`. RLS: self-read for the actor +
|
||||
global_admin read-all. **Pattern to follow:** insert event row at
|
||||
state transition (see `ExportService.WriteAuditRow` in
|
||||
`internal/services/export_service.go:1120` for the canonical shape).
|
||||
|
||||
- **`paliad.project_events`** is the project-timeline audit sink and is
|
||||
already wired for checklist instance lifecycle events
|
||||
(`checklist_created`, `_renamed`, `_unlinked`, `_linked`, `_reset`,
|
||||
`_deleted`). We do NOT need to invent a new event_type for instance
|
||||
events; we'll add a few `_snapshot_taken` / template-level events to
|
||||
`system_audit_log` and keep instance events on `project_events`.
|
||||
|
||||
- **`paliad.users.office`** is `text` (CHECK against the office key
|
||||
list in `internal/offices/offices.go` — 8 keys: munich, duesseldorf,
|
||||
hamburg, amsterdam, london, paris, milan, madrid). Multi-office users
|
||||
have `additional_offices text[]`. Both are first-class columns; no
|
||||
separate `offices` table.
|
||||
|
||||
- **`paliad.partner_units`** (cols: id, name, lead_user_id, office,
|
||||
timestamps) is the Dezernat / practice-group table. Membership lives
|
||||
in `paliad.partner_unit_members`. Projects attach via
|
||||
`paliad.project_partner_units` (with derivation flags). All three
|
||||
are referenceable from a share recipient.
|
||||
|
||||
- **`paliad.users.global_role`** is `text`; values include
|
||||
`'global_admin'`. Used for the firm-wide promote/demote authority.
|
||||
|
||||
- **`paliad.project_teams`** (mig 111 just added) carries
|
||||
`responsibility` ∈ {admin, lead, member, observer, external}. We
|
||||
reuse `can_see_project` (visibility) for share-to-project recipients,
|
||||
NOT `effective_project_admin`. The semantic of "share with a project
|
||||
team" is "anyone on the matter sees it", not "anyone who can edit
|
||||
membership sees it".
|
||||
|
||||
- **No precedent for entity-level sharing in paliad.** The personal-
|
||||
sidecar tables (`user_views`, `user_dashboard_layouts`,
|
||||
`user_pinned_projects`, `user_card_layouts`) are owner-only with no
|
||||
share columns. Existing visibility predicates
|
||||
(`paliad.can_see_project`) walk the project tree, not arbitrary
|
||||
entities. This design introduces the first multi-axis share pattern
|
||||
in the codebase (§3.2).
|
||||
|
||||
## 3. Architecture: hybrid templates + share table
|
||||
|
||||
### 3.1 Two template sources, one read layer
|
||||
|
||||
**KEEP** the static Go template registry as the firm's curated catalog.
|
||||
It's version-controlled, code-reviewed, immutable at runtime, and the
|
||||
right substrate for legally-curated content (RoP citations, EPC rule
|
||||
references). Migrating those into DB rows would lose the git review
|
||||
trail for content that requires lawyer eyes.
|
||||
|
||||
**ADD** `paliad.checklists` as the DB catalog for user-authored content.
|
||||
Same Template shape (slug, titles, regime, court, groups[], items[])
|
||||
but stored as JSONB so the schema doesn't have to chase content
|
||||
evolution.
|
||||
|
||||
A `ChecklistCatalogService` unifies the two at read time:
|
||||
- `ListVisible(user)` → static templates ∪ DB rows the user can see
|
||||
- `Find(slug, user)` → static lookup first, then DB lookup with visibility check
|
||||
- Slug-uniqueness enforced **across both spaces** at write time (DB slugs
|
||||
rejected if they collide with a static slug).
|
||||
|
||||
Existing `/api/checklists` and `/api/checklists/{slug}` endpoints keep
|
||||
their JSON shape — they just delegate to the catalog service instead of
|
||||
the bare static registry.
|
||||
|
||||
### 3.2 Multi-axis sharing — checklist-specific table, polymorphism deferred
|
||||
|
||||
The task brief asks for a "modular / abstract" solution. I considered a
|
||||
polymorphic `paliad.entity_shares(target_kind, target_id, recipient_kind,
|
||||
recipient_*)` table that could later carry shares for views, dashboards,
|
||||
saved searches, project templates, etc.
|
||||
|
||||
**Decision: keep it checklist-specific (`paliad.checklist_shares`) for
|
||||
v1.** Reasons:
|
||||
|
||||
1. There is NO second entity in paliad that requests sharing today —
|
||||
`user_views`, `user_dashboard_layouts`, `user_card_layouts`,
|
||||
`user_pinned_projects` are all explicitly owner-only by design (see
|
||||
migration comments). The "future reuse" is hypothetical.
|
||||
2. Polymorphic FKs forfeit ON DELETE CASCADE — every recipient kind
|
||||
needs its own deletion trigger. That complexity is real, the
|
||||
reusability gain is not.
|
||||
3. The CORRECT abstraction emerges by extracting *after* the second use
|
||||
case shows up. Right now we don't know whether dashboards want the
|
||||
same recipient axes (user / office / partner-unit / project) or a
|
||||
different set (e.g. dashboards probably want "everyone on a project"
|
||||
not "the whole firm").
|
||||
|
||||
The design IS modular in the sense that the recipient resolution logic
|
||||
(below) is centralized in one SQL predicate (§4.3) which a future
|
||||
polymorphic refactor can lift verbatim.
|
||||
|
||||
If the second entity asks for sharing within ~3 months, refactor to
|
||||
`paliad.entity_shares` as a single-mig follow-up. Until then,
|
||||
`paliad.checklist_shares` keeps the schema honest.
|
||||
|
||||
### 3.3 Visibility states
|
||||
|
||||
`paliad.checklists.visibility text` (CHECK enum):
|
||||
|
||||
| state | who sees | who edits |
|
||||
|-----------|----------------------------------------------------|---------------------|
|
||||
| `private` | owner only | owner |
|
||||
| `shared` | owner + explicit recipients in checklist_shares | owner |
|
||||
| `firm` | owner + every authenticated paliad user | owner |
|
||||
| `global` | owner + every authenticated paliad user + catalog | owner + global_admin|
|
||||
|
||||
`firm` vs `global` distinction:
|
||||
- `firm` = author self-published. Author can flip back to private/shared
|
||||
any time. Does NOT appear in the main `/checklists` Vorlagen tab; only
|
||||
in the new "Geteilte Vorlagen" / "Shared by colleagues" surface.
|
||||
- `global` = admin-promoted into the firm catalog. Appears in the main
|
||||
Vorlagen tab alongside the static templates. Author retains edit
|
||||
authority by default; only `global_admin` can demote.
|
||||
|
||||
Demotion target: `global → firm` (preserves visibility for users who
|
||||
already started instances). Author can subsequently narrow further.
|
||||
|
||||
### 3.4 Template snapshot on instance create
|
||||
|
||||
m's brief calls this out as a design decision: when an author edits a
|
||||
template, do existing instances pick up the changes (propagate) or stay
|
||||
on the version they were created from (snapshot)?
|
||||
|
||||
**Pick: snapshot.** Inventor pick (R). Rationale:
|
||||
|
||||
1. **Data integrity.** Instances are working artefacts. A user halfway
|
||||
through a Klageerwiderung instance shouldn't have items disappear or
|
||||
reorder under them because the author edited the template.
|
||||
2. **Audit story.** The completed instance shows exactly what the
|
||||
author saw when they started. Reconstruction without git-blame on
|
||||
the template.
|
||||
3. **Visibility narrowing safe by construction.** If author unshares
|
||||
from a colleague who already has an instance, the instance survives
|
||||
because the snapshot is local.
|
||||
4. Cost is trivial: a typical template is <2 KB JSONB; instances rarely
|
||||
exceed a few per user per template. Even 10× the row size of today
|
||||
is fine.
|
||||
|
||||
Schema cost: one nullable `template_snapshot jsonb` column on
|
||||
`paliad.checklist_instances`. Backfilled lazily — existing instances
|
||||
keep `NULL`, service falls back to looking the slug up in the catalog;
|
||||
new instances always get a snapshot. Slice C can backfill the column
|
||||
for already-existing rows via a one-off `UPDATE` if we want strict
|
||||
consistency.
|
||||
|
||||
## 4. Schema (migration 112 — verify slot at coder shift)
|
||||
|
||||
Single migration file `internal/db/migrations/112_user_checklists.up.sql`
|
||||
+ matching `.down.sql`. Idempotent throughout
|
||||
(`CREATE TABLE IF NOT EXISTS`, `DO $$ … EXCEPTION` guards).
|
||||
|
||||
> Slot caveat: at design time, latest disk = 111, live tracker = 106
|
||||
> (mig 107-111 pending deploy). Coder MUST re-verify
|
||||
> `ls internal/db/migrations/ | tail` at shift start. If a higher
|
||||
> number lands first (e.g. boltzmann's gap-tolerant runner ships as
|
||||
> 112), bump to the next free slot.
|
||||
|
||||
### 4.1 `paliad.checklists` — authored template catalog
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.checklists (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL UNIQUE,
|
||||
-- Authoring metadata
|
||||
owner_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
regime text NOT NULL DEFAULT 'OTHER', -- UPC | DE | EPA | OTHER
|
||||
court text NOT NULL DEFAULT '',
|
||||
reference text NOT NULL DEFAULT '',
|
||||
deadline text NOT NULL DEFAULT '',
|
||||
lang text NOT NULL DEFAULT 'de', -- 'de' | 'en' — author's primary language
|
||||
-- Body
|
||||
body jsonb NOT NULL, -- { groups: [{ title, items: [{ label, note, rule }] }] }
|
||||
-- Lifecycle
|
||||
visibility text NOT NULL DEFAULT 'private'
|
||||
CHECK (visibility IN ('private', 'shared', 'firm', 'global')),
|
||||
promoted_at timestamptz, -- set on transition to 'global'
|
||||
promoted_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
-- Timestamps
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX checklists_owner_idx ON paliad.checklists (owner_id);
|
||||
CREATE INDEX checklists_visibility_idx ON paliad.checklists (visibility) WHERE visibility IN ('firm', 'global');
|
||||
CREATE INDEX checklists_regime_idx ON paliad.checklists (regime);
|
||||
```
|
||||
|
||||
**Slug-collision safety net:** application layer validates that the
|
||||
chosen slug doesn't collide with a static template slug. The static
|
||||
list is loaded into a `map[string]bool` at boot. New authored slugs
|
||||
auto-prefixed with `u-` so collisions with static slugs are structurally
|
||||
unlikely (`u-my-strategy-2026` vs `upc-statement-of-claim`).
|
||||
|
||||
### 4.2 `paliad.checklist_shares` — explicit grants
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.checklist_shares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
checklist_id uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE,
|
||||
recipient_kind text NOT NULL CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')),
|
||||
recipient_user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
recipient_office text,
|
||||
recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
|
||||
recipient_project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
granted_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
granted_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- XOR check: exactly one recipient_* column populated per kind
|
||||
CONSTRAINT checklist_shares_recipient_xor CHECK (
|
||||
(recipient_kind = 'user' AND recipient_user_id IS NOT NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'office' AND recipient_office IS NOT NULL AND recipient_user_id IS NULL AND recipient_partner_unit_id IS NULL AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'partner_unit' AND recipient_partner_unit_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'project' AND recipient_project_id IS NOT NULL AND recipient_user_id IS NULL AND recipient_office IS NULL AND recipient_partner_unit_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Avoid duplicates per recipient
|
||||
CREATE UNIQUE INDEX checklist_shares_user_uniq ON paliad.checklist_shares (checklist_id, recipient_user_id) WHERE recipient_kind = 'user';
|
||||
CREATE UNIQUE INDEX checklist_shares_office_uniq ON paliad.checklist_shares (checklist_id, recipient_office) WHERE recipient_kind = 'office';
|
||||
CREATE UNIQUE INDEX checklist_shares_partner_unit_uniq ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id) WHERE recipient_kind = 'partner_unit';
|
||||
CREATE UNIQUE INDEX checklist_shares_project_uniq ON paliad.checklist_shares (checklist_id, recipient_project_id) WHERE recipient_kind = 'project';
|
||||
|
||||
-- Hot-path index for the visibility predicate
|
||||
CREATE INDEX checklist_shares_lookup_idx ON paliad.checklist_shares (checklist_id);
|
||||
```
|
||||
|
||||
### 4.3 `paliad.can_see_checklist(_user_id, _checklist_id)` predicate
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $$
|
||||
-- Owner can always see
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id
|
||||
AND c.owner_id = _user_id
|
||||
)
|
||||
-- 'firm' / 'global' visible to all authenticated users
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id
|
||||
AND c.visibility IN ('firm', 'global')
|
||||
)
|
||||
-- Explicit share: user
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklist_shares s
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'user'
|
||||
AND s.recipient_user_id = _user_id
|
||||
)
|
||||
-- Explicit share: office (matches user.office OR additional_offices)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.users u ON u.id = _user_id
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'office'
|
||||
AND (s.recipient_office = u.office
|
||||
OR s.recipient_office = ANY(u.additional_offices))
|
||||
)
|
||||
-- Explicit share: partner_unit (caller is a member)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.partner_unit_members pum
|
||||
ON pum.partner_unit_id = s.recipient_partner_unit_id
|
||||
AND pum.user_id = _user_id
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'partner_unit'
|
||||
)
|
||||
-- Explicit share: project (caller can see the project via existing predicate)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklist_shares s
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'project'
|
||||
AND paliad.can_see_project(s.recipient_project_id) -- reuses ltree walk
|
||||
);
|
||||
$$;
|
||||
```
|
||||
|
||||
> Note on `can_see_project` self-reference: that function reads
|
||||
> `auth.uid()` internally — when called from inside another SECURITY
|
||||
> DEFINER body it picks up the caller's uid via search_path inheritance
|
||||
> (same pattern as `effective_project_admin` reuse in mig 111).
|
||||
|
||||
### 4.4 RLS on `paliad.checklists`
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.checklists ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: owner OR visible via can_see_checklist
|
||||
CREATE POLICY checklists_select
|
||||
ON paliad.checklists FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_checklist(auth.uid(), id));
|
||||
|
||||
-- INSERT: caller can only create templates owned by themselves
|
||||
CREATE POLICY checklists_insert
|
||||
ON paliad.checklists FOR INSERT TO authenticated
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- UPDATE: owner always; global_admin if visibility='global' (for demotion)
|
||||
CREATE POLICY checklists_update
|
||||
ON paliad.checklists FOR UPDATE TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- DELETE: owner OR global_admin
|
||||
CREATE POLICY checklists_delete
|
||||
ON paliad.checklists FOR DELETE TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### 4.5 RLS on `paliad.checklist_shares`
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: caller can see if they own the checklist OR they are the recipient OR global_admin
|
||||
CREATE POLICY checklist_shares_select
|
||||
ON paliad.checklist_shares FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
|
||||
OR (recipient_kind = 'user' AND recipient_user_id = auth.uid())
|
||||
OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
);
|
||||
|
||||
-- INSERT: only the checklist owner can grant
|
||||
CREATE POLICY checklist_shares_insert
|
||||
ON paliad.checklist_shares FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
|
||||
AND granted_by = auth.uid()
|
||||
);
|
||||
|
||||
-- DELETE: owner OR global_admin (no UPDATE policy — shares are immutable; revoke = delete + reinsert)
|
||||
CREATE POLICY checklist_shares_delete
|
||||
ON paliad.checklist_shares FOR DELETE TO authenticated
|
||||
USING (
|
||||
EXISTS (SELECT 1 FROM paliad.checklists c WHERE c.id = checklist_id AND c.owner_id = auth.uid())
|
||||
OR EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
);
|
||||
```
|
||||
|
||||
### 4.6 `paliad.checklist_instances.template_snapshot jsonb`
|
||||
|
||||
```sql
|
||||
-- Idempotent — column NULL on existing rows; service handles fallback to catalog lookup.
|
||||
ALTER TABLE paliad.checklist_instances
|
||||
ADD COLUMN IF NOT EXISTS template_snapshot jsonb;
|
||||
```
|
||||
|
||||
Existing RLS on `checklist_instances` untouched.
|
||||
|
||||
## 5. Service layer
|
||||
|
||||
### 5.1 `internal/services/checklist_catalog_service.go` (new)
|
||||
|
||||
Unified read facade over static + DB templates.
|
||||
|
||||
```go
|
||||
type ChecklistCatalogService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
type CatalogEntry struct {
|
||||
Slug string // matches checklists.Template.Slug or paliad.checklists.slug
|
||||
Origin string // "static" | "authored"
|
||||
OwnerID *uuid.UUID // nil for static
|
||||
OwnerName string // empty for static
|
||||
Visibility string // "static" | "private" | "shared" | "firm" | "global"
|
||||
Template checklists.Template
|
||||
}
|
||||
|
||||
// ListVisible returns every catalog entry the caller can see.
|
||||
// Static entries are always returned. DB entries pass through RLS.
|
||||
func (s *ChecklistCatalogService) ListVisible(ctx context.Context, userID uuid.UUID) ([]CatalogEntry, error)
|
||||
|
||||
// Find returns one entry by slug (static lookup first, then DB).
|
||||
func (s *ChecklistCatalogService) Find(ctx context.Context, userID uuid.UUID, slug string) (*CatalogEntry, error)
|
||||
|
||||
// SnapshotBody returns the JSONB body for a slug — used at instance creation to capture the template state.
|
||||
func (s *ChecklistCatalogService) SnapshotBody(ctx context.Context, userID uuid.UUID, slug string) (json.RawMessage, error)
|
||||
```
|
||||
|
||||
### 5.2 `internal/services/checklist_template_service.go` (new — Slice A)
|
||||
|
||||
CRUD on `paliad.checklists`.
|
||||
|
||||
```go
|
||||
type ChecklistTemplateService struct {
|
||||
db *sqlx.DB
|
||||
users *UserService
|
||||
}
|
||||
|
||||
type CreateTemplateInput struct {
|
||||
Title string
|
||||
Description string
|
||||
Regime string
|
||||
Court string
|
||||
Reference string
|
||||
Deadline string
|
||||
Lang string
|
||||
Body checklists.Template // unmarshalled to body jsonb minus slug/titles/etc
|
||||
}
|
||||
|
||||
func (s *ChecklistTemplateService) Create(ctx, userID, input) (*Template, error)
|
||||
func (s *ChecklistTemplateService) Update(ctx, userID, slug, input) (*Template, error)
|
||||
func (s *ChecklistTemplateService) Delete(ctx, userID, slug) error
|
||||
func (s *ChecklistTemplateService) SetVisibility(ctx, userID, slug, visibility) error // private/firm only
|
||||
func (s *ChecklistTemplateService) ListOwnedBy(ctx, userID) ([]Template, error)
|
||||
```
|
||||
|
||||
Slug generation: lowercase, alphanumeric+hyphen, `u-` prefix, unique
|
||||
suffix (collision retry up to 3x). Validator enforces
|
||||
`^u-[a-z0-9][a-z0-9-]{2,62}$`. Reserved slugs from
|
||||
`internal/checklists/checklists.go` Templates rejected at write time.
|
||||
|
||||
### 5.3 `internal/services/checklist_share_service.go` (new — Slice B)
|
||||
|
||||
```go
|
||||
type ChecklistShareService struct { db *sqlx.DB }
|
||||
|
||||
type ShareGrantInput struct {
|
||||
RecipientKind string
|
||||
UserID *uuid.UUID
|
||||
Office string
|
||||
PartnerUnitID *uuid.UUID
|
||||
ProjectID *uuid.UUID
|
||||
}
|
||||
|
||||
func (s *ChecklistShareService) Grant(ctx, callerID, checklistID, input) (*Share, error)
|
||||
func (s *ChecklistShareService) Revoke(ctx, callerID, shareID) error
|
||||
func (s *ChecklistShareService) ListGrants(ctx, callerID, checklistID) ([]Share, error)
|
||||
```
|
||||
|
||||
### 5.4 `internal/services/checklist_promotion_service.go` (new — Slice B)
|
||||
|
||||
`global_admin`-only operations.
|
||||
|
||||
```go
|
||||
type ChecklistPromotionService struct { db *sqlx.DB, audit *SystemAuditLogService }
|
||||
|
||||
func (s *ChecklistPromotionService) Promote(ctx, callerID, checklistID) error
|
||||
func (s *ChecklistPromotionService) Demote(ctx, callerID, checklistID, target /* 'firm' | 'private' */) error
|
||||
```
|
||||
|
||||
Promote: assert caller.global_role = 'global_admin' → UPDATE visibility =
|
||||
'global', promoted_at = now(), promoted_by = caller → audit row
|
||||
`event_type='checklist.promoted_global'`.
|
||||
|
||||
Demote: assert caller is global_admin → UPDATE visibility = target
|
||||
(default 'firm') → audit row `event_type='checklist.demoted'`.
|
||||
|
||||
### 5.5 Wire instance create to take snapshot
|
||||
|
||||
`ChecklistInstanceService.Create` extends to capture
|
||||
`template_snapshot` at insert time via
|
||||
`catalog.SnapshotBody(ctx, userID, slug)`. Existing instances unchanged
|
||||
(NULL snapshot, fallback path in read layer).
|
||||
|
||||
### 5.6 Endpoints
|
||||
|
||||
| Method | Path | Slice | Purpose |
|
||||
|--------|------|-------|---------|
|
||||
| `GET` | `/api/checklists` | (existing)| Merged catalog list (static + visible DB) |
|
||||
| `GET` | `/api/checklists/{slug}` | (existing)| Single template (static or DB) |
|
||||
| `POST` | `/api/checklists/templates` | A | Create authored template |
|
||||
| `GET` | `/api/checklists/templates/mine` | A | List own authored templates |
|
||||
| `PATCH` | `/api/checklists/templates/{slug}` | A | Edit authored template |
|
||||
| `DELETE` | `/api/checklists/templates/{slug}` | A | Delete authored template |
|
||||
| `PATCH` | `/api/checklists/templates/{slug}/visibility` | A | Toggle private/firm |
|
||||
| `GET` | `/api/checklists/templates/{slug}/shares` | B | List grants |
|
||||
| `POST` | `/api/checklists/templates/{slug}/shares` | B | Grant share |
|
||||
| `DELETE` | `/api/checklists/shares/{id}` | B | Revoke share |
|
||||
| `POST` | `/api/admin/checklists/{slug}/promote` | B | Admin promote to global |
|
||||
| `POST` | `/api/admin/checklists/{slug}/demote` | B | Admin demote |
|
||||
| `GET` | `/api/checklists/gallery` | C | Browse all firm + global templates |
|
||||
|
||||
## 6. Instance snapshot lifecycle
|
||||
|
||||
**On Create (`ChecklistInstanceService.Create`):**
|
||||
1. Resolve slug via `catalog.Find(userID, slug)` — enforces visibility.
|
||||
2. `snapshot = catalog.SnapshotBody(userID, slug)` — captures the
|
||||
template body (groups + items) at this moment, as JSONB.
|
||||
3. Insert into `checklist_instances` with
|
||||
`template_snapshot = snapshot`, `template_slug = slug`,
|
||||
`state = '{}'::jsonb`.
|
||||
|
||||
**On Read (`ChecklistInstanceService.GetByID`):**
|
||||
- Return the instance with `template_snapshot` if non-null.
|
||||
- If NULL (legacy row created before mig 112), fall back to
|
||||
`catalog.Find(slug)`. Logged at INFO; not a fatal path.
|
||||
|
||||
**On Template Edit (Slice A):**
|
||||
- Owner edits template via PATCH → DB row mutated → `checklists.updated_at`
|
||||
bumped → no propagation. Existing instances continue rendering their
|
||||
snapshot. New instances pick up the edit.
|
||||
- Audit row `event_type='checklist.edited'`,
|
||||
`metadata={ checklist_id, slug, changes:[...] }`.
|
||||
|
||||
**On Template Delete:**
|
||||
- DB row deleted. Instances that snapshotted survive (snapshot is
|
||||
local). Instances that DIDN'T snapshot (NULL) gracefully degrade —
|
||||
service detects "template not found in catalog" and returns the
|
||||
instance with a sentinel "template withdrawn" body (renders a small
|
||||
banner client-side; checkboxes still work because `state` is the
|
||||
source of truth, not the template).
|
||||
|
||||
**On Visibility Narrow (firm → shared → private):**
|
||||
- Existing instances unaffected (snapshot is local; visibility check is
|
||||
on the template, not instance).
|
||||
- New instance attempts fail with `ErrNotVisible` (the user can no
|
||||
longer see the template to instantiate it).
|
||||
|
||||
## 7. Frontend (concise sketch — coder owns the detail)
|
||||
|
||||
### 7.1 `/checklists` (existing page) — Slice A adds "Meine Vorlagen"
|
||||
|
||||
Add a third tab between "Vorlagen" and "Vorhandene Instanzen":
|
||||
|
||||
```
|
||||
[Vorlagen] [Meine Vorlagen] [Vorhandene Instanzen]
|
||||
```
|
||||
|
||||
- **Vorlagen** (existing): static catalog + global-promoted DB
|
||||
templates, grouped by Regime, filter pills (UPC/DE/EPA).
|
||||
- **Meine Vorlagen** (NEW): caller's own authored templates + a "Neue
|
||||
Vorlage" CTA. Each card shows title, description, visibility chip,
|
||||
Aktions-Buttons (Bearbeiten / Teilen / Löschen).
|
||||
- **Vorhandene Instanzen** (existing): unchanged behaviour; rows now
|
||||
optionally render an "📌 Snapshot" badge when `template_snapshot` is
|
||||
non-null (Slice A backfill marker).
|
||||
|
||||
Slice C adds a fourth tab: **Geteilte Vorlagen** (firm-level shared
|
||||
templates not yet promoted — discovery surface).
|
||||
|
||||
### 7.2 `/checklists/new` (NEW — Slice A)
|
||||
|
||||
Authoring wizard. Three steps:
|
||||
1. Metadata — title, description, regime (UPC/DE/EPA/OTHER), court,
|
||||
reference, deadline.
|
||||
2. Sections + items — repeating editor (group title → items[] of
|
||||
{label, note, rule}).
|
||||
3. Visibility — radio: privat / firm-weit. (Sharing flow comes in
|
||||
Slice B.)
|
||||
|
||||
Save → POST `/api/checklists/templates` → redirect to
|
||||
`/checklists/{slug}` detail.
|
||||
|
||||
### 7.3 `/checklists/{slug}/edit` (NEW — Slice A)
|
||||
|
||||
Same wizard, prefilled. Owner-only (404 otherwise).
|
||||
|
||||
### 7.4 `/checklists/{slug}` detail page
|
||||
|
||||
Existing detail page renders the template (static OR authored).
|
||||
Additions:
|
||||
- Owner-only "Bearbeiten" / "Löschen" / "Teilen" buttons in the header.
|
||||
- `global_admin`-only "Als Firmen-Vorlage hinterlegen" / "Aus Katalog
|
||||
entfernen" button (Slice B).
|
||||
- Provenance line under the title: "Erstellt von <author> · <date>"
|
||||
(only for DB templates).
|
||||
|
||||
### 7.5 Share modal (Slice B)
|
||||
|
||||
Triggered by "Teilen" on owner's detail page. Four pickers stacked:
|
||||
- Kollegen (user-picker, multi-select)
|
||||
- Office (chip-select from `offices.All`)
|
||||
- Dezernat (chip-select from `partner_units`)
|
||||
- Projekt (autocomplete from owner-visible projects)
|
||||
|
||||
Footer: "Visibility" radio (privat / geteilt / firm-weit). Picking
|
||||
"firm-weit" greys out the picker (firm-weit doesn't need grants).
|
||||
|
||||
Apply → POST grants individually → audit emits one
|
||||
`event_type='checklist.shared'` per grant with
|
||||
`metadata={ recipient_kind, recipient_id, checklist_id }`.
|
||||
|
||||
### 7.6 i18n keys
|
||||
|
||||
~28 new keys (DE+EN) under `checklisten.authoring.*`,
|
||||
`checklisten.share.*`, `checklisten.promote.*`. Naming convention
|
||||
matches existing `checklisten.tab.*` / `checklisten.instances.*`.
|
||||
|
||||
## 8. Audit events
|
||||
|
||||
Org-scope (`paliad.system_audit_log` via a small new helper
|
||||
`SystemAuditLogService.WriteChecklistEvent`):
|
||||
|
||||
| event_type | actor | metadata keys |
|
||||
|----------------------------------|-------------|----------------------------------------------------|
|
||||
| `checklist.authored` | owner | checklist_id, slug, visibility |
|
||||
| `checklist.edited` | owner | checklist_id, slug, changed_fields[] |
|
||||
| `checklist.visibility_changed` | owner | checklist_id, slug, from, to |
|
||||
| `checklist.shared` | owner | checklist_id, slug, recipient_kind, recipient_id |
|
||||
| `checklist.unshared` | owner | checklist_id, slug, recipient_kind, recipient_id |
|
||||
| `checklist.promoted_global` | global_admin| checklist_id, slug, owner_id |
|
||||
| `checklist.demoted` | global_admin| checklist_id, slug, target_visibility |
|
||||
| `checklist.deleted` | owner OR ga | checklist_id, slug, was_visibility |
|
||||
|
||||
Project-scope (`paliad.project_events` — existing helper
|
||||
`insertProjectEventWithMeta`): existing checklist-instance events
|
||||
unchanged. NO new project_events types for templates — templates are
|
||||
not project-scoped.
|
||||
|
||||
`AuditService.ListEntries` already reads from `system_audit_log` via
|
||||
the UNION ALL branch added in t-paliad-214 — no changes needed there;
|
||||
new event_types surface automatically in the audit log UI.
|
||||
|
||||
## 9. Slice plan
|
||||
|
||||
### Slice A — Foundation (~700 LoC)
|
||||
|
||||
**Schema:** mig 112 §4.1 (`paliad.checklists`) + §4.3 predicate + §4.4
|
||||
RLS + §4.6 instance snapshot column. **Skip** §4.2 / §4.5 in Slice A —
|
||||
no share table yet; visibility limited to private/firm.
|
||||
|
||||
**Service:** `ChecklistCatalogService` (unified read), `ChecklistTemplateService`
|
||||
(CRUD), `ChecklistInstanceService.Create` snapshot wiring,
|
||||
`SystemAuditLogService.WriteChecklistEvent` helper.
|
||||
|
||||
**Endpoints:** `/api/checklists` (delegate to catalog), `POST/PATCH/DELETE
|
||||
/api/checklists/templates`, `PATCH /api/checklists/templates/{slug}/visibility`.
|
||||
|
||||
**Frontend:** "Meine Vorlagen" tab on `/checklists`, `/checklists/new`,
|
||||
`/checklists/{slug}/edit`, owner controls on detail page.
|
||||
|
||||
**Test pass:** unit tests for slug validation, snapshot capture,
|
||||
visibility predicate (without share rows), audit emit, fallback to
|
||||
catalog when snapshot NULL.
|
||||
|
||||
**No share, no admin promote, no gallery.** Ships immediately useful
|
||||
for solo authoring + firm-wide publishing.
|
||||
|
||||
### Slice B — Sharing + Promotion (~600 LoC)
|
||||
|
||||
**Schema:** mig 113 — `paliad.checklist_shares` (§4.2) + revised RLS
|
||||
(§4.5) + extend visibility CHECK to include 'shared' if Slice A used a
|
||||
sub-enum (Slice A schema already includes 'shared' as valid value —
|
||||
just no grants point at it yet).
|
||||
|
||||
**Service:** `ChecklistShareService`, `ChecklistPromotionService`.
|
||||
|
||||
**Endpoints:** shares endpoints + admin promote/demote.
|
||||
|
||||
**Frontend:** Share modal, "Make global" admin button on detail page,
|
||||
share-grant chip list on detail page (owner-only).
|
||||
|
||||
**Audit:** new event_types (shared, unshared, promoted_global, demoted).
|
||||
|
||||
### Slice C — Discoverability + UX polish (~400 LoC)
|
||||
|
||||
**Gallery page** `/checklists/gallery`: browses every template the user
|
||||
can see that's NOT their own, grouped by Regime / Author / Recency.
|
||||
Filter pills. "Diese Vorlage verwenden" → instantiates with snapshot.
|
||||
|
||||
**Backfill** existing `checklist_instances` with `template_snapshot`
|
||||
via a one-off migration (mig 114) — pure data move, no schema change.
|
||||
After backfill, the catalog-fallback path can be removed (deferred to
|
||||
Slice D / cleanup).
|
||||
|
||||
**Optional**:
|
||||
- "Vorlage kopieren" action — clone an existing template (static OR
|
||||
authored) into the caller's "Meine Vorlagen" for personal adaptation.
|
||||
- Per-template instance counter ("12 Kollegen haben diese Vorlage
|
||||
benutzt") — surfaced from `checklist_instances` group-by.
|
||||
|
||||
## 10. Trade-offs flagged
|
||||
|
||||
1. **Hybrid catalog (static + DB).** Two sources of truth means two
|
||||
slug spaces to merge. Mitigated by `u-` prefix on authored slugs +
|
||||
reserved-list rejection. Refactoring all static templates into DB
|
||||
loses the git review trail; the hybrid is the right cost.
|
||||
2. **Polymorphism deferred.** A future second sharable entity will need
|
||||
to either copy the `checklist_shares` pattern (cheap but duplicative)
|
||||
or refactor to `entity_shares` (one mig). The refactor is small;
|
||||
premature abstraction now would pay complexity for no current
|
||||
benefit.
|
||||
3. **Snapshot semantics may surprise.** A user who edits their template
|
||||
expecting downstream instances to update will be confused.
|
||||
Mitigations: (a) UI banner on edit ("Bearbeitungen wirken nur auf
|
||||
neue Instanzen"); (b) "Neu instantiieren" affordance on the instance
|
||||
detail page that re-snapshots from the current template (preserves
|
||||
the user's checkbox state to the extent items still match).
|
||||
4. **Office membership is set-membership, not hierarchy.** Sharing to
|
||||
"munich" reaches every user with `office='munich'` OR
|
||||
`'munich' = ANY(additional_offices)`. There's no concept of "Munich
|
||||
plus its sub-teams" because offices don't nest in paliad. Fine.
|
||||
5. **Partner-unit membership join is N+1 on the predicate.** Each
|
||||
visibility check touches `partner_unit_members` if any partner-unit
|
||||
share exists. Indexes on `partner_unit_members(user_id, partner_unit_id)`
|
||||
already exist (per mig 027 lineage); the join is single-row.
|
||||
6. **Share-to-project recipient resolution uses
|
||||
`can_see_project(s.recipient_project_id)`.** That predicate reads
|
||||
`auth.uid()` from the session, so it works correctly inside our
|
||||
SECURITY DEFINER body. Confirmed by reading `can_see_project`'s body
|
||||
in `paliad.can_see_project` source — same pattern that
|
||||
`effective_project_admin` uses in mig 111.
|
||||
7. **`global_admin` UPDATE RLS on `paliad.checklists` is full-row.**
|
||||
Means a global_admin can edit content of any user's template, not
|
||||
just visibility. This is intentional for catalog hygiene
|
||||
(correcting typos, removing inflammatory content) but should be used
|
||||
sparingly and audited. The audit log captures every
|
||||
global_admin-attributed edit via `checklist.edited` with actor_id.
|
||||
8. **Instance snapshot fallback path lives indefinitely.** Existing
|
||||
pre-mig-112 instances stay NULL until Slice C backfills. The
|
||||
fallback code in `ChecklistInstanceService.GetByID` is ~10 LoC and
|
||||
no hot-path concern — but it's "dead code" once the backfill runs.
|
||||
Acceptable until Slice C.
|
||||
9. **Cascade on owner deletion.** If an authored template's owner is
|
||||
removed (`paliad.users.id` cascades), the template is wiped along
|
||||
with all its shares. Existing instances survive via snapshot. The
|
||||
alternative (transfer ownership to global_admin on user-delete) is
|
||||
more polite but introduces governance questions ("which admin?")
|
||||
that aren't worth Slice A complexity. Flag for Slice C if it bites.
|
||||
10. **Slug uniqueness across origins enforced application-side.**
|
||||
The static catalog is in-memory at boot. If a deploy adds a static
|
||||
slug that collides with an existing DB slug, the deploy boots
|
||||
cleanly but the DB row becomes unreachable via the catalog read
|
||||
layer (static wins on slug lookup). Mitigation: a boot-time
|
||||
integrity check in `cmd/server/main.go` logs WARN if collision
|
||||
detected. Owner can rename their template manually via the edit UI.
|
||||
|
||||
## 11. m's decisions ledger (all defaulted to (R) per task brief)
|
||||
|
||||
Per task brief "NO AskUserQuestion. Defaults to (R). Escalate to head if
|
||||
material." I have not escalated; all picks below default to (R).
|
||||
|
||||
| # | Question | (R) pick |
|
||||
|---|---------------------------------------------------------|-------------------------------------------|
|
||||
| 1 | Storage model for authored templates | Hybrid: keep static catalog + new `paliad.checklists` DB table |
|
||||
| 2 | Instance lifecycle on template edit | **Snapshot** at instance create (NOT propagate) |
|
||||
| 3 | Visibility enum values | `private`, `shared`, `firm`, `global` |
|
||||
| 4 | Share recipients | user, office, partner_unit, project (4 axes) |
|
||||
| 5 | Share-to-project resolution | Reuse `can_see_project` (visibility, not just team rows) |
|
||||
| 6 | Promotion authority | `global_admin` only (no per-project admin promote in v1) |
|
||||
| 7 | Demotion target | `global → firm` (preserves visibility for in-flight instances) |
|
||||
| 8 | Slug strategy | `u-` prefix on authored, application-side collision check vs static |
|
||||
| 9 | Polymorphic share table (`entity_shares`) vs scoped | **Scoped (`checklist_shares`).** Refactor to polymorphic *after* second sharable entity appears |
|
||||
| 10| Authoring i18n | Author picks single language (DE or EN) per template via `lang` column; verbatim render |
|
||||
| 11| Audit sink for template lifecycle | `paliad.system_audit_log` (org-scope); instance events stay on `paliad.project_events` |
|
||||
| 12| Slice ordering | A (foundation) → B (share + promote) → C (gallery + backfill) |
|
||||
|
||||
Material escalation list: empty. If m disagrees with any of the above,
|
||||
amend §11 in the next inventor shift; the schema is designed to be
|
||||
forward-compatible with most reversals (e.g. flipping snapshot →
|
||||
propagate is a service-layer change, not a schema change).
|
||||
|
||||
## 12. Acceptance criteria — Slice A
|
||||
|
||||
1. **Migration 112 applies cleanly on a fresh DB** and is idempotent
|
||||
on re-apply (verified via `BEGIN…ROLLBACK` dry-run against the live
|
||||
`paliad` schema).
|
||||
2. **`/api/checklists` returns merged catalog** — static templates
|
||||
plus DB templates the caller can see (visibility ∈ {firm, global}
|
||||
OR owner = caller).
|
||||
3. **POST `/api/checklists/templates`** creates a row, returns the
|
||||
created template with auto-generated `u-…` slug, emits
|
||||
`checklist.authored` audit row.
|
||||
4. **PATCH `/api/checklists/templates/{slug}`** updates owner-only
|
||||
fields, rejects 403 from non-owner non-admin, emits
|
||||
`checklist.edited`.
|
||||
5. **PATCH `/api/checklists/templates/{slug}/visibility`** toggles
|
||||
private↔firm; rejects `shared` and `global` in Slice A (those land
|
||||
in Slice B); emits `checklist.visibility_changed`.
|
||||
6. **DELETE `/api/checklists/templates/{slug}`** removes the row;
|
||||
existing instances survive via snapshot.
|
||||
7. **Instance create snapshots the template body** —
|
||||
`template_snapshot` non-null on every new instance row.
|
||||
8. **Legacy instances (NULL snapshot) still render** via catalog
|
||||
fallback (covered by a regression test).
|
||||
9. **"Meine Vorlagen" tab** lists owner's templates; "Neue Vorlage"
|
||||
CTA navigates to `/checklists/new`; wizard saves successfully.
|
||||
10. **`go build ./... && go vet ./... && go test ./internal/...`
|
||||
clean.** `bun run build` clean (i18n key count incremented by ~20).
|
||||
11. **Live smoke**: tester@hlc.de can create + edit + delete a private
|
||||
template; setting visibility to `firm` makes it visible to a second
|
||||
tester account; deleting the template doesn't break existing
|
||||
instances.
|
||||
|
||||
## 13. Recommended implementer
|
||||
|
||||
Pattern-fluent **Sonnet coder**, NOT cronus (per project memory
|
||||
directive 2026-05-06). Substrate is well-trodden:
|
||||
|
||||
- Migration shape mirrors mig 111 (gauss) for the predicate function +
|
||||
policy replacement pattern.
|
||||
- Service shape mirrors `ChecklistInstanceService` for CRUD + audit
|
||||
emit + visibility check.
|
||||
- Endpoint shape mirrors `internal/handlers/checklist_instances.go`.
|
||||
- Frontend tab pattern mirrors the existing
|
||||
`entity-tabs` / `entity-tab-panel` substrate in `checklists.tsx`.
|
||||
|
||||
Novel pieces:
|
||||
- Catalog merge layer (~80 LoC) — the only logic the coder needs to
|
||||
prototype before committing to the full slice. Pure function; easy
|
||||
to unit-test.
|
||||
- Share predicate (Slice B) — straightforward translation of §4.3 SQL
|
||||
into a STABLE SECURITY DEFINER function; pattern matches mig 111
|
||||
exactly.
|
||||
|
||||
Branch: keep on `mai/dirac/user-checklists`. Three slices = three PRs,
|
||||
or one branch with three commits — coder's call. Each slice ends with
|
||||
acceptance criteria; head merges between slices for fast feedback.
|
||||
|
||||
## 14. Out of scope (explicitly)
|
||||
|
||||
- Importing checklists from external sources (Notion, Trello, .docx).
|
||||
- Approval-policy gating on checklist edits (admin pre-publish review).
|
||||
- Cross-firm template marketplace.
|
||||
- Translation workflow (de↔en) for authored templates — Slice A
|
||||
ships single-language; if firm appetite shows up post-launch, file
|
||||
a follow-up.
|
||||
- Static-catalog editor UI (the static templates remain code-only).
|
||||
- Versioning UI ("show me the version this instance was created from")
|
||||
— snapshot is captured; surfacing it is Slice C polish.
|
||||
|
||||
---
|
||||
|
||||
**Inventor parked per gate protocol.** No auto-shift to coder. Head
|
||||
decides: same worker as `/mai-coder` with this brief, fresh coder, or
|
||||
rescope. Slice ordering A → B → C is independent enough that the head
|
||||
can also greenlight Slice A alone and re-design B/C after Slice A
|
||||
ships.
|
||||
@@ -1,52 +0,0 @@
|
||||
# t-paliad-207 follow-up scope — close-out assessment
|
||||
|
||||
**Author:** fermi (inventor)
|
||||
**Date:** 2026-05-20
|
||||
**Verdict:** **(A) DONE** — interactive session scope is shipped; remaining tail is filed-or-fileable as discrete issues, not a fresh fermi slice.
|
||||
|
||||
---
|
||||
|
||||
## 0. What shipped under t-paliad-207
|
||||
|
||||
Six substantive deliveries on `mai/fermi/interactive-session`, all merged to main as of 2026-05-20 morning:
|
||||
|
||||
1. **Verfahrensablauf + Fristenrechner polish** — jurisdiction prefix on the picked proceeding, trigger-event label derived from the root rule, flag rows lifted to `/tools/verfahrensablauf`, rule references rendered as `youpc.org/laws#…` links via new `BuildLegalSourceURL`, `Vorab-Einrede → Einspruch` rename (DE i18n).
|
||||
2. **DE proceeding picker — sub-group headers** (`Verletzungsverfahren` / `Nichtigkeitsverfahren`) + parallel labels (`LG (1. Instanz)` / `OLG (Berufung)` / …).
|
||||
3. **mig 099** — drop the `with_po` flag from the two RoP 19 rules (Einspruch is always-available, not flag-gated).
|
||||
4. **mig 100** — `upc.inf.cfi.ccr` visible rule (`Nichtigkeitswiderklage`) so the CCR filing event surfaces when `with_ccr` is set; later corrected to `priority='optional'` via mig 101.
|
||||
5. **mig 101** — strip rule-cite brackets from the two Einspruch names + flip the CCR priority `informational → optional`.
|
||||
6. **mig 102** — track-aware sequence reshuffle on `upc.inf.cfi` so at any tied date the order is infringement (Replik) → revocation (Erwiderung Nichtigkeitswiderklage) → amendment.
|
||||
7. **Notes toggle** — `Hinweise anzeigen` checkbox in the view-toggle bar; compact ⓘ hover hint when off (default), inline `timeline-notes` block when on. `localStorage` shared across both tool pages.
|
||||
|
||||
Filed two follow-up issues during the session:
|
||||
|
||||
- **m/paliad#39** — link DE + EPA + EU rule references to `youpc.org/laws` (depends on youpc.org ingesting the corpus).
|
||||
- **m/paliad#41** — DE proceedings as one combined timeline per type (LG→OLG→BGH, BPatG→BGH) — corpus + spawn + de-duplication + multi-instance UI.
|
||||
|
||||
## 1. Why (A) DONE
|
||||
|
||||
Every concrete thing m surfaced in the session was addressed and merged. The two larger unaddressed asks — combined-timeline behaviour for DE proceedings, and DE/EPA rule-link coverage — are already captured in #39 and #41 with concrete scope notes. Neither belongs as a fermi "next slice" because:
|
||||
|
||||
- **#41** is a corpus + UI design pass of its own (3 new spawn rules, de-duplication of the existing `de.inf.lg.berufung ↔ de.inf.olg.berufung` pair, multi-court picker shape, instance markers in the timeline body). That's its own design ticket, not a fermi follow-up.
|
||||
- **#39** is primarily a youpc.org-side ingest task; the paliad-side change is a 5-line `switch` extension once youpc serves the URLs. Wait for the dependency, then small.
|
||||
|
||||
Everything else I surfaced in the read-only audit is either pre-existing (not introduced by this session) or speculative (no user complaint behind it).
|
||||
|
||||
## 2. Optional tail — would file as discrete issues, not a fermi slice
|
||||
|
||||
Surfacing these for completeness; none are blocking, and most would be small enough to either roll into the existing tickets or land as one-off polish:
|
||||
|
||||
| # | Candidate | Size | Already covered? |
|
||||
|---|---|---|---|
|
||||
| 1 | **`legal_source` backfill on 47 unsourced active rules** — query: 4 of `upc.inf.cfi`, 4 of `upc.pi.cfi` (100% gap), 6 of `upc.rev.cfi`, others. Pre-condition for #39's links to bite. | Medium — corpus research per rule | Partially: huygens did the broader citation backfill in t-paliad-208 / mig 097. This is the remaining tail. |
|
||||
| 2 | **`upc.pi.cfi` corpus completeness audit** — all 4 of its rules lack `legal_source`; likely also missing the analogous track-of-decision spawn rules to `upc.apl.merits`. | Small audit, medium fix | No — would be a fresh task. |
|
||||
| 3 | **Touch-device fallback for the ⓘ hover hint** — `title=` attribute degrades poorly on phones (no hover, no tap-to-show). Either a click-to-popover variant, or accept the gap. | Tiny | No, but no user complaint yet. |
|
||||
| 4 | **R.46 mutatis-mutandis distinction in `upc.rev.cfi.prelim` description** — when mig 101 stripped the `(R. 19 i.V.m. R. 46)` cite, the legal nuance dropped from the user-visible name. Could be surfaced in the description text where it doesn't crowd the timeline cell. | Tiny (one row update) | No. |
|
||||
| 5 | **Save-modal warning on SoD + CCR double-check** — with mig 100's new `upc.inf.cfi.ccr` rule, a user can save both `sod` and `ccr` from the same modal and get two `paliad.deadlines` rows on the same date. Today's pre-uncheck behaviour for optional priority mitigates accidental double-write but doesn't surface the duplication actively. | Small | No. |
|
||||
| 6 | **Deferred slices from earlier design docs that touch this surface**: t-paliad-179 Slice 2-4 (variant chips, lane view, side-by-side compare on `/tools/verfahrensablauf`); t-paliad-169 "+ Eintrag" CTA on the SmartTimeline (project-bound) path. | Each a separate slice. | Yes — parked from their original tasks; would be revisited when m prioritises. |
|
||||
|
||||
None of these warrant a "next fermi slice" right now. They're polish + corpus tail, and best handled as individual issues that m can pick from.
|
||||
|
||||
## 3. Recommendation
|
||||
|
||||
Close t-paliad-207. Fire fermi. The remaining tail (items 1–6 above) is appropriate as a small "polish backlog" m can dip into when relevant, but not a coherent unit of work that needs a parked inventor.
|
||||
@@ -10,7 +10,6 @@ import { renderLinks } from "./src/links";
|
||||
import { renderGlossary } from "./src/glossary";
|
||||
import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
|
||||
import { renderChecklists } from "./src/checklists";
|
||||
import { renderChecklistsAuthor } from "./src/checklists-author";
|
||||
import { renderChecklistsDetail } from "./src/checklists-detail";
|
||||
import { renderChecklistsInstance } from "./src/checklists-instance";
|
||||
import { renderCourts } from "./src/courts";
|
||||
@@ -21,8 +20,10 @@ import { renderProjectsChart } from "./src/projects-chart";
|
||||
import { renderEvents } from "./src/events";
|
||||
import { renderDeadlinesNew } from "./src/deadlines-new";
|
||||
import { renderDeadlinesDetail } from "./src/deadlines-detail";
|
||||
import { renderDeadlinesCalendar } from "./src/deadlines-calendar";
|
||||
import { renderAppointmentsNew } from "./src/appointments-new";
|
||||
import { renderAppointmentsDetail } from "./src/appointments-detail";
|
||||
import { renderAppointmentsCalendar } from "./src/appointments-calendar";
|
||||
import { renderSettings } from "./src/settings";
|
||||
import { renderDashboard } from "./src/dashboard";
|
||||
import { renderAgenda } from "./src/agenda";
|
||||
@@ -244,7 +245,6 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/glossary.ts"),
|
||||
join(import.meta.dir, "src/client/gebuehrentabellen.ts"),
|
||||
join(import.meta.dir, "src/client/checklists.ts"),
|
||||
join(import.meta.dir, "src/client/checklists-author.ts"),
|
||||
join(import.meta.dir, "src/client/checklists-detail.ts"),
|
||||
join(import.meta.dir, "src/client/checklists-instance.ts"),
|
||||
join(import.meta.dir, "src/client/courts.ts"),
|
||||
@@ -255,8 +255,10 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-new.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-detail.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-calendar.ts"),
|
||||
join(import.meta.dir, "src/client/appointments-new.ts"),
|
||||
join(import.meta.dir, "src/client/appointments-detail.ts"),
|
||||
join(import.meta.dir, "src/client/appointments-calendar.ts"),
|
||||
join(import.meta.dir, "src/client/settings.ts"),
|
||||
join(import.meta.dir, "src/client/dashboard.ts"),
|
||||
join(import.meta.dir, "src/client/agenda.ts"),
|
||||
@@ -368,7 +370,6 @@ async function build() {
|
||||
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
|
||||
await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen());
|
||||
await Bun.write(join(DIST, "checklists.html"), renderChecklists());
|
||||
await Bun.write(join(DIST, "checklists-author.html"), renderChecklistsAuthor());
|
||||
await Bun.write(join(DIST, "checklists-detail.html"), renderChecklistsDetail());
|
||||
await Bun.write(join(DIST, "checklists-instance.html"), renderChecklistsInstance());
|
||||
await Bun.write(join(DIST, "courts.html"), renderCourts());
|
||||
@@ -383,8 +384,10 @@ async function build() {
|
||||
await Bun.write(join(DIST, "events.html"), renderEvents());
|
||||
await Bun.write(join(DIST, "deadlines-new.html"), renderDeadlinesNew());
|
||||
await Bun.write(join(DIST, "deadlines-detail.html"), renderDeadlinesDetail());
|
||||
await Bun.write(join(DIST, "deadlines-calendar.html"), renderDeadlinesCalendar());
|
||||
await Bun.write(join(DIST, "appointments-new.html"), renderAppointmentsNew());
|
||||
await Bun.write(join(DIST, "appointments-detail.html"), renderAppointmentsDetail());
|
||||
await Bun.write(join(DIST, "appointments-calendar.html"), renderAppointmentsCalendar());
|
||||
await Bun.write(join(DIST, "settings.html"), renderSettings());
|
||||
await Bun.write(join(DIST, "dashboard.html"), renderDashboard());
|
||||
await Bun.write(join(DIST, "agenda.html"), renderAgenda());
|
||||
|
||||
@@ -33,9 +33,6 @@ export function renderAdminTeam(): string {
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-team-actions">
|
||||
<button className="btn-primary" id="admin-team-add-full" type="button" data-i18n="admin.team.add.full">
|
||||
Konto direkt anlegen
|
||||
</button>
|
||||
<button className="btn-primary" id="admin-team-direct-add" type="button" data-i18n="admin.team.add.direct">
|
||||
Bestehendes Konto onboarden
|
||||
</button>
|
||||
@@ -135,67 +132,6 @@ export function renderAdminTeam(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal.
|
||||
Creates BOTH the auth.users row (via Supabase Admin API) and
|
||||
the paliad.users row in one click. New user is visible in
|
||||
dropdowns immediately. */}
|
||||
<div className="modal-overlay" id="admin-add-full-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="admin.team.add_full.title">Konto direkt anlegen</h2>
|
||||
<button className="modal-close" id="admin-af-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p data-i18n="admin.team.add_full.body" className="invite-modal-body">
|
||||
Legt sowohl das Login-Konto als auch das Paliad-Profil an. Die neue Person erhält eine E-Mail mit einem Link, über den sie ein Passwort setzt.
|
||||
</p>
|
||||
<form id="admin-add-full-form" className="entity-form" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-email" data-i18n="admin.team.add_full.email">E-Mail</label>
|
||||
<input type="email" id="admin-af-email" name="email" required autocomplete="off" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-name" data-i18n="admin.team.add_full.name">Anzeigename</label>
|
||||
<input type="text" id="admin-af-name" name="display_name" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-office" data-i18n="admin.team.add_full.office">Standort</label>
|
||||
<select id="admin-af-office" name="office" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-profession" data-i18n="admin.team.add_full.profession">Profession</label>
|
||||
<select id="admin-af-profession" name="profession">
|
||||
<option value="partner" data-i18n="projects.team.profession.partner">Partner</option>
|
||||
<option value="of_counsel" data-i18n="projects.team.profession.of_counsel">Of Counsel</option>
|
||||
<option value="associate" selected data-i18n="projects.team.profession.associate">Associate</option>
|
||||
<option value="senior_pa" data-i18n="projects.team.profession.senior_pa">Senior PA</option>
|
||||
<option value="pa" data-i18n="projects.team.profession.pa">PA</option>
|
||||
<option value="paralegal" data-i18n="projects.team.profession.paralegal">Paralegal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-job-title" data-i18n="admin.team.add_full.job_title">Berufsbezeichnung</label>
|
||||
<input type="text" id="admin-af-job-title" name="job_title" placeholder="Associate" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-af-lang" data-i18n="admin.team.add_full.lang">Sprache</label>
|
||||
<select id="admin-af-lang" name="lang">
|
||||
<option value="de" selected>Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" id="admin-af-send-welcome" checked />
|
||||
<span data-i18n="admin.team.add_full.send_welcome">Willkommens-E-Mail mit Login-Link senden</span>
|
||||
</label>
|
||||
<div id="admin-af-feedback" className="form-msg" style="display:none" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="admin-af-cancel" data-i18n="admin.team.add_full.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary" id="admin-af-submit" data-i18n="admin.team.add_full.submit">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-team.js"></script>
|
||||
|
||||
103
frontend/src/appointments-calendar.tsx
Normal file
103
frontend/src/appointments-calendar.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAppointmentsCalendar(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="appointments.kalender.title">Terminkalender — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/events?type=appointment" />
|
||||
<BottomNav currentPath="/events?type=appointment" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div className="entity-header-row">
|
||||
<div>
|
||||
<h1 data-i18n="appointments.kalender.heading">Terminkalender</h1>
|
||||
<p className="tool-subtitle" data-i18n="appointments.kalender.subtitle">
|
||||
Monatsübersicht aller Termine.
|
||||
</p>
|
||||
</div>
|
||||
<div className="fristen-header-actions">
|
||||
<a href="/events?type=appointment" className="btn-secondary" data-i18n="appointments.kalender.list">Listenansicht</a>
|
||||
<a href="/appointments/new" className="btn-primary btn-cta-lime" data-i18n="appointments.list.new">Neuer Termin</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="frist-calendar-controls">
|
||||
<button type="button" id="cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">←</button>
|
||||
<h2 id="cal-month-label" className="frist-cal-month-label" />
|
||||
<button type="button" id="cal-next" className="btn-secondary btn-small" aria-label="Nächster Monat">→</button>
|
||||
<button type="button" id="cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
|
||||
</div>
|
||||
|
||||
<div className="termin-cal-legend">
|
||||
<span className="termin-cal-legend-item">
|
||||
<span className="termin-dot termin-type-hearing" />
|
||||
<span data-i18n="appointments.type.hearing">Verhandlung</span>
|
||||
</span>
|
||||
<span className="termin-cal-legend-item">
|
||||
<span className="termin-dot termin-type-meeting" />
|
||||
<span data-i18n="appointments.type.meeting">Besprechung</span>
|
||||
</span>
|
||||
<span className="termin-cal-legend-item">
|
||||
<span className="termin-dot termin-type-consultation" />
|
||||
<span data-i18n="appointments.type.consultation">Beratung</span>
|
||||
</span>
|
||||
<span className="termin-cal-legend-item">
|
||||
<span className="termin-dot termin-type-deadline_hearing" />
|
||||
<span data-i18n="appointments.type.deadline_hearing">Fristverhandlung</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="frist-calendar" id="appointment-calendar">
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
|
||||
<div id="appointment-cal-grid" className="frist-cal-grid" />
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="appointment-cal-empty" style="display:none" data-i18n="appointments.kalender.empty">
|
||||
Keine Termine im ausgewählten Zeitraum.
|
||||
</p>
|
||||
|
||||
<div className="modal-overlay" id="cal-popup" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="cal-popup-date" />
|
||||
<button className="modal-close" id="cal-popup-close" type="button">×</button>
|
||||
</div>
|
||||
<ul className="frist-cal-popup-list" id="cal-popup-list" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/appointments-calendar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Authoring wizard for paliad.checklists. Both /checklists/new and
|
||||
// /checklists/templates/{slug}/edit serve this same bundle; the client reads
|
||||
// window.location.pathname to decide create vs edit mode.
|
||||
export function renderChecklistsAuthor(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="checklisten.author.title">Vorlage erstellen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/checklists" />
|
||||
<BottomNav currentPath="/checklists" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 id="author-heading" data-i18n="checklisten.author.heading.new">Neue Checklisten-Vorlage</h1>
|
||||
<p className="tool-subtitle" data-i18n="checklisten.author.subtitle">
|
||||
Erstellen Sie eine eigene Checkliste mit Sektionen und Punkten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form id="author-form" className="form-stack" autoComplete="off">
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="title" data-i18n="checklisten.author.field.title">Titel</label>
|
||||
<input className="form-input" id="title" name="title" type="text" required maxLength="200" />
|
||||
<p className="form-hint" data-i18n="checklisten.author.field.title.hint">z.B. „UPC SoC — interne Checkliste“.</p>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="description" data-i18n="checklisten.author.field.description">Kurzbeschreibung</label>
|
||||
<textarea className="form-input" id="description" name="description" rows="3" maxLength="2000" />
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid-2">
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="regime" data-i18n="checklisten.author.field.regime">Regime</label>
|
||||
<select className="form-input" id="regime" name="regime">
|
||||
<option value="UPC">UPC</option>
|
||||
<option value="DE">DE</option>
|
||||
<option value="EPA">EPA</option>
|
||||
<option value="OTHER" selected>OTHER</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="lang" data-i18n="checklisten.author.field.lang">Sprache</label>
|
||||
<select className="form-input" id="lang" name="lang">
|
||||
<option value="de" selected>Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid-2">
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="court" data-i18n="checklisten.author.field.court">Gericht / Behörde</label>
|
||||
<input className="form-input" id="court" name="court" type="text" maxLength="200" />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="reference" data-i18n="checklisten.author.field.reference">Rechtsgrundlage</label>
|
||||
<input className="form-input" id="reference" name="reference" type="text" maxLength="200" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="deadline" data-i18n="checklisten.author.field.deadline">Deadline (optional)</label>
|
||||
<input className="form-input" id="deadline" name="deadline" type="text" maxLength="200" />
|
||||
</div>
|
||||
|
||||
<fieldset className="form-fieldset">
|
||||
<legend data-i18n="checklisten.author.field.visibility">Sichtbarkeit</legend>
|
||||
<label className="form-radio">
|
||||
<input type="radio" name="visibility" value="private" checked />
|
||||
<span><strong data-i18n="checklisten.mine.visibility.private">Privat</strong> — <span data-i18n="checklisten.author.visibility.private.hint">Nur für Sie sichtbar.</span></span>
|
||||
</label>
|
||||
<label className="form-radio">
|
||||
<input type="radio" name="visibility" value="firm" />
|
||||
<span><strong data-i18n="checklisten.mine.visibility.firm">Firmenweit</strong> — <span data-i18n="checklisten.author.visibility.firm.hint">Für alle angemeldeten Kolleginnen und Kollegen sichtbar.</span></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="form-fieldset">
|
||||
<legend data-i18n="checklisten.author.groups.heading">Sektionen und Punkte</legend>
|
||||
<div id="groups-container" />
|
||||
<button type="button" className="btn btn-secondary" id="add-group" data-i18n="checklisten.author.groups.add">+ Sektion hinzufügen</button>
|
||||
</fieldset>
|
||||
|
||||
<p id="author-error" className="form-error" style="display:none" role="alert" />
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" id="author-save" data-i18n="checklisten.author.save">Speichern</button>
|
||||
<a className="btn btn-secondary" href="/checklists?tab=mine" data-i18n="checklisten.author.cancel">Abbrechen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/checklists-author.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -39,28 +39,12 @@ export function renderChecklistsDetail(): string {
|
||||
<div>
|
||||
<h1 id="checklist-title"> </h1>
|
||||
<p className="tool-subtitle" id="checklist-subtitle"> </p>
|
||||
{/* Provenance line — visible only for authored
|
||||
templates; populated by the client from the
|
||||
catalog response's owner_display_name. */}
|
||||
<p className="checklist-provenance" id="checklist-provenance" style="display:none" />
|
||||
<dl className="checklist-meta" id="checklist-meta" />
|
||||
</div>
|
||||
<div className="checklist-actions">
|
||||
<button type="button" id="btn-new-instance" className="btn-primary btn-cta-lime" data-i18n="checklisten.newInstance">
|
||||
Neue Instanz
|
||||
</button>
|
||||
{/* Owner controls (Slice B) — toggled on by the
|
||||
client once /api/checklists/{slug} returns
|
||||
origin='authored' AND owner_email matches the
|
||||
logged-in user. Kept hidden by default so
|
||||
guests / non-owners never see them. */}
|
||||
<a id="btn-edit-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.edit">Bearbeiten</a>
|
||||
<button type="button" id="btn-share-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.share">Teilen</button>
|
||||
<button type="button" id="btn-delete-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.mine.delete">Löschen</button>
|
||||
{/* global_admin controls — revealed by the client
|
||||
when /api/me reports global_role='global_admin'. */}
|
||||
<button type="button" id="btn-promote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.promote">Als Firmen-Vorlage hinterlegen</button>
|
||||
<button type="button" id="btn-demote-template" className="btn-cta-lime btn-outline" style="display:none" data-i18n="checklisten.detail.demote">Aus Katalog entfernen</button>
|
||||
<button type="button" id="btn-feedback" className="btn-cta-lime btn-outline">
|
||||
<span data-i18n="checklisten.feedback.btn">Feedback</span>
|
||||
</button>
|
||||
@@ -138,65 +122,6 @@ export function renderChecklistsDetail(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Share modal (Slice B) — owner-only, hidden until btn-share-template
|
||||
opens it. Four recipient kinds in a single modal: pick the kind,
|
||||
then the matching entity (user / office / partner_unit / project). */}
|
||||
<div className="modal-overlay" id="share-modal" style="display:none">
|
||||
<div className="modal-card modal-card-wide">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="checklisten.share.title">Vorlage teilen</h2>
|
||||
<button className="modal-close" id="share-close" type="button">×</button>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label data-i18n="checklisten.share.kind">Empfängertyp</label>
|
||||
<div className="filter-pills" id="share-kind-pills">
|
||||
<button type="button" className="filter-pill active" data-kind="user" data-i18n="checklisten.share.kind.user">Kollege</button>
|
||||
<button type="button" className="filter-pill" data-kind="office" data-i18n="checklisten.share.kind.office">Office</button>
|
||||
<button type="button" className="filter-pill" data-kind="partner_unit" data-i18n="checklisten.share.kind.partner_unit">Dezernat</button>
|
||||
<button type="button" className="filter-pill" data-kind="project" data-i18n="checklisten.share.kind.project">Projekt</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field share-kind-section" data-kind="user">
|
||||
<label htmlFor="share-user" data-i18n="checklisten.share.kind.user">Kollege</label>
|
||||
<select id="share-user">
|
||||
<option value="" data-i18n="checklisten.share.pick">— auswählen —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field share-kind-section" data-kind="office" style="display:none">
|
||||
<label htmlFor="share-office" data-i18n="checklisten.share.kind.office">Office</label>
|
||||
<select id="share-office">
|
||||
<option value="" data-i18n="checklisten.share.pick">— auswählen —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field share-kind-section" data-kind="partner_unit" style="display:none">
|
||||
<label htmlFor="share-partner-unit" data-i18n="checklisten.share.kind.partner_unit">Dezernat</label>
|
||||
<select id="share-partner-unit">
|
||||
<option value="" data-i18n="checklisten.share.pick">— auswählen —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field share-kind-section" data-kind="project" style="display:none">
|
||||
<label htmlFor="share-project" data-i18n="checklisten.share.kind.project">Projekt</label>
|
||||
<select id="share-project">
|
||||
<option value="" data-i18n="checklisten.share.pick">— auswählen —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="share-cancel" data-i18n="checklisten.share.cancel">Abbrechen</button>
|
||||
<button type="button" className="btn-primary btn-cta-lime" id="share-submit" data-i18n="checklisten.share.submit">Freigeben</button>
|
||||
</div>
|
||||
<p className="form-msg" id="share-msg" />
|
||||
|
||||
{/* Existing grants — populated on open from
|
||||
/api/checklists/templates/{slug}/shares. */}
|
||||
<h3 className="share-grants-heading" data-i18n="checklisten.share.grants.heading">Bestehende Freigaben</h3>
|
||||
<ul className="share-grants-list" id="share-grants-list">
|
||||
<li className="entity-events-empty" id="share-grants-empty" data-i18n="checklisten.share.grants.empty">Keine Freigaben.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback modal */}
|
||||
<div className="modal-overlay" id="feedback-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
|
||||
@@ -58,10 +58,6 @@ export function renderChecklistsInstance(): string {
|
||||
</div>
|
||||
<p className="tool-subtitle" id="instance-template-title"> </p>
|
||||
<dl className="checklist-meta" id="instance-meta" />
|
||||
{/* Slice C: 'template updated since this instance
|
||||
was created' banner. Populated by the client
|
||||
when instance.template_version < template.version. */}
|
||||
<div id="instance-outdated-slot" />
|
||||
</div>
|
||||
<div className="checklist-actions">
|
||||
<button type="button" id="btn-print" className="btn-ghost" data-i18n="checklisten.print">Drucken</button>
|
||||
@@ -122,21 +118,6 @@ export function renderChecklistsInstance(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slice C: template-diff modal — opened from the
|
||||
"Änderungen anzeigen" button on the outdated banner. */}
|
||||
<div className="modal-overlay" id="instance-diff-modal" style="display:none">
|
||||
<div className="modal-card modal-card-wide">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="checklisten.instance.diff.title">Geänderte Punkte</h2>
|
||||
<button className="modal-close" id="instance-diff-close" type="button">×</button>
|
||||
</div>
|
||||
<div id="instance-diff-body" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="instance-diff-close-bottom" data-i18n="checklisten.instance.diff.close">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/checklists-instance.js"></script>
|
||||
|
||||
@@ -34,8 +34,6 @@ export function renderChecklists(): string {
|
||||
|
||||
<nav className="entity-tabs" id="checklists-tabs" aria-label="Checklisten-Ansichten">
|
||||
<a className="entity-tab active" data-tab="templates" href="/checklists" data-i18n="checklisten.tab.templates">Vorlagen</a>
|
||||
<a className="entity-tab" data-tab="mine" href="/checklists?tab=mine" data-i18n="checklisten.tab.mine">Meine Vorlagen</a>
|
||||
<a className="entity-tab" data-tab="gallery" href="/checklists?tab=gallery" data-i18n="checklisten.tab.gallery">Geteilte Vorlagen</a>
|
||||
<a className="entity-tab" data-tab="instances" href="/checklists?tab=instances" data-i18n="checklisten.tab.instances">Vorhandene Instanzen</a>
|
||||
</nav>
|
||||
|
||||
@@ -51,36 +49,6 @@ export function renderChecklists(): string {
|
||||
<div className="checklist-grid" id="checklist-grid" />
|
||||
</section>
|
||||
|
||||
{/* Meine Vorlagen tab — caller's own authored templates */}
|
||||
<section className="entity-tab-panel" id="tab-mine" style="display:none">
|
||||
<div className="tool-actions" style="margin-bottom:1rem">
|
||||
<a href="/checklists/new" className="btn btn-primary" data-i18n="checklisten.mine.new">Neue Vorlage</a>
|
||||
</div>
|
||||
<p className="entity-events-empty" id="checklists-mine-loading" data-i18n="checklisten.mine.loading">Lädt…</p>
|
||||
<p className="entity-events-empty" id="checklists-mine-empty" style="display:none" data-i18n="checklisten.mine.empty">
|
||||
Sie haben noch keine eigene Vorlage angelegt.
|
||||
</p>
|
||||
<div className="checklist-grid" id="checklists-mine-grid" style="display:none" />
|
||||
</section>
|
||||
|
||||
{/* Geteilte Vorlagen tab — discovery surface for templates
|
||||
that aren't owned by the caller (firm-published,
|
||||
globally-promoted, or explicitly shared). Slice C. */}
|
||||
<section className="entity-tab-panel" id="tab-gallery" style="display:none">
|
||||
<div className="checklist-filters" id="checklist-gallery-filters">
|
||||
<button className="filter-pill active" data-regime="all" type="button" data-i18n="checklisten.filter.all">Alle</button>
|
||||
<button className="filter-pill" data-regime="UPC" type="button">UPC</button>
|
||||
<button className="filter-pill" data-regime="DE" type="button" data-i18n="checklisten.filter.de">DE</button>
|
||||
<button className="filter-pill" data-regime="EPA" type="button">EPA</button>
|
||||
<button className="filter-pill" data-regime="OTHER" type="button" data-i18n="checklisten.filter.other">Sonstige</button>
|
||||
</div>
|
||||
<p className="entity-events-empty" id="checklists-gallery-loading" data-i18n="checklisten.mine.loading">Lädt…</p>
|
||||
<p className="entity-events-empty" id="checklists-gallery-empty" style="display:none" data-i18n="checklisten.gallery.empty">
|
||||
Noch keine geteilten Vorlagen sichtbar.
|
||||
</p>
|
||||
<div className="checklist-grid" id="checklists-gallery-grid" style="display:none" />
|
||||
</section>
|
||||
|
||||
{/* Instances tab — every visible instance across templates */}
|
||||
<section className="entity-tab-panel" id="tab-instances" style="display:none">
|
||||
<p className="entity-events-empty" id="checklists-instances-loading" data-i18n="checklisten.instances.all.loading">Lädt…</p>
|
||||
|
||||
@@ -468,125 +468,11 @@ function initInviteButton() {
|
||||
});
|
||||
}
|
||||
|
||||
// t-paliad-223 Slice B (#49) — "Konto direkt anlegen" modal. Creates both
|
||||
// the auth.users row (via Supabase Admin API) and the paliad.users row in
|
||||
// one POST. New user appears in dropdowns immediately. Welcome email with
|
||||
// magic-link is sent by default; admin can opt out via the checkbox.
|
||||
function openAddFullModal() {
|
||||
const modal = document.getElementById("admin-add-full-modal")!;
|
||||
const fb = document.getElementById("admin-af-feedback")!;
|
||||
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
|
||||
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
|
||||
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
|
||||
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
|
||||
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
|
||||
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
|
||||
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
|
||||
|
||||
fb.style.display = "none";
|
||||
emailField.value = "";
|
||||
nameField.value = "";
|
||||
jobTitleField.value = "";
|
||||
profSel.value = "associate";
|
||||
langSel.value = "de";
|
||||
sendWelcome.checked = true;
|
||||
officeSel.innerHTML = officeOptions("munich");
|
||||
|
||||
modal.style.display = "flex";
|
||||
emailField.focus();
|
||||
}
|
||||
|
||||
function closeAddFullModal() {
|
||||
document.getElementById("admin-add-full-modal")!.style.display = "none";
|
||||
}
|
||||
|
||||
function initAddFullModal() {
|
||||
document.getElementById("admin-team-add-full")!.addEventListener("click", openAddFullModal);
|
||||
document.getElementById("admin-af-close")!.addEventListener("click", closeAddFullModal);
|
||||
document.getElementById("admin-af-cancel")!.addEventListener("click", closeAddFullModal);
|
||||
document.getElementById("admin-add-full-modal")!.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeAddFullModal();
|
||||
});
|
||||
|
||||
const emailField = document.getElementById("admin-af-email") as HTMLInputElement;
|
||||
const nameField = document.getElementById("admin-af-name") as HTMLInputElement;
|
||||
// Pre-fill the display name from the email local-part the first time the
|
||||
// admin tabs out of the email field — mirrors the existing onboard flow.
|
||||
emailField.addEventListener("blur", () => {
|
||||
if (nameField.value || !emailField.value) return;
|
||||
const local = emailField.value.split("@")[0] ?? "";
|
||||
nameField.value = local
|
||||
.split(/[._-]/)
|
||||
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
|
||||
.join(" ")
|
||||
.trim();
|
||||
});
|
||||
|
||||
const form = document.getElementById("admin-add-full-form") as HTMLFormElement;
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const fb = document.getElementById("admin-af-feedback")!;
|
||||
fb.style.display = "none";
|
||||
|
||||
const officeSel = document.getElementById("admin-af-office") as HTMLSelectElement;
|
||||
const jobTitleField = document.getElementById("admin-af-job-title") as HTMLInputElement;
|
||||
const profSel = document.getElementById("admin-af-profession") as HTMLSelectElement;
|
||||
const langSel = document.getElementById("admin-af-lang") as HTMLSelectElement;
|
||||
const sendWelcome = document.getElementById("admin-af-send-welcome") as HTMLInputElement;
|
||||
const submitBtn = document.getElementById("admin-af-submit") as HTMLButtonElement;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
email: emailField.value.trim().toLowerCase(),
|
||||
display_name: nameField.value.trim(),
|
||||
office: officeSel.value,
|
||||
job_title: jobTitleField.value.trim() || "Associate",
|
||||
profession: profSel.value,
|
||||
lang: langSel.value,
|
||||
send_welcome_mail: sendWelcome.checked,
|
||||
};
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch("/api/admin/users/full", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
// Map two friendly cases inline; everything else surfaces the
|
||||
// server message so the admin can act on it.
|
||||
if (resp.status === 503) {
|
||||
fb.textContent = t("admin.team.add_full.error.unavailable")
|
||||
|| "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).";
|
||||
} else if (resp.status === 409) {
|
||||
fb.textContent = body.error
|
||||
|| (t("admin.team.add_full.error.email_exists")
|
||||
|| "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.");
|
||||
} else {
|
||||
fb.textContent = body.error || (t("admin.team.add_full.error.generic") || "Fehler.");
|
||||
}
|
||||
fb.className = "form-msg form-msg-error";
|
||||
fb.style.display = "block";
|
||||
return;
|
||||
}
|
||||
const created = (await resp.json()) as User;
|
||||
users = users.concat(created);
|
||||
closeAddFullModal();
|
||||
showFeedback(t("admin.team.add_full.feedback.added") || "Konto angelegt.", false);
|
||||
render();
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initSearch();
|
||||
initDirectAddModal();
|
||||
initAddFullModal();
|
||||
initInviteButton();
|
||||
onLangChange(() => {
|
||||
buildOfficeFilters();
|
||||
|
||||
193
frontend/src/client/appointments-calendar.ts
Normal file
193
frontend/src/client/appointments-calendar.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
title: string;
|
||||
start_at: string;
|
||||
end_at?: string;
|
||||
appointment_type?: string;
|
||||
project_reference?: string;
|
||||
project_title?: string;
|
||||
}
|
||||
|
||||
let allAppointments: Appointment[] = [];
|
||||
let viewYear = 0;
|
||||
let viewMonth = 0;
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtMonth(year: number, month: number): string {
|
||||
return `${tDyn(`cal.month.${month}`)} ${year}`;
|
||||
}
|
||||
|
||||
function isoDate(year: number, month: number, day: number): string {
|
||||
const m = String(month + 1).padStart(2, "0");
|
||||
const d = String(day).padStart(2, "0");
|
||||
return `${year}-${m}-${d}`;
|
||||
}
|
||||
|
||||
async function loadAppointments() {
|
||||
// Pull a wide window (current month plus a little buffer either side).
|
||||
// We could narrow this, but the user typically navigates ±1-2 months
|
||||
// and the dataset is small.
|
||||
try {
|
||||
const resp = await fetch("/api/appointments");
|
||||
if (resp.ok) allAppointments = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function appointmentsForDate(iso: string): Appointment[] {
|
||||
return allAppointments.filter((t) => t.start_at.slice(0, 10) === iso);
|
||||
}
|
||||
|
||||
function typeClass(t?: string): string {
|
||||
return t ? `termin-type-${t}` : "termin-type-default";
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
|
||||
|
||||
const firstDay = new Date(viewYear, viewMonth, 1);
|
||||
const jsWeekday = firstDay.getDay();
|
||||
const offset = (jsWeekday + 6) % 7;
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
|
||||
const cells: string[] = [];
|
||||
for (let i = 0; i < offset; i++) {
|
||||
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const iso = isoDate(viewYear, viewMonth, day);
|
||||
const items = appointmentsForDate(iso);
|
||||
const isToday = iso === todayISO;
|
||||
|
||||
const dots = items
|
||||
.slice(0, 4)
|
||||
.map((tt) => `<span class="termin-dot ${typeClass(tt.appointment_type)}" title="${esc(tt.title)}"></span>`)
|
||||
.join("");
|
||||
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
|
||||
|
||||
cells.push(
|
||||
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
|
||||
<span class="frist-cal-day">${day}</span>
|
||||
<div class="frist-cal-dots">${dots}${more}</div>
|
||||
</div>`,
|
||||
);
|
||||
}
|
||||
|
||||
const grid = document.getElementById("appointment-cal-grid")!;
|
||||
grid.innerHTML = cells.join("");
|
||||
|
||||
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
|
||||
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
|
||||
});
|
||||
|
||||
const monthStart = isoDate(viewYear, viewMonth, 1);
|
||||
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
|
||||
const hasInMonth = allAppointments.some((tt) => {
|
||||
const iso = tt.start_at.slice(0, 10);
|
||||
return iso >= monthStart && iso <= monthEnd;
|
||||
});
|
||||
const empty = document.getElementById("appointment-cal-empty")!;
|
||||
empty.style.display = hasInMonth ? "none" : "";
|
||||
}
|
||||
|
||||
function openPopup(iso: string) {
|
||||
const items = appointmentsForDate(iso);
|
||||
if (items.length === 0) return;
|
||||
const popup = document.getElementById("cal-popup")!;
|
||||
const dateEl = document.getElementById("cal-popup-date")!;
|
||||
const list = document.getElementById("cal-popup-list")!;
|
||||
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
list.innerHTML = items
|
||||
.map((tt) => {
|
||||
const akteRef = tt.project_id
|
||||
? `<a href="/projects/${esc(tt.project_id)}" class="frist-cal-popup-project">${esc(tt.project_reference ?? "")}</a>`
|
||||
: `<span class="termin-personal-tag">${esc(t("appointments.personal"))}</span>`;
|
||||
return `<li class="frist-cal-popup-item">
|
||||
<span class="termin-dot ${typeClass(tt.appointment_type)}"></span>
|
||||
<span class="frist-cal-popup-time">${esc(fmtTime(tt.start_at))}</span>
|
||||
<a href="/appointments/${esc(tt.id)}" class="frist-cal-popup-title">${esc(tt.title)}</a>
|
||||
${akteRef}
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
popup.style.display = "flex";
|
||||
}
|
||||
|
||||
function initPopup() {
|
||||
const popup = document.getElementById("cal-popup")!;
|
||||
const close = document.getElementById("cal-popup-close")!;
|
||||
close.addEventListener("click", () => (popup.style.display = "none"));
|
||||
popup.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) popup.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function initNav() {
|
||||
document.getElementById("cal-prev")!.addEventListener("click", () => {
|
||||
viewMonth -= 1;
|
||||
if (viewMonth < 0) {
|
||||
viewMonth = 11;
|
||||
viewYear -= 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
document.getElementById("cal-next")!.addEventListener("click", () => {
|
||||
viewMonth += 1;
|
||||
if (viewMonth > 11) {
|
||||
viewMonth = 0;
|
||||
viewYear += 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
document.getElementById("cal-today")!.addEventListener("click", () => {
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
initNav();
|
||||
initPopup();
|
||||
onLangChange(render);
|
||||
await loadAppointments();
|
||||
render();
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
bucketByDate,
|
||||
filterByDay,
|
||||
isToday,
|
||||
isoDate,
|
||||
shift,
|
||||
startOfDay,
|
||||
startOfWeek,
|
||||
type CalendarItem,
|
||||
} from "./mount-calendar";
|
||||
|
||||
// Regression tests for t-paliad-224: the calendar bucket / week / shift
|
||||
// helpers underpin both /events Kalender and the Custom Views shape=
|
||||
// calendar. DOM-rendering is covered by manual smoke (frontend tests in
|
||||
// this repo run in plain Node, no jsdom — see verfahrensablauf-core.test
|
||||
// ts comment), so the pure date-math goes here.
|
||||
|
||||
const item = (overrides: Partial<CalendarItem> = {}): CalendarItem => ({
|
||||
kind: "deadline",
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
title: "Klageerwiderung",
|
||||
event_date: "2026-05-08T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("isoDate / startOfDay / startOfWeek", () => {
|
||||
test("isoDate pads month + day", () => {
|
||||
expect(isoDate(new Date(2026, 0, 3))).toBe("2026-01-03");
|
||||
expect(isoDate(new Date(2026, 11, 31))).toBe("2026-12-31");
|
||||
});
|
||||
|
||||
test("startOfDay strips time", () => {
|
||||
const d = new Date(2026, 4, 8, 13, 47, 22);
|
||||
const out = startOfDay(d);
|
||||
expect(out.getHours()).toBe(0);
|
||||
expect(out.getMinutes()).toBe(0);
|
||||
expect(out.getSeconds()).toBe(0);
|
||||
expect(isoDate(out)).toBe("2026-05-08");
|
||||
});
|
||||
|
||||
test("startOfWeek snaps to Monday (Mon=0)", () => {
|
||||
// 2026-05-08 was a Friday.
|
||||
const fri = new Date(2026, 4, 8);
|
||||
expect(isoDate(startOfWeek(fri))).toBe("2026-05-04");
|
||||
// Sunday wraps backward to the same Monday, not forward to the next.
|
||||
const sun = new Date(2026, 4, 10);
|
||||
expect(isoDate(startOfWeek(sun))).toBe("2026-05-04");
|
||||
// Monday is its own startOfWeek.
|
||||
const mon = new Date(2026, 4, 4);
|
||||
expect(isoDate(startOfWeek(mon))).toBe("2026-05-04");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shift", () => {
|
||||
test("month shift lands on day=1 of the target month", () => {
|
||||
const out = shift(new Date(2026, 4, 15), "month", 1);
|
||||
expect(out.getFullYear()).toBe(2026);
|
||||
expect(out.getMonth()).toBe(5);
|
||||
expect(out.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
test("month shift wraps year boundary", () => {
|
||||
const out = shift(new Date(2026, 11, 15), "month", 1);
|
||||
expect(out.getFullYear()).toBe(2027);
|
||||
expect(out.getMonth()).toBe(0);
|
||||
expect(out.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
test("week shift moves seven days", () => {
|
||||
const out = shift(new Date(2026, 4, 8), "week", 1);
|
||||
expect(isoDate(out)).toBe("2026-05-15");
|
||||
});
|
||||
|
||||
test("day shift moves one day", () => {
|
||||
const out = shift(new Date(2026, 4, 8), "day", -1);
|
||||
expect(isoDate(out)).toBe("2026-05-07");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bucketByDate", () => {
|
||||
test("groups items by ISO date and skips items outside the filter", () => {
|
||||
const rows = [
|
||||
item({ id: "a", event_date: "2026-05-08T00:00:00Z" }),
|
||||
item({ id: "b", event_date: "2026-05-08T15:30:00Z" }),
|
||||
item({ id: "c", event_date: "2026-05-09T00:00:00Z" }),
|
||||
// outside the May 2026 filter:
|
||||
item({ id: "x", event_date: "2026-06-01T00:00:00Z" }),
|
||||
// malformed:
|
||||
item({ id: "bad", event_date: "not-a-date" }),
|
||||
];
|
||||
const out = bucketByDate(rows, (d) => d.getMonth() === 4 && d.getFullYear() === 2026);
|
||||
expect(out.size).toBe(2);
|
||||
expect(out.get("2026-05-08")?.map((r) => r.id)).toEqual(["a", "b"]);
|
||||
expect(out.get("2026-05-09")?.map((r) => r.id)).toEqual(["c"]);
|
||||
expect(out.has("2026-06-01")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterByDay", () => {
|
||||
test("returns only items whose calendar day equals the target", () => {
|
||||
const rows = [
|
||||
item({ id: "a", event_date: "2026-05-08T00:00:00Z" }),
|
||||
item({ id: "b", event_date: "2026-05-08T23:59:00Z" }),
|
||||
item({ id: "c", event_date: "2026-05-09T00:00:00Z" }),
|
||||
];
|
||||
expect(filterByDay(rows, new Date(2026, 4, 8)).map((r) => r.id)).toEqual(["a", "b"]);
|
||||
expect(filterByDay(rows, new Date(2026, 4, 9)).map((r) => r.id)).toEqual(["c"]);
|
||||
expect(filterByDay(rows, new Date(2026, 4, 10))).toEqual([]);
|
||||
});
|
||||
|
||||
test("ignores malformed dates", () => {
|
||||
const rows = [
|
||||
item({ id: "ok", event_date: "2026-05-08T00:00:00Z" }),
|
||||
item({ id: "bad", event_date: "not-a-date" }),
|
||||
];
|
||||
expect(filterByDay(rows, new Date(2026, 4, 8)).map((r) => r.id)).toEqual(["ok"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isToday", () => {
|
||||
test("matches today's calendar day", () => {
|
||||
expect(isToday(new Date())).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects yesterday + tomorrow", () => {
|
||||
const now = new Date();
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
expect(isToday(yesterday)).toBe(false);
|
||||
expect(isToday(tomorrow)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,579 +0,0 @@
|
||||
import { t, tDyn, getLang, type I18nKey } from "../i18n";
|
||||
|
||||
// mount-calendar.ts — the canonical month/week/day calendar (t-paliad-224).
|
||||
// Lifted from the original shape-calendar.ts so both Custom Views
|
||||
// (shape=calendar) and /events Kalender tab render through the same DOM.
|
||||
// See docs/design-calendar-view-align-2026-05-20.md for the audit + plan.
|
||||
//
|
||||
// Surfaces wire in via mountCalendar(host, items, opts). The returned
|
||||
// handle exposes update(items) for re-render after a filter change and
|
||||
// destroy() for teardown when the host swaps to a different view.
|
||||
|
||||
export type CalendarKind =
|
||||
| "deadline" | "appointment" | "project_event" | "approval_request";
|
||||
|
||||
export interface CalendarItem {
|
||||
kind: CalendarKind;
|
||||
id: string;
|
||||
title: string;
|
||||
/** ISO-8601 timestamp or date string. First 10 chars are read as the
|
||||
* calendar bucket (yyyy-mm-dd). */
|
||||
event_date: string;
|
||||
project_id?: string;
|
||||
project_title?: string;
|
||||
project_reference?: string;
|
||||
}
|
||||
|
||||
export type CalendarView = "month" | "week" | "day";
|
||||
|
||||
export interface CalendarOpts {
|
||||
/** Initial view if URL has no override (or urlState is disabled). */
|
||||
defaultView?: CalendarView;
|
||||
/** Read/write ?cal_view + ?cal_date so a refresh restores the calendar.
|
||||
* Surfaces that own their own URL contract pass urlState=false. */
|
||||
urlState?: boolean;
|
||||
/** Optional URL param prefix (e.g. "events" → ?eventsCalView=…). Only
|
||||
* meaningful when urlState=true. Leave empty for the default
|
||||
* ?cal_view / ?cal_date contract. */
|
||||
urlPrefix?: string;
|
||||
/** Override how a row's href is built. Default routes by kind. */
|
||||
hrefFor?: (item: CalendarItem) => string;
|
||||
}
|
||||
|
||||
export interface CalendarHandle {
|
||||
/** Replace the item set and re-paint at the current view+anchor. */
|
||||
update(items: CalendarItem[]): void;
|
||||
/** Clear host + drop the keep-alive state. After destroy(), the handle
|
||||
* is dead; create a fresh one with mountCalendar(). */
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
const MAX_PILLS_PER_MONTH_CELL = 3;
|
||||
|
||||
export function mountCalendar(
|
||||
host: HTMLElement,
|
||||
initialItems: CalendarItem[],
|
||||
opts: CalendarOpts = {},
|
||||
): CalendarHandle {
|
||||
let items = initialItems;
|
||||
let view: CalendarView;
|
||||
let anchor: Date;
|
||||
let destroyed = false;
|
||||
|
||||
const urlEnabled = opts.urlState ?? false;
|
||||
const viewParam = urlEnabled ? paramName(opts.urlPrefix, "cal_view") : "";
|
||||
const dateParam = urlEnabled ? paramName(opts.urlPrefix, "cal_date") : "";
|
||||
|
||||
view = urlEnabled
|
||||
? readView(viewParam, opts.defaultView ?? "month")
|
||||
: (opts.defaultView ?? "month");
|
||||
anchor = urlEnabled ? readAnchor(dateParam, items) : firstAnchor(items);
|
||||
|
||||
paint();
|
||||
|
||||
return {
|
||||
update(nextItems) {
|
||||
if (destroyed) return;
|
||||
items = nextItems;
|
||||
paint();
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
host.innerHTML = "";
|
||||
},
|
||||
};
|
||||
|
||||
// --- paint -----------------------------------------------------------
|
||||
|
||||
function paint(): void {
|
||||
if (destroyed) return;
|
||||
host.innerHTML = "";
|
||||
|
||||
// Mobile fallback notice (<600px). Documented in design-calendar-
|
||||
// view-align-2026-05-20.md §6. CSS still lays out the grid; the
|
||||
// notice just nudges users toward a friendlier view.
|
||||
if (typeof window !== "undefined" && window.innerWidth < 600) {
|
||||
const notice = document.createElement("p");
|
||||
notice.className = "views-calendar-mobile-notice";
|
||||
notice.textContent = t("views.calendar.mobile_fallback");
|
||||
host.appendChild(notice);
|
||||
}
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `views-calendar views-calendar--${view}`;
|
||||
wrap.appendChild(renderToolbar());
|
||||
if (view === "month") {
|
||||
wrap.appendChild(renderMonth());
|
||||
} else if (view === "week") {
|
||||
wrap.appendChild(renderWeek());
|
||||
} else {
|
||||
wrap.appendChild(renderDay());
|
||||
}
|
||||
host.appendChild(wrap);
|
||||
}
|
||||
|
||||
function setView(nextView: CalendarView, nextAnchor: Date): void {
|
||||
view = nextView;
|
||||
anchor = nextAnchor;
|
||||
if (urlEnabled) writeURL(viewParam, dateParam, nextView, nextAnchor);
|
||||
paint();
|
||||
}
|
||||
|
||||
// --- Toolbar ---------------------------------------------------------
|
||||
|
||||
function renderToolbar(): HTMLElement {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "views-calendar-toolbar";
|
||||
|
||||
const switcher = document.createElement("div");
|
||||
switcher.className = "views-calendar-view-switcher agenda-chip-row";
|
||||
switcher.setAttribute("role", "tablist");
|
||||
for (const v of ["month", "week", "day"] as CalendarView[]) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
|
||||
chip.dataset.calView = v;
|
||||
chip.setAttribute("role", "tab");
|
||||
chip.setAttribute("aria-selected", v === view ? "true" : "false");
|
||||
chip.textContent = t(`cal.view.${v}` as I18nKey);
|
||||
chip.addEventListener("click", () => {
|
||||
if (v === view) return;
|
||||
setView(v, anchor);
|
||||
});
|
||||
switcher.appendChild(chip);
|
||||
}
|
||||
bar.appendChild(switcher);
|
||||
|
||||
const nav = document.createElement("div");
|
||||
nav.className = "views-calendar-nav";
|
||||
|
||||
const prev = document.createElement("button");
|
||||
prev.type = "button";
|
||||
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
|
||||
prev.textContent = "‹";
|
||||
prev.addEventListener("click", () => setView(view, shift(anchor, view, -1)));
|
||||
nav.appendChild(prev);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "views-calendar-nav-label";
|
||||
label.textContent = formatRangeLabel(view, anchor);
|
||||
nav.appendChild(label);
|
||||
|
||||
const next = document.createElement("button");
|
||||
next.type = "button";
|
||||
next.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
|
||||
next.textContent = "›";
|
||||
next.addEventListener("click", () => setView(view, shift(anchor, view, 1)));
|
||||
nav.appendChild(next);
|
||||
|
||||
// "Heute" button — jump back to today in the current view. Adds a
|
||||
// recognisable affordance for the /events Kalender users who relied
|
||||
// on the old toolbar's "Heute" button.
|
||||
const today = document.createElement("button");
|
||||
today.type = "button";
|
||||
today.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
today.textContent = t("cal.today");
|
||||
today.addEventListener("click", () => setView(view, startOfDay(new Date())));
|
||||
nav.appendChild(today);
|
||||
|
||||
if (view !== "month") {
|
||||
const backToMonth = document.createElement("button");
|
||||
backToMonth.type = "button";
|
||||
backToMonth.className = "btn-link views-calendar-back-to-month";
|
||||
backToMonth.textContent = t("cal.day.back_to_month");
|
||||
backToMonth.addEventListener("click", () => setView("month", anchor));
|
||||
nav.appendChild(backToMonth);
|
||||
}
|
||||
|
||||
bar.appendChild(nav);
|
||||
return bar;
|
||||
}
|
||||
|
||||
// --- Month -----------------------------------------------------------
|
||||
|
||||
function renderMonth(): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-month";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
wrap.appendChild(header);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-grid";
|
||||
|
||||
const weekdayKeys: I18nKey[] = [
|
||||
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
|
||||
"cal.day.fri", "cal.day.sat", "cal.day.sun",
|
||||
];
|
||||
for (const k of weekdayKeys) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-weekday";
|
||||
cell.textContent = t(k);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
||||
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
|
||||
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
|
||||
|
||||
for (let i = 0; i < startWeekday; i++) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell views-calendar-cell--out";
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
const byDate = bucketByDate(items, (d) =>
|
||||
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
|
||||
);
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
|
||||
const dateKey = isoDate(dayDate);
|
||||
const dayRows = byDate.get(dateKey) ?? [];
|
||||
grid.appendChild(renderMonthCell(dayDate, day, dayRows));
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderMonthCell(dayDate: Date, dayNum: number, dayRows: CalendarItem[]): HTMLElement {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell";
|
||||
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
|
||||
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
|
||||
|
||||
const dayLabel = document.createElement("button");
|
||||
dayLabel.type = "button";
|
||||
dayLabel.className = "views-calendar-cell-day";
|
||||
dayLabel.textContent = String(dayNum);
|
||||
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
|
||||
dayLabel.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setView("day", dayDate);
|
||||
});
|
||||
cell.appendChild(dayLabel);
|
||||
|
||||
if (dayRows.length > 0) {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-pills";
|
||||
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
|
||||
for (const row of visible) ul.appendChild(renderPill(row));
|
||||
if (dayRows.length > visible.length) {
|
||||
const more = document.createElement("li");
|
||||
const moreBtn = document.createElement("button");
|
||||
moreBtn.type = "button";
|
||||
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
|
||||
moreBtn.textContent = `+${dayRows.length - visible.length}`;
|
||||
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
|
||||
moreBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setView("day", dayDate);
|
||||
});
|
||||
more.appendChild(moreBtn);
|
||||
ul.appendChild(more);
|
||||
}
|
||||
cell.appendChild(ul);
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
// --- Week ------------------------------------------------------------
|
||||
|
||||
function renderWeek(): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-week";
|
||||
|
||||
const weekStart = startOfWeek(anchor);
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
|
||||
wrap.appendChild(header);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-week-grid";
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(weekStart);
|
||||
day.setDate(weekStart.getDate() + i);
|
||||
grid.appendChild(renderWeekColumn(day));
|
||||
}
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderWeekColumn(day: Date): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const col = document.createElement("div");
|
||||
col.className = "views-calendar-week-column";
|
||||
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "views-calendar-week-head";
|
||||
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
|
||||
const dow = document.createElement("span");
|
||||
dow.className = "views-calendar-week-dow";
|
||||
dow.textContent = t(weekdayKey);
|
||||
const dnum = document.createElement("span");
|
||||
dnum.className = "views-calendar-week-dnum";
|
||||
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
|
||||
head.appendChild(dow);
|
||||
head.appendChild(dnum);
|
||||
col.appendChild(head);
|
||||
|
||||
const dayRows = filterByDay(items, day);
|
||||
if (dayRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "views-calendar-week-empty";
|
||||
empty.textContent = t("cal.day.no_entries");
|
||||
col.appendChild(empty);
|
||||
return col;
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-week-list";
|
||||
for (const row of dayRows) {
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(renderRowAnchor(row, "week"));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
col.appendChild(ul);
|
||||
return col;
|
||||
}
|
||||
|
||||
// --- Day -------------------------------------------------------------
|
||||
|
||||
function renderDay(): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-day-wrap";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, {
|
||||
weekday: "long", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
wrap.appendChild(header);
|
||||
|
||||
const dayRows = filterByDay(items, anchor);
|
||||
if (dayRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "views-calendar-day-empty";
|
||||
empty.textContent = t("cal.day.no_entries");
|
||||
wrap.appendChild(empty);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-day-list";
|
||||
for (const row of dayRows) {
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(renderRowAnchor(row, "day"));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
wrap.appendChild(ul);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// --- Row rendering ---------------------------------------------------
|
||||
|
||||
function renderPill(row: CalendarItem): HTMLElement {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
|
||||
a.href = hrefFor(row);
|
||||
a.textContent = row.title;
|
||||
a.title = row.title + (row.project_title ? ` — ${row.project_title}` : "");
|
||||
a.addEventListener("click", (e) => e.stopPropagation());
|
||||
li.appendChild(a);
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderRowAnchor(row: CalendarItem, density: "week" | "day"): HTMLElement {
|
||||
const a = document.createElement("a");
|
||||
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
|
||||
a.href = hrefFor(row);
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
|
||||
a.appendChild(dot);
|
||||
|
||||
const body = document.createElement("span");
|
||||
body.className = "views-calendar-row-body";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "views-calendar-row-title";
|
||||
title.textContent = row.title;
|
||||
body.appendChild(title);
|
||||
|
||||
const metaParts: string[] = [];
|
||||
metaParts.push(tDyn("views.kind." + row.kind));
|
||||
if (row.project_reference) metaParts.push(row.project_reference);
|
||||
else if (row.project_title) metaParts.push(row.project_title);
|
||||
if (metaParts.length > 0) {
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "views-calendar-row-meta";
|
||||
meta.textContent = metaParts.join(" · ");
|
||||
body.appendChild(meta);
|
||||
}
|
||||
|
||||
a.appendChild(body);
|
||||
return a;
|
||||
}
|
||||
|
||||
function hrefFor(row: CalendarItem): string {
|
||||
if (opts.hrefFor) return opts.hrefFor(row);
|
||||
return defaultHrefFor(row);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pure helpers (shared, not closure-bound) ----------------------------
|
||||
|
||||
const WEEKDAY_KEYS: I18nKey[] = [
|
||||
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
|
||||
"cal.day.fri", "cal.day.sat", "cal.day.sun",
|
||||
];
|
||||
|
||||
function navLabelKey(view: CalendarView, dir: "prev" | "next"): I18nKey {
|
||||
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
|
||||
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
|
||||
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
|
||||
}
|
||||
|
||||
function defaultHrefFor(row: CalendarItem): string {
|
||||
switch (row.kind) {
|
||||
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
|
||||
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
|
||||
case "approval_request": return `/inbox`;
|
||||
case "project_event": return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
|
||||
}
|
||||
}
|
||||
|
||||
export function bucketByDate(
|
||||
rows: CalendarItem[], filter: (d: Date) => boolean,
|
||||
): Map<string, CalendarItem[]> {
|
||||
const out = new Map<string, CalendarItem[]>();
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
if (!filter(d)) continue;
|
||||
const key = isoDate(d);
|
||||
const arr = out.get(key);
|
||||
if (arr) arr.push(row);
|
||||
else out.set(key, [row]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function filterByDay(rows: CalendarItem[], day: Date): CalendarItem[] {
|
||||
const key = isoDate(day);
|
||||
return rows.filter((r) => {
|
||||
const d = new Date(r.event_date);
|
||||
if (isNaN(d.getTime())) return false;
|
||||
return isoDate(d) === key;
|
||||
});
|
||||
}
|
||||
|
||||
export function startOfWeek(d: Date): Date {
|
||||
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const offset = (out.getDay() + 6) % 7;
|
||||
out.setDate(out.getDate() - offset);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function startOfDay(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
|
||||
export function shift(d: Date, view: CalendarView, dir: number): Date {
|
||||
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
|
||||
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
|
||||
}
|
||||
|
||||
export function isToday(d: Date): boolean {
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear()
|
||||
&& d.getMonth() === now.getMonth()
|
||||
&& d.getDate() === now.getDate();
|
||||
}
|
||||
|
||||
export function isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function formatRangeLabel(view: CalendarView, anchor: Date): string {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
if (view === "month") {
|
||||
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
}
|
||||
if (view === "week") {
|
||||
const start = startOfWeek(anchor);
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
return formatWeekHeader(start, end, lang);
|
||||
}
|
||||
return anchor.toLocaleDateString(lang, {
|
||||
weekday: "short", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatWeekHeader(start: Date, end: Date, lang: string): string {
|
||||
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
|
||||
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
|
||||
return `${startStr} – ${endStr}`;
|
||||
}
|
||||
|
||||
function firstAnchor(rows: CalendarItem[]): Date {
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (!isNaN(d.getTime())) return startOfDay(d);
|
||||
}
|
||||
return startOfDay(new Date());
|
||||
}
|
||||
|
||||
function paramName(prefix: string | undefined, base: string): string {
|
||||
if (!prefix) return base;
|
||||
return `${prefix}_${base}`;
|
||||
}
|
||||
|
||||
function readView(viewParam: string, fallback: CalendarView): CalendarView {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(viewParam);
|
||||
if (raw === "month" || raw === "week" || raw === "day") return raw;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function readAnchor(dateParam: string, rows: CalendarItem[]): Date {
|
||||
if (typeof window === "undefined") return firstAnchor(rows);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(dateParam);
|
||||
if (raw) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
|
||||
if (m) {
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
}
|
||||
return firstAnchor(rows);
|
||||
}
|
||||
|
||||
function writeURL(viewParam: string, dateParam: string, view: CalendarView, anchor: Date): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(viewParam, view);
|
||||
url.searchParams.set(dateParam, isoDate(anchor));
|
||||
history.replaceState(null, "", url.toString());
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
// Authoring wizard for paliad.checklists. Serves both /checklists/new
|
||||
// (create) and /checklists/templates/{slug}/edit (edit). The HTML bundle is the
|
||||
// same; this client reads location.pathname to decide which mode to
|
||||
// boot into.
|
||||
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Item {
|
||||
labelDE: string;
|
||||
labelEN: string;
|
||||
noteDE?: string;
|
||||
noteEN?: string;
|
||||
rule?: string;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
interface Checklist {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
regime: string;
|
||||
court: string;
|
||||
reference: string;
|
||||
deadline: string;
|
||||
lang: string;
|
||||
visibility: string;
|
||||
body: { groups: Group[] };
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function detectMode(): { mode: "create" | "edit"; slug?: string } {
|
||||
const path = window.location.pathname;
|
||||
if (path === "/checklists/new") {
|
||||
return { mode: "create" };
|
||||
}
|
||||
const m = path.match(/^\/checklists\/templates\/([^/]+)\/edit$/);
|
||||
if (m) {
|
||||
return { mode: "edit", slug: m[1] };
|
||||
}
|
||||
return { mode: "create" };
|
||||
}
|
||||
|
||||
let groups: Group[] = [];
|
||||
|
||||
function renderGroups() {
|
||||
const container = document.getElementById("groups-container")!;
|
||||
if (groups.length === 0) {
|
||||
// Seed with a single empty group + item so the user has something
|
||||
// to fill out rather than a blank canvas.
|
||||
groups = [{ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] }];
|
||||
}
|
||||
container.innerHTML = groups.map((g, gi) => {
|
||||
const itemsHTML = g.items.map((it, ii) => {
|
||||
return `<div class="author-item" data-gi="${gi}" data-ii="${ii}">
|
||||
<div class="form-row">
|
||||
<label class="form-label">${esc(t("checklisten.author.item.label"))}</label>
|
||||
<input class="form-input" data-field="label" value="${escAttr(it.labelDE || "")}" />
|
||||
</div>
|
||||
<div class="form-grid form-grid-2">
|
||||
<div class="form-row">
|
||||
<label class="form-label">${esc(t("checklisten.author.item.note"))}</label>
|
||||
<input class="form-input" data-field="note" value="${escAttr(it.noteDE || "")}" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">${esc(t("checklisten.author.item.rule"))}</label>
|
||||
<input class="form-input" data-field="rule" value="${escAttr(it.rule || "")}" />
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-small btn-danger" data-action="remove-item">${esc(t("checklisten.author.item.remove"))}</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
return `<div class="author-group" data-gi="${gi}">
|
||||
<div class="form-row">
|
||||
<label class="form-label">${esc(t("checklisten.author.group.title"))}</label>
|
||||
<input class="form-input" data-field="group-title" value="${escAttr(g.titleDE || "")}" />
|
||||
</div>
|
||||
<div class="author-items">${itemsHTML}</div>
|
||||
<div class="author-group-actions">
|
||||
<button type="button" class="btn btn-small" data-action="add-item">${esc(t("checklisten.author.item.add"))}</button>
|
||||
<button type="button" class="btn btn-small btn-danger" data-action="remove-group">${esc(t("checklisten.author.group.remove"))}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
// Wire input changes back into the data array.
|
||||
container.querySelectorAll<HTMLInputElement>(".author-group > .form-row input[data-field=group-title]").forEach((input) => {
|
||||
const groupDiv = input.closest<HTMLElement>(".author-group")!;
|
||||
const gi = parseInt(groupDiv.dataset.gi!, 10);
|
||||
input.addEventListener("input", () => {
|
||||
groups[gi].titleDE = input.value;
|
||||
groups[gi].titleEN = input.value; // single-language for Slice A
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll<HTMLDivElement>(".author-item").forEach((itemDiv) => {
|
||||
const gi = parseInt(itemDiv.dataset.gi!, 10);
|
||||
const ii = parseInt(itemDiv.dataset.ii!, 10);
|
||||
itemDiv.querySelectorAll<HTMLInputElement>("input[data-field]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
const field = input.dataset.field!;
|
||||
if (field === "label") {
|
||||
groups[gi].items[ii].labelDE = input.value;
|
||||
groups[gi].items[ii].labelEN = input.value;
|
||||
} else if (field === "note") {
|
||||
groups[gi].items[ii].noteDE = input.value || undefined;
|
||||
groups[gi].items[ii].noteEN = input.value || undefined;
|
||||
} else if (field === "rule") {
|
||||
groups[gi].items[ii].rule = input.value || undefined;
|
||||
}
|
||||
});
|
||||
});
|
||||
itemDiv.querySelector<HTMLButtonElement>("button[data-action=remove-item]")!.addEventListener("click", () => {
|
||||
groups[gi].items.splice(ii, 1);
|
||||
if (groups[gi].items.length === 0) {
|
||||
groups[gi].items.push({ labelDE: "", labelEN: "" });
|
||||
}
|
||||
renderGroups();
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll<HTMLButtonElement>("button[data-action=add-item]").forEach((btn) => {
|
||||
const groupDiv = btn.closest<HTMLElement>(".author-group")!;
|
||||
const gi = parseInt(groupDiv.dataset.gi!, 10);
|
||||
btn.addEventListener("click", () => {
|
||||
groups[gi].items.push({ labelDE: "", labelEN: "" });
|
||||
renderGroups();
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll<HTMLButtonElement>("button[data-action=remove-group]").forEach((btn) => {
|
||||
const groupDiv = btn.closest<HTMLElement>(".author-group")!;
|
||||
const gi = parseInt(groupDiv.dataset.gi!, 10);
|
||||
btn.addEventListener("click", () => {
|
||||
groups.splice(gi, 1);
|
||||
renderGroups();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showError(msg: string) {
|
||||
const err = document.getElementById("author-error")!;
|
||||
err.textContent = msg;
|
||||
err.style.display = "";
|
||||
err.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
const err = document.getElementById("author-error")!;
|
||||
err.textContent = "";
|
||||
err.style.display = "none";
|
||||
}
|
||||
|
||||
function collectInput() {
|
||||
const title = (document.getElementById("title") as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById("description") as HTMLTextAreaElement).value.trim();
|
||||
const regime = (document.getElementById("regime") as HTMLSelectElement).value;
|
||||
const court = (document.getElementById("court") as HTMLInputElement).value.trim();
|
||||
const reference = (document.getElementById("reference") as HTMLInputElement).value.trim();
|
||||
const deadline = (document.getElementById("deadline") as HTMLInputElement).value.trim();
|
||||
const lang = (document.getElementById("lang") as HTMLSelectElement).value;
|
||||
const visibilityInput = document.querySelector<HTMLInputElement>("input[name=visibility]:checked");
|
||||
const visibility = visibilityInput?.value || "private";
|
||||
return { title, description, regime, court, reference, deadline, lang, visibility };
|
||||
}
|
||||
|
||||
function validateGroups(): boolean {
|
||||
if (groups.length === 0) return false;
|
||||
let totalItems = 0;
|
||||
for (const g of groups) {
|
||||
if (!g.titleDE.trim()) return false;
|
||||
for (const it of g.items) {
|
||||
if (it.labelDE.trim()) totalItems += 1;
|
||||
}
|
||||
}
|
||||
return totalItems > 0;
|
||||
}
|
||||
|
||||
function trimmedGroups(): Group[] {
|
||||
return groups
|
||||
.filter((g) => g.titleDE.trim() && g.items.some((it) => it.labelDE.trim()))
|
||||
.map((g) => ({
|
||||
titleDE: g.titleDE.trim(),
|
||||
titleEN: g.titleEN.trim(),
|
||||
items: g.items
|
||||
.filter((it) => it.labelDE.trim())
|
||||
.map((it) => ({
|
||||
labelDE: it.labelDE.trim(),
|
||||
labelEN: it.labelEN.trim(),
|
||||
noteDE: it.noteDE?.trim() || undefined,
|
||||
noteEN: it.noteEN?.trim() || undefined,
|
||||
rule: it.rule?.trim() || undefined,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadEditTemplate(slug: string) {
|
||||
// Use /api/checklists/{slug} (catalog Find with visibility check) +
|
||||
// the mine list to ensure we have the editable fields. Templates the
|
||||
// caller doesn't own/admin will trip the PATCH gate later.
|
||||
const resp = await fetch(`/api/checklists/templates/mine`);
|
||||
if (!resp.ok) {
|
||||
showError(t("checklisten.author.error.notfound"));
|
||||
return;
|
||||
}
|
||||
const rows: Checklist[] = (await resp.json()) ?? [];
|
||||
const tpl = rows.find((r) => r.slug === slug);
|
||||
if (!tpl) {
|
||||
showError(t("checklisten.author.error.notfound"));
|
||||
return;
|
||||
}
|
||||
(document.getElementById("author-heading")!).textContent = t("checklisten.author.heading.edit");
|
||||
document.title = t("checklisten.author.title.edit");
|
||||
(document.getElementById("title") as HTMLInputElement).value = tpl.title;
|
||||
(document.getElementById("description") as HTMLTextAreaElement).value = tpl.description;
|
||||
(document.getElementById("regime") as HTMLSelectElement).value = tpl.regime;
|
||||
(document.getElementById("court") as HTMLInputElement).value = tpl.court;
|
||||
(document.getElementById("reference") as HTMLInputElement).value = tpl.reference;
|
||||
(document.getElementById("deadline") as HTMLInputElement).value = tpl.deadline;
|
||||
(document.getElementById("lang") as HTMLSelectElement).value = tpl.lang || "de";
|
||||
const visIn = document.querySelector<HTMLInputElement>(`input[name=visibility][value=${tpl.visibility}]`);
|
||||
if (visIn) visIn.checked = true;
|
||||
groups = (tpl.body?.groups || []).map((g) => ({
|
||||
titleDE: g.titleDE || "",
|
||||
titleEN: g.titleEN || g.titleDE || "",
|
||||
items: g.items.map((it) => ({
|
||||
labelDE: it.labelDE || "",
|
||||
labelEN: it.labelEN || it.labelDE || "",
|
||||
noteDE: it.noteDE,
|
||||
noteEN: it.noteEN,
|
||||
rule: it.rule,
|
||||
})),
|
||||
}));
|
||||
if (groups.length === 0) {
|
||||
groups = [{ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] }];
|
||||
}
|
||||
renderGroups();
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
clearError();
|
||||
const input = collectInput();
|
||||
if (!input.title) {
|
||||
showError(t("checklisten.author.error.title"));
|
||||
return;
|
||||
}
|
||||
if (!validateGroups()) {
|
||||
showError(t("checklisten.author.error.no_groups"));
|
||||
return;
|
||||
}
|
||||
const saveBtn = document.getElementById("author-save") as HTMLButtonElement;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = t("checklisten.author.saving");
|
||||
const body = JSON.stringify({ ...input, body: { groups: trimmedGroups() } });
|
||||
const resp = await fetch("/api/checklists/templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = t("checklisten.author.save");
|
||||
if (!resp.ok) {
|
||||
let msg = t("checklisten.author.error.generic");
|
||||
try {
|
||||
const j = await resp.json();
|
||||
if (j?.error) msg = j.error;
|
||||
} catch { /* keep generic */ }
|
||||
showError(msg);
|
||||
return;
|
||||
}
|
||||
const created: Checklist = await resp.json();
|
||||
window.location.href = `/checklists/${encodeURIComponent(created.slug)}`;
|
||||
}
|
||||
|
||||
async function submitEdit(slug: string) {
|
||||
clearError();
|
||||
const input = collectInput();
|
||||
if (!input.title) {
|
||||
showError(t("checklisten.author.error.title"));
|
||||
return;
|
||||
}
|
||||
if (!validateGroups()) {
|
||||
showError(t("checklisten.author.error.no_groups"));
|
||||
return;
|
||||
}
|
||||
const saveBtn = document.getElementById("author-save") as HTMLButtonElement;
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = t("checklisten.author.saving");
|
||||
const patch = {
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
regime: input.regime,
|
||||
court: input.court,
|
||||
reference: input.reference,
|
||||
deadline: input.deadline,
|
||||
body: { groups: trimmedGroups() },
|
||||
};
|
||||
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
// Visibility lives on its own endpoint so the audit row reflects the
|
||||
// distinct transition. Only call if it actually changed.
|
||||
if (resp.ok && input.visibility) {
|
||||
await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}/visibility`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ visibility: input.visibility }),
|
||||
});
|
||||
}
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = t("checklisten.author.save");
|
||||
if (!resp.ok) {
|
||||
let msg = t("checklisten.author.error.generic");
|
||||
try {
|
||||
const j = await resp.json();
|
||||
if (j?.error) msg = j.error;
|
||||
} catch { /* keep generic */ }
|
||||
showError(msg);
|
||||
return;
|
||||
}
|
||||
window.location.href = `/checklists/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
renderGroups();
|
||||
|
||||
document.getElementById("add-group")!.addEventListener("click", () => {
|
||||
groups.push({ titleDE: "", titleEN: "", items: [{ labelDE: "", labelEN: "" }] });
|
||||
renderGroups();
|
||||
});
|
||||
|
||||
const { mode, slug } = detectMode();
|
||||
|
||||
if (mode === "edit" && slug) {
|
||||
void loadEditTemplate(slug);
|
||||
}
|
||||
|
||||
document.getElementById("author-form")!.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
if (mode === "edit" && slug) {
|
||||
void submitEdit(slug);
|
||||
} else {
|
||||
void submitCreate();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -30,37 +30,6 @@ interface Checklist {
|
||||
referenceDE?: string;
|
||||
referenceEN?: string;
|
||||
groups: ChecklistGroup[];
|
||||
// Slice B fields — present on authored entries via the merged
|
||||
// catalog response. 'static' templates don't carry these.
|
||||
origin?: "static" | "authored";
|
||||
visibility?: string;
|
||||
owner_email?: string;
|
||||
owner_display_name?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
global_role?: string;
|
||||
}
|
||||
|
||||
interface UserSummary {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
interface PartnerUnit {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Share {
|
||||
id: string;
|
||||
checklist_id: string;
|
||||
recipient_kind: "user" | "office" | "partner_unit" | "project";
|
||||
recipient_label: string;
|
||||
}
|
||||
|
||||
interface ChecklistInstance {
|
||||
@@ -402,320 +371,13 @@ function rerenderAll() {
|
||||
renderInstances();
|
||||
}
|
||||
|
||||
// --- Slice B: owner actions + admin promote + share modal ----------------
|
||||
|
||||
let me: Me | null = null;
|
||||
let isOwner = false;
|
||||
let isAdmin = false;
|
||||
let shareUsers: UserSummary[] = [];
|
||||
let sharePartnerUnits: PartnerUnit[] = [];
|
||||
let shareProjects: AkteSummary[] = [];
|
||||
let activeShareKind: "user" | "office" | "partner_unit" | "project" = "user";
|
||||
|
||||
async function loadMe(): Promise<Me | null> {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function templateOriginInfo() {
|
||||
return template as unknown as {
|
||||
origin?: string;
|
||||
visibility?: string;
|
||||
owner_email?: string;
|
||||
owner_display_name?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function applyOwnerControls() {
|
||||
const info = templateOriginInfo();
|
||||
const isAuthored = info?.origin === "authored";
|
||||
const provenance = document.getElementById("checklist-provenance")!;
|
||||
if (isAuthored && info?.owner_display_name) {
|
||||
provenance.style.display = "";
|
||||
provenance.textContent = t("checklisten.detail.authored.by").replace("{author}", info.owner_display_name);
|
||||
} else {
|
||||
provenance.style.display = "none";
|
||||
}
|
||||
|
||||
isOwner = !!(isAuthored && me && info?.owner_email && me.email.toLowerCase() === info.owner_email.toLowerCase());
|
||||
isAdmin = !!(me && me.global_role === "global_admin");
|
||||
const ownerOnly = (id: string, show: boolean) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) (el as HTMLElement).style.display = show ? "" : "none";
|
||||
};
|
||||
if (template) {
|
||||
(document.getElementById("btn-edit-template") as HTMLAnchorElement | null)?.setAttribute(
|
||||
"href",
|
||||
`/checklists/templates/${encodeURIComponent(template.slug)}/edit`,
|
||||
);
|
||||
}
|
||||
ownerOnly("btn-edit-template", isOwner);
|
||||
ownerOnly("btn-share-template", isOwner);
|
||||
ownerOnly("btn-delete-template", isOwner);
|
||||
|
||||
// Admin promote/demote — only when an authored template is visible to
|
||||
// an admin, and only the appropriate one for the current visibility.
|
||||
if (isAuthored && isAdmin) {
|
||||
const isGlobal = info?.visibility === "global";
|
||||
ownerOnly("btn-promote-template", !isGlobal);
|
||||
ownerOnly("btn-demote-template", isGlobal);
|
||||
} else {
|
||||
ownerOnly("btn-promote-template", false);
|
||||
ownerOnly("btn-demote-template", false);
|
||||
}
|
||||
}
|
||||
|
||||
function initOwnerActions() {
|
||||
document.getElementById("btn-delete-template")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
const isEN = getLang() === "en";
|
||||
const title = isEN ? template.titleEN : template.titleDE;
|
||||
const msg = t("checklisten.detail.delete.confirm").replace("{title}", title);
|
||||
if (!window.confirm(msg)) return;
|
||||
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
window.alert(t("checklisten.detail.delete.error"));
|
||||
return;
|
||||
}
|
||||
window.location.href = "/checklists?tab=mine";
|
||||
});
|
||||
|
||||
document.getElementById("btn-promote-template")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
if (!window.confirm(t("checklisten.detail.promote.confirm"))) return;
|
||||
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/promote`, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
window.alert(t("checklisten.detail.promote.error"));
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
document.getElementById("btn-demote-template")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
if (!window.confirm(t("checklisten.detail.demote.confirm"))) return;
|
||||
const resp = await fetch(`/api/admin/checklists/${encodeURIComponent(template.slug)}/demote`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target: "firm" }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
window.alert(t("checklisten.detail.promote.error"));
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSharePickerData() {
|
||||
// Fire all three lookups in parallel — the share modal needs all of
|
||||
// them but doesn't depend on their order.
|
||||
try {
|
||||
const [usersResp, unitsResp, projectsResp] = await Promise.all([
|
||||
fetch("/api/users"),
|
||||
fetch("/api/partner-units"),
|
||||
fetch("/api/projects"),
|
||||
]);
|
||||
shareUsers = usersResp.ok ? await usersResp.json() : [];
|
||||
sharePartnerUnits = unitsResp.ok ? await unitsResp.json() : [];
|
||||
shareProjects = projectsResp.ok ? await projectsResp.json() : [];
|
||||
} catch {
|
||||
/* leave whatever loaded */
|
||||
}
|
||||
populateSharePickerOptions();
|
||||
}
|
||||
|
||||
function populateSharePickerOptions() {
|
||||
const userSel = document.getElementById("share-user") as HTMLSelectElement;
|
||||
if (userSel) {
|
||||
userSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
||||
shareUsers
|
||||
.slice()
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name))
|
||||
.forEach((u) => {
|
||||
if (me && u.id === me.id) return; // can't share with self
|
||||
const opt = document.createElement("option");
|
||||
opt.value = u.id;
|
||||
opt.textContent = `${u.display_name} (${u.email})`;
|
||||
userSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
const officeSel = document.getElementById("share-office") as HTMLSelectElement;
|
||||
if (officeSel) {
|
||||
const officeKeys = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan", "madrid"];
|
||||
officeSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
||||
officeKeys.forEach((k) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = k;
|
||||
opt.textContent = k.charAt(0).toUpperCase() + k.slice(1);
|
||||
officeSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
const puSel = document.getElementById("share-partner-unit") as HTMLSelectElement;
|
||||
if (puSel) {
|
||||
puSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
||||
sharePartnerUnits
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.forEach((u) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = u.id;
|
||||
opt.textContent = u.name;
|
||||
puSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
const prSel = document.getElementById("share-project") as HTMLSelectElement;
|
||||
if (prSel) {
|
||||
prSel.innerHTML = `<option value="">${esc(t("checklisten.share.pick"))}</option>`;
|
||||
shareProjects
|
||||
.slice()
|
||||
.sort((a, b) => (a.reference || a.title).localeCompare(b.reference || b.title))
|
||||
.forEach((p) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${p.reference || ""} — ${p.title}`;
|
||||
prSel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function switchShareKind(kind: "user" | "office" | "partner_unit" | "project") {
|
||||
activeShareKind = kind;
|
||||
document.querySelectorAll<HTMLButtonElement>("#share-kind-pills .filter-pill").forEach((p) => {
|
||||
p.classList.toggle("active", p.dataset.kind === kind);
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>(".share-kind-section").forEach((s) => {
|
||||
s.style.display = s.dataset.kind === kind ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
function initShareModal() {
|
||||
const modal = document.getElementById("share-modal")!;
|
||||
const msg = document.getElementById("share-msg")!;
|
||||
const close = () => { modal.style.display = "none"; };
|
||||
|
||||
document.getElementById("btn-share-template")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
switchShareKind("user");
|
||||
modal.style.display = "flex";
|
||||
await loadSharePickerData();
|
||||
await renderGrants();
|
||||
});
|
||||
|
||||
document.getElementById("share-close")?.addEventListener("click", close);
|
||||
document.getElementById("share-cancel")?.addEventListener("click", close);
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
|
||||
|
||||
document.getElementById("share-kind-pills")?.addEventListener("click", (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill[data-kind]");
|
||||
if (!btn) return;
|
||||
switchShareKind(btn.dataset.kind as typeof activeShareKind);
|
||||
});
|
||||
|
||||
document.getElementById("share-submit")?.addEventListener("click", async () => {
|
||||
if (!template) return;
|
||||
const input: Record<string, unknown> = { recipient_kind: activeShareKind };
|
||||
switch (activeShareKind) {
|
||||
case "user": {
|
||||
const v = (document.getElementById("share-user") as HTMLSelectElement).value;
|
||||
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
||||
input["recipient_user_id"] = v;
|
||||
break;
|
||||
}
|
||||
case "office": {
|
||||
const v = (document.getElementById("share-office") as HTMLSelectElement).value;
|
||||
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
||||
input["recipient_office"] = v;
|
||||
break;
|
||||
}
|
||||
case "partner_unit": {
|
||||
const v = (document.getElementById("share-partner-unit") as HTMLSelectElement).value;
|
||||
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
||||
input["recipient_partner_unit_id"] = v;
|
||||
break;
|
||||
}
|
||||
case "project": {
|
||||
const v = (document.getElementById("share-project") as HTMLSelectElement).value;
|
||||
if (!v) { msg.textContent = t("checklisten.share.error.pick"); msg.className = "form-msg form-msg-error"; return; }
|
||||
input["recipient_project_id"] = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let errMsg = t("checklisten.share.error.generic");
|
||||
try {
|
||||
const j = await resp.json();
|
||||
if (j?.error) errMsg = j.error;
|
||||
} catch { /* keep generic */ }
|
||||
msg.textContent = errMsg;
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
msg.textContent = t("checklisten.share.success");
|
||||
msg.className = "form-msg form-msg-success";
|
||||
await renderGrants();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderGrants() {
|
||||
if (!template) return;
|
||||
const list = document.getElementById("share-grants-list")!;
|
||||
const empty = document.getElementById("share-grants-empty")!;
|
||||
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(template.slug)}/shares`);
|
||||
const rows: Share[] = resp.ok ? await resp.json() : [];
|
||||
if (rows.length === 0) {
|
||||
list.innerHTML = "";
|
||||
list.appendChild(empty);
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = rows.map((s) => {
|
||||
const kindLabel = esc(t(("checklisten.share.grants.recipient." + s.recipient_kind) as never) || s.recipient_kind);
|
||||
return `<li class="share-grant-row" data-id="${esc(s.id)}">
|
||||
<span class="share-grant-kind">${kindLabel}</span>
|
||||
<span class="share-grant-label">${esc(s.recipient_label || "")}</span>
|
||||
<button type="button" class="btn-small btn-ghost" data-action="revoke" data-id="${esc(s.id)}">${esc(t("checklisten.share.grants.revoke"))}</button>
|
||||
</li>`;
|
||||
}).join("");
|
||||
list.querySelectorAll<HTMLButtonElement>("button[data-action=revoke]").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!window.confirm(t("checklisten.share.grants.revoke.confirm"))) return;
|
||||
const resp = await fetch(`/api/checklists/shares/${encodeURIComponent(btn.dataset.id!)}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
window.alert(t("checklisten.share.grants.revoke.error"));
|
||||
return;
|
||||
}
|
||||
await renderGrants();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initNewInstance();
|
||||
initFeedback();
|
||||
initOwnerActions();
|
||||
initShareModal();
|
||||
onLangChange(rerenderAll);
|
||||
void (async () => {
|
||||
me = await loadMe();
|
||||
await loadTemplate();
|
||||
applyOwnerControls();
|
||||
})();
|
||||
void loadTemplate();
|
||||
void loadInstances();
|
||||
void loadAkten();
|
||||
});
|
||||
|
||||
@@ -40,16 +40,6 @@ interface Instance {
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Slice C — snapshot of the template body + its version at create time.
|
||||
template_snapshot?: { groups: ChecklistGroup[] } | null;
|
||||
template_version?: number | null;
|
||||
}
|
||||
|
||||
// Slice C — augmented Checklist with origin + version, returned by
|
||||
// /api/checklists/{slug}.
|
||||
interface ChecklistWithMeta extends Checklist {
|
||||
origin?: "static" | "authored";
|
||||
version?: number;
|
||||
}
|
||||
|
||||
let template: Checklist | null = null;
|
||||
@@ -165,119 +155,6 @@ function renderHeader() {
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${akteLabel}</dt><dd><a href="/projects/${esc(instance.project_id)}">${t("checklisten.instance.akte.open") || "Öffnen"}</a></dd></div>`);
|
||||
}
|
||||
document.getElementById("instance-meta")!.innerHTML = parts.join("");
|
||||
renderOutdatedBadge();
|
||||
}
|
||||
|
||||
// Slice C — show an "outdated" badge when the live template has a
|
||||
// version > the instance's snapshot version. Both values must be
|
||||
// non-null for the comparison to be meaningful (pre-Slice-C instances
|
||||
// have NULL template_version; static templates always have version=1
|
||||
// and never bump).
|
||||
function renderOutdatedBadge() {
|
||||
const slot = document.getElementById("instance-outdated-slot");
|
||||
if (!slot || !instance || !template) return;
|
||||
const tplMeta = template as ChecklistWithMeta;
|
||||
const instVersion = instance.template_version;
|
||||
const tplVersion = tplMeta.version;
|
||||
if (
|
||||
instVersion == null ||
|
||||
tplVersion == null ||
|
||||
tplMeta.origin !== "authored" ||
|
||||
tplVersion <= instVersion
|
||||
) {
|
||||
slot.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const badge = esc(t("checklisten.instance.outdated.badge"));
|
||||
const note = esc(
|
||||
t("checklisten.instance.outdated.note")
|
||||
.replace("{from}", String(instVersion))
|
||||
.replace("{to}", String(tplVersion)),
|
||||
);
|
||||
const action = esc(t("checklisten.instance.outdated.diff"));
|
||||
slot.innerHTML = `<div class="instance-outdated-banner">
|
||||
<span class="instance-outdated-badge">${badge}</span>
|
||||
<span class="instance-outdated-note">${note}</span>
|
||||
<button type="button" class="btn-small" id="btn-show-diff">${action}</button>
|
||||
</div>`;
|
||||
document.getElementById("btn-show-diff")!.addEventListener("click", openDiffModal);
|
||||
}
|
||||
|
||||
// Shallow diff between two checklist bodies. Compares item label/note/
|
||||
// rule pairs grouped by section title. Items with the same group title
|
||||
// + same label are matched; differences in note/rule are flagged
|
||||
// 'changed'. Items present only in snapshot are 'removed'; items only
|
||||
// in current are 'added'.
|
||||
function diffBodies(snapshot: { groups: ChecklistGroup[] } | null | undefined, current: ChecklistGroup[]):
|
||||
{ added: string[]; removed: string[]; changed: string[] } {
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const changed: string[] = [];
|
||||
const oldGroups = snapshot?.groups ?? [];
|
||||
const oldMap: Record<string, ChecklistItem> = {};
|
||||
for (const g of oldGroups) {
|
||||
for (const it of g.items) {
|
||||
const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`;
|
||||
oldMap[key] = it;
|
||||
}
|
||||
}
|
||||
const newMap: Record<string, ChecklistItem> = {};
|
||||
for (const g of current) {
|
||||
for (const it of g.items) {
|
||||
const key = `${g.titleDE || g.titleEN}::${it.labelDE || it.labelEN}`;
|
||||
newMap[key] = it;
|
||||
if (!(key in oldMap)) {
|
||||
added.push(it.labelDE || it.labelEN);
|
||||
} else {
|
||||
const o = oldMap[key];
|
||||
if ((o.noteDE || o.noteEN || "") !== (it.noteDE || it.noteEN || "") ||
|
||||
(o.rule || "") !== (it.rule || "")) {
|
||||
changed.push(it.labelDE || it.labelEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key in oldMap) {
|
||||
if (!(key in newMap)) {
|
||||
const labelParts = key.split("::");
|
||||
removed.push(labelParts[1] || key);
|
||||
}
|
||||
}
|
||||
return { added, removed, changed };
|
||||
}
|
||||
|
||||
function openDiffModal() {
|
||||
if (!template || !instance) return;
|
||||
const modal = document.getElementById("instance-diff-modal")!;
|
||||
const body = document.getElementById("instance-diff-body")!;
|
||||
const diff = diffBodies(instance.template_snapshot, template.groups);
|
||||
const empty = diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0;
|
||||
if (empty) {
|
||||
body.innerHTML = `<p class="entity-events-empty">${esc(t("checklisten.instance.diff.empty"))}</p>`;
|
||||
} else {
|
||||
const section = (label: string, klass: string, items: string[]) => {
|
||||
if (items.length === 0) return "";
|
||||
return `<section class="instance-diff-section ${klass}">
|
||||
<h3>${esc(label)}</h3>
|
||||
<ul>${items.map((s) => `<li>${esc(s)}</li>`).join("")}</ul>
|
||||
</section>`;
|
||||
};
|
||||
body.innerHTML = [
|
||||
section(t("checklisten.instance.diff.added"), "instance-diff-added", diff.added),
|
||||
section(t("checklisten.instance.diff.removed"), "instance-diff-removed", diff.removed),
|
||||
section(t("checklisten.instance.diff.changed"), "instance-diff-changed", diff.changed),
|
||||
].join("");
|
||||
}
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
function initDiffModal() {
|
||||
const modal = document.getElementById("instance-diff-modal");
|
||||
if (!modal) return;
|
||||
const close = () => { modal.style.display = "none"; };
|
||||
document.getElementById("instance-diff-close")?.addEventListener("click", close);
|
||||
document.getElementById("instance-diff-close-bottom")?.addEventListener("click", close);
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
@@ -512,7 +389,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initPrint();
|
||||
initRename();
|
||||
initFeedback();
|
||||
initDiffModal();
|
||||
onLangChange(renderAll);
|
||||
void bootstrap();
|
||||
});
|
||||
|
||||
@@ -11,26 +11,6 @@ interface ChecklistSummary {
|
||||
courtDE: string;
|
||||
courtEN: string;
|
||||
itemCount: number;
|
||||
origin?: "static" | "authored";
|
||||
visibility?: string;
|
||||
owner_email?: string;
|
||||
owner_display_name?: string;
|
||||
}
|
||||
|
||||
interface MyChecklist {
|
||||
id: string;
|
||||
slug: string;
|
||||
owner_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
regime: string;
|
||||
court: string;
|
||||
reference: string;
|
||||
deadline: string;
|
||||
lang: string;
|
||||
visibility: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ChecklistInstance {
|
||||
@@ -46,20 +26,15 @@ interface ChecklistInstance {
|
||||
project_title?: string | null;
|
||||
}
|
||||
|
||||
type TabId = "templates" | "mine" | "gallery" | "instances";
|
||||
type TabId = "templates" | "instances";
|
||||
|
||||
const VALID_TABS: TabId[] = ["templates", "mine", "gallery", "instances"];
|
||||
const VALID_TABS: TabId[] = ["templates", "instances"];
|
||||
|
||||
let allChecklists: ChecklistSummary[] = [];
|
||||
let activeRegime = "all";
|
||||
let galleryRegime = "all";
|
||||
let allInstances: ChecklistInstance[] = [];
|
||||
let templatesBySlug: Record<string, ChecklistSummary> = {};
|
||||
let instancesLoaded = false;
|
||||
let myTemplates: MyChecklist[] = [];
|
||||
let myTemplatesLoaded = false;
|
||||
let galleryLoaded = false;
|
||||
let me: { id: string; email: string } | null = null;
|
||||
let activeTab: TabId = "templates";
|
||||
|
||||
function esc(s: string): string {
|
||||
@@ -233,10 +208,7 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
|
||||
el.style.display = el.id === `tab-${tab}` ? "" : "none";
|
||||
});
|
||||
if (opts.pushHistory ?? true) {
|
||||
let newURL = "/checklists";
|
||||
if (tab === "instances") newURL = "/checklists?tab=instances";
|
||||
if (tab === "mine") newURL = "/checklists?tab=mine";
|
||||
if (tab === "gallery") newURL = "/checklists?tab=gallery";
|
||||
const newURL = tab === "instances" ? "/checklists?tab=instances" : "/checklists";
|
||||
if (window.location.pathname + window.location.search !== newURL) {
|
||||
window.history.replaceState({}, "", newURL);
|
||||
}
|
||||
@@ -244,155 +216,6 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
|
||||
if (tab === "instances") {
|
||||
void loadInstances();
|
||||
}
|
||||
if (tab === "mine") {
|
||||
void loadMyTemplates();
|
||||
}
|
||||
if (tab === "gallery") {
|
||||
void loadGallery();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGallery(force = false) {
|
||||
if (galleryLoaded && !force) return;
|
||||
galleryLoaded = true;
|
||||
// /api/checklists already returns the merged catalog; the gallery
|
||||
// filter just narrows to non-static + non-owned + non-private.
|
||||
if (allChecklists.length === 0) {
|
||||
await loadTemplates();
|
||||
}
|
||||
renderGallery();
|
||||
}
|
||||
|
||||
function renderGallery() {
|
||||
const loading = document.getElementById("checklists-gallery-loading")!;
|
||||
const empty = document.getElementById("checklists-gallery-empty")!;
|
||||
const grid = document.getElementById("checklists-gallery-grid") as HTMLElement;
|
||||
|
||||
loading.style.display = "none";
|
||||
|
||||
const visible = allChecklists.filter((c) => {
|
||||
if (c.origin !== "authored") return false;
|
||||
if (me && c.owner_email && me.email.toLowerCase() === c.owner_email.toLowerCase()) return false;
|
||||
if (galleryRegime !== "all" && c.regime !== galleryRegime) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (visible.length === 0) {
|
||||
empty.style.display = "";
|
||||
grid.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
grid.style.display = "";
|
||||
|
||||
const isEN = getLang() === "en";
|
||||
grid.innerHTML = visible.map((c) => {
|
||||
const title = isEN ? c.titleEN : c.titleDE;
|
||||
const desc = isEN ? c.descriptionEN : c.descriptionDE;
|
||||
const court = isEN ? c.courtEN : c.courtDE;
|
||||
const itemsLabel = isEN ? "items" : "Punkte";
|
||||
const visKey = `checklisten.mine.visibility.${c.visibility || ""}`;
|
||||
const visLabel = c.visibility ? esc(t(visKey as never) || c.visibility) : "";
|
||||
const authorLine = c.owner_display_name
|
||||
? `<p class="checklist-card-author">${esc(t("checklisten.detail.authored.by").replace("{author}", c.owner_display_name))}</p>`
|
||||
: "";
|
||||
return `<a href="/checklists/${esc(c.slug)}" class="checklist-card">
|
||||
<div class="checklist-card-top">
|
||||
<span class="checklist-regime checklist-regime-${esc(c.regime)}">${esc(c.regime)}</span>
|
||||
<span class="checklist-card-count">${c.itemCount} ${itemsLabel}</span>
|
||||
</div>
|
||||
<h2 class="checklist-card-title">${esc(title)}</h2>
|
||||
<p class="checklist-card-desc">${esc(desc)}</p>
|
||||
<p class="checklist-card-court">${esc(court)}</p>
|
||||
${authorLine}
|
||||
${visLabel ? `<span class="visibility-chip visibility-chip-${esc(c.visibility || "")}">${visLabel}</span>` : ""}
|
||||
</a>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function initGalleryFilters() {
|
||||
const container = document.getElementById("checklist-gallery-filters");
|
||||
if (!container) return;
|
||||
container.addEventListener("click", (e) => {
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(".filter-pill");
|
||||
if (!btn) return;
|
||||
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
galleryRegime = btn.dataset.regime ?? "all";
|
||||
renderGallery();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (resp.ok) me = await resp.json();
|
||||
} catch { /* leave me=null */ }
|
||||
}
|
||||
|
||||
async function loadMyTemplates(force = false) {
|
||||
if (myTemplatesLoaded && !force) return;
|
||||
myTemplatesLoaded = true;
|
||||
const resp = await fetch("/api/checklists/templates/mine");
|
||||
if (!resp.ok) {
|
||||
myTemplates = [];
|
||||
} else {
|
||||
myTemplates = (await resp.json()) ?? [];
|
||||
}
|
||||
renderMyTemplates();
|
||||
}
|
||||
|
||||
function renderMyTemplates() {
|
||||
const loading = document.getElementById("checklists-mine-loading")!;
|
||||
const empty = document.getElementById("checklists-mine-empty")!;
|
||||
const grid = document.getElementById("checklists-mine-grid") as HTMLElement;
|
||||
|
||||
loading.style.display = "none";
|
||||
|
||||
if (myTemplates.length === 0) {
|
||||
empty.style.display = "";
|
||||
grid.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
grid.style.display = "";
|
||||
|
||||
grid.innerHTML = myTemplates.map((tpl) => {
|
||||
const visKey = `checklisten.mine.visibility.${tpl.visibility}`;
|
||||
const visLabel = esc(t(visKey as never) || tpl.visibility);
|
||||
const titleSafe = esc(tpl.title);
|
||||
return `<article class="checklist-card checklist-card-mine" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}">
|
||||
<div class="checklist-card-top">
|
||||
<span class="checklist-regime checklist-regime-${esc(tpl.regime)}">${esc(tpl.regime)}</span>
|
||||
<span class="checklist-card-count visibility-chip visibility-chip-${esc(tpl.visibility)}">${visLabel}</span>
|
||||
</div>
|
||||
<h2 class="checklist-card-title">
|
||||
<a href="/checklists/${esc(tpl.slug)}">${titleSafe}</a>
|
||||
</h2>
|
||||
<p class="checklist-card-desc">${esc(tpl.description || "")}</p>
|
||||
<p class="checklist-card-court">${esc(tpl.court || "")}</p>
|
||||
<div class="checklist-card-actions">
|
||||
<a class="btn btn-small" href="/checklists/templates/${esc(tpl.slug)}/edit" data-i18n="checklisten.mine.edit">Bearbeiten</a>
|
||||
<button class="btn btn-small btn-danger" data-action="delete" data-slug="${esc(tpl.slug)}" data-title="${escAttr(tpl.title)}" data-i18n="checklisten.mine.delete">Löschen</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}).join("");
|
||||
|
||||
grid.querySelectorAll<HTMLButtonElement>("button[data-action=delete]").forEach((btn) => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
const slug = btn.dataset.slug!;
|
||||
const title = btn.dataset.title || slug;
|
||||
const msg = t("checklisten.mine.delete.confirm").replace("{title}", title);
|
||||
if (!window.confirm(msg)) return;
|
||||
const resp = await fetch(`/api/checklists/templates/${encodeURIComponent(slug)}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
window.alert(t("checklisten.mine.delete.error"));
|
||||
return;
|
||||
}
|
||||
await loadMyTemplates(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initTabs() {
|
||||
@@ -411,15 +234,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initFilters();
|
||||
initGalleryFilters();
|
||||
initTabs();
|
||||
onLangChange(() => {
|
||||
renderTemplates();
|
||||
if (instancesLoaded) renderInstances();
|
||||
if (myTemplatesLoaded) renderMyTemplates();
|
||||
if (galleryLoaded) renderGallery();
|
||||
});
|
||||
void loadMe();
|
||||
void loadTemplates();
|
||||
showTab(parseTab(), { pushHistory: false });
|
||||
});
|
||||
|
||||
@@ -76,15 +76,12 @@ interface FieldSpec {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
// Deadline-only fields rendered in the editable section. `rule_code` and
|
||||
// `event_type_ids` are intentionally NOT here — they're bundled into the
|
||||
// dedicated "Verfahrenshandlung" section below the base fields so the
|
||||
// event-type (parent concept) reads before the rule (m/paliad#56).
|
||||
const DEADLINE_FIELDS: ReadonlyArray<FieldSpec> = [
|
||||
{ key: "title", labelKey: "deadlines.field.title", inputType: "text", required: true },
|
||||
{ key: "due_date", labelKey: "deadlines.field.due", inputType: "date" },
|
||||
{ key: "original_due_date", labelKey: "approvals.suggest.field.original_due_date", inputType: "date" },
|
||||
{ key: "warning_date", labelKey: "approvals.suggest.field.warning_date", inputType: "date" },
|
||||
{ key: "rule_code", labelKey: "approvals.suggest.field.rule_code", inputType: "text" },
|
||||
{ key: "description", labelKey: "approvals.suggest.field.description", inputType: "textarea" },
|
||||
{ key: "notes", labelKey: "deadlines.field.notes", inputType: "textarea" },
|
||||
];
|
||||
@@ -124,7 +121,7 @@ export async function openApprovalEditModal(
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let eventTypePickerLoaded = false;
|
||||
if (args.entityType === "deadline") {
|
||||
const pickerSection = renderEventTypePickerSection(original, preImage);
|
||||
const pickerSection = renderEventTypePickerSection();
|
||||
body.appendChild(pickerSection.section);
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -194,94 +191,67 @@ function renderFieldsSection(
|
||||
section.appendChild(h);
|
||||
|
||||
for (const f of fields) {
|
||||
section.appendChild(renderSingleField(f, original, preImage));
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "form-field approval-suggest-field";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = t(f.labelKey as never);
|
||||
wrap.appendChild(label);
|
||||
|
||||
const value = formatFieldForInput(original[f.key], f.inputType);
|
||||
|
||||
let input: HTMLInputElement | HTMLTextAreaElement;
|
||||
if (f.inputType === "textarea") {
|
||||
input = document.createElement("textarea");
|
||||
input.rows = 3;
|
||||
(input as HTMLTextAreaElement).value = value;
|
||||
} else {
|
||||
input = document.createElement("input");
|
||||
(input as HTMLInputElement).type = f.inputType;
|
||||
(input as HTMLInputElement).value = value;
|
||||
}
|
||||
input.dataset.suggestField = f.key;
|
||||
input.dataset.suggestOriginal = value;
|
||||
input.dataset.suggestInputType = f.inputType;
|
||||
if (f.required) input.required = true;
|
||||
|
||||
// Wire the <label> to focus the <input> on click.
|
||||
const inputID = `suggest-field-${f.key}`;
|
||||
input.id = inputID;
|
||||
label.setAttribute("for", inputID);
|
||||
|
||||
wrap.appendChild(input);
|
||||
|
||||
// "Vorher" hint when pre_image carries a distinct value for this field.
|
||||
const preVal = formatFieldForInput(preImage[f.key], f.inputType);
|
||||
if (preVal && preVal !== value) {
|
||||
const hint = document.createElement("span");
|
||||
hint.className = "approval-suggest-prehint";
|
||||
hint.textContent = `${t("approvals.diff.before")}: ${preVal}`;
|
||||
wrap.appendChild(hint);
|
||||
}
|
||||
|
||||
section.appendChild(wrap);
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
// Verfahrenshandlung section — bundles the event-type picker and the
|
||||
// rule_code input so the editor reads "what procedural step? which rule
|
||||
// cites it?" instead of two disconnected fields with rule above type
|
||||
// (m/paliad#56). The hint underneath spells out the parent/child
|
||||
// relationship so first-time editors don't read them as peers.
|
||||
function renderEventTypePickerSection(
|
||||
original: Record<string, unknown>,
|
||||
preImage: Record<string, unknown>,
|
||||
): { section: HTMLElement; host: HTMLElement } {
|
||||
function renderEventTypePickerSection(): { section: HTMLElement; host: HTMLElement } {
|
||||
const section = document.createElement("section");
|
||||
section.className = "approval-suggest-section approval-suggest-section--editable";
|
||||
|
||||
const h = document.createElement("h3");
|
||||
h.className = "approval-suggest-section-title";
|
||||
h.textContent = t("approvals.suggest.section.event_type_rule");
|
||||
h.textContent = t("deadlines.field.event_type");
|
||||
section.appendChild(h);
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.className = "approval-suggest-event-type-picker";
|
||||
section.appendChild(host);
|
||||
|
||||
// Rule citation — rendered as a sub-field directly beneath the picker so
|
||||
// the visual hierarchy matches the conceptual one (rule is meta on the
|
||||
// event type, not a peer).
|
||||
const ruleField: FieldSpec = {
|
||||
key: "rule_code",
|
||||
labelKey: "approvals.suggest.field.rule_code",
|
||||
inputType: "text",
|
||||
};
|
||||
section.appendChild(renderSingleField(ruleField, original, preImage));
|
||||
|
||||
return { section, host };
|
||||
}
|
||||
|
||||
// renderSingleField builds one labelled input in the same shape as the
|
||||
// fields-section loop. Extracted so the Verfahrenshandlung section can
|
||||
// host the rule_code input next to the picker without duplicating the
|
||||
// wiring (dirty-tracking, pre_image hint, label/for binding).
|
||||
function renderSingleField(
|
||||
f: FieldSpec,
|
||||
original: Record<string, unknown>,
|
||||
preImage: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "form-field approval-suggest-field";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = t(f.labelKey as never);
|
||||
wrap.appendChild(label);
|
||||
|
||||
const value = formatFieldForInput(original[f.key], f.inputType);
|
||||
|
||||
let input: HTMLInputElement | HTMLTextAreaElement;
|
||||
if (f.inputType === "textarea") {
|
||||
input = document.createElement("textarea");
|
||||
input.rows = 3;
|
||||
(input as HTMLTextAreaElement).value = value;
|
||||
} else {
|
||||
input = document.createElement("input");
|
||||
(input as HTMLInputElement).type = f.inputType;
|
||||
(input as HTMLInputElement).value = value;
|
||||
}
|
||||
input.dataset.suggestField = f.key;
|
||||
input.dataset.suggestOriginal = value;
|
||||
input.dataset.suggestInputType = f.inputType;
|
||||
if (f.required) input.required = true;
|
||||
|
||||
const inputID = `suggest-field-${f.key}`;
|
||||
input.id = inputID;
|
||||
label.setAttribute("for", inputID);
|
||||
|
||||
wrap.appendChild(input);
|
||||
|
||||
const preVal = formatFieldForInput(preImage[f.key], f.inputType);
|
||||
if (preVal && preVal !== value) {
|
||||
const hint = document.createElement("span");
|
||||
hint.className = "approval-suggest-prehint";
|
||||
hint.textContent = `${t("approvals.diff.before")}: ${preVal}`;
|
||||
wrap.appendChild(hint);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderContextSection(
|
||||
args: ApprovalEditModalArgs,
|
||||
original: Record<string, unknown>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
181
frontend/src/client/deadlines-calendar.ts
Normal file
181
frontend/src/client/deadlines-calendar.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Deadline {
|
||||
id: string;
|
||||
project_id: string;
|
||||
title: string;
|
||||
due_date: string;
|
||||
status: string;
|
||||
project_reference: string;
|
||||
project_title: string;
|
||||
}
|
||||
|
||||
let allDeadlines: Deadline[] = [];
|
||||
let viewYear = 0;
|
||||
let viewMonth = 0; // 0-11
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtMonth(year: number, month: number): string {
|
||||
return `${tDyn(`cal.month.${month}`)} ${year}`;
|
||||
}
|
||||
|
||||
function urgencyClass(due: string, status: string): string {
|
||||
if (status === "completed") return "frist-urgency-done";
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const d = new Date(due.slice(0, 10) + "T00:00:00");
|
||||
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
|
||||
if (diffDays < 0) return "frist-urgency-overdue";
|
||||
if (diffDays <= 7) return "frist-urgency-soon";
|
||||
return "frist-urgency-later";
|
||||
}
|
||||
|
||||
async function loadDeadlines() {
|
||||
try {
|
||||
const resp = await fetch("/api/deadlines?status=all");
|
||||
if (resp.ok) allDeadlines = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function deadlinesForDate(iso: string): Deadline[] {
|
||||
return allDeadlines.filter((f) => f.due_date.slice(0, 10) === iso);
|
||||
}
|
||||
|
||||
function isoDate(year: number, month: number, day: number): string {
|
||||
const m = String(month + 1).padStart(2, "0");
|
||||
const d = String(day).padStart(2, "0");
|
||||
return `${year}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
|
||||
|
||||
const firstDay = new Date(viewYear, viewMonth, 1);
|
||||
const jsWeekday = firstDay.getDay();
|
||||
const offset = (jsWeekday + 6) % 7;
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
|
||||
const cells: string[] = [];
|
||||
for (let i = 0; i < offset; i++) {
|
||||
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const iso = isoDate(viewYear, viewMonth, day);
|
||||
const items = deadlinesForDate(iso);
|
||||
const isToday = iso === todayISO;
|
||||
|
||||
const dots = items
|
||||
.slice(0, 4)
|
||||
.map((f) => `<span class="frist-cal-dot ${urgencyClass(f.due_date, f.status)}"></span>`)
|
||||
.join("");
|
||||
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
|
||||
|
||||
cells.push(
|
||||
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
|
||||
<span class="frist-cal-day">${day}</span>
|
||||
<div class="frist-cal-dots">${dots}${more}</div>
|
||||
</div>`,
|
||||
);
|
||||
}
|
||||
|
||||
const grid = document.getElementById("deadline-cal-grid")!;
|
||||
grid.innerHTML = cells.join("");
|
||||
|
||||
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
|
||||
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
|
||||
});
|
||||
|
||||
const monthStart = isoDate(viewYear, viewMonth, 1);
|
||||
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
|
||||
const hasInMonth = allDeadlines.some((f) => {
|
||||
const iso = f.due_date.slice(0, 10);
|
||||
return iso >= monthStart && iso <= monthEnd;
|
||||
});
|
||||
const empty = document.getElementById("deadline-cal-empty")!;
|
||||
empty.style.display = hasInMonth ? "none" : "";
|
||||
}
|
||||
|
||||
function openPopup(iso: string) {
|
||||
const items = deadlinesForDate(iso);
|
||||
if (items.length === 0) return;
|
||||
const popup = document.getElementById("cal-popup")!;
|
||||
const dateEl = document.getElementById("cal-popup-date")!;
|
||||
const list = document.getElementById("cal-popup-list")!;
|
||||
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
list.innerHTML = items
|
||||
.map((f) => {
|
||||
const cls = urgencyClass(f.due_date, f.status);
|
||||
return `<li class="frist-cal-popup-item">
|
||||
<span class="frist-cal-dot ${cls}"></span>
|
||||
<a href="/deadlines/${esc(f.id)}" class="frist-cal-popup-title">${esc(f.title)}</a>
|
||||
<a href="/projects/${esc(f.project_id)}" class="frist-cal-popup-project">${esc(f.project_reference)}</a>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
popup.style.display = "flex";
|
||||
}
|
||||
|
||||
function initPopup() {
|
||||
const popup = document.getElementById("cal-popup")!;
|
||||
const close = document.getElementById("cal-popup-close")!;
|
||||
close.addEventListener("click", () => (popup.style.display = "none"));
|
||||
popup.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) popup.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function initNav() {
|
||||
document.getElementById("cal-prev")!.addEventListener("click", () => {
|
||||
viewMonth -= 1;
|
||||
if (viewMonth < 0) {
|
||||
viewMonth = 11;
|
||||
viewYear -= 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
document.getElementById("cal-next")!.addEventListener("click", () => {
|
||||
viewMonth += 1;
|
||||
if (viewMonth > 11) {
|
||||
viewMonth = 0;
|
||||
viewYear += 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
document.getElementById("cal-today")!.addEventListener("click", () => {
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
initNav();
|
||||
initPopup();
|
||||
onLangChange(render);
|
||||
await loadDeadlines();
|
||||
render();
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type FilterHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
|
||||
|
||||
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
|
||||
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
|
||||
@@ -126,11 +125,8 @@ const STATUS_OPTIONS_DEADLINE: StatusOption[] = [
|
||||
{ value: "completed", key: "deadlines.filter.completed" },
|
||||
];
|
||||
|
||||
// Appointment status options — m/paliad#54: the legacy 'upcoming' /
|
||||
// "Ab heute" option was a UI lie (backend never narrowed past events for
|
||||
// appointments) and is removed. 'today' is the sane default — matches the
|
||||
// dashboard tile. 'all' stays as the explicit opt-in for past events.
|
||||
const STATUS_OPTIONS_APPOINTMENT: StatusOption[] = [
|
||||
{ value: "upcoming", key: "events.filter.status.upcoming" },
|
||||
{ value: "today", key: "deadlines.filter.today" },
|
||||
{ value: "this_week", key: "deadlines.filter.thisweek" },
|
||||
{ value: "next_week", key: "deadlines.filter.nextweek" },
|
||||
@@ -144,7 +140,7 @@ function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
|
||||
}
|
||||
|
||||
function defaultStatusFor(type: EventTypeChoice): string {
|
||||
return type === "appointment" ? "today" : "pending";
|
||||
return type === "appointment" ? "upcoming" : "pending";
|
||||
}
|
||||
|
||||
let currentType: EventTypeChoice = "deadline";
|
||||
@@ -158,10 +154,8 @@ let me: Me | null = null;
|
||||
let eventTypeFilter: FilterHandle | null = null;
|
||||
let eventTypeByID: Map<string, EventType> = new Map();
|
||||
let loadedOK = false;
|
||||
// Calendar handle is created lazily when /events first switches into the
|
||||
// Kalender view (t-paliad-224). The handle owns its own month/week/day
|
||||
// state + ?cal_view / ?cal_date URL contract via mountCalendar.
|
||||
let calendar: CalendarHandle | null = null;
|
||||
let calYear = 0;
|
||||
let calMonth = 0;
|
||||
|
||||
function urlParams(): URLSearchParams {
|
||||
return new URLSearchParams(window.location.search);
|
||||
@@ -432,13 +426,12 @@ function hideTableAndCalendar() {
|
||||
const calWrap = document.getElementById("events-calendar-wrap");
|
||||
if (tableWrap) tableWrap.style.display = "none";
|
||||
if (calWrap) calWrap.hidden = true;
|
||||
teardownCalendar();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!loadedOK) return;
|
||||
if (currentView === "calendar") {
|
||||
renderCalendarView();
|
||||
renderCalendar();
|
||||
} else {
|
||||
renderTable();
|
||||
}
|
||||
@@ -561,57 +554,135 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// toCalendarItem adapts an EventListItem to the canonical CalendarItem
|
||||
// shape consumed by mountCalendar (t-paliad-224). Bucketing date matches
|
||||
// the pre-refactor behaviour: deadlines bucket on due_date (fallback to
|
||||
// event_date); appointments bucket on start_at (fallback to event_date).
|
||||
function toCalendarItem(item: EventListItem): CalendarItem {
|
||||
let bucketDate: string;
|
||||
// itemDateISO returns the per-item bucketing date (YYYY-MM-DD) used when
|
||||
// plotting an event onto the calendar. Deadlines bucket on due_date;
|
||||
// appointments on start_at's local-date component.
|
||||
function itemDateISO(item: EventListItem): string {
|
||||
if (item.type === "deadline") {
|
||||
bucketDate = item.due_date ?? item.event_date;
|
||||
} else if (item.start_at) {
|
||||
bucketDate = item.start_at;
|
||||
} else {
|
||||
bucketDate = item.event_date;
|
||||
const src = item.due_date ?? item.event_date;
|
||||
return src.slice(0, 10);
|
||||
}
|
||||
return {
|
||||
kind: item.type,
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
event_date: bucketDate,
|
||||
project_id: item.project_id,
|
||||
project_title: item.project_title,
|
||||
project_reference: item.project_reference,
|
||||
};
|
||||
if (!item.start_at) return item.event_date.slice(0, 10);
|
||||
const d = new Date(item.start_at);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function renderCalendarView() {
|
||||
const host = document.getElementById("events-calendar-wrap");
|
||||
if (!host) return;
|
||||
function isoDate(year: number, month: number, day: number): string {
|
||||
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function fmtMonthYear(year: number, month: number): string {
|
||||
return `${tDyn(`cal.month.${month}`)} ${year}`;
|
||||
}
|
||||
|
||||
function calDotClass(item: EventListItem): string {
|
||||
// Per-item dot colour. Deadlines reuse the existing urgency palette;
|
||||
// appointments get their own colour so they're visually distinct from
|
||||
// deadlines on a mixed (Beides) calendar.
|
||||
if (item.type === "appointment") return "events-cal-dot-appointment";
|
||||
return urgencyClass(item).replace("frist-urgency-", "frist-urgency-");
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
const wrap = document.getElementById("events-calendar-wrap")!;
|
||||
const grid = document.getElementById("events-cal-grid")!;
|
||||
const empty = document.getElementById("events-cal-empty") as HTMLElement;
|
||||
const monthLabel = document.getElementById("events-cal-month-label")!;
|
||||
const tableEmpty = document.getElementById("events-empty")!;
|
||||
const tableEmptyFiltered = document.getElementById("events-empty-filtered")!;
|
||||
|
||||
// Calendar always renders the visible month from allItems, regardless of
|
||||
// pristine vs filtered state — empty calendar is allowed (the per-month
|
||||
// empty hint communicates "no items in this month" without confusing it
|
||||
// with the table-mode "no items at all" empty state).
|
||||
tableEmpty.style.display = "none";
|
||||
tableEmptyFiltered.style.display = "none";
|
||||
(host as HTMLElement).hidden = false;
|
||||
wrap.hidden = false;
|
||||
|
||||
const items = allItems.map(toCalendarItem);
|
||||
if (calendar) {
|
||||
calendar.update(items);
|
||||
return;
|
||||
monthLabel.textContent = fmtMonthYear(calYear, calMonth);
|
||||
|
||||
const firstDay = new Date(calYear, calMonth, 1);
|
||||
const jsWeekday = firstDay.getDay();
|
||||
const offset = (jsWeekday + 6) % 7;
|
||||
const daysInMonth = new Date(calYear, calMonth + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
|
||||
// Bucket items by ISO date once, so day-cell rendering is O(d) not O(d*n).
|
||||
const byDate = new Map<string, EventListItem[]>();
|
||||
for (const item of allItems) {
|
||||
const iso = itemDateISO(item);
|
||||
const list = byDate.get(iso);
|
||||
if (list) list.push(item);
|
||||
else byDate.set(iso, [item]);
|
||||
}
|
||||
// urlState=true: the Kalender tab persists its month/week/day + anchor
|
||||
// in ?cal_view + ?cal_date so a refresh / shared link lands on the same
|
||||
// calendar state (per t-paliad-224 §11 Q3 head decision).
|
||||
calendar = mountCalendar(host as HTMLElement, items, {
|
||||
urlState: true,
|
||||
defaultView: "month",
|
||||
|
||||
const cells: string[] = [];
|
||||
for (let i = 0; i < offset; i++) {
|
||||
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const iso = isoDate(calYear, calMonth, day);
|
||||
const items = byDate.get(iso) ?? [];
|
||||
const isToday = iso === todayISO;
|
||||
const dots = items
|
||||
.slice(0, 4)
|
||||
.map((it) => `<span class="frist-cal-dot ${calDotClass(it)}"></span>`)
|
||||
.join("");
|
||||
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
|
||||
cells.push(
|
||||
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
|
||||
<span class="frist-cal-day">${day}</span>
|
||||
<div class="frist-cal-dots">${dots}${more}</div>
|
||||
</div>`,
|
||||
);
|
||||
}
|
||||
grid.innerHTML = cells.join("");
|
||||
|
||||
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
|
||||
cell.addEventListener("click", () => openCalPopup(cell.dataset.iso!, byDate.get(cell.dataset.iso!) ?? []));
|
||||
});
|
||||
|
||||
const monthStart = isoDate(calYear, calMonth, 1);
|
||||
const monthEnd = isoDate(calYear, calMonth, daysInMonth);
|
||||
const hasInMonth = allItems.some((it) => {
|
||||
const iso = itemDateISO(it);
|
||||
return iso >= monthStart && iso <= monthEnd;
|
||||
});
|
||||
empty.hidden = hasInMonth;
|
||||
}
|
||||
|
||||
function teardownCalendar() {
|
||||
if (!calendar) return;
|
||||
calendar.destroy();
|
||||
calendar = null;
|
||||
function openCalPopup(iso: string, items: EventListItem[]) {
|
||||
if (items.length === 0) return;
|
||||
const popup = document.getElementById("events-cal-popup") as HTMLElement;
|
||||
const dateEl = document.getElementById("events-cal-popup-date")!;
|
||||
const list = document.getElementById("events-cal-popup-list")!;
|
||||
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
list.innerHTML = items
|
||||
.map((it) => {
|
||||
const cls = calDotClass(it);
|
||||
const href = it.type === "deadline" ? `/deadlines/${esc(it.id)}` : `/appointments/${esc(it.id)}`;
|
||||
const projectHref = it.project_id ? `/projects/${esc(it.project_id)}` : "";
|
||||
const projectLabel = it.project_reference ?? "";
|
||||
const projectCell = projectHref
|
||||
? `<a href="${projectHref}" class="frist-cal-popup-akte">${esc(projectLabel)}</a>`
|
||||
: "";
|
||||
return `<li class="frist-cal-popup-item">
|
||||
<span class="frist-cal-dot ${cls}"></span>
|
||||
<a href="${href}" class="frist-cal-popup-title">${rowTypeChip(it)} ${esc(it.title)}</a>
|
||||
${projectCell}
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
popup.style.display = "flex";
|
||||
}
|
||||
|
||||
function applyView() {
|
||||
@@ -632,18 +703,12 @@ function applyView() {
|
||||
// Cards view = the original layout (5-card summary + table).
|
||||
// List view = no summary cards, table only — gives more vertical space
|
||||
// and matches users' mental model of a flat list.
|
||||
// Calendar view = mountCalendar() canon (month/week/day); cards + table
|
||||
// both hidden. The handle is torn down when the user leaves Kalender
|
||||
// so its URL state isn't reapplied to other shapes.
|
||||
// Calendar view = month grid; cards + table both hidden.
|
||||
summary.style.display = currentView === "cards" ? "" : "none";
|
||||
tableWrap.style.display = currentView === "calendar" ? "none" : "";
|
||||
calWrap.hidden = currentView !== "calendar";
|
||||
|
||||
if (currentView === "calendar") {
|
||||
if (loadedOK) renderCalendarView();
|
||||
} else {
|
||||
teardownCalendar();
|
||||
}
|
||||
if (currentView === "calendar" && loadedOK) renderCalendar();
|
||||
}
|
||||
|
||||
function wireRowHandlers(tbody: HTMLElement) {
|
||||
@@ -945,10 +1010,12 @@ function initFilters() {
|
||||
}
|
||||
|
||||
function initView() {
|
||||
// Kalender state (view + anchor) lives inside mountCalendar; no
|
||||
// events-page-level wiring needed. The view chips below switch
|
||||
// between Karten / Liste / Kalender; applyView() handles the
|
||||
// mount + teardown.
|
||||
// Calendar always opens on the current month — month navigation is
|
||||
// local to the view (cheap pagination, doesn't refetch).
|
||||
const now = new Date();
|
||||
calYear = now.getFullYear();
|
||||
calMonth = now.getMonth();
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const next = btn.dataset.eventView as EventView;
|
||||
@@ -958,6 +1025,31 @@ function initView() {
|
||||
syncURLParams();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("events-cal-prev")?.addEventListener("click", () => {
|
||||
calMonth -= 1;
|
||||
if (calMonth < 0) { calMonth = 11; calYear -= 1; }
|
||||
renderCalendar();
|
||||
});
|
||||
document.getElementById("events-cal-next")?.addEventListener("click", () => {
|
||||
calMonth += 1;
|
||||
if (calMonth > 11) { calMonth = 0; calYear += 1; }
|
||||
renderCalendar();
|
||||
});
|
||||
document.getElementById("events-cal-today")?.addEventListener("click", () => {
|
||||
const t = new Date();
|
||||
calYear = t.getFullYear();
|
||||
calMonth = t.getMonth();
|
||||
renderCalendar();
|
||||
});
|
||||
|
||||
const popup = document.getElementById("events-cal-popup") as HTMLElement;
|
||||
document.getElementById("events-cal-popup-close")?.addEventListener("click", () => {
|
||||
popup.style.display = "none";
|
||||
});
|
||||
popup?.addEventListener("click", (e) => {
|
||||
if (e.target === popup) popup.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function initSummaryCards() {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// 3-step wizard: select proceeding -> enter date -> view timeline
|
||||
//
|
||||
// Rendering primitives (renderTimelineBody / renderColumnsBody /
|
||||
// deadlineCardHtml / formatDate / partyBadge / court picker / inline
|
||||
// date editor) live in `./views/verfahrensablauf-core` and are shared
|
||||
// with /tools/verfahrensablauf. This module owns the Step1/2/3a
|
||||
// wizard, Pathway A/B, Akte save flow — none of which Verfahrensablauf
|
||||
// wants.
|
||||
// deadlineCardHtml / formatDate / partyBadge / court picker) live in
|
||||
// `./views/verfahrensablauf-core` and are shared with the
|
||||
// /tools/verfahrensablauf page (t-paliad-179 Slice 1). This module owns
|
||||
// the Step1/2/3a wizard, Pathway A/B, Akte save flow, anchor-override
|
||||
// click-to-edit — none of which Verfahrensablauf wants.
|
||||
|
||||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
priorityRendering,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
wireDateEditClicks,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
@@ -187,21 +186,12 @@ interface ProjectOption {
|
||||
// (Slice 3b) can scope the cascade by the project's jurisdiction
|
||||
// without an extra fetch.
|
||||
proceeding_type_id?: number | null;
|
||||
// our_side carries which side the firm represents on this case
|
||||
// project (Client Role; t-paliad-164, widened in t-paliad-222).
|
||||
// When a user selects an Akte, the perspective chip pre-locks via
|
||||
// ourSideToPerspective(); a small hint above the strip flags the
|
||||
// our_side carries which side the firm represents on this project
|
||||
// (t-paliad-164). When a user selects an Akte, the perspective chip
|
||||
// pre-locks to this value; a small hint above the strip flags the
|
||||
// pre-selection and the user can still click another chip to
|
||||
// override. NULL/undefined leaves the chip unset (free-pick).
|
||||
our_side?:
|
||||
| "claimant"
|
||||
| "defendant"
|
||||
| "applicant"
|
||||
| "appellant"
|
||||
| "respondent"
|
||||
| "third_party"
|
||||
| "other"
|
||||
| null;
|
||||
our_side?: "claimant" | "defendant" | "court" | "both" | null;
|
||||
}
|
||||
|
||||
async function fetchProjects(): Promise<ProjectOption[]> {
|
||||
@@ -260,19 +250,6 @@ function closeSaveModal() {
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
// preselectedProjectId returns the project the user picked in Step 1
|
||||
// (if any) so the various save/add flows can default their project
|
||||
// pickers to it. Carries through anywhere a "save to Akte" pop-out
|
||||
// renders \u2014 preselection is *only* a default; the picker still
|
||||
// renders every available project and the user can override.
|
||||
// m/paliad#57 part 1: 2026-05-20 user complaint \u2014 "the pre-selected
|
||||
// project should be pre-selected" on Add.
|
||||
function preselectedProjectId(): string {
|
||||
return currentStep1Context.kind === "project" && currentStep1Context.projectId
|
||||
? currentStep1Context.projectId
|
||||
: "";
|
||||
}
|
||||
|
||||
async function openSaveModal() {
|
||||
if (!lastResponse) return;
|
||||
ensureSaveModal();
|
||||
@@ -289,7 +266,6 @@ async function openSaveModal() {
|
||||
sel.style.display = "";
|
||||
noProjects.style.display = "none";
|
||||
submit.disabled = false;
|
||||
const preselected = preselectedProjectId();
|
||||
sel.innerHTML = projects
|
||||
.map((p) => {
|
||||
const ref = (p.reference || "").trim();
|
||||
@@ -297,11 +273,9 @@ async function openSaveModal() {
|
||||
const label = ref
|
||||
? `${indent}${escHtml(ref)} \u2014 ${escHtml(p.title)}`
|
||||
: `${indent}${escHtml(p.title)}`;
|
||||
const selected = p.id === preselected ? " selected" : "";
|
||||
return `<option value="${escAttr(p.id)}"${selected}>${label}</option>`;
|
||||
return `<option value="${escAttr(p.id)}">${label}</option>`;
|
||||
})
|
||||
.join("");
|
||||
if (preselected) sel.value = preselected;
|
||||
}
|
||||
|
||||
const list = document.getElementById("frist-save-list")!;
|
||||
@@ -456,21 +430,54 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
applyPendingFocus();
|
||||
}
|
||||
|
||||
// onDateEditCommit is the click-to-edit callback handed to the shared
|
||||
// wireDateEditClicks() helper: persist the per-rule override (empty value
|
||||
// clears it) then recompute so downstream rules re-anchor.
|
||||
function onDateEditCommit(ruleCode: string, newValue: string) {
|
||||
if (newValue === "") {
|
||||
anchorOverrides.delete(ruleCode);
|
||||
} else {
|
||||
anchorOverrides.set(ruleCode, newValue);
|
||||
}
|
||||
void calculate();
|
||||
// openInlineDateEditor swaps the date span for a date input. On commit
|
||||
// (blur or Enter), the override is recorded and the timeline re-fetched.
|
||||
// On Escape, the editor closes without changing anything. An empty
|
||||
// commit clears the override (lets the user revert to the calculated
|
||||
// date or to the IsCourtSet placeholder).
|
||||
function openInlineDateEditor(span: HTMLElement) {
|
||||
const ruleCode = span.dataset.ruleCode!;
|
||||
const current = span.dataset.currentDate || anchorOverrides.get(ruleCode) || "";
|
||||
const editor = document.createElement("input");
|
||||
editor.type = "date";
|
||||
editor.className = "frist-date-edit-input";
|
||||
editor.value = current;
|
||||
|
||||
const commit = (newValue: string) => {
|
||||
if (newValue === "") {
|
||||
anchorOverrides.delete(ruleCode);
|
||||
} else {
|
||||
anchorOverrides.set(ruleCode, newValue);
|
||||
}
|
||||
void calculate();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
editor.replaceWith(span);
|
||||
};
|
||||
|
||||
editor.addEventListener("blur", () => {
|
||||
if (editor.value !== current) commit(editor.value);
|
||||
else cancel();
|
||||
});
|
||||
editor.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key === "Enter") {
|
||||
e.preventDefault();
|
||||
editor.blur();
|
||||
} else if (ke.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
|
||||
span.replaceWith(editor);
|
||||
editor.focus();
|
||||
if (editor.value) editor.select();
|
||||
}
|
||||
|
||||
// deadlineCardHtml / renderTimelineBody / renderColumnsBody /
|
||||
// openInlineDateEditor / wireDateEditClicks moved to
|
||||
// ./views/verfahrensablauf-core.
|
||||
// deadlineCardHtml / renderTimelineBody / renderColumnsBody moved to
|
||||
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
|
||||
|
||||
function reset() {
|
||||
selectedType = "";
|
||||
@@ -641,7 +648,21 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
// rules re-anchor on the user's date. Delegated on the container so
|
||||
// it survives renderProcedureResults() innerHTML rewrites.
|
||||
const timelineContainer = document.getElementById("timeline-container");
|
||||
if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit);
|
||||
if (timelineContainer) {
|
||||
timelineContainer.addEventListener("click", (e) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
openInlineDateEditor(target);
|
||||
});
|
||||
timelineContainer.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key !== "Enter" && ke.key !== " ") return;
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
e.preventDefault();
|
||||
openInlineDateEditor(target);
|
||||
});
|
||||
}
|
||||
|
||||
// Reset button
|
||||
document.getElementById("reset-btn")!.addEventListener("click", reset);
|
||||
@@ -1285,27 +1306,19 @@ function expandCardCalc(card: HTMLElement, autoSelectPill: HTMLElement | null) {
|
||||
card.classList.add("is-expanded");
|
||||
card.setAttribute("aria-expanded", "true");
|
||||
|
||||
// m/paliad#57 part 4: when the user clicked a specific rule pill, the
|
||||
// context is already known — the calc panel renders with that pill
|
||||
// locked in and no "Which context?" picker. The card's pill list is
|
||||
// hidden via CSS while is-expanded so the rules aren't listed twice.
|
||||
// When the user clicked the card body (no autoSelectPill), the picker
|
||||
// is the primary surface — still no duplicate pill list above it.
|
||||
const lockedPill = (autoSelectPill && autoSelectPill.dataset.kind === "rule")
|
||||
? rulePills.find((p) =>
|
||||
p.proceeding?.code === autoSelectPill.dataset.proc
|
||||
&& (autoSelectPill.dataset.focus
|
||||
? p.rule_local_code === autoSelectPill.dataset.focus
|
||||
: true))
|
||||
: undefined;
|
||||
|
||||
const panel = buildCalcPanel(cardData, rulePills, lockedPill || null);
|
||||
const panel = buildCalcPanel(cardData, rulePills);
|
||||
card.appendChild(panel);
|
||||
|
||||
// Auto-select the clicked pill if it's a rule pill; otherwise the
|
||||
// first pill is preselected by buildCalcPanel.
|
||||
if (autoSelectPill && autoSelectPill.dataset.kind === "rule") {
|
||||
selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus);
|
||||
}
|
||||
|
||||
scheduleCardCalc(card);
|
||||
}
|
||||
|
||||
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPill: SearchPill | null = null): HTMLElement {
|
||||
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): HTMLElement {
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "fristen-card-calc";
|
||||
// stopPropagation so clicks inside the panel don't bubble to the
|
||||
@@ -1316,38 +1329,10 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPi
|
||||
const lang = getLang();
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
// Picker semantics (m/paliad#57 part 4):
|
||||
// - lockedPill set → context known (user clicked a specific
|
||||
// rule pill on the card). Render as a
|
||||
// hidden input only; the calc panel shows
|
||||
// no "Which context?" question. A small
|
||||
// "ändern" link reopens the picker fieldset.
|
||||
// - rulePills.length <= 1 → only one possible context, never a
|
||||
// picker (hidden input carries the data).
|
||||
// - otherwise → show the picker as primary surface; the
|
||||
// card's pill list is hidden via CSS while
|
||||
// the panel is open, so the user isn't
|
||||
// asked the same thing twice.
|
||||
let pickerHtml: string;
|
||||
if (lockedPill) {
|
||||
const procName = lockedPill.proceeding
|
||||
? (lang === "en" && lockedPill.proceeding.name_en ? lockedPill.proceeding.name_en : lockedPill.proceeding.name_de)
|
||||
: "";
|
||||
const ruleName = lang === "en" && lockedPill.rule_name_en ? lockedPill.rule_name_en : lockedPill.rule_name_de;
|
||||
const src = lockedPill.legal_source_display || lockedPill.legal_source || "";
|
||||
const reopenLabel = t("deadlines.card.calc.pill_picker.change");
|
||||
pickerHtml = `<div class="fristen-card-calc-pill-locked">
|
||||
<span class="fristen-card-calc-pill-locked-label">${escHtml(t("deadlines.card.calc.pill_picker.locked_label"))}</span>
|
||||
<span class="fristen-card-calc-pill-locked-proc">${escHtml(procName)}</span>
|
||||
<span class="fristen-card-calc-pill-locked-rule">${escHtml(ruleName)}</span>
|
||||
${src ? `<span class="fristen-card-calc-pill-locked-source">${escHtml(src)}</span>` : ""}
|
||||
${rulePills.length > 1 ? `<button type="button" class="fristen-card-calc-pill-change">${escHtml(reopenLabel)}</button>` : ""}
|
||||
<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(lockedPill.proceeding?.code || "")}" data-focus="${escAttr(lockedPill.rule_local_code || "")}" />
|
||||
</div>`;
|
||||
} else if (rulePills.length <= 1) {
|
||||
pickerHtml = `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`;
|
||||
} else {
|
||||
pickerHtml = `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
|
||||
// Pill picker (only when >1 rule pill).
|
||||
const pickerHtml = rulePills.length <= 1
|
||||
? `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`
|
||||
: `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
|
||||
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
|
||||
${rulePills.map((p, i) => {
|
||||
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
|
||||
@@ -1361,7 +1346,6 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPi
|
||||
</label>`;
|
||||
}).join("")}
|
||||
</fieldset>`;
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
<button type="button" class="fristen-card-calc-close" aria-label="${escAttr(t("deadlines.card.calc.close"))}">×</button>
|
||||
@@ -1414,38 +1398,6 @@ function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPi
|
||||
void addCalcToProject(card, last);
|
||||
});
|
||||
|
||||
// "ändern" — swap the locked-context caption for the full radio
|
||||
// picker so the user can change context without collapsing the panel.
|
||||
panel.querySelector<HTMLButtonElement>(".fristen-card-calc-pill-change")?.addEventListener("click", () => {
|
||||
const card = panel.closest<HTMLElement>(".fristen-card");
|
||||
const locked = panel.querySelector<HTMLElement>(".fristen-card-calc-pill-locked");
|
||||
if (!card || !locked) return;
|
||||
const fieldset = document.createElement("fieldset");
|
||||
fieldset.className = "fristen-card-calc-pill-picker";
|
||||
fieldset.setAttribute("role", "radiogroup");
|
||||
const lockedProc = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.proc || "";
|
||||
const lockedFocus = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.focus || "";
|
||||
fieldset.innerHTML = `
|
||||
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
|
||||
${rulePills.map((p, i) => {
|
||||
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
|
||||
const ruleName = lang === "en" && p.rule_name_en ? p.rule_name_en : p.rule_name_de;
|
||||
const src = p.legal_source_display || p.legal_source || "";
|
||||
const isChecked = (p.proceeding?.code || "") === lockedProc
|
||||
&& (p.rule_local_code || "") === lockedFocus;
|
||||
return `<label class="fristen-card-calc-pill-option">
|
||||
<input type="radio" name="fristen-card-calc-pill" value="${i}" ${isChecked ? "checked" : ""} data-proc="${escAttr(p.proceeding?.code || "")}" data-focus="${escAttr(p.rule_local_code || "")}" />
|
||||
<span class="fristen-card-calc-pill-option-proc">${escHtml(procName)}</span>
|
||||
<span class="fristen-card-calc-pill-option-rule">${escHtml(ruleName)}</span>
|
||||
${src ? `<span class="fristen-card-calc-pill-option-source">${escHtml(src)}</span>` : ""}
|
||||
</label>`;
|
||||
}).join("")}`;
|
||||
locked.replaceWith(fieldset);
|
||||
fieldset.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]').forEach((r) => {
|
||||
r.addEventListener("change", () => scheduleCardCalc(card, 0));
|
||||
});
|
||||
});
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
@@ -1649,7 +1601,6 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
|
||||
const lang = getLang();
|
||||
const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
|
||||
const dueLabel = formatDate(calc.dueDate);
|
||||
const preselected = preselectedProjectId();
|
||||
msgEl.innerHTML = `
|
||||
<div class="fristen-card-calc-add-picker">
|
||||
<label class="fristen-card-calc-label">${escHtml(t("deadlines.save.modal.akte"))}
|
||||
@@ -1658,8 +1609,7 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
|
||||
const ref = (p.reference || "").trim();
|
||||
const indent = projectIndent(p.path);
|
||||
const label = ref ? `${indent}${ref} — ${p.title}` : `${indent}${p.title}`;
|
||||
const selected = p.id === preselected ? " selected" : "";
|
||||
return `<option value="${escAttr(p.id)}"${selected}>${escHtml(label)}</option>`;
|
||||
return `<option value="${escAttr(p.id)}">${escHtml(label)}</option>`;
|
||||
}).join("")}
|
||||
</select>
|
||||
</label>
|
||||
@@ -1669,7 +1619,6 @@ async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
|
||||
`;
|
||||
|
||||
const sel = msgEl.querySelector<HTMLSelectElement>(".fristen-card-calc-add-select")!;
|
||||
if (preselected) sel.value = preselected;
|
||||
msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-cancel")!.addEventListener("click", () => {
|
||||
msgEl.innerHTML = "";
|
||||
addBtn.disabled = false;
|
||||
@@ -1739,12 +1688,12 @@ function renderConceptCard(card: SearchCard, lang: "de" | "en"): string {
|
||||
const triggerPills = card.pills.filter((p) => p.kind === "trigger");
|
||||
|
||||
const ruleSection = rulePills.length === 0 ? "" : `
|
||||
<div class="fristen-card-pills-section fristen-card-pills-section--rules">
|
||||
<div class="fristen-card-pills-section">
|
||||
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.heading"))}</h4>
|
||||
<div class="fristen-card-pills">${rulePills.map((p) => renderPill(p, lang)).join("")}</div>
|
||||
</div>`;
|
||||
const triggerSection = triggerPills.length === 0 ? "" : `
|
||||
<div class="fristen-card-pills-section fristen-card-pills-section--cross">
|
||||
<div class="fristen-card-pills-section">
|
||||
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.cross_cutting"))}</h4>
|
||||
<div class="fristen-card-pills">${triggerPills.map((p) => renderPill(p, lang)).join("")}</div>
|
||||
</div>`;
|
||||
@@ -2520,17 +2469,6 @@ interface EventCategoryNode {
|
||||
let eventCategoryTree: EventCategoryNode[] | null = null;
|
||||
let eventCategoryFetchInflight: Promise<EventCategoryNode[]> | null = null;
|
||||
|
||||
// Top-level cascade roots that represent forward-looking workflows ("I
|
||||
// want to file X, what deadlines does my action trigger?") rather than
|
||||
// the backward-looking calc the Fristenrechner is built for ("event Y
|
||||
// happened, what deadlines spawn?"). m's 2026-05-20 ask (m/paliad#57):
|
||||
// remove these from the "Was ist passiert?" picker — they belong in a
|
||||
// future forward-workflow tool, not here. The DB rows stay so that
|
||||
// future tool can pick them back up; we just hide them at the UI layer.
|
||||
const HIDDEN_CASCADE_ROOTS: ReadonlySet<string> = new Set([
|
||||
"ich-moechte-einreichen",
|
||||
]);
|
||||
|
||||
async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
|
||||
if (eventCategoryTree) return eventCategoryTree;
|
||||
if (eventCategoryFetchInflight) return eventCategoryFetchInflight;
|
||||
@@ -2539,8 +2477,7 @@ async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
|
||||
const r = await fetch("/api/tools/fristenrechner/event-categories");
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data = await r.json();
|
||||
const raw = (data.tree || []) as EventCategoryNode[];
|
||||
eventCategoryTree = raw.filter((n) => !HIDDEN_CASCADE_ROOTS.has(n.slug));
|
||||
eventCategoryTree = (data.tree || []) as EventCategoryNode[];
|
||||
return eventCategoryTree;
|
||||
} finally {
|
||||
eventCategoryFetchInflight = null;
|
||||
@@ -3810,30 +3747,14 @@ function applyPerspective(p: Perspective) {
|
||||
triggerCascadeRefresh();
|
||||
}
|
||||
|
||||
// ourSideToPerspective maps the project-level "Client Role" enum
|
||||
// (DB column: our_side) onto the chip-strip Perspective.
|
||||
//
|
||||
// Per t-paliad-222 (m/paliad#47) the field carries one of seven
|
||||
// sub-role values grouped at display time:
|
||||
// Active (we initiate) : claimant, applicant, appellant → "claimant"
|
||||
// Reactive (we defend) : defendant, respondent → "defendant"
|
||||
// Other : third_party, other, NULL → null
|
||||
//
|
||||
// Legacy 'court' / 'both' values no longer exist in the column
|
||||
// (mig 110 backfilled them to NULL); both fall through to the null
|
||||
// default arm if a stale value sneaks in.
|
||||
// ourSideToPerspective maps the project-level "Wir vertreten" enum
|
||||
// onto the chip-strip Perspective. 'court' / 'both' map to null
|
||||
// (chip cleared) — court actions are neutral to the user's side and
|
||||
// "both" is explicit no-filter intent.
|
||||
function ourSideToPerspective(os: string | null | undefined): Perspective {
|
||||
switch (os) {
|
||||
case "claimant":
|
||||
case "applicant":
|
||||
case "appellant":
|
||||
return "claimant";
|
||||
case "defendant":
|
||||
case "respondent":
|
||||
return "defendant";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
if (os === "claimant") return "claimant";
|
||||
if (os === "defendant") return "defendant";
|
||||
return null;
|
||||
}
|
||||
|
||||
// applyOurSidePredefine locks the perspective from project.our_side
|
||||
|
||||
@@ -272,10 +272,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.step1.divider.new": "oder eine neue Akte",
|
||||
"deadlines.step1.divider.adhoc": "oder ad-hoc, ohne Akte",
|
||||
"deadlines.step1.new.cta": "+ Neue Akte anlegen",
|
||||
"deadlines.step1.adhoc.upc": "UPC-Verfahren",
|
||||
"deadlines.step1.adhoc.de": "DE-Verfahren",
|
||||
"deadlines.step1.adhoc.epa": "EPA-Verfahren",
|
||||
"deadlines.step1.adhoc.dpma": "DPMA-Verfahren",
|
||||
"deadlines.step1.adhoc.upc": "Custom UPC-Verfahren",
|
||||
"deadlines.step1.adhoc.de": "Custom DE-Verfahren",
|
||||
"deadlines.step1.adhoc.epa": "Custom EPA-Verfahren",
|
||||
"deadlines.step1.adhoc.dpma": "Custom DPMA-Verfahren",
|
||||
"deadlines.step1.selected": "Akte:",
|
||||
"deadlines.step1.reselect": "Andere Akte",
|
||||
"deadlines.step1.summary.adhoc.suffix": "ohne Akte (Erkundung)",
|
||||
@@ -345,8 +345,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.card.calc.expand_hint": "Frist berechnen oder zu Akte hinzufügen",
|
||||
"deadlines.card.calc.close": "schließen",
|
||||
"deadlines.card.calc.pill_picker.label": "Welcher Kontext?",
|
||||
"deadlines.card.calc.pill_picker.locked_label": "Kontext:",
|
||||
"deadlines.card.calc.pill_picker.change": "ändern",
|
||||
"deadlines.card.calc.trigger.label": "Datum des auslösenden Ereignisses",
|
||||
"deadlines.card.calc.flags.label": "Bedingungen:",
|
||||
"deadlines.card.calc.flag.with_ccr": "Mit Nichtigkeitswiderklage",
|
||||
@@ -555,101 +553,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"checklisten.heading": "Checklisten",
|
||||
"checklisten.subtitle": "Interaktive Checklisten f\u00fcr typische Verfahrensschritte vor UPC, BPatG und EPA. Abhaken, ausdrucken, kein Punkt vergessen.",
|
||||
"checklisten.tab.templates": "Vorlagen",
|
||||
"checklisten.tab.mine": "Meine Vorlagen",
|
||||
"checklisten.tab.instances": "Vorhandene Instanzen",
|
||||
"checklisten.mine.empty": "Sie haben noch keine eigene Vorlage angelegt.",
|
||||
"checklisten.tab.gallery": "Geteilte Vorlagen",
|
||||
"checklisten.gallery.empty": "Noch keine geteilten Vorlagen sichtbar.",
|
||||
"checklisten.filter.other": "Sonstige",
|
||||
"checklisten.instance.outdated.badge": "Vorlage aktualisiert",
|
||||
"checklisten.instance.outdated.note": "Die zugrundeliegende Vorlage wurde seit dem Anlegen dieser Instanz aktualisiert (v{from} → v{to}).",
|
||||
"checklisten.instance.outdated.diff": "Änderungen anzeigen",
|
||||
"checklisten.instance.diff.title": "Geänderte Punkte",
|
||||
"checklisten.instance.diff.close": "Schließen",
|
||||
"checklisten.instance.diff.added": "Neu",
|
||||
"checklisten.instance.diff.removed": "Entfernt",
|
||||
"checklisten.instance.diff.changed": "Geändert",
|
||||
"checklisten.instance.diff.empty": "Keine inhaltlichen Unterschiede in den Punkten.",
|
||||
"checklisten.instance.diff.error": "Vergleich fehlgeschlagen.",
|
||||
"checklisten.mine.new": "Neue Vorlage",
|
||||
"checklisten.mine.loading": "Lädt…",
|
||||
"checklisten.mine.visibility.private": "Privat",
|
||||
"checklisten.mine.visibility.firm": "Firmenweit",
|
||||
"checklisten.mine.visibility.shared": "Geteilt",
|
||||
"checklisten.mine.visibility.global": "Im Katalog",
|
||||
"checklisten.mine.edit": "Bearbeiten",
|
||||
"checklisten.mine.delete": "Löschen",
|
||||
"checklisten.mine.delete.confirm": "Vorlage „{title}“ wirklich löschen? Bestehende Instanzen bleiben erhalten.",
|
||||
"checklisten.mine.delete.error": "Löschen fehlgeschlagen.",
|
||||
"checklisten.mine.origin.authored": "Eigene Vorlage",
|
||||
"checklisten.author.title": "Vorlage erstellen — Paliad",
|
||||
"checklisten.author.title.edit": "Vorlage bearbeiten — Paliad",
|
||||
"checklisten.author.heading.new": "Neue Checklisten-Vorlage",
|
||||
"checklisten.author.heading.edit": "Vorlage bearbeiten",
|
||||
"checklisten.author.subtitle": "Erstellen Sie eine eigene Checkliste mit Sektionen und Punkten. Sie können sie privat halten oder firmenweit verfügbar machen.",
|
||||
"checklisten.author.field.title": "Titel",
|
||||
"checklisten.author.field.title.hint": "z.B. „UPC SoC — interne Checkliste“.",
|
||||
"checklisten.author.field.description": "Kurzbeschreibung",
|
||||
"checklisten.author.field.regime": "Regime",
|
||||
"checklisten.author.field.court": "Gericht / Behörde",
|
||||
"checklisten.author.field.reference": "Rechtsgrundlage",
|
||||
"checklisten.author.field.deadline": "Deadline (optional)",
|
||||
"checklisten.author.field.lang": "Sprache",
|
||||
"checklisten.author.field.visibility": "Sichtbarkeit",
|
||||
"checklisten.author.visibility.private.hint": "Nur für Sie sichtbar.",
|
||||
"checklisten.author.visibility.firm.hint": "Für alle angemeldeten Kolleginnen und Kollegen sichtbar.",
|
||||
"checklisten.author.groups.heading": "Sektionen und Punkte",
|
||||
"checklisten.author.groups.add": "+ Sektion hinzufügen",
|
||||
"checklisten.author.group.title": "Sektionsname",
|
||||
"checklisten.author.group.remove": "Sektion löschen",
|
||||
"checklisten.author.item.add": "+ Punkt hinzufügen",
|
||||
"checklisten.author.item.label": "Punkt",
|
||||
"checklisten.author.item.note": "Notiz (optional)",
|
||||
"checklisten.author.item.rule": "Vorschrift (optional)",
|
||||
"checklisten.author.item.remove": "Punkt löschen",
|
||||
"checklisten.author.save": "Speichern",
|
||||
"checklisten.author.cancel": "Abbrechen",
|
||||
"checklisten.author.saving": "Speichert…",
|
||||
"checklisten.author.error.title": "Bitte geben Sie einen Titel ein.",
|
||||
"checklisten.author.error.no_groups": "Bitte mindestens eine Sektion mit einem Punkt anlegen.",
|
||||
"checklisten.author.error.generic": "Speichern fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"checklisten.author.error.notfound": "Diese Vorlage existiert nicht oder Sie haben keine Berechtigung sie zu bearbeiten.",
|
||||
"checklisten.detail.edit": "Bearbeiten",
|
||||
"checklisten.detail.delete": "Löschen",
|
||||
"checklisten.detail.share": "Teilen",
|
||||
"checklisten.detail.promote": "Als Firmen-Vorlage hinterlegen",
|
||||
"checklisten.detail.demote": "Aus Katalog entfernen",
|
||||
"checklisten.detail.promote.confirm": "Diese Vorlage in den Firmen-Katalog übernehmen? Alle Kolleg:innen sehen sie dann unter Vorlagen.",
|
||||
"checklisten.detail.demote.confirm": "Vorlage aus dem Firmen-Katalog entfernen? Sie bleibt firmenweit sichtbar.",
|
||||
"checklisten.detail.promote.error": "Übernahme fehlgeschlagen.",
|
||||
"checklisten.detail.delete.confirm": "Vorlage „{title}\" wirklich löschen? Bestehende Instanzen bleiben erhalten.",
|
||||
"checklisten.detail.delete.error": "Löschen fehlgeschlagen.",
|
||||
"checklisten.detail.authored.by": "Erstellt von {author}",
|
||||
"checklisten.detail.visibility": "Sichtbarkeit: {state}",
|
||||
"checklisten.detail.visibility.set.firm": "Für Firma freigeben",
|
||||
"checklisten.detail.visibility.set.private": "Privat schalten",
|
||||
"checklisten.detail.visibility.error": "Sichtbarkeit konnte nicht geändert werden.",
|
||||
"checklisten.share.title": "Vorlage teilen",
|
||||
"checklisten.share.kind": "Empfängertyp",
|
||||
"checklisten.share.kind.user": "Kollege",
|
||||
"checklisten.share.kind.office": "Office",
|
||||
"checklisten.share.kind.partner_unit": "Dezernat",
|
||||
"checklisten.share.kind.project": "Projekt",
|
||||
"checklisten.share.pick": "— auswählen —",
|
||||
"checklisten.share.submit": "Freigeben",
|
||||
"checklisten.share.cancel": "Abbrechen",
|
||||
"checklisten.share.error.pick": "Bitte einen Empfänger auswählen.",
|
||||
"checklisten.share.error.generic": "Freigeben fehlgeschlagen.",
|
||||
"checklisten.share.success": "Freigegeben.",
|
||||
"checklisten.share.grants.heading": "Bestehende Freigaben",
|
||||
"checklisten.share.grants.empty": "Keine Freigaben.",
|
||||
"checklisten.share.grants.revoke": "Entfernen",
|
||||
"checklisten.share.grants.revoke.confirm": "Freigabe entfernen?",
|
||||
"checklisten.share.grants.revoke.error": "Entfernen fehlgeschlagen.",
|
||||
"checklisten.share.grants.recipient.user": "Kollege",
|
||||
"checklisten.share.grants.recipient.office": "Office",
|
||||
"checklisten.share.grants.recipient.partner_unit": "Dezernat",
|
||||
"checklisten.share.grants.recipient.project": "Projekt",
|
||||
"checklisten.instances.all.loading": "L\u00e4dt\u2026",
|
||||
"checklisten.instances.all.empty": "Noch keine Checklisten-Instanzen erfasst. Legen Sie eine \u00fcber den Vorlagen-Tab an.",
|
||||
"checklisten.instances.all.col.template": "Vorlage",
|
||||
@@ -788,6 +692,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.list.heading": "Fristen",
|
||||
"deadlines.list.subtitle": "Persistente Fristen f\u00fcr Ihre Akten. \u00dcberf\u00e4llig, heute, diese Woche, n\u00e4chste Woche \u2014 auf einen Blick.",
|
||||
"deadlines.list.new": "Neue Frist",
|
||||
"deadlines.list.calendar": "Kalenderansicht",
|
||||
"deadlines.summary.overdue": "\u00dcberf\u00e4llig",
|
||||
"deadlines.summary.today": "Heute",
|
||||
"deadlines.summary.thisweek": "Diese Woche",
|
||||
@@ -910,6 +815,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.source.caldav": "CalDAV",
|
||||
"deadlines.source.imported": "Import",
|
||||
|
||||
"deadlines.kalender.title": "Fristenkalender \u2014 Paliad",
|
||||
"deadlines.kalender.heading": "Fristenkalender",
|
||||
"deadlines.kalender.subtitle": "Monats\u00fcbersicht aller Fristen Ihrer Akten.",
|
||||
"deadlines.kalender.list": "Listenansicht",
|
||||
"deadlines.kalender.today": "Heute",
|
||||
"deadlines.kalender.empty": "Keine Fristen im ausgew\u00e4hlten Zeitraum.",
|
||||
"cal.day.mon": "Mo",
|
||||
"cal.day.tue": "Di",
|
||||
"cal.day.wed": "Mi",
|
||||
@@ -1000,56 +911,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dashboard.agenda.heading": "Agenda",
|
||||
"dashboard.agenda.empty": "Keine F\u00e4lligkeiten in den n\u00e4chsten 30 Tagen.",
|
||||
"dashboard.agenda.full_link": "Vollst\u00e4ndige Agenda \u00f6ffnen \u2192",
|
||||
// Inbox-approvals widget (t-paliad-219).
|
||||
"dashboard.inbox.heading": "Offene Freigaben",
|
||||
"dashboard.inbox.empty": "Keine offenen Freigaben.",
|
||||
"dashboard.inbox.full_link": "Vollst\u00e4ndigen Posteingang \u00f6ffnen \u2192",
|
||||
"dashboard.inbox.entity.deadline": "Frist",
|
||||
"dashboard.inbox.entity.appointment": "Termin",
|
||||
// Edit-mode chrome (t-paliad-219 Slice B). The toggle in the
|
||||
// dashboard header flips body.dashboard-editing; the keys below
|
||||
// power the in-page chrome (drag handle, \u2191/\u2193, hide, gear, picker,
|
||||
// reset) plus the autosave toast.
|
||||
"dashboard.edit.toggle": "Anpassen",
|
||||
"dashboard.edit.exit": "Fertig",
|
||||
"dashboard.edit.add_widget": "Widget hinzuf\u00fcgen",
|
||||
"dashboard.edit.reset": "Auf Standard zur\u00fccksetzen",
|
||||
"dashboard.edit.reset_confirm": "Layout auf Standard zur\u00fccksetzen? Diese Aktion kann nicht r\u00fcckg\u00e4ngig gemacht werden.",
|
||||
// Slice C: admin promote \u2014 visible only when global_role==global_admin.
|
||||
"dashboard.edit.promote": "Als Firmen-Standard speichern",
|
||||
"dashboard.edit.promote_confirm": "Dein aktuelles Layout als Firmen-Standard speichern? Neue Nutzer:innen und 'Auf Standard zur\u00fccksetzen' verwenden danach diese Vorlage.",
|
||||
"dashboard.edit.promoted": "Als Firmen-Standard gespeichert",
|
||||
// Slice C: pinned-projects widget (reuses PinService).
|
||||
"dashboard.pinned.heading": "Angepinnte Akten",
|
||||
"dashboard.pinned.empty": "Noch keine Akten angepinnt.",
|
||||
"dashboard.pinned.full_link": "Alle Akten \u00f6ffnen \u2192",
|
||||
// Slice C: quick-actions widget \u2014 pure UI affordances.
|
||||
"dashboard.quick.heading": "Schnellzugriff",
|
||||
"dashboard.quick.new_project": "+ Akte",
|
||||
"dashboard.quick.new_deadline": "+ Frist",
|
||||
"dashboard.quick.new_appointment": "+ Termin",
|
||||
"dashboard.edit.move_up": "Nach oben bewegen",
|
||||
"dashboard.edit.move_down": "Nach unten bewegen",
|
||||
"dashboard.edit.hide": "Ausblenden",
|
||||
"dashboard.edit.settings": "Einstellungen",
|
||||
"dashboard.edit.drag": "Ziehen, um neu zu ordnen",
|
||||
"dashboard.edit.saved": "Gespeichert",
|
||||
"dashboard.edit.save_failed": "Speichern fehlgeschlagen",
|
||||
"dashboard.edit.setting.count": "Anzahl",
|
||||
"dashboard.edit.setting.count.custom": "Eigener Wert (max. {n})",
|
||||
"dashboard.edit.setting.horizon": "Zeitraum",
|
||||
"dashboard.edit.setting.horizon.days": "{n} Tage",
|
||||
"dashboard.edit.setting.horizon.custom": "Eigener Wert in Tagen (max. {n})",
|
||||
"dashboard.edit.setting.view": "Ansicht",
|
||||
"dashboard.edit.setting.size": "Größe",
|
||||
"dashboard.edit.setting.position": "Position",
|
||||
"dashboard.edit.resize": "Größe ändern",
|
||||
"dashboard.picker.title": "Widget hinzuf\u00fcgen",
|
||||
"dashboard.picker.status.active": "Aktiv",
|
||||
"dashboard.picker.status.hidden": "Versteckt",
|
||||
"dashboard.picker.status.absent": "Nicht hinzugef\u00fcgt",
|
||||
"dashboard.picker.close": "Schlie\u00dfen",
|
||||
"dashboard.picker.empty": "Alle Widgets sind hinzugef\u00fcgt.",
|
||||
// Collapsible-section toggle a11y labels (t-paliad-162). Both states
|
||||
// are needed because the aria-label flips with the expanded state.
|
||||
"dashboard.section.collapse": "Abschnitt einklappen",
|
||||
@@ -1341,30 +1202,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.field.our_side.unset": "Unbekannt / nicht gesetzt",
|
||||
"projects.field.our_side.claimant": "Klägerseite",
|
||||
"projects.field.our_side.defendant": "Beklagtenseite",
|
||||
"projects.field.our_side.applicant": "Antragsteller",
|
||||
"projects.field.our_side.appellant": "Berufungsführer",
|
||||
"projects.field.our_side.respondent": "Antragsgegner",
|
||||
"projects.field.our_side.third_party": "Streithelfer / Dritter",
|
||||
"projects.field.our_side.other": "Sonstige Beteiligte",
|
||||
"projects.field.our_side.court": "Gericht / Tribunal",
|
||||
"projects.field.our_side.both": "Beide Seiten",
|
||||
"projects.field.our_side.none": "—",
|
||||
"projects.field.client_role": "Mandantenrolle",
|
||||
"projects.field.client_role.hint": "Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator: Aktiv → Klägerseite, Reaktiv → Beklagtenseite. Lässt sich dort jederzeit überschreiben.",
|
||||
"projects.field.client_role.unset": "Unbekannt",
|
||||
"projects.field.client_role.group.active": "Aktiv (wir greifen an)",
|
||||
"projects.field.client_role.group.reactive": "Reaktiv (wir verteidigen)",
|
||||
"projects.field.client_role.group.other": "Dritte / Sonstige",
|
||||
"projects.field.client_role.claimant": "Klägerseite",
|
||||
"projects.field.client_role.applicant": "Antragsteller",
|
||||
"projects.field.client_role.appellant": "Berufungsführer",
|
||||
"projects.field.client_role.defendant": "Beklagtenseite",
|
||||
"projects.field.client_role.respondent": "Antragsgegner",
|
||||
"projects.field.client_role.third_party": "Streithelfer / Dritter",
|
||||
"projects.field.client_role.other": "Sonstige Beteiligte",
|
||||
"projects.field.opponent_code": "Gegner-Kürzel",
|
||||
"projects.field.opponent_code.placeholder": "z.B. OPNT",
|
||||
"projects.field.opponent_code.hint": "Kurzes Kürzel der Gegenseite (Großbuchstaben, Ziffern, Bindestriche, max. 16 Zeichen). Wird als mittleres Segment in automatisch abgeleiteten Projekt-Codes verwendet (z.B. EXMPL.OPNT.567.INF.CFI).",
|
||||
"projects.field.status": "Status",
|
||||
"projects.error.title_required": "Titel erforderlich",
|
||||
"projects.detail.edit.type_change_warning.title": "Diese Felder werden geleert:",
|
||||
@@ -1422,8 +1262,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.notizen": "Notizen",
|
||||
"projects.detail.tab.checklisten": "Checklisten",
|
||||
"projects.detail.tab.submissions": "Schriftsätze",
|
||||
"projects.detail.export.button": "Daten exportieren",
|
||||
"projects.detail.export.tooltip": "Daten dieses Projekts (mit Unter-Projekten) als Excel + JSON + CSV herunterladen.",
|
||||
"projects.detail.submissions.empty": "Für dieses Verfahren sind keine Schriftsätze hinterlegt.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Bitte zuerst einen Verfahrenstyp setzen.",
|
||||
"projects.detail.submissions.col.name": "Schriftsatz",
|
||||
@@ -1559,7 +1397,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.type.patent": "Patent",
|
||||
"projects.type.case": "Verfahren",
|
||||
"projects.type.project": "Projekt",
|
||||
"projects.type.other": "Sonstiges",
|
||||
"projects.team.role.lead": "Leitung",
|
||||
"projects.team.role.associate": "Associate",
|
||||
"projects.team.role.pa": "PA",
|
||||
@@ -1567,15 +1404,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.team.role.local_counsel": "Local Counsel",
|
||||
"projects.team.role.expert": "Experte",
|
||||
"projects.team.role.observer": "Beobachter",
|
||||
"projects.team.responsibility.admin": "Admin",
|
||||
"projects.team.responsibility.admin.hint": "Kann Team und Rollen auf diesem Projekt und Unterprojekten verwalten",
|
||||
"projects.team.responsibility.lead": "Leitung",
|
||||
"projects.team.responsibility.member": "Mitglied",
|
||||
"projects.team.responsibility.observer": "Beobachter",
|
||||
"projects.team.responsibility.external": "Extern",
|
||||
"projects.team.error.last_admin": "Mindestens ein Admin muss auf diesem Projekt oder einem übergeordneten verbleiben.",
|
||||
"projects.team.error.forbidden": "Diese Aktion ist nicht erlaubt.",
|
||||
"projects.team.error.generic": "Aktion fehlgeschlagen.",
|
||||
"projects.team.profession.partner": "Partner",
|
||||
"projects.team.profession.of_counsel": "Of Counsel",
|
||||
"projects.team.profession.associate": "Associate",
|
||||
@@ -1625,7 +1457,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.chip.type.patent": "Patent",
|
||||
"projects.chip.type.case": "Verfahren",
|
||||
"projects.chip.type.project": "Projekt",
|
||||
"projects.chip.type.other": "Sonstiges",
|
||||
"projects.chip.multi.none": "Keine Auswahl",
|
||||
"projects.chip.multi.count": "{n} ausgew\u00e4hlt",
|
||||
"projects.empty.filtered.action": "Filter zur\u00fccksetzen",
|
||||
@@ -1731,6 +1562,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"appointments.list.title": "Termine \u2014 Paliad",
|
||||
"appointments.list.heading": "Termine",
|
||||
"appointments.list.subtitle": "Verhandlungen, Besprechungen, Beratungen \u2014 pers\u00f6nlich oder aktenbezogen.",
|
||||
"appointments.list.calendar": "Kalenderansicht",
|
||||
"appointments.list.new": "Neuer Termin",
|
||||
"appointments.summary.today": "Heute",
|
||||
"appointments.summary.thisweek": "Diese Woche",
|
||||
@@ -1786,6 +1618,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"appointments.detail.saved": "Gespeichert.",
|
||||
"appointments.detail.delete": "Termin l\u00f6schen",
|
||||
"appointments.detail.delete.confirm": "Diesen Termin wirklich l\u00f6schen?",
|
||||
"appointments.kalender.title": "Terminkalender \u2014 Paliad",
|
||||
"appointments.kalender.heading": "Terminkalender",
|
||||
"appointments.kalender.subtitle": "Monats\u00fcbersicht aller Termine.",
|
||||
"appointments.kalender.list": "Listenansicht",
|
||||
"appointments.kalender.empty": "Keine Termine im ausgew\u00e4hlten Zeitraum.",
|
||||
|
||||
// t-paliad-110 \u2014 unified Events page (rendered on both /deadlines and
|
||||
// /appointments). The user-facing "Fristen" / "Termine" branding stays;
|
||||
@@ -1809,6 +1646,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"events.view.cards": "Karten",
|
||||
"events.view.list": "Liste",
|
||||
"events.view.calendar": "Kalender",
|
||||
"events.calendar.empty": "Keine Eintr\u00e4ge im ausgew\u00e4hlten Zeitraum.",
|
||||
"caldav.title": "CalDAV-Synchronisation \u2014 Paliad",
|
||||
"caldav.heading": "CalDAV-Synchronisation",
|
||||
"caldav.subtitle": "Synchronisieren Sie Ihre Paliad-Termine mit Ihrem externen Kalender (Nextcloud, iCloud, Outlook, mailcow\u2026). Das Passwort wird verschl\u00fcsselt gespeichert und nie zur\u00fcckgegeben.",
|
||||
@@ -1845,45 +1683,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"caldav.log.col.error": "Fehler",
|
||||
"caldav.log.empty": "Noch keine Synchronisationen aufgezeichnet.",
|
||||
|
||||
// CalDAV multi-calendar bindings (t-paliad-212 Slice 2b)
|
||||
"caldav.bindings.heading": "Kalender",
|
||||
"caldav.bindings.hint": "Verbinde mehrere Kalender mit Paliad — einen Master für alles oder eigene Kalender pro Projekt.",
|
||||
"caldav.bindings.add": "+ Kalender hinzufügen",
|
||||
"caldav.bindings.empty": "Noch keine Kalender konfiguriert.",
|
||||
"caldav.bindings.scope.all_visible": "Alles",
|
||||
"caldav.bindings.scope.personal_only": "Nur persönlich",
|
||||
"caldav.bindings.scope.project": "Projekt",
|
||||
"caldav.bindings.card.enabled": "Aktiv",
|
||||
"caldav.bindings.card.edit": "Bearbeiten",
|
||||
"caldav.bindings.card.remove": "Entfernen",
|
||||
"caldav.bindings.modal.add_title": "Kalender hinzufügen",
|
||||
"caldav.bindings.modal.edit_title": "Kalender bearbeiten",
|
||||
"caldav.bindings.modal.source": "Kalender",
|
||||
"caldav.bindings.modal.source.loading": "Lädt …",
|
||||
"caldav.bindings.modal.source.existing": "Vorhandenen Kalender wählen",
|
||||
"caldav.bindings.modal.source.create": "Neuen Kalender erstellen",
|
||||
"caldav.bindings.modal.source.custom": "Eigene URL eingeben",
|
||||
"caldav.bindings.modal.source.degrade": "Dieser Anbieter erlaubt das Erstellen neuer Kalender nicht via CalDAV. Erstelle den Kalender direkt in der Anbieter-Oberfläche und füge ihn hier per URL hinzu.",
|
||||
"caldav.bindings.modal.source.discover_failed": "Kalender konnten nicht ermittelt werden — eigene URL eingeben.",
|
||||
"caldav.bindings.modal.source.discover_empty": "Keine Kalender gefunden — eigene URL eingeben.",
|
||||
"caldav.bindings.modal.display_name": "Anzeigename (optional)",
|
||||
"caldav.bindings.modal.display_name.placeholder": "z.B. Projekt Acme v Bosch",
|
||||
"caldav.bindings.modal.scope": "Inhalt",
|
||||
"caldav.bindings.modal.scope.all_visible": "Alles, was ich sehe",
|
||||
"caldav.bindings.modal.scope.personal_only": "Nur persönliche Termine",
|
||||
"caldav.bindings.modal.scope.project": "Ein Projekt:",
|
||||
"caldav.bindings.modal.scope.project.loading": "Lädt …",
|
||||
"caldav.bindings.modal.submit_add": "Hinzufügen",
|
||||
"caldav.bindings.modal.submit_edit": "Speichern",
|
||||
"caldav.bindings.delete.confirm": "Diesen Kalender wirklich entfernen? Die zugehörigen Termine werden im externen Kalender gelöscht.",
|
||||
"caldav.bindings.delete.failed": "Entfernen fehlgeschlagen — bitte später erneut versuchen.",
|
||||
"caldav.bindings.error.scope": "Bitte einen Inhaltsbereich wählen.",
|
||||
"caldav.bindings.error.scope_project": "Bitte ein Projekt auswählen.",
|
||||
"caldav.bindings.error.path": "Bitte einen Kalender wählen oder eine URL eingeben.",
|
||||
"caldav.bindings.error.create_name_required": "Bitte einen Anzeigenamen eingeben.",
|
||||
"caldav.bindings.error.create_name_taken": "Name bereits vergeben — bitte einen anderen Anzeigenamen wählen.",
|
||||
"caldav.bindings.error.create_unsupported": "Dein Anbieter unterstützt das Erstellen neuer Kalender nicht. Bitte 'Eigene URL eingeben' verwenden.",
|
||||
|
||||
// Notizen (polymorphic notes — Phase I)
|
||||
"notes.section.title": "Notizen",
|
||||
"notes.placeholder": "Notiz hinzuf\u00fcgen\u2026",
|
||||
@@ -1926,13 +1725,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"agenda.appointment_type.deadline_hearing": "Fristentermin",
|
||||
"agenda.day.today": "Heute",
|
||||
"agenda.day.tomorrow": "Morgen",
|
||||
"agenda.day.mo": "Mo",
|
||||
"agenda.day.di": "Di",
|
||||
"agenda.day.mi": "Mi",
|
||||
"agenda.day.do": "Do",
|
||||
"agenda.day.fr": "Fr",
|
||||
"agenda.day.sa": "Sa",
|
||||
"agenda.day.so": "So",
|
||||
"agenda.urgency.overdue": "Überfällig",
|
||||
"agenda.urgency.today": "Heute",
|
||||
"agenda.urgency.tomorrow": "Morgen",
|
||||
@@ -1968,14 +1760,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"team.filter.project.all": "Alle Projekte",
|
||||
"team.filter.project.selected": "ausgewählt",
|
||||
"team.filter.project.clear": "Alle abwählen",
|
||||
// Click-to-select (t-paliad-223 #53). Layered ON TOP of the existing
|
||||
// filter pills — selection is an explicit subset of the visible set,
|
||||
// pruned on filter change, wiped on page navigation.
|
||||
"team.selection.count": "{n} ausgewählt",
|
||||
"team.selection.clear": "Auswahl aufheben",
|
||||
"team.selection.send": "E-Mail an Auswahl",
|
||||
"team.selection.select_all": "Alle sichtbaren auswählen",
|
||||
"team.selection.toggle_card": "Kontakt auswählen",
|
||||
// Broadcast modal (t-paliad-147)
|
||||
"team.broadcast.button": "E-Mail an Auswahl",
|
||||
"team.broadcast.title": "E-Mail an Auswahl",
|
||||
@@ -2208,24 +1992,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.heading": "Team-Verwaltung",
|
||||
"admin.team.subtitle": "Alle Paliad-Konten anzeigen, bearbeiten oder hinzufügen.",
|
||||
"admin.team.search.placeholder": "Nach Name oder E-Mail suchen…",
|
||||
"admin.team.add.full": "Konto direkt anlegen",
|
||||
"admin.team.add.direct": "Bestehendes Konto onboarden",
|
||||
"admin.team.add.invite": "Neue:n Kolleg:in einladen",
|
||||
"admin.team.add_full.title": "Konto direkt anlegen",
|
||||
"admin.team.add_full.body": "Legt sowohl das Login-Konto als auch das Paliad-Profil an. Die neue Person erhält eine E-Mail mit einem Link, über den sie ein Passwort setzt.",
|
||||
"admin.team.add_full.email": "E-Mail",
|
||||
"admin.team.add_full.name": "Anzeigename",
|
||||
"admin.team.add_full.office": "Standort",
|
||||
"admin.team.add_full.profession": "Profession",
|
||||
"admin.team.add_full.job_title": "Berufsbezeichnung",
|
||||
"admin.team.add_full.lang": "Sprache",
|
||||
"admin.team.add_full.send_welcome": "Willkommens-E-Mail mit Login-Link senden",
|
||||
"admin.team.add_full.cancel": "Abbrechen",
|
||||
"admin.team.add_full.submit": "Anlegen",
|
||||
"admin.team.add_full.feedback.added": "Konto angelegt.",
|
||||
"admin.team.add_full.error.unavailable": "Add-User-Pfad ist nicht konfiguriert (SUPABASE_SERVICE_ROLE_KEY fehlt am Server).",
|
||||
"admin.team.add_full.error.email_exists": "Es existiert bereits ein Konto für diese E-Mail — bitte 'Bestehendes Konto onboarden' verwenden.",
|
||||
"admin.team.add_full.error.generic": "Konto konnte nicht angelegt werden.",
|
||||
"admin.team.loading": "Lade…",
|
||||
"admin.team.empty": "Keine Treffer.",
|
||||
"admin.team.error.forbidden": "Zugriff nur für Admins.",
|
||||
@@ -2478,7 +2246,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.suggest.next_request_link": "→ Neuer Vorschlag von {name}",
|
||||
"approvals.suggest.unsupported_lifecycle": "Änderungen vorschlagen ist nur für Update-Anfragen möglich.",
|
||||
"approvals.suggest.section.editable": "Felder",
|
||||
"approvals.suggest.section.event_type_rule": "Verfahrenshandlung (Typ + Regel)",
|
||||
"approvals.suggest.section.context": "Kontext",
|
||||
"approvals.suggest.context.project": "Projekt",
|
||||
"approvals.suggest.context.requester": "Eingereicht von",
|
||||
@@ -3146,10 +2913,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.step1.divider.new": "or a new matter",
|
||||
"deadlines.step1.divider.adhoc": "or ad-hoc, without a matter",
|
||||
"deadlines.step1.new.cta": "+ Create new matter",
|
||||
"deadlines.step1.adhoc.upc": "UPC proceeding",
|
||||
"deadlines.step1.adhoc.de": "DE proceeding",
|
||||
"deadlines.step1.adhoc.epa": "EPA proceeding",
|
||||
"deadlines.step1.adhoc.dpma": "DPMA proceeding",
|
||||
"deadlines.step1.adhoc.upc": "Custom UPC proceeding",
|
||||
"deadlines.step1.adhoc.de": "Custom DE proceeding",
|
||||
"deadlines.step1.adhoc.epa": "Custom EPA proceeding",
|
||||
"deadlines.step1.adhoc.dpma": "Custom DPMA proceeding",
|
||||
"deadlines.step1.selected": "Matter:",
|
||||
"deadlines.step1.reselect": "Other matter",
|
||||
"deadlines.step1.summary.adhoc.suffix": "no matter (exploration)",
|
||||
@@ -3226,8 +2993,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.card.calc.expand_hint": "Calculate deadline or add to project",
|
||||
"deadlines.card.calc.close": "close",
|
||||
"deadlines.card.calc.pill_picker.label": "Which context?",
|
||||
"deadlines.card.calc.pill_picker.locked_label": "Context:",
|
||||
"deadlines.card.calc.pill_picker.change": "change",
|
||||
"deadlines.card.calc.trigger.label": "Date of triggering event",
|
||||
"deadlines.card.calc.flags.label": "Conditions:",
|
||||
"deadlines.card.calc.flag.with_ccr": "With counterclaim for revocation",
|
||||
@@ -3429,101 +3194,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"checklisten.heading": "Checklists",
|
||||
"checklisten.subtitle": "Interactive checklists for typical procedural steps before the UPC, German Patent Court, and EPO. Tick off, print, miss nothing.",
|
||||
"checklisten.tab.templates": "Templates",
|
||||
"checklisten.tab.mine": "My templates",
|
||||
"checklisten.tab.instances": "Existing instances",
|
||||
"checklisten.mine.empty": "You haven't authored a template yet.",
|
||||
"checklisten.tab.gallery": "Shared templates",
|
||||
"checklisten.gallery.empty": "No shared templates visible yet.",
|
||||
"checklisten.filter.other": "Other",
|
||||
"checklisten.instance.outdated.badge": "Template updated",
|
||||
"checklisten.instance.outdated.note": "The underlying template has been updated since this instance was created (v{from} → v{to}).",
|
||||
"checklisten.instance.outdated.diff": "Show changes",
|
||||
"checklisten.instance.diff.title": "Changed items",
|
||||
"checklisten.instance.diff.close": "Close",
|
||||
"checklisten.instance.diff.added": "Added",
|
||||
"checklisten.instance.diff.removed": "Removed",
|
||||
"checklisten.instance.diff.changed": "Changed",
|
||||
"checklisten.instance.diff.empty": "No content differences in items.",
|
||||
"checklisten.instance.diff.error": "Diff failed.",
|
||||
"checklisten.mine.new": "New template",
|
||||
"checklisten.mine.loading": "Loading…",
|
||||
"checklisten.mine.visibility.private": "Private",
|
||||
"checklisten.mine.visibility.firm": "Firm-wide",
|
||||
"checklisten.mine.visibility.shared": "Shared",
|
||||
"checklisten.mine.visibility.global": "In catalog",
|
||||
"checklisten.mine.edit": "Edit",
|
||||
"checklisten.mine.delete": "Delete",
|
||||
"checklisten.mine.delete.confirm": "Delete template \"{title}\"? Existing instances remain.",
|
||||
"checklisten.mine.delete.error": "Delete failed.",
|
||||
"checklisten.mine.origin.authored": "Your template",
|
||||
"checklisten.author.title": "Author template — Paliad",
|
||||
"checklisten.author.title.edit": "Edit template — Paliad",
|
||||
"checklisten.author.heading.new": "New checklist template",
|
||||
"checklisten.author.heading.edit": "Edit template",
|
||||
"checklisten.author.subtitle": "Author your own checklist with sections and items. Keep it private or open it firm-wide.",
|
||||
"checklisten.author.field.title": "Title",
|
||||
"checklisten.author.field.title.hint": "e.g. \"UPC SoC — internal checklist\".",
|
||||
"checklisten.author.field.description": "Short description",
|
||||
"checklisten.author.field.regime": "Regime",
|
||||
"checklisten.author.field.court": "Court / authority",
|
||||
"checklisten.author.field.reference": "Legal source",
|
||||
"checklisten.author.field.deadline": "Deadline (optional)",
|
||||
"checklisten.author.field.lang": "Language",
|
||||
"checklisten.author.field.visibility": "Visibility",
|
||||
"checklisten.author.visibility.private.hint": "Visible only to you.",
|
||||
"checklisten.author.visibility.firm.hint": "Visible to every authenticated colleague.",
|
||||
"checklisten.author.groups.heading": "Sections and items",
|
||||
"checklisten.author.groups.add": "+ Add section",
|
||||
"checklisten.author.group.title": "Section title",
|
||||
"checklisten.author.group.remove": "Remove section",
|
||||
"checklisten.author.item.add": "+ Add item",
|
||||
"checklisten.author.item.label": "Item",
|
||||
"checklisten.author.item.note": "Note (optional)",
|
||||
"checklisten.author.item.rule": "Rule (optional)",
|
||||
"checklisten.author.item.remove": "Remove item",
|
||||
"checklisten.author.save": "Save",
|
||||
"checklisten.author.cancel": "Cancel",
|
||||
"checklisten.author.saving": "Saving…",
|
||||
"checklisten.author.error.title": "Please enter a title.",
|
||||
"checklisten.author.error.no_groups": "Please add at least one section with one item.",
|
||||
"checklisten.author.error.generic": "Save failed. Please try again.",
|
||||
"checklisten.author.error.notfound": "Template not found or you don't have permission to edit it.",
|
||||
"checklisten.detail.edit": "Edit",
|
||||
"checklisten.detail.delete": "Delete",
|
||||
"checklisten.detail.share": "Share",
|
||||
"checklisten.detail.promote": "Add to firm catalog",
|
||||
"checklisten.detail.demote": "Remove from catalog",
|
||||
"checklisten.detail.promote.confirm": "Add this template to the firm catalog? Every colleague will see it under Templates.",
|
||||
"checklisten.detail.demote.confirm": "Remove this template from the firm catalog? It stays firm-visible.",
|
||||
"checklisten.detail.promote.error": "Promotion failed.",
|
||||
"checklisten.detail.delete.confirm": "Delete template \"{title}\"? Existing instances remain.",
|
||||
"checklisten.detail.delete.error": "Delete failed.",
|
||||
"checklisten.detail.authored.by": "Authored by {author}",
|
||||
"checklisten.detail.visibility": "Visibility: {state}",
|
||||
"checklisten.detail.visibility.set.firm": "Share with firm",
|
||||
"checklisten.detail.visibility.set.private": "Make private",
|
||||
"checklisten.detail.visibility.error": "Couldn't change visibility.",
|
||||
"checklisten.share.title": "Share template",
|
||||
"checklisten.share.kind": "Recipient type",
|
||||
"checklisten.share.kind.user": "Colleague",
|
||||
"checklisten.share.kind.office": "Office",
|
||||
"checklisten.share.kind.partner_unit": "Practice unit",
|
||||
"checklisten.share.kind.project": "Project",
|
||||
"checklisten.share.pick": "— pick —",
|
||||
"checklisten.share.submit": "Share",
|
||||
"checklisten.share.cancel": "Cancel",
|
||||
"checklisten.share.error.pick": "Please pick a recipient.",
|
||||
"checklisten.share.error.generic": "Share failed.",
|
||||
"checklisten.share.success": "Shared.",
|
||||
"checklisten.share.grants.heading": "Existing grants",
|
||||
"checklisten.share.grants.empty": "No grants.",
|
||||
"checklisten.share.grants.revoke": "Remove",
|
||||
"checklisten.share.grants.revoke.confirm": "Remove this grant?",
|
||||
"checklisten.share.grants.revoke.error": "Revoke failed.",
|
||||
"checklisten.share.grants.recipient.user": "Colleague",
|
||||
"checklisten.share.grants.recipient.office": "Office",
|
||||
"checklisten.share.grants.recipient.partner_unit": "Practice unit",
|
||||
"checklisten.share.grants.recipient.project": "Project",
|
||||
"checklisten.instances.all.loading": "Loading…",
|
||||
"checklisten.instances.all.empty": "No checklist instances yet. Create one from the Templates tab.",
|
||||
"checklisten.instances.all.col.template": "Template",
|
||||
@@ -3662,6 +3333,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.list.heading": "Deadlines",
|
||||
"deadlines.list.subtitle": "Persistent deadlines for your matters. Overdue, today, this week, next week \u2014 at a glance.",
|
||||
"deadlines.list.new": "New deadline",
|
||||
"deadlines.list.calendar": "Calendar view",
|
||||
"deadlines.summary.overdue": "Overdue",
|
||||
"deadlines.summary.today": "Today",
|
||||
"deadlines.summary.thisweek": "This week",
|
||||
@@ -3784,6 +3456,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.source.caldav": "CalDAV",
|
||||
"deadlines.source.imported": "Import",
|
||||
|
||||
"deadlines.kalender.title": "Deadline calendar \u2014 Paliad",
|
||||
"deadlines.kalender.heading": "Deadline calendar",
|
||||
"deadlines.kalender.subtitle": "Monthly view of all deadlines on your matters.",
|
||||
"deadlines.kalender.list": "List view",
|
||||
"deadlines.kalender.today": "Today",
|
||||
"deadlines.kalender.empty": "No deadlines in the selected period.",
|
||||
"cal.day.mon": "Mon",
|
||||
"cal.day.tue": "Tue",
|
||||
"cal.day.wed": "Wed",
|
||||
@@ -3813,7 +3491,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"cal.day.prev": "Previous day",
|
||||
"cal.day.next": "Next day",
|
||||
"cal.day.back_to_month": "Back to month",
|
||||
"cal.today": "Today",
|
||||
"cal.day.open_day": "Open day view",
|
||||
"cal.day.no_entries": "Nothing scheduled this day.",
|
||||
|
||||
@@ -3871,48 +3548,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dashboard.agenda.heading": "Agenda",
|
||||
"dashboard.agenda.empty": "Nothing due in the next 30 days.",
|
||||
"dashboard.agenda.full_link": "Open full agenda →",
|
||||
"dashboard.inbox.heading": "Open approvals",
|
||||
"dashboard.inbox.empty": "No open approvals.",
|
||||
"dashboard.inbox.full_link": "Open full inbox →",
|
||||
"dashboard.inbox.entity.deadline": "Deadline",
|
||||
"dashboard.inbox.entity.appointment": "Appointment",
|
||||
"dashboard.edit.toggle": "Customize",
|
||||
"dashboard.edit.exit": "Done",
|
||||
"dashboard.edit.add_widget": "Add widget",
|
||||
"dashboard.edit.reset": "Reset to default",
|
||||
"dashboard.edit.reset_confirm": "Reset layout to default? This cannot be undone.",
|
||||
"dashboard.edit.promote": "Save as firm default",
|
||||
"dashboard.edit.promote_confirm": "Save your current layout as the firm default? New users and 'Reset to default' will use this layout afterwards.",
|
||||
"dashboard.edit.promoted": "Saved as firm default",
|
||||
"dashboard.pinned.heading": "Pinned matters",
|
||||
"dashboard.pinned.empty": "No pinned matters yet.",
|
||||
"dashboard.pinned.full_link": "Open all matters →",
|
||||
"dashboard.quick.heading": "Quick actions",
|
||||
"dashboard.quick.new_project": "+ Matter",
|
||||
"dashboard.quick.new_deadline": "+ Deadline",
|
||||
"dashboard.quick.new_appointment": "+ Appointment",
|
||||
"dashboard.edit.move_up": "Move up",
|
||||
"dashboard.edit.move_down": "Move down",
|
||||
"dashboard.edit.hide": "Hide",
|
||||
"dashboard.edit.settings": "Settings",
|
||||
"dashboard.edit.drag": "Drag to reorder",
|
||||
"dashboard.edit.saved": "Saved",
|
||||
"dashboard.edit.save_failed": "Save failed",
|
||||
"dashboard.edit.setting.count": "Count",
|
||||
"dashboard.edit.setting.count.custom": "Custom value (max {n})",
|
||||
"dashboard.edit.setting.horizon": "Horizon",
|
||||
"dashboard.edit.setting.horizon.days": "{n} days",
|
||||
"dashboard.edit.setting.horizon.custom": "Custom horizon in days (max {n})",
|
||||
"dashboard.edit.setting.view": "View",
|
||||
"dashboard.edit.setting.size": "Size",
|
||||
"dashboard.edit.setting.position": "Position",
|
||||
"dashboard.edit.resize": "Resize",
|
||||
"dashboard.picker.title": "Add widget",
|
||||
"dashboard.picker.status.active": "Active",
|
||||
"dashboard.picker.status.hidden": "Hidden",
|
||||
"dashboard.picker.status.absent": "Not added",
|
||||
"dashboard.picker.close": "Done",
|
||||
"dashboard.picker.empty": "All widgets are already added.",
|
||||
"dashboard.section.collapse": "Collapse section",
|
||||
"dashboard.section.expand": "Expand section",
|
||||
"dashboard.urgency.overdue": "Overdue",
|
||||
@@ -4196,30 +3831,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.field.our_side.unset": "Unknown / not set",
|
||||
"projects.field.our_side.claimant": "Claimant side",
|
||||
"projects.field.our_side.defendant": "Defendant side",
|
||||
"projects.field.our_side.applicant": "Applicant",
|
||||
"projects.field.our_side.appellant": "Appellant",
|
||||
"projects.field.our_side.respondent": "Respondent",
|
||||
"projects.field.our_side.third_party": "Third Party",
|
||||
"projects.field.our_side.other": "Other party",
|
||||
"projects.field.our_side.court": "Court / tribunal",
|
||||
"projects.field.our_side.both": "Both sides",
|
||||
"projects.field.our_side.none": "—",
|
||||
"projects.field.client_role": "Client Role",
|
||||
"projects.field.client_role.hint": "Pre-selects the perspective chip in the Fristenrechner Determinator: Active → claimant side, Reactive → defendant side. Always overridable from there.",
|
||||
"projects.field.client_role.unset": "Unknown",
|
||||
"projects.field.client_role.group.active": "Active (we initiate)",
|
||||
"projects.field.client_role.group.reactive": "Reactive (we defend)",
|
||||
"projects.field.client_role.group.other": "Third Party / Other",
|
||||
"projects.field.client_role.claimant": "Claimant side",
|
||||
"projects.field.client_role.applicant": "Applicant",
|
||||
"projects.field.client_role.appellant": "Appellant",
|
||||
"projects.field.client_role.defendant": "Defendant side",
|
||||
"projects.field.client_role.respondent": "Respondent",
|
||||
"projects.field.client_role.third_party": "Third Party",
|
||||
"projects.field.client_role.other": "Other party",
|
||||
"projects.field.opponent_code": "Opponent code",
|
||||
"projects.field.opponent_code.placeholder": "e.g. OPNT",
|
||||
"projects.field.opponent_code.hint": "Short slug for the opposing party (uppercase letters, digits, dashes, max 16 chars). Used as the middle segment in auto-derived project codes (e.g. EXMPL.OPNT.567.INF.CFI).",
|
||||
"projects.field.status": "Status",
|
||||
"projects.error.title_required": "Title required",
|
||||
"projects.detail.edit.type_change_warning.title": "These fields will be cleared:",
|
||||
@@ -4277,8 +3891,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.notizen": "Notes",
|
||||
"projects.detail.tab.checklisten": "Checklists",
|
||||
"projects.detail.tab.submissions": "Submissions",
|
||||
"projects.detail.export.button": "Export data",
|
||||
"projects.detail.export.tooltip": "Download this project's data (including sub-projects) as Excel + JSON + CSV.",
|
||||
"projects.detail.submissions.empty": "No submissions are configured for this proceeding.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Please set a proceeding type first.",
|
||||
"projects.detail.submissions.col.name": "Submission",
|
||||
@@ -4413,7 +4025,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.type.patent": "Patent",
|
||||
"projects.type.case": "Case",
|
||||
"projects.type.project": "Project",
|
||||
"projects.type.other": "Other",
|
||||
"projects.team.role.lead": "Lead",
|
||||
"projects.team.role.associate": "Associate",
|
||||
"projects.team.role.pa": "PA",
|
||||
@@ -4421,15 +4032,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.team.role.local_counsel": "Local Counsel",
|
||||
"projects.team.role.expert": "Expert",
|
||||
"projects.team.role.observer": "Observer",
|
||||
"projects.team.responsibility.admin": "Admin",
|
||||
"projects.team.responsibility.admin.hint": "Can manage team and roles on this project and its sub-projects",
|
||||
"projects.team.responsibility.lead": "Lead",
|
||||
"projects.team.responsibility.member": "Member",
|
||||
"projects.team.responsibility.observer": "Observer",
|
||||
"projects.team.responsibility.external": "External",
|
||||
"projects.team.error.last_admin": "At least one admin must remain on this project or an ancestor.",
|
||||
"projects.team.error.forbidden": "This action is not permitted.",
|
||||
"projects.team.error.generic": "Action failed.",
|
||||
"projects.team.profession.partner": "Partner",
|
||||
"projects.team.profession.of_counsel": "Of Counsel",
|
||||
"projects.team.profession.associate": "Associate",
|
||||
@@ -4479,7 +4085,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.chip.type.patent": "Patent",
|
||||
"projects.chip.type.case": "Case",
|
||||
"projects.chip.type.project": "Project",
|
||||
"projects.chip.type.other": "Other",
|
||||
"projects.chip.multi.none": "Nothing selected",
|
||||
"projects.chip.multi.count": "{n} selected",
|
||||
"projects.empty.filtered.action": "Reset filters",
|
||||
@@ -4585,6 +4190,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"appointments.list.title": "Appointments \u2014 Paliad",
|
||||
"appointments.list.heading": "Appointments",
|
||||
"appointments.list.subtitle": "Hearings, meetings, consultations \u2014 personal or matter-linked.",
|
||||
"appointments.list.calendar": "Calendar view",
|
||||
"appointments.list.new": "New appointment",
|
||||
"appointments.summary.today": "Today",
|
||||
"appointments.summary.thisweek": "This week",
|
||||
@@ -4640,6 +4246,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"appointments.detail.saved": "Saved.",
|
||||
"appointments.detail.delete": "Delete appointment",
|
||||
"appointments.detail.delete.confirm": "Really delete this appointment?",
|
||||
"appointments.kalender.title": "Appointment calendar \u2014 Paliad",
|
||||
"appointments.kalender.heading": "Appointment calendar",
|
||||
"appointments.kalender.subtitle": "Monthly overview of all appointments.",
|
||||
"appointments.kalender.list": "List view",
|
||||
"appointments.kalender.empty": "No appointments in the selected period.",
|
||||
|
||||
// t-paliad-110 \u2014 unified Events page (rendered on /deadlines + /appointments).
|
||||
"events.toggle.deadline": "Deadlines",
|
||||
@@ -4660,6 +4271,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"events.view.cards": "Cards",
|
||||
"events.view.list": "List",
|
||||
"events.view.calendar": "Calendar",
|
||||
"events.calendar.empty": "No entries in the selected period.",
|
||||
"caldav.title": "CalDAV sync \u2014 Paliad",
|
||||
"caldav.heading": "CalDAV sync",
|
||||
"caldav.subtitle": "Sync your Paliad appointments with your external calendar (Nextcloud, iCloud, Outlook, mailcow\u2026). The password is stored encrypted and never returned.",
|
||||
@@ -4696,45 +4308,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"caldav.log.col.error": "Error",
|
||||
"caldav.log.empty": "No sync attempts recorded yet.",
|
||||
|
||||
// CalDAV multi-calendar bindings (t-paliad-212 Slice 2b)
|
||||
"caldav.bindings.heading": "Calendars",
|
||||
"caldav.bindings.hint": "Connect multiple calendars to Paliad — one master for everything or separate calendars per project.",
|
||||
"caldav.bindings.add": "+ Add calendar",
|
||||
"caldav.bindings.empty": "No calendars configured yet.",
|
||||
"caldav.bindings.scope.all_visible": "Everything",
|
||||
"caldav.bindings.scope.personal_only": "Personal only",
|
||||
"caldav.bindings.scope.project": "Project",
|
||||
"caldav.bindings.card.enabled": "Enabled",
|
||||
"caldav.bindings.card.edit": "Edit",
|
||||
"caldav.bindings.card.remove": "Remove",
|
||||
"caldav.bindings.modal.add_title": "Add calendar",
|
||||
"caldav.bindings.modal.edit_title": "Edit calendar",
|
||||
"caldav.bindings.modal.source": "Calendar",
|
||||
"caldav.bindings.modal.source.loading": "Loading…",
|
||||
"caldav.bindings.modal.source.existing": "Pick existing calendar",
|
||||
"caldav.bindings.modal.source.create": "Create new calendar",
|
||||
"caldav.bindings.modal.source.custom": "Enter custom URL",
|
||||
"caldav.bindings.modal.source.degrade": "This provider doesn't allow creating calendars via CalDAV. Please create the calendar in your provider's UI and add it here by URL.",
|
||||
"caldav.bindings.modal.source.discover_failed": "Couldn't discover calendars — enter URL manually.",
|
||||
"caldav.bindings.modal.source.discover_empty": "No calendars found — enter URL manually.",
|
||||
"caldav.bindings.modal.display_name": "Display name (optional)",
|
||||
"caldav.bindings.modal.display_name.placeholder": "e.g. Project Acme v Bosch",
|
||||
"caldav.bindings.modal.scope": "Contents",
|
||||
"caldav.bindings.modal.scope.all_visible": "Everything I can see",
|
||||
"caldav.bindings.modal.scope.personal_only": "Personal appointments only",
|
||||
"caldav.bindings.modal.scope.project": "One project:",
|
||||
"caldav.bindings.modal.scope.project.loading": "Loading…",
|
||||
"caldav.bindings.modal.submit_add": "Add",
|
||||
"caldav.bindings.modal.submit_edit": "Save",
|
||||
"caldav.bindings.delete.confirm": "Remove this calendar? Its events will be deleted from the external calendar.",
|
||||
"caldav.bindings.delete.failed": "Removal failed — please try again later.",
|
||||
"caldav.bindings.error.scope": "Please pick a content scope.",
|
||||
"caldav.bindings.error.scope_project": "Please pick a project.",
|
||||
"caldav.bindings.error.path": "Please pick a calendar or enter a URL.",
|
||||
"caldav.bindings.error.create_name_required": "Please enter a display name.",
|
||||
"caldav.bindings.error.create_name_taken": "Name already in use — please pick a different display name.",
|
||||
"caldav.bindings.error.create_unsupported": "Your provider doesn't support creating calendars. Please use 'Enter custom URL' instead.",
|
||||
|
||||
// Notizen (polymorphic notes — Phase I)
|
||||
"notes.section.title": "Notes",
|
||||
"notes.placeholder": "Add a note\u2026",
|
||||
@@ -4777,13 +4350,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"agenda.appointment_type.deadline_hearing": "Deadline hearing",
|
||||
"agenda.day.today": "Today",
|
||||
"agenda.day.tomorrow": "Tomorrow",
|
||||
"agenda.day.mo": "Mon",
|
||||
"agenda.day.di": "Tue",
|
||||
"agenda.day.mi": "Wed",
|
||||
"agenda.day.do": "Thu",
|
||||
"agenda.day.fr": "Fri",
|
||||
"agenda.day.sa": "Sat",
|
||||
"agenda.day.so": "Sun",
|
||||
"agenda.urgency.overdue": "Overdue",
|
||||
"agenda.urgency.today": "Today",
|
||||
"agenda.urgency.tomorrow": "Tomorrow",
|
||||
@@ -4819,12 +4385,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"team.filter.project.all": "All projects",
|
||||
"team.filter.project.selected": "selected",
|
||||
"team.filter.project.clear": "Deselect all",
|
||||
// Click-to-select (t-paliad-223 #53).
|
||||
"team.selection.count": "{n} selected",
|
||||
"team.selection.clear": "Clear selection",
|
||||
"team.selection.send": "Email selection",
|
||||
"team.selection.select_all": "Select all visible",
|
||||
"team.selection.toggle_card": "Select contact",
|
||||
// Broadcast modal (t-paliad-147)
|
||||
"team.broadcast.button": "Email selection",
|
||||
"team.broadcast.title": "Email selection",
|
||||
@@ -5057,24 +4617,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.team.heading": "Team Management",
|
||||
"admin.team.subtitle": "View, edit and add Paliad accounts.",
|
||||
"admin.team.search.placeholder": "Search by name or email…",
|
||||
"admin.team.add.full": "Add account directly",
|
||||
"admin.team.add.direct": "Onboard existing account",
|
||||
"admin.team.add.invite": "Invite Colleague",
|
||||
"admin.team.add_full.title": "Add account directly",
|
||||
"admin.team.add_full.body": "Creates both the login account and the Paliad profile. The new colleague receives an email with a link to set a password.",
|
||||
"admin.team.add_full.email": "Email",
|
||||
"admin.team.add_full.name": "Display name",
|
||||
"admin.team.add_full.office": "Office",
|
||||
"admin.team.add_full.profession": "Profession",
|
||||
"admin.team.add_full.job_title": "Job title",
|
||||
"admin.team.add_full.lang": "Language",
|
||||
"admin.team.add_full.send_welcome": "Send welcome email with login link",
|
||||
"admin.team.add_full.cancel": "Cancel",
|
||||
"admin.team.add_full.submit": "Create",
|
||||
"admin.team.add_full.feedback.added": "Account created.",
|
||||
"admin.team.add_full.error.unavailable": "Add-User path is not configured (SUPABASE_SERVICE_ROLE_KEY missing on the server).",
|
||||
"admin.team.add_full.error.email_exists": "An account already exists for this email — please use 'Onboard existing account' instead.",
|
||||
"admin.team.add_full.error.generic": "Could not create the account.",
|
||||
"admin.team.loading": "Loading…",
|
||||
"admin.team.empty": "No matches.",
|
||||
"admin.team.error.forbidden": "Admins only.",
|
||||
@@ -5327,7 +4871,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.suggest.next_request_link": "→ New suggestion by {name}",
|
||||
"approvals.suggest.unsupported_lifecycle": "Suggest changes is only available for update requests.",
|
||||
"approvals.suggest.section.editable": "Fields",
|
||||
"approvals.suggest.section.event_type_rule": "Event type + rule",
|
||||
"approvals.suggest.section.context": "Context",
|
||||
"approvals.suggest.context.project": "Project",
|
||||
"approvals.suggest.context.requester": "Submitted by",
|
||||
|
||||
@@ -93,13 +93,12 @@ export function routeNameFor(pathname: string): string {
|
||||
if (/^\/projects\/[^/]+\/settings/.test(pathname)) return "projects.settings";
|
||||
if (/^\/deadlines\/[^/]+$/.test(pathname)) return "deadlines.detail";
|
||||
if (pathname === "/deadlines/new") return "deadlines.new";
|
||||
if (pathname === "/deadlines/calendar") return "deadlines.calendar";
|
||||
if (pathname === "/deadlines") return "deadlines.list";
|
||||
if (/^\/appointments\/[^/]+$/.test(pathname)) return "appointments.detail";
|
||||
if (pathname === "/appointments/new") return "appointments.new";
|
||||
if (pathname === "/appointments/calendar") return "appointments.calendar";
|
||||
if (pathname === "/appointments") return "appointments.list";
|
||||
// /deadlines/calendar + /appointments/calendar are 301 redirects to
|
||||
// /events?type=…&view=calendar since t-paliad-224 — the client never
|
||||
// sees those pathnames any more.
|
||||
if (pathname === "/agenda") return "agenda";
|
||||
if (pathname === "/inbox") return "inbox";
|
||||
if (pathname === "/dashboard" || pathname === "/") return "dashboard";
|
||||
|
||||
@@ -8,11 +8,6 @@ export interface ProjectMini {
|
||||
title: string;
|
||||
type: string;
|
||||
reference?: string | null;
|
||||
// t-paliad-222 / m/paliad#50: auto-derived dotted project code from
|
||||
// the ancestor tree. Populated by the service projection on every
|
||||
// /api/projects response, so the picker can show the code without an
|
||||
// extra fetch.
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface ProjectFormState {
|
||||
@@ -53,11 +48,9 @@ function tryGet(id: string): HTMLElement | null {
|
||||
export function showFieldsForType(typeSel: string) {
|
||||
const parentWrap = tryGet("projekt-parent-wrap") as HTMLDivElement | null;
|
||||
const clientFields = tryGet("fields-client") as HTMLDivElement | null;
|
||||
const litigationFields = tryGet("fields-litigation") as HTMLDivElement | null;
|
||||
const patentFields = tryGet("fields-patent") as HTMLDivElement | null;
|
||||
const caseFields = tryGet("fields-case") as HTMLDivElement | null;
|
||||
if (clientFields) clientFields.style.display = typeSel === "client" ? "block" : "none";
|
||||
if (litigationFields) litigationFields.style.display = typeSel === "litigation" ? "block" : "none";
|
||||
if (patentFields) patentFields.style.display = typeSel === "patent" ? "block" : "none";
|
||||
if (caseFields) caseFields.style.display = typeSel === "case" ? "block" : "none";
|
||||
if (parentWrap) parentWrap.style.display = typeSel === "client" ? "none" : "block";
|
||||
@@ -95,28 +88,18 @@ export function initParentPicker() {
|
||||
}
|
||||
const matches = parentCandidates
|
||||
.filter((p) => {
|
||||
// Search across title + manual reference + auto-derived code
|
||||
// so the user can type "EXMPL" or "INF.CFI" and find the row.
|
||||
const hay = (p.title + " " + (p.reference || "") + " " + (p.code || "")).toLowerCase();
|
||||
const hay = (p.title + " " + (p.reference || "")).toLowerCase();
|
||||
return hay.includes(q);
|
||||
})
|
||||
.slice(0, 8);
|
||||
sugs.innerHTML = matches
|
||||
.map((p) => {
|
||||
// Render the auto-derived code (if any, and distinct from
|
||||
// reference) as a small mono badge on the right so the user
|
||||
// can disambiguate two same-titled projects by their tree
|
||||
// position. Single template literal kept readable inline.
|
||||
const code = p.code && p.code !== (p.reference || "") ? p.code : "";
|
||||
const codeBadge = code
|
||||
? `<span class="entity-ref entity-ref-code">${esc(code)}</span>`
|
||||
: "";
|
||||
return `<div class="collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
|
||||
.map(
|
||||
(p) =>
|
||||
`<div class="collab-suggestion" data-id="${esc(p.id)}" data-title="${esc(p.title)}">
|
||||
<strong>${esc(p.title)}</strong>
|
||||
<span class="entity-type-chip entity-type-${esc(p.type)}">${esc(tDyn("projects.type." + p.type) || p.type)}</span>
|
||||
${codeBadge}
|
||||
</div>`;
|
||||
})
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
sugs.querySelectorAll<HTMLDivElement>(".collab-suggestion").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
@@ -191,32 +174,20 @@ export function readPayload(
|
||||
const gd = ($("project-grant-date") as HTMLInputElement).value;
|
||||
if (gd) payload.grant_date = gd + "T00:00:00Z";
|
||||
}
|
||||
if (type === "litigation") {
|
||||
// opponent_code is the litigation-only short slug used as the
|
||||
// middle segment when BuildProjectCode auto-derives a project
|
||||
// code from the ancestor tree (t-paliad-222 / m/paliad#50).
|
||||
// Uppercased on submit so the user can type lowercase comfortably
|
||||
// — the DB CHECK enforces the [A-Z0-9-]{1,16} pattern.
|
||||
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
|
||||
if (ocEl) {
|
||||
const v = ocEl.value.trim().toUpperCase();
|
||||
if (v) payload.opponent_code = v;
|
||||
else if (!opts.omitEmpty) payload.opponent_code = "";
|
||||
}
|
||||
}
|
||||
if (type === "case") {
|
||||
stringField("project-court", "court");
|
||||
stringField("project-case-number", "case_number");
|
||||
}
|
||||
|
||||
// Client Role (DB column: our_side) — case-only after t-paliad-222.
|
||||
// The select uses "" for the unset option; the service maps empty
|
||||
// string to NULL via nullableOurSide.
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) {
|
||||
const v = osSel.value.trim();
|
||||
if (v) payload.our_side = v;
|
||||
else if (!opts.omitEmpty) payload.our_side = "";
|
||||
}
|
||||
// our_side is type-agnostic — every project type can carry "Wir
|
||||
// vertreten" because the Determinator picks it up regardless of
|
||||
// type. The select uses "" for the unset option; the service maps
|
||||
// empty string to NULL via nullableOurSide.
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) {
|
||||
const v = osSel.value.trim();
|
||||
if (v) payload.our_side = v;
|
||||
else if (!opts.omitEmpty) payload.our_side = "";
|
||||
}
|
||||
|
||||
const desc = ($("project-description") as HTMLTextAreaElement).value.trim();
|
||||
@@ -257,8 +228,6 @@ export function prefillForm(p: Record<string, unknown>) {
|
||||
get("project-case-number").value = String(p.case_number ?? "");
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) osSel.value = String(p.our_side ?? "");
|
||||
const ocEl = tryGet("project-opponent-code") as HTMLInputElement | null;
|
||||
if (ocEl) ocEl.value = String(p.opponent_code ?? "");
|
||||
getTA("project-description").value = String(p.description ?? "");
|
||||
getSel("project-status").value = String(p.status ?? "active");
|
||||
}
|
||||
|
||||
@@ -21,12 +21,6 @@ interface Project {
|
||||
path: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
// t-paliad-222 / m/paliad#50: auto-derived dotted project code from
|
||||
// the ancestor tree (e.g. EXMPL.OPNT.789.INF.CFI). Populated by the
|
||||
// service layer on every projection; equal to `reference` when the
|
||||
// user typed an override.
|
||||
code?: string;
|
||||
opponent_code?: string | null;
|
||||
description?: string | null;
|
||||
status: string;
|
||||
client_number?: string | null;
|
||||
@@ -40,12 +34,6 @@ interface Project {
|
||||
grant_date?: string | null;
|
||||
court?: string | null;
|
||||
case_number?: string | null;
|
||||
// t-paliad-223: piggybacked onto the GET /api/projects/{id} payload so
|
||||
// the team panel can render an inline <select> for callers who can
|
||||
// change responsibilities (global_admin or effective_project_admin on
|
||||
// this project / ancestor). Optional for back-compat with cached
|
||||
// payloads.
|
||||
effective_admin?: boolean;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -1101,24 +1089,6 @@ function renderHeader() {
|
||||
(document.getElementById("project-title-display") as HTMLElement).textContent = project.title;
|
||||
(document.getElementById("project-ref-display") as HTMLElement).textContent = project.reference || "";
|
||||
|
||||
// t-paliad-222 / m/paliad#50 — show the auto-derived project code
|
||||
// as a second badge whenever it's non-empty AND distinct from the
|
||||
// manual reference. Hides when the derived value equals reference
|
||||
// (avoids visual duplication when the user typed the same string)
|
||||
// or when no derivation produced a value.
|
||||
const codeEl = document.getElementById("project-code-display") as HTMLElement | null;
|
||||
if (codeEl) {
|
||||
const code = project.code ?? "";
|
||||
const ref = project.reference ?? "";
|
||||
if (code && code !== ref) {
|
||||
codeEl.textContent = code;
|
||||
codeEl.style.display = "";
|
||||
} else {
|
||||
codeEl.textContent = "";
|
||||
codeEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-177 — link from Verlauf header to standalone chart page.
|
||||
// Wired here (not in the TSX shell) because we need the resolved
|
||||
// project id, which only exists after the detail fetch settles.
|
||||
@@ -2094,7 +2064,6 @@ async function main() {
|
||||
initAttachUnitForm(id);
|
||||
initNotesContainer(id);
|
||||
mountVerlaufFilterBar(id);
|
||||
wireExportButton(id);
|
||||
showTab(parseTab());
|
||||
}
|
||||
|
||||
@@ -2524,11 +2493,6 @@ function renderTeam() {
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
// t-paliad-223: callers with effective_project_admin authority see an
|
||||
// inline <select> on the Rolle cell. Everyone else sees the read-only
|
||||
// <span>. The bool comes from the GET /api/projects/{id} payload.
|
||||
const canEditResponsibility = !!project?.effective_admin;
|
||||
|
||||
body.innerHTML = teamMembers
|
||||
.map((m) => {
|
||||
// t-paliad-148: profession is firm-wide (read-only badge) and
|
||||
@@ -2554,20 +2518,11 @@ function renderTeam() {
|
||||
: "";
|
||||
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||
const profCls = m.user_profession ? "projekt-team-profession" : "projekt-team-profession projekt-team-profession--none";
|
||||
|
||||
// Inline-select only on direct rows where the caller can edit.
|
||||
// Inherited rows stay read-only — the edit must happen at the
|
||||
// ancestor where the row is direct.
|
||||
const responsibilityCell =
|
||||
canEditResponsibility && !m.inherited
|
||||
? renderResponsibilitySelect(m.user_id, responsibility)
|
||||
: `<span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span>`;
|
||||
|
||||
return `<tr>
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
|
||||
<td>${responsibilityCell}</td>
|
||||
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
|
||||
<td>${source}</td>
|
||||
<td>${removeBtn}</td>
|
||||
</tr>`;
|
||||
@@ -2586,47 +2541,6 @@ function renderTeam() {
|
||||
if (resp.ok) {
|
||||
await loadTeam(project.id);
|
||||
renderTeam();
|
||||
} else {
|
||||
await showTeamErrorToast(resp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
body.querySelectorAll<HTMLSelectElement>(".team-responsibility-select").forEach((sel) => {
|
||||
// Capture the pre-change value on focus so we can roll back the
|
||||
// <select> if the PATCH fails (e.g. last-admin guard).
|
||||
sel.dataset.previous = sel.value;
|
||||
sel.addEventListener("focus", () => {
|
||||
sel.dataset.previous = sel.value;
|
||||
});
|
||||
sel.addEventListener("change", async () => {
|
||||
if (!project) return;
|
||||
const userID = sel.dataset.userId!;
|
||||
const previous = sel.dataset.previous || "member";
|
||||
const next = sel.value;
|
||||
if (next === previous) return;
|
||||
sel.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${project.id}/team/${encodeURIComponent(userID)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ responsibility: next }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
sel.value = previous;
|
||||
await showTeamErrorToast(resp);
|
||||
return;
|
||||
}
|
||||
sel.dataset.previous = next;
|
||||
// Refresh the team list so derived/descendant sections re-render
|
||||
// with the new authority shape.
|
||||
await loadTeam(project.id);
|
||||
renderTeam();
|
||||
} finally {
|
||||
sel.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2772,92 +2686,10 @@ function canManagePartnerUnits(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// canExportProject mirrors the §4 server-side gate for /api/projects/{id}/export:
|
||||
// global_admin OR direct team responsibility ∈ {lead, member}. Used to
|
||||
// reveal the export button — server still re-enforces on the request.
|
||||
function canExportProject(): boolean {
|
||||
if (!me || !project) return false;
|
||||
if (me.global_role === "global_admin") return true;
|
||||
return teamMembers.some(
|
||||
(m) =>
|
||||
m.user_id === me!.id &&
|
||||
m.project_id === project!.id &&
|
||||
(m.responsibility === "lead" || m.responsibility === "member"),
|
||||
);
|
||||
}
|
||||
|
||||
// wireExportButton reveals + hooks up the project-export button on the
|
||||
// tabs nav. Triggers a download via a transient <a download> — same
|
||||
// pattern as the personal export in client/settings.ts.
|
||||
function wireExportButton(projectID: string): void {
|
||||
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
if (!canExportProject()) {
|
||||
btn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
btn.style.display = "";
|
||||
btn.addEventListener("click", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
|
||||
a.download = "";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
}
|
||||
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
if (!me) return false;
|
||||
if (m.user_id === me.id) return true;
|
||||
if (me.global_role === "global_admin") return true;
|
||||
// t-paliad-223: effective_project_admin (from the project payload)
|
||||
// also covers remove. RLS makes the request fail anyway if the bit is
|
||||
// stale; this just hides the affordance.
|
||||
return !!project?.effective_admin;
|
||||
}
|
||||
|
||||
// t-paliad-223: build the inline <select> for the responsibility cell.
|
||||
// Options mirror the IsValidResponsibility set in approval_levels.go.
|
||||
function renderResponsibilitySelect(userID: string, current: string): string {
|
||||
const options = ["admin", "lead", "member", "observer", "external"]
|
||||
.map((v) => {
|
||||
const label = tDyn(`projects.team.responsibility.${v}`) || v;
|
||||
const sel = v === current ? " selected" : "";
|
||||
return `<option value="${esc(v)}"${sel}>${esc(label)}</option>`;
|
||||
})
|
||||
.join("");
|
||||
return `<select class="team-responsibility-select projekt-team-responsibility" data-user-id="${esc(userID)}">${options}</select>`;
|
||||
}
|
||||
|
||||
// t-paliad-223: surface backend error responses (last-admin guard / 403
|
||||
// from RLS / etc.) as a transient toast. We have no global toast service
|
||||
// yet on this page, so write into #team-msg.
|
||||
async function showTeamErrorToast(resp: Response): Promise<void> {
|
||||
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
|
||||
if (!msg) return;
|
||||
let text = "";
|
||||
try {
|
||||
const data = (await resp.json()) as { error?: string };
|
||||
text = data?.error || "";
|
||||
} catch {
|
||||
text = "";
|
||||
}
|
||||
if (!text) {
|
||||
if (resp.status === 409) text = t("projects.team.error.last_admin") || "Mindestens ein Admin muss auf diesem Projekt oder einem übergeordneten verbleiben.";
|
||||
else if (resp.status === 403 || resp.status === 404) text = t("projects.team.error.forbidden") || "Diese Aktion ist nicht erlaubt.";
|
||||
else text = t("projects.team.error.generic") || "Aktion fehlgeschlagen.";
|
||||
}
|
||||
msg.textContent = text;
|
||||
msg.classList.add("form-msg--error");
|
||||
// Auto-clear after 5s so a stale error doesn't linger past the next
|
||||
// successful action.
|
||||
window.setTimeout(() => {
|
||||
if (msg.textContent === text) {
|
||||
msg.textContent = "";
|
||||
msg.classList.remove("form-msg--error");
|
||||
}
|
||||
}, 5000);
|
||||
return me.global_role === "global_admin";
|
||||
}
|
||||
|
||||
function initTeamForm(id: string) {
|
||||
|
||||
@@ -412,11 +412,6 @@ async function loadCalDAVTab() {
|
||||
fillCalDAVForm();
|
||||
renderCalDAVStatus();
|
||||
await loadCalDAVLog();
|
||||
// Slice 2b — multi-calendar bindings. loadBindingProjects feeds the
|
||||
// project picker for scope=project; runs in parallel with the binding
|
||||
// list fetch.
|
||||
void loadBindingProjects();
|
||||
await loadBindings();
|
||||
}
|
||||
|
||||
async function loadCalDAVConfig(): Promise<boolean> {
|
||||
@@ -602,415 +597,6 @@ async function deleteCalDAVConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- CalDAV bindings (Slice 2b multi-calendar picker) ---------------------
|
||||
|
||||
interface UserCalendarBinding {
|
||||
id: string;
|
||||
user_id: string;
|
||||
calendar_path: string;
|
||||
display_name: string;
|
||||
scope_kind: "all_visible" | "personal_only" | "project" | "client" | "litigation" | "patent" | "case";
|
||||
scope_id?: string | null;
|
||||
include_personal: boolean;
|
||||
enabled: boolean;
|
||||
last_sync_at?: string | null;
|
||||
last_sync_error?: string | null;
|
||||
}
|
||||
|
||||
interface DiscoveredCalendar {
|
||||
href: string;
|
||||
display_name: string;
|
||||
supported_components?: string[];
|
||||
}
|
||||
|
||||
interface ProjectListItem {
|
||||
id: string;
|
||||
reference?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
let bindings: UserCalendarBinding[] = [];
|
||||
let discoveredCalendars: DiscoveredCalendar[] = [];
|
||||
let bindingProjects: ProjectListItem[] = [];
|
||||
let editingBindingID: string | null = null;
|
||||
// Slice 2c — capability cached from /api/caldav-discover. null = unprobed,
|
||||
// true = MKCALENDAR supported (show "Create new calendar" radio),
|
||||
// false = degrade UX (hide radio, surface bilingual notice).
|
||||
let supportsMKCalendar: boolean | null = null;
|
||||
|
||||
async function loadBindings(): Promise<void> {
|
||||
const section = document.getElementById("caldav-bindings-section");
|
||||
if (!section) return;
|
||||
try {
|
||||
const resp = await fetch("/api/caldav-bindings");
|
||||
if (resp.status === 501) return; // CalDAV unavailable; leave hidden
|
||||
if (!resp.ok) return;
|
||||
bindings = (await resp.json()) as UserCalendarBinding[];
|
||||
section.style.display = "";
|
||||
renderBindingsList();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function renderBindingsList(): void {
|
||||
const list = document.getElementById("caldav-bindings-list")!;
|
||||
const empty = document.getElementById("caldav-bindings-empty")!;
|
||||
if (!bindings.length) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = bindings.map(renderBindingCard).join("");
|
||||
// Wire per-card buttons.
|
||||
for (const b of bindings) {
|
||||
const card = document.getElementById(`caldav-binding-card-${b.id}`);
|
||||
if (!card) continue;
|
||||
card.querySelector(".caldav-binding-edit-btn")?.addEventListener("click", () => openBindingModal(b));
|
||||
card.querySelector(".caldav-binding-delete-btn")?.addEventListener("click", () => deleteBinding(b));
|
||||
const toggle = card.querySelector(".caldav-binding-enabled-toggle") as HTMLInputElement | null;
|
||||
toggle?.addEventListener("change", () => toggleBindingEnabled(b, toggle.checked));
|
||||
}
|
||||
}
|
||||
|
||||
function renderBindingCard(b: UserCalendarBinding): string {
|
||||
const label = b.display_name || b.calendar_path;
|
||||
const scope = scopeLabel(b);
|
||||
const last = b.last_sync_at ? fmtDateTime(b.last_sync_at) : t("caldav.never");
|
||||
const err = b.last_sync_error ? `<span class="caldav-status-error">${esc(b.last_sync_error)}</span>` : "";
|
||||
return `<div class="caldav-binding-card" id="caldav-binding-card-${esc(b.id)}">
|
||||
<div class="caldav-binding-card-row">
|
||||
<div class="caldav-binding-card-title">
|
||||
<strong>${esc(label)}</strong>
|
||||
<span class="caldav-binding-scope-chip">${esc(scope)}</span>
|
||||
</div>
|
||||
<label class="caldav-toggle-label">
|
||||
<input type="checkbox" class="caldav-binding-enabled-toggle" ${b.enabled ? "checked" : ""} />
|
||||
<span data-i18n="caldav.bindings.card.enabled">Aktiv</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="caldav-binding-card-row caldav-binding-card-meta">
|
||||
<span class="caldav-binding-path">${esc(b.calendar_path)}</span>
|
||||
<span class="caldav-binding-last-sync">${esc(t("caldav.status.last_sync"))} ${esc(last)} ${err}</span>
|
||||
</div>
|
||||
<div class="caldav-binding-card-actions">
|
||||
<button type="button" class="btn-secondary caldav-binding-edit-btn" data-i18n="caldav.bindings.card.edit">Bearbeiten</button>
|
||||
<button type="button" class="btn-danger caldav-binding-delete-btn" data-i18n="caldav.bindings.card.remove">Entfernen</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function scopeLabel(b: UserCalendarBinding): string {
|
||||
switch (b.scope_kind) {
|
||||
case "all_visible":
|
||||
return t("caldav.bindings.scope.all_visible");
|
||||
case "personal_only":
|
||||
return t("caldav.bindings.scope.personal_only");
|
||||
case "project": {
|
||||
const p = bindingProjects.find((p) => p.id === b.scope_id);
|
||||
const name = p ? p.title || p.reference || p.id.slice(0, 8) : "?";
|
||||
return `${t("caldav.bindings.scope.project")}: ${name}`;
|
||||
}
|
||||
default:
|
||||
return b.scope_kind;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBindingProjects(): Promise<void> {
|
||||
if (bindingProjects.length) return;
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (resp.ok) bindingProjects = (await resp.json()) as ProjectListItem[];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiscoveredCalendars(): Promise<void> {
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.loading"))}</option>`;
|
||||
try {
|
||||
const resp = await fetch("/api/caldav-discover");
|
||||
if (!resp.ok) {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
|
||||
supportsMKCalendar = null;
|
||||
syncBindingSourceModeUI();
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as {
|
||||
calendars: DiscoveredCalendar[];
|
||||
supports_mkcalendar?: boolean | null;
|
||||
};
|
||||
discoveredCalendars = data.calendars || [];
|
||||
supportsMKCalendar = data.supports_mkcalendar ?? null;
|
||||
if (!discoveredCalendars.length) {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_empty"))}</option>`;
|
||||
} else {
|
||||
sel.innerHTML = discoveredCalendars
|
||||
.map((c) => `<option value="${esc(c.href)}">${esc(c.display_name || c.href)}</option>`)
|
||||
.join("");
|
||||
}
|
||||
syncBindingSourceModeUI();
|
||||
} catch {
|
||||
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
|
||||
supportsMKCalendar = null;
|
||||
syncBindingSourceModeUI();
|
||||
}
|
||||
}
|
||||
|
||||
// syncBindingSourceModeUI shows / hides the "Neuen Kalender erstellen"
|
||||
// radio + the Google-degrade notice based on the cached
|
||||
// supports_mkcalendar capability. Also flips the visible input
|
||||
// (dropdown vs URL text box) to match the currently selected mode.
|
||||
function syncBindingSourceModeUI(): void {
|
||||
const createRow = document.getElementById("caldav-binding-source-mode-create-row");
|
||||
const degrade = document.getElementById("caldav-binding-degrade-notice");
|
||||
if (createRow) createRow.style.display = supportsMKCalendar === true ? "" : "none";
|
||||
if (degrade) degrade.style.display = supportsMKCalendar === false ? "" : "none";
|
||||
|
||||
// If supports_mkcalendar flipped to false while "create" was selected,
|
||||
// fall back to "existing" so the user isn't staring at a hidden radio.
|
||||
if (supportsMKCalendar !== true) {
|
||||
const createRadio = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="create"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (createRadio?.checked) {
|
||||
const existing = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="existing"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (existing) existing.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
const mode = currentBindingSourceMode();
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
sel.style.display = mode === "existing" ? "" : "none";
|
||||
customInput.style.display = mode === "custom" ? "" : "none";
|
||||
}
|
||||
|
||||
function currentBindingSourceMode(): "existing" | "create" | "custom" {
|
||||
const checked = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"]:checked',
|
||||
) as HTMLInputElement | null;
|
||||
return (checked?.value as "existing" | "create" | "custom") ?? "existing";
|
||||
}
|
||||
|
||||
function openBindingModal(b: UserCalendarBinding | null) {
|
||||
editingBindingID = b ? b.id : null;
|
||||
const modal = document.getElementById("caldav-binding-modal")!;
|
||||
const title = document.getElementById("caldav-binding-modal-title")!;
|
||||
const submitBtn = document.getElementById("caldav-binding-submit-btn")!;
|
||||
const sourceField = document.getElementById("caldav-binding-source-field")!;
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
|
||||
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
||||
const msg = document.getElementById("caldav-binding-msg")!;
|
||||
msg.textContent = "";
|
||||
|
||||
if (b) {
|
||||
title.textContent = t("caldav.bindings.modal.edit_title");
|
||||
submitBtn.textContent = t("caldav.bindings.modal.submit_edit");
|
||||
sourceField.style.display = "none";
|
||||
nameInput.value = b.display_name;
|
||||
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="${b.scope_kind}"]`) as HTMLInputElement | null;
|
||||
if (radio) radio.checked = true;
|
||||
} else {
|
||||
title.textContent = t("caldav.bindings.modal.add_title");
|
||||
submitBtn.textContent = t("caldav.bindings.modal.submit_add");
|
||||
sourceField.style.display = "";
|
||||
// Reset the 3-way source-mode radio to "existing" (most common path).
|
||||
const existingRadio = document.querySelector(
|
||||
'input[name="caldav-binding-source-mode"][value="existing"]',
|
||||
) as HTMLInputElement | null;
|
||||
if (existingRadio) existingRadio.checked = true;
|
||||
customInput.value = "";
|
||||
nameInput.value = "";
|
||||
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="all_visible"]`) as HTMLInputElement;
|
||||
radio.checked = true;
|
||||
void loadDiscoveredCalendars();
|
||||
}
|
||||
|
||||
// Project picker — populate options when project scope is picked.
|
||||
projectSel.innerHTML = bindingProjects
|
||||
.map((p) => `<option value="${esc(p.id)}">${esc((p.title || p.reference || p.id.slice(0, 8)))}</option>`)
|
||||
.join("");
|
||||
if (b && b.scope_kind === "project" && b.scope_id) {
|
||||
projectSel.value = b.scope_id;
|
||||
projectSel.disabled = false;
|
||||
}
|
||||
syncBindingScopeUI();
|
||||
syncBindingSourceModeUI();
|
||||
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
function closeBindingModal() {
|
||||
document.getElementById("caldav-binding-modal")!.style.display = "none";
|
||||
editingBindingID = null;
|
||||
}
|
||||
|
||||
function syncBindingScopeUI(): void {
|
||||
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
|
||||
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
||||
projectSel.disabled = scope !== "project";
|
||||
}
|
||||
|
||||
async function submitBindingModal(ev: Event): Promise<void> {
|
||||
ev.preventDefault();
|
||||
const msg = document.getElementById("caldav-binding-msg")!;
|
||||
msg.textContent = "";
|
||||
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
|
||||
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
|
||||
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
|
||||
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
|
||||
const submitBtn = document.getElementById("caldav-binding-submit-btn") as HTMLButtonElement;
|
||||
|
||||
const scope = (document.querySelector('input[name="caldav-binding-scope"]:checked') as HTMLInputElement | null)?.value;
|
||||
if (!scope) {
|
||||
msg.textContent = t("caldav.bindings.error.scope");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (scope === "project" && !projectSel.value) {
|
||||
msg.textContent = t("caldav.bindings.error.scope_project");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
if (editingBindingID) {
|
||||
const patchPayload: Record<string, unknown> = {
|
||||
display_name: nameInput.value.trim(),
|
||||
scope_kind: scope,
|
||||
enabled: true,
|
||||
};
|
||||
if (scope === "project") patchPayload.scope_id = projectSel.value;
|
||||
const resp = await fetch(`/api/caldav-bindings/${editingBindingID}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patchPayload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const mode = currentBindingSourceMode();
|
||||
if (mode === "create") {
|
||||
// Slice 2c MKCALENDAR path.
|
||||
const displayName = nameInput.value.trim();
|
||||
if (!displayName) {
|
||||
msg.textContent = t("caldav.bindings.error.create_name_required");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const createPayload: Record<string, unknown> = {
|
||||
display_name: displayName,
|
||||
scope_kind: scope,
|
||||
};
|
||||
if (scope === "project") createPayload.scope_id = projectSel.value;
|
||||
const resp = await fetch("/api/caldav-mkcalendar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(createPayload),
|
||||
});
|
||||
if (resp.status === 501) {
|
||||
// Race: probe flipped to false between modal-open and submit.
|
||||
// Re-sync the UI and surface a helpful message.
|
||||
supportsMKCalendar = false;
|
||||
syncBindingSourceModeUI();
|
||||
msg.textContent = t("caldav.bindings.error.create_unsupported");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (resp.status === 409) {
|
||||
msg.textContent = t("caldav.bindings.error.create_name_taken");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// existing | custom — POST /api/caldav-bindings with the path.
|
||||
const path = mode === "custom" ? customInput.value.trim() : sel.value;
|
||||
if (!path) {
|
||||
msg.textContent = t("caldav.bindings.error.path");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const postPayload: Record<string, unknown> = {
|
||||
calendar_path: path,
|
||||
display_name: nameInput.value.trim(),
|
||||
scope_kind: scope,
|
||||
enabled: true,
|
||||
};
|
||||
if (scope === "project") postPayload.scope_id = projectSel.value;
|
||||
if (!postPayload.display_name && mode === "existing") {
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
postPayload.display_name = opt ? opt.text : "";
|
||||
}
|
||||
const resp = await fetch("/api/caldav-bindings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(postPayload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = err.error || t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
closeBindingModal();
|
||||
await loadBindings();
|
||||
} catch {
|
||||
msg.textContent = t("caldav.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBinding(b: UserCalendarBinding): Promise<void> {
|
||||
if (!confirm(t("caldav.bindings.delete.confirm"))) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/caldav-bindings/${b.id}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204 && resp.status !== 202) {
|
||||
alert(t("caldav.bindings.delete.failed"));
|
||||
return;
|
||||
}
|
||||
await loadBindings();
|
||||
} catch {
|
||||
alert(t("caldav.bindings.delete.failed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBindingEnabled(b: UserCalendarBinding, enabled: boolean): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(`/api/caldav-bindings/${b.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
b.enabled = enabled;
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
// --- "Meine Partner Units" card on the profile tab -------------------------
|
||||
//
|
||||
// Read-only summary of the current user's structural memberships. Membership
|
||||
@@ -1131,18 +717,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("caldav-form")!.addEventListener("submit", saveCalDAV);
|
||||
document.getElementById("caldav-test-btn")!.addEventListener("click", testCalDAVConnection);
|
||||
document.getElementById("caldav-delete-btn")!.addEventListener("click", deleteCalDAVConfig);
|
||||
|
||||
// CalDAV bindings (Slice 2b + 2c) — add/edit modal wiring.
|
||||
document.getElementById("caldav-bindings-add-btn")?.addEventListener("click", () => openBindingModal(null));
|
||||
document.getElementById("caldav-binding-modal-close")?.addEventListener("click", closeBindingModal);
|
||||
document.getElementById("caldav-binding-cancel-btn")?.addEventListener("click", closeBindingModal);
|
||||
document.getElementById("caldav-binding-form")?.addEventListener("submit", submitBindingModal);
|
||||
document.querySelectorAll('input[name="caldav-binding-source-mode"]').forEach((el) => {
|
||||
el.addEventListener("change", syncBindingSourceModeUI);
|
||||
});
|
||||
document.querySelectorAll('input[name="caldav-binding-scope"]').forEach((el) => {
|
||||
el.addEventListener("change", syncBindingScopeUI);
|
||||
});
|
||||
const exportBtn = document.getElementById("export-btn");
|
||||
if (exportBtn) exportBtn.addEventListener("click", runExport);
|
||||
|
||||
|
||||
@@ -77,25 +77,6 @@ let activeRole = "all";
|
||||
let activeProjectIDs: Set<string> = new Set();
|
||||
let searchQuery = "";
|
||||
|
||||
// t-paliad-223 (#53) — explicit click-to-select layer ON TOP of the existing
|
||||
// filter pills. When selection.size > 0 the sticky footer takes over the
|
||||
// broadcast action and targets only the explicit subset; with empty
|
||||
// selection the existing top-bar broadcast button still targets the whole
|
||||
// filter result (purely additive).
|
||||
//
|
||||
// Invariant: selection only ever holds user_ids that match the current
|
||||
// filter set — render() prunes drop-outs every cycle. This keeps the
|
||||
// counter honest and avoids "hidden-but-selected" debug nightmares.
|
||||
const selectedUserIDs: Set<string> = new Set();
|
||||
// For Shift-click range select — the user_id of the most recent toggle
|
||||
// in the currently-rendered list order. Reset to null on any filter
|
||||
// change so the range never spans an invisible row.
|
||||
let lastToggledUserID: string | null = null;
|
||||
// Snapshot of the rendered user-IDs in DOM order, refreshed on each render.
|
||||
// Drives Shift-click range expansion and the master-checkbox "select all
|
||||
// visible" action.
|
||||
let renderedUserIDs: string[] = [];
|
||||
|
||||
const ICON_MAIL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
|
||||
const ICON_PIN = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>';
|
||||
|
||||
@@ -422,17 +403,8 @@ function memberAsUser(m: DepartmentMember): User | undefined {
|
||||
function renderUserCard(u: User): string {
|
||||
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
|
||||
const jobTitle = (u.job_title ?? "").trim();
|
||||
// t-paliad-223 (#53): per-row select-checkbox. Wrapped in a label so a
|
||||
// click on the checkbox cell triggers the toggle; the rest of the card
|
||||
// (links, email, etc.) keeps its native behaviour. Selection state
|
||||
// mirrored to data-selected so the CSS can highlight the card.
|
||||
const selected = selectedUserIDs.has(u.id);
|
||||
const selectAria = t("team.selection.toggle_card") || "Kontakt auswählen";
|
||||
return `
|
||||
<article class="team-card" data-user-id="${esc(u.id)}" data-selected="${selected ? "true" : "false"}">
|
||||
<label class="team-card-select" title="${escAttr(selectAria)}">
|
||||
<input type="checkbox" class="team-card-select-input" data-user-id="${esc(u.id)}"${selected ? " checked" : ""} aria-label="${escAttr(selectAria)}" />
|
||||
</label>
|
||||
<article class="team-card">
|
||||
<div class="team-avatar" aria-hidden="true">${esc(initials(u.display_name))}</div>
|
||||
<div class="team-card-body">
|
||||
<div class="team-card-name">${esc(u.display_name)}</div>
|
||||
@@ -446,13 +418,6 @@ function renderUserCard(u: User): string {
|
||||
</article>`;
|
||||
}
|
||||
|
||||
// escAttr is the attribute-context counterpart of esc. Used in title=""
|
||||
// + aria-label="" where esc()'s div-textContent trick is fine but
|
||||
// double-quote-escaping is the bit we actually need.
|
||||
function escAttr(s: string): string {
|
||||
return esc(s).replace(/"/g, """);
|
||||
}
|
||||
|
||||
function renderGroupByOffice(filtered: User[]): string {
|
||||
const present = presentOffices();
|
||||
const sections = present
|
||||
@@ -540,22 +505,12 @@ function render() {
|
||||
const filtered = users.filter(
|
||||
(u) => userMatchesOffice(u) && userMatchesRole(u) && userMatchesProject(u) && userMatchesSearch(u),
|
||||
);
|
||||
|
||||
// t-paliad-223 (#53): prune drop-outs from the explicit selection. The
|
||||
// invariant is "selection ⊆ visible"; carrying invisible IDs forward
|
||||
// would create stale "12 selected" counters that don't match what the
|
||||
// user sees on screen.
|
||||
pruneSelectionToVisible(new Set(filtered.map((u) => u.id)));
|
||||
|
||||
count.textContent = `${filtered.length} / ${users.length}`;
|
||||
updateBroadcastButton();
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
renderedUserIDs = [];
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
@@ -563,223 +518,6 @@ function render() {
|
||||
list.innerHTML = groupBy === "office"
|
||||
? renderGroupByOffice(filtered)
|
||||
: renderGroupByDepartment(filtered);
|
||||
|
||||
// Refresh the DOM-order snapshot Shift-click + master-checkbox rely on.
|
||||
renderedUserIDs = Array.from(
|
||||
list.querySelectorAll<HTMLElement>(".team-card"),
|
||||
).map((el) => el.dataset.userId || "");
|
||||
|
||||
wireSelectionCheckboxes(list);
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
}
|
||||
|
||||
// pruneSelectionToVisible drops user_ids from selection that no longer
|
||||
// match the visible set. Always called from render() before painting so
|
||||
// the per-row "checked" state and the footer counter stay in sync.
|
||||
function pruneSelectionToVisible(visible: Set<string>): void {
|
||||
const removed: string[] = [];
|
||||
for (const id of selectedUserIDs) {
|
||||
if (!visible.has(id)) removed.push(id);
|
||||
}
|
||||
for (const id of removed) selectedUserIDs.delete(id);
|
||||
if (removed.length > 0 && lastToggledUserID && !visible.has(lastToggledUserID)) {
|
||||
lastToggledUserID = null;
|
||||
}
|
||||
}
|
||||
|
||||
// wireSelectionCheckboxes attaches click handlers to every per-row
|
||||
// checkbox in the freshly-rendered list. Each click toggles the
|
||||
// underlying selection Set + the data-selected attribute on the card.
|
||||
// Shift-click extends a contiguous range from the previous toggle to
|
||||
// the current row using renderedUserIDs as the order reference.
|
||||
function wireSelectionCheckboxes(list: HTMLElement): void {
|
||||
list.querySelectorAll<HTMLInputElement>(".team-card-select-input").forEach((cb) => {
|
||||
cb.addEventListener("click", (ev) => {
|
||||
const id = cb.dataset.userId || "";
|
||||
if (!id) return;
|
||||
const checked = cb.checked;
|
||||
if ((ev as MouseEvent).shiftKey && lastToggledUserID && lastToggledUserID !== id) {
|
||||
applyRangeSelection(lastToggledUserID, id, checked);
|
||||
} else {
|
||||
if (checked) selectedUserIDs.add(id);
|
||||
else selectedUserIDs.delete(id);
|
||||
}
|
||||
lastToggledUserID = id;
|
||||
// Visual + footer refresh without a full re-render (selection
|
||||
// changes don't affect the filter set; render() is reserved for
|
||||
// filter/data changes to keep typing in the search box fast).
|
||||
refreshCardSelectedAttribute();
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// applyRangeSelection sets selection state for every user between
|
||||
// (inclusive) startID and endID in renderedUserIDs order. Mode = the
|
||||
// final state — checked => add to selection, unchecked => remove.
|
||||
function applyRangeSelection(startID: string, endID: string, mode: boolean): void {
|
||||
const a = renderedUserIDs.indexOf(startID);
|
||||
const b = renderedUserIDs.indexOf(endID);
|
||||
if (a === -1 || b === -1) {
|
||||
// One of the anchors dropped out of the current visible set; fall
|
||||
// back to a single-row toggle of the end-id.
|
||||
if (mode) selectedUserIDs.add(endID);
|
||||
else selectedUserIDs.delete(endID);
|
||||
return;
|
||||
}
|
||||
const [lo, hi] = a <= b ? [a, b] : [b, a];
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
const id = renderedUserIDs[i];
|
||||
if (mode) selectedUserIDs.add(id);
|
||||
else selectedUserIDs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// refreshCardSelectedAttribute syncs every visible card's data-selected
|
||||
// + checkbox.checked to the canonical Set, without a full re-render.
|
||||
function refreshCardSelectedAttribute(): void {
|
||||
const list = document.getElementById("team-list");
|
||||
if (!list) return;
|
||||
list.querySelectorAll<HTMLElement>(".team-card").forEach((card) => {
|
||||
const id = card.dataset.userId || "";
|
||||
const selected = selectedUserIDs.has(id);
|
||||
card.dataset.selected = selected ? "true" : "false";
|
||||
const cb = card.querySelector<HTMLInputElement>(".team-card-select-input");
|
||||
if (cb) cb.checked = selected;
|
||||
});
|
||||
}
|
||||
|
||||
// renderSelectionFooter mounts (or hides) the sticky footer that takes
|
||||
// over the broadcast action when ≥ 1 row is checked. The footer lives
|
||||
// outside the main content tree so it can be position: fixed without
|
||||
// fighting any of the existing layout rules.
|
||||
function renderSelectionFooter(): void {
|
||||
let footer = document.getElementById("team-selection-footer") as HTMLDivElement | null;
|
||||
const n = selectedUserIDs.size;
|
||||
if (n === 0) {
|
||||
if (footer) footer.style.display = "none";
|
||||
document.body.classList.remove("team-has-selection");
|
||||
return;
|
||||
}
|
||||
if (!footer) {
|
||||
footer = document.createElement("div");
|
||||
footer.id = "team-selection-footer";
|
||||
footer.className = "team-selection-footer";
|
||||
document.body.appendChild(footer);
|
||||
}
|
||||
const countLabel = (t("team.selection.count") || "{n} ausgewählt").replace(
|
||||
"{n}",
|
||||
String(n),
|
||||
);
|
||||
footer.innerHTML = `
|
||||
<span class="team-selection-count">${esc(countLabel)}</span>
|
||||
<button type="button" class="btn-secondary btn-small" id="team-selection-clear">
|
||||
${esc(t("team.selection.clear") || "Auswahl aufheben")}
|
||||
</button>
|
||||
<button type="button" class="btn-primary" id="team-selection-send">
|
||||
${esc(t("team.selection.send") || "E-Mail an Auswahl")}
|
||||
</button>
|
||||
`;
|
||||
footer.style.display = "";
|
||||
document.body.classList.add("team-has-selection");
|
||||
document.getElementById("team-selection-clear")?.addEventListener("click", () => {
|
||||
selectedUserIDs.clear();
|
||||
lastToggledUserID = null;
|
||||
refreshCardSelectedAttribute();
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
});
|
||||
document.getElementById("team-selection-send")?.addEventListener("click", () => {
|
||||
onBroadcastFromSelection();
|
||||
});
|
||||
}
|
||||
|
||||
// selectedRecipients maps the explicit selection Set into the
|
||||
// BroadcastRecipient shape openBroadcastModal expects. Mirrors the
|
||||
// role-resolution rules of displayedRecipients() (active project
|
||||
// filter wins; falls back to first available role).
|
||||
function selectedRecipients(): BroadcastRecipient[] {
|
||||
const out: BroadcastRecipient[] = [];
|
||||
for (const id of selectedUserIDs) {
|
||||
const u = users.find((u) => u.id === id);
|
||||
if (!u) continue;
|
||||
const m = memberships.find((m) => m.user_id === u.id);
|
||||
let role = "";
|
||||
if (m) {
|
||||
if (activeProjectIDs.size > 0) {
|
||||
const idx = m.project_ids.findIndex((pid) => activeProjectIDs.has(pid));
|
||||
if (idx >= 0) role = m.roles[idx];
|
||||
} else if (m.roles.length > 0) {
|
||||
role = m.roles[0];
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
user_id: u.id,
|
||||
email: u.email,
|
||||
display_name: u.display_name,
|
||||
first_name: firstName(u.display_name),
|
||||
role_on_project: role,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function onBroadcastFromSelection(): void {
|
||||
const recipients = selectedRecipients();
|
||||
if (recipients.length === 0) return;
|
||||
const selectedProjectIDs = Array.from(activeProjectIDs);
|
||||
// Same scope-resolution as displayedRecipients/onBroadcastClick: pass
|
||||
// project_id only when exactly one is selected so the server can
|
||||
// verify lead-ship; multi-project relies on global_admin.
|
||||
const projectID = selectedProjectIDs.length === 1 ? selectedProjectIDs[0] : null;
|
||||
const offices = activeOffice === "all" ? [] : [activeOffice];
|
||||
const roles = activeRole === "all" ? [] : [activeRole];
|
||||
openBroadcastModal({
|
||||
recipients,
|
||||
projectID,
|
||||
projectIDs: selectedProjectIDs,
|
||||
offices,
|
||||
roles,
|
||||
});
|
||||
}
|
||||
|
||||
// syncMasterCheckbox refreshes the master "select all visible" checkbox
|
||||
// to one of three states: empty / partial / full. The HTML element lives
|
||||
// in team.tsx (#team-select-master); when missing (older shells) the
|
||||
// helper no-ops so the page still works.
|
||||
function syncMasterCheckbox(): void {
|
||||
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
|
||||
if (!master) return;
|
||||
const visible = renderedUserIDs.length;
|
||||
if (visible === 0) {
|
||||
master.checked = false;
|
||||
master.indeterminate = false;
|
||||
master.disabled = true;
|
||||
return;
|
||||
}
|
||||
master.disabled = false;
|
||||
let selectedHere = 0;
|
||||
for (const id of renderedUserIDs) {
|
||||
if (selectedUserIDs.has(id)) selectedHere++;
|
||||
}
|
||||
master.checked = selectedHere === visible;
|
||||
master.indeterminate = selectedHere > 0 && selectedHere < visible;
|
||||
}
|
||||
|
||||
function onMasterToggle(): void {
|
||||
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
|
||||
if (!master) return;
|
||||
const checked = master.checked;
|
||||
for (const id of renderedUserIDs) {
|
||||
if (checked) selectedUserIDs.add(id);
|
||||
else selectedUserIDs.delete(id);
|
||||
}
|
||||
lastToggledUserID = checked && renderedUserIDs.length > 0 ? renderedUserIDs[renderedUserIDs.length - 1] : null;
|
||||
refreshCardSelectedAttribute();
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
}
|
||||
|
||||
function initToggle() {
|
||||
@@ -809,8 +547,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initSidebar();
|
||||
initSearch();
|
||||
initToggle();
|
||||
// t-paliad-223 (#53): master checkbox toggles every visible row.
|
||||
document.getElementById("team-select-master")?.addEventListener("change", onMasterToggle);
|
||||
onLangChange(() => {
|
||||
buildOfficeFilters();
|
||||
buildRoleFilters();
|
||||
|
||||
@@ -13,26 +13,15 @@ import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
calculateDeadlines,
|
||||
escHtml,
|
||||
formatDate,
|
||||
populateCourtPicker,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
wireDateEditClicks,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||
// user's chosen date. Cleared whenever the trigger changes (proceeding,
|
||||
// trigger date, flag toggle) so a fresh calc starts unanchored — same
|
||||
// semantic as /tools/fristenrechner.
|
||||
const anchorOverrides = new Map<string, string>();
|
||||
function clearAnchorOverrides() { anchorOverrides.clear(); }
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
@@ -136,14 +125,10 @@ async function doCalc() {
|
||||
? courtPicker.value
|
||||
: "";
|
||||
|
||||
const overrides: Record<string, string> = {};
|
||||
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
flags: readFlags(),
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
@@ -158,19 +143,13 @@ async function doCalc() {
|
||||
// the first event in the proceeding — e.g. Klageerhebung for
|
||||
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
|
||||
// active proceeding name if no root rule fires (shouldn't happen for
|
||||
// healthy data, but safer than a blank). Fallback respects language —
|
||||
// proceedingNameEN is consulted on EN before the DE proceedingName
|
||||
// (m/paliad#58: prior fallback rendered DE on EN for sub-track
|
||||
// proceedings like upc.ccr.cfi which had no rules → no root).
|
||||
// healthy data, but safer than a blank).
|
||||
function triggerEventLabelFor(data: DeadlineResponse): string {
|
||||
const root = data.deadlines.find((d) => d.isRootEvent);
|
||||
if (root) {
|
||||
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
||||
}
|
||||
if (getLang() === "en") {
|
||||
return data.proceedingNameEN || data.proceedingName || "";
|
||||
}
|
||||
return data.proceedingName || data.proceedingNameEN || "";
|
||||
return data.proceedingName || "";
|
||||
}
|
||||
|
||||
function syncTriggerEventLabel() {
|
||||
@@ -200,23 +179,11 @@ function renderResults(data: DeadlineResponse) {
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
// Sub-track contextual note (m/paliad#58). Surfaces above the
|
||||
// timeline body when the server routed the user-picked proceeding
|
||||
// through a parent (e.g. upc.ccr.cfi → upc.inf.cfi with with_ccr).
|
||||
// Plain-text banner — server-side copy is plain text per the
|
||||
// SubTrackRouting contract.
|
||||
const noteText = getLang() === "en"
|
||||
? (data.contextualNoteEN || data.contextualNote || "")
|
||||
: (data.contextualNote || data.contextualNoteEN || "");
|
||||
const noteHtml = noteText
|
||||
? `<div class="timeline-context-note" role="note">${escHtml(noteText)}</div>`
|
||||
: "";
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
? renderColumnsBody(data, { showNotes })
|
||||
: renderTimelineBody(data, { showParty: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + noteHtml + bodyHtml;
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
if (printBtn) printBtn.style.display = "block";
|
||||
if (toggle) toggle.style.display = "";
|
||||
|
||||
@@ -262,12 +229,7 @@ function syncInfAmendEnabled() {
|
||||
function selectProceeding(btn: HTMLButtonElement) {
|
||||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
const nextType = btn.dataset.code || "";
|
||||
// Different proceeding tree → previously-set overrides reference
|
||||
// rule codes that don't exist in the new tree. Clear before the
|
||||
// next calc so the fresh proceeding starts unanchored.
|
||||
if (selectedType !== nextType) clearAnchorOverrides();
|
||||
selectedType = nextType;
|
||||
selectedType = btn.dataset.code || "";
|
||||
|
||||
// Trigger-event label fires from the calc response (root rule).
|
||||
// Until step 3 renders, fall back to an em-dash placeholder.
|
||||
@@ -350,21 +312,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
||||
|
||||
// Click-to-edit on timeline / column date cells — same delegated
|
||||
// pattern as /tools/fristenrechner. Survives renderResults()'s
|
||||
// innerHTML rewrites because the listener lives on the container.
|
||||
const timelineContainer = document.getElementById("timeline-container");
|
||||
if (timelineContainer) {
|
||||
wireDateEditClicks(timelineContainer, (ruleCode, newValue) => {
|
||||
if (newValue === "") {
|
||||
anchorOverrides.delete(ruleCode);
|
||||
} else {
|
||||
anchorOverrides.set(ruleCode, newValue);
|
||||
}
|
||||
scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
// Notes toggle — restores last preference on load + re-renders when
|
||||
// the user flips it. Lives in the same toggle bar as the view picker.
|
||||
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
|
||||
|
||||
@@ -1,28 +1,525 @@
|
||||
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
|
||||
import { t, tDyn, type I18nKey, getLang } from "../i18n";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
|
||||
// shape-calendar — Custom Views calendar shape. Since t-paliad-224 this
|
||||
// is a thin adapter on top of the canonical mountCalendar() in
|
||||
// frontend/src/client/calendar/mount-calendar.ts. /events Kalender tab
|
||||
// uses the same module so both surfaces render identical DOM.
|
||||
// See docs/design-calendar-view-align-2026-05-20.md.
|
||||
// shape-calendar: month / week / day views. The view switcher is rendered
|
||||
// inline above the grid; the active view persists in the URL via
|
||||
// ?cal_view= so /views/<slug>?cal_view=day&cal_date=2026-05-18 is a
|
||||
// shareable deep-link. Each view buckets the same flat ViewRow[] by
|
||||
// ISO-date — only the rendering differs.
|
||||
|
||||
type CalView = "month" | "week" | "day";
|
||||
|
||||
const VIEW_PARAM = "cal_view";
|
||||
const DATE_PARAM = "cal_date";
|
||||
const MAX_PILLS_PER_MONTH_CELL = 3;
|
||||
|
||||
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
||||
const items: CalendarItem[] = rows.map(toCalendarItem);
|
||||
mountCalendar(host, items, {
|
||||
defaultView: render.calendar?.default_view ?? "month",
|
||||
urlState: true,
|
||||
host.innerHTML = "";
|
||||
const cfg = render.calendar ?? {};
|
||||
|
||||
// Mobile fallback: viewport <600px collapses to cards (cleaner on narrow
|
||||
// screens). Documented in design §9 trade-off 8.
|
||||
if (window.innerWidth < 600) {
|
||||
const notice = document.createElement("p");
|
||||
notice.className = "views-calendar-mobile-notice";
|
||||
notice.textContent = t("views.calendar.mobile_fallback");
|
||||
host.appendChild(notice);
|
||||
}
|
||||
|
||||
const initialView = readView(cfg.default_view);
|
||||
const anchor = readAnchor(rows);
|
||||
paint(host, rows, anchor, initialView);
|
||||
}
|
||||
|
||||
// paint redraws the calendar in the supplied view + anchor. Called from
|
||||
// the view switcher and from the day/week navigation buttons. Each paint
|
||||
// clears the host so we don't leak prior DOM.
|
||||
function paint(host: HTMLElement, rows: ViewRow[], anchor: Date, view: CalView): void {
|
||||
// Keep the mobile-notice (first child) if present; everything else is
|
||||
// re-rendered each time.
|
||||
const notice = host.querySelector<HTMLElement>(".views-calendar-mobile-notice");
|
||||
host.innerHTML = "";
|
||||
if (notice) host.appendChild(notice);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = `views-calendar views-calendar--${view}`;
|
||||
wrap.appendChild(renderToolbar(view, anchor, (nextView, nextAnchor) => {
|
||||
writeURL(nextView, nextAnchor);
|
||||
paint(host, rows, nextAnchor, nextView);
|
||||
}));
|
||||
|
||||
if (view === "month") {
|
||||
wrap.appendChild(renderMonth(anchor, rows, (clickedDate) => {
|
||||
writeURL("day", clickedDate);
|
||||
paint(host, rows, clickedDate, "day");
|
||||
}));
|
||||
} else if (view === "week") {
|
||||
wrap.appendChild(renderWeek(anchor, rows));
|
||||
} else {
|
||||
wrap.appendChild(renderDay(anchor, rows));
|
||||
}
|
||||
|
||||
host.appendChild(wrap);
|
||||
}
|
||||
|
||||
// --- Toolbar -------------------------------------------------------------
|
||||
|
||||
function renderToolbar(
|
||||
view: CalView,
|
||||
anchor: Date,
|
||||
onNav: (view: CalView, anchor: Date) => void,
|
||||
): HTMLElement {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "views-calendar-toolbar";
|
||||
|
||||
// View switcher: month / week / day chips.
|
||||
const switcher = document.createElement("div");
|
||||
switcher.className = "views-calendar-view-switcher agenda-chip-row";
|
||||
switcher.setAttribute("role", "tablist");
|
||||
for (const v of ["month", "week", "day"] as CalView[]) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "agenda-chip views-calendar-view-chip" + (v === view ? " agenda-chip-active" : "");
|
||||
chip.dataset.calView = v;
|
||||
chip.setAttribute("role", "tab");
|
||||
chip.setAttribute("aria-selected", v === view ? "true" : "false");
|
||||
chip.textContent = t(`cal.view.${v}` as I18nKey);
|
||||
chip.addEventListener("click", () => {
|
||||
if (v === view) return;
|
||||
onNav(v, anchor);
|
||||
});
|
||||
switcher.appendChild(chip);
|
||||
}
|
||||
bar.appendChild(switcher);
|
||||
|
||||
// Prev / current-label / next. Step size depends on the view.
|
||||
const nav = document.createElement("div");
|
||||
nav.className = "views-calendar-nav";
|
||||
|
||||
const prev = document.createElement("button");
|
||||
prev.type = "button";
|
||||
prev.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
prev.setAttribute("aria-label", t(navLabelKey(view, "prev")));
|
||||
prev.textContent = "‹";
|
||||
prev.addEventListener("click", () => onNav(view, shift(anchor, view, -1)));
|
||||
nav.appendChild(prev);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "views-calendar-nav-label";
|
||||
label.textContent = formatRangeLabel(view, anchor);
|
||||
nav.appendChild(label);
|
||||
|
||||
const next = document.createElement("button");
|
||||
next.type = "button";
|
||||
next.className = "btn-secondary btn-small views-calendar-nav-btn";
|
||||
next.setAttribute("aria-label", t(navLabelKey(view, "next")));
|
||||
next.textContent = "›";
|
||||
next.addEventListener("click", () => onNav(view, shift(anchor, view, 1)));
|
||||
nav.appendChild(next);
|
||||
|
||||
// Day/week view: provide a "Zurück zum Monat" link so users can climb
|
||||
// back without hunting for the switcher chip.
|
||||
if (view !== "month") {
|
||||
const backToMonth = document.createElement("button");
|
||||
backToMonth.type = "button";
|
||||
backToMonth.className = "btn-link views-calendar-back-to-month";
|
||||
backToMonth.textContent = t("cal.day.back_to_month");
|
||||
backToMonth.addEventListener("click", () => onNav("month", anchor));
|
||||
nav.appendChild(backToMonth);
|
||||
}
|
||||
|
||||
bar.appendChild(nav);
|
||||
return bar;
|
||||
}
|
||||
|
||||
function navLabelKey(view: CalView, dir: "prev" | "next"): I18nKey {
|
||||
if (view === "month") return dir === "prev" ? "cal.month.prev" : "cal.month.next";
|
||||
if (view === "week") return dir === "prev" ? "cal.week.prev" : "cal.week.next";
|
||||
return dir === "prev" ? "cal.day.prev" : "cal.day.next";
|
||||
}
|
||||
|
||||
// --- Month view ----------------------------------------------------------
|
||||
|
||||
function renderMonth(anchor: Date, rows: ViewRow[], onDayDrill: (d: Date) => void): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-month";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
wrap.appendChild(header);
|
||||
|
||||
// Single grid with one column-template that the weekday row and the day
|
||||
// cells share. The header row is added with `grid-column: span 7` so
|
||||
// it spans the full width above the day grid (laid out below).
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-grid";
|
||||
|
||||
const weekdayKeys: I18nKey[] = [
|
||||
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
|
||||
"cal.day.fri", "cal.day.sat", "cal.day.sun",
|
||||
];
|
||||
for (const k of weekdayKeys) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-weekday";
|
||||
cell.textContent = t(k);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
const monthStart = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
||||
const startWeekday = (monthStart.getDay() + 6) % 7; // Mon=0
|
||||
const daysInMonth = new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0).getDate();
|
||||
|
||||
// Pad start with prev-month spillover.
|
||||
for (let i = 0; i < startWeekday; i++) {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell views-calendar-cell--out";
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
// Bucket rows by ISO date (yyyy-mm-dd) within the visible month.
|
||||
const byDate = bucketByDate(rows, (d) =>
|
||||
d.getMonth() === anchor.getMonth() && d.getFullYear() === anchor.getFullYear(),
|
||||
);
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dayDate = new Date(anchor.getFullYear(), anchor.getMonth(), day);
|
||||
const dateKey = isoDate(dayDate);
|
||||
const dayRows = byDate.get(dateKey) ?? [];
|
||||
const cell = renderMonthCell(dayDate, day, dayRows, onDayDrill);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderMonthCell(
|
||||
dayDate: Date,
|
||||
dayNum: number,
|
||||
dayRows: ViewRow[],
|
||||
onDayDrill: (d: Date) => void,
|
||||
): HTMLElement {
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "views-calendar-cell";
|
||||
if (isToday(dayDate)) cell.classList.add("views-calendar-cell--today");
|
||||
if (dayRows.length > 0) cell.classList.add("views-calendar-cell--has");
|
||||
|
||||
// Day-number is a click-target that switches to the day view. We render
|
||||
// it as a button to keep keyboard semantics; the surrounding cell stays
|
||||
// a div so it doesn't compete with the inner row anchors.
|
||||
const dayLabel = document.createElement("button");
|
||||
dayLabel.type = "button";
|
||||
dayLabel.className = "views-calendar-cell-day";
|
||||
dayLabel.textContent = String(dayNum);
|
||||
dayLabel.setAttribute("aria-label", t("cal.day.open_day"));
|
||||
dayLabel.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onDayDrill(dayDate);
|
||||
});
|
||||
cell.appendChild(dayLabel);
|
||||
|
||||
if (dayRows.length > 0) {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-pills";
|
||||
const visible = dayRows.slice(0, MAX_PILLS_PER_MONTH_CELL);
|
||||
for (const row of visible) {
|
||||
ul.appendChild(renderPill(row));
|
||||
}
|
||||
if (dayRows.length > visible.length) {
|
||||
const more = document.createElement("li");
|
||||
const moreBtn = document.createElement("button");
|
||||
moreBtn.type = "button";
|
||||
moreBtn.className = "views-calendar-pill views-calendar-pill--more";
|
||||
moreBtn.textContent = `+${dayRows.length - visible.length}`;
|
||||
moreBtn.setAttribute("aria-label", t("cal.day.open_day"));
|
||||
moreBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
onDayDrill(dayDate);
|
||||
});
|
||||
more.appendChild(moreBtn);
|
||||
ul.appendChild(more);
|
||||
}
|
||||
cell.appendChild(ul);
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
// --- Week view -----------------------------------------------------------
|
||||
|
||||
function renderWeek(anchor: Date, rows: ViewRow[]): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-week";
|
||||
|
||||
const weekStart = startOfWeek(anchor);
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
header.textContent = formatWeekHeader(weekStart, weekEnd, lang);
|
||||
wrap.appendChild(header);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "views-calendar-week-grid";
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(weekStart);
|
||||
day.setDate(weekStart.getDate() + i);
|
||||
const col = renderWeekColumn(day, rows);
|
||||
grid.appendChild(col);
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderWeekColumn(day: Date, rows: ViewRow[]): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const col = document.createElement("div");
|
||||
col.className = "views-calendar-week-column";
|
||||
if (isToday(day)) col.classList.add("views-calendar-week-column--today");
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "views-calendar-week-head";
|
||||
const weekdayKey = WEEKDAY_KEYS[(day.getDay() + 6) % 7];
|
||||
const dow = document.createElement("span");
|
||||
dow.className = "views-calendar-week-dow";
|
||||
dow.textContent = t(weekdayKey);
|
||||
const dnum = document.createElement("span");
|
||||
dnum.className = "views-calendar-week-dnum";
|
||||
dnum.textContent = day.toLocaleDateString(lang, { day: "numeric", month: "short" });
|
||||
head.appendChild(dow);
|
||||
head.appendChild(dnum);
|
||||
col.appendChild(head);
|
||||
|
||||
// No 3-row cap on week / day views — show everything for that day.
|
||||
const dayRows = filterByDay(rows, day);
|
||||
if (dayRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "views-calendar-week-empty";
|
||||
empty.textContent = t("cal.day.no_entries");
|
||||
col.appendChild(empty);
|
||||
return col;
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-week-list";
|
||||
for (const row of dayRows) {
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(renderRowAnchor(row, "week"));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
col.appendChild(ul);
|
||||
return col;
|
||||
}
|
||||
|
||||
// --- Day view ------------------------------------------------------------
|
||||
|
||||
function renderDay(anchor: Date, rows: ViewRow[]): HTMLElement {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "views-calendar-day-wrap";
|
||||
|
||||
const header = document.createElement("h2");
|
||||
header.className = "views-calendar-month-label";
|
||||
header.textContent = anchor.toLocaleDateString(lang, {
|
||||
weekday: "long", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
wrap.appendChild(header);
|
||||
|
||||
const dayRows = filterByDay(rows, anchor);
|
||||
if (dayRows.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "views-calendar-day-empty";
|
||||
empty.textContent = t("cal.day.no_entries");
|
||||
wrap.appendChild(empty);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "views-calendar-day-list";
|
||||
for (const row of dayRows) {
|
||||
const li = document.createElement("li");
|
||||
li.appendChild(renderRowAnchor(row, "day"));
|
||||
ul.appendChild(li);
|
||||
}
|
||||
wrap.appendChild(ul);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// --- Row rendering -------------------------------------------------------
|
||||
|
||||
function renderPill(row: ViewRow): HTMLElement {
|
||||
const li = document.createElement("li");
|
||||
const a = document.createElement("a");
|
||||
a.className = `views-calendar-pill views-calendar-pill--${row.kind}`;
|
||||
a.href = rowHref(row);
|
||||
a.textContent = row.title;
|
||||
a.title = row.title + (row.project_title ? ` — ${row.project_title}` : "");
|
||||
// Pills are anchors — month-cell day-button click ignores them via
|
||||
// stopPropagation on the button; cell-level handlers would intercept
|
||||
// them otherwise.
|
||||
a.addEventListener("click", (e) => e.stopPropagation());
|
||||
li.appendChild(a);
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderRowAnchor(row: ViewRow, density: "week" | "day"): HTMLElement {
|
||||
const a = document.createElement("a");
|
||||
a.className = `views-calendar-row views-calendar-row--${density} views-calendar-row--${row.kind}`;
|
||||
a.href = rowHref(row);
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = `views-calendar-row-dot views-calendar-row-dot--${row.kind}`;
|
||||
a.appendChild(dot);
|
||||
|
||||
const body = document.createElement("span");
|
||||
body.className = "views-calendar-row-body";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "views-calendar-row-title";
|
||||
title.textContent = row.title;
|
||||
body.appendChild(title);
|
||||
|
||||
const metaParts: string[] = [];
|
||||
metaParts.push(tDyn("views.kind." + row.kind));
|
||||
if (row.project_reference) metaParts.push(row.project_reference);
|
||||
else if (row.project_title) metaParts.push(row.project_title);
|
||||
if (metaParts.length > 0) {
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "views-calendar-row-meta";
|
||||
meta.textContent = metaParts.join(" · ");
|
||||
body.appendChild(meta);
|
||||
}
|
||||
|
||||
a.appendChild(body);
|
||||
return a;
|
||||
}
|
||||
|
||||
function rowHref(row: ViewRow): string {
|
||||
switch (row.kind) {
|
||||
case "deadline": return `/deadlines/${encodeURIComponent(row.id)}`;
|
||||
case "appointment": return `/appointments/${encodeURIComponent(row.id)}`;
|
||||
case "approval_request": return `/inbox`;
|
||||
case "project_event":
|
||||
// project_events surface on the project's Verlauf — best we can do
|
||||
// is link to the project. If no project, leave as a non-link target.
|
||||
return row.project_id ? `/projects/${encodeURIComponent(row.project_id)}` : "#";
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bucketing / date helpers --------------------------------------------
|
||||
|
||||
const WEEKDAY_KEYS: I18nKey[] = [
|
||||
"cal.day.mon", "cal.day.tue", "cal.day.wed", "cal.day.thu",
|
||||
"cal.day.fri", "cal.day.sat", "cal.day.sun",
|
||||
];
|
||||
|
||||
function bucketByDate(rows: ViewRow[], filter: (d: Date) => boolean): Map<string, ViewRow[]> {
|
||||
const out = new Map<string, ViewRow[]>();
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
if (!filter(d)) continue;
|
||||
const key = isoDate(d);
|
||||
const arr = out.get(key);
|
||||
if (arr) arr.push(row);
|
||||
else out.set(key, [row]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function filterByDay(rows: ViewRow[], day: Date): ViewRow[] {
|
||||
const key = isoDate(day);
|
||||
return rows.filter((r) => {
|
||||
const d = new Date(r.event_date);
|
||||
if (isNaN(d.getTime())) return false;
|
||||
return isoDate(d) === key;
|
||||
});
|
||||
}
|
||||
|
||||
function toCalendarItem(row: ViewRow): CalendarItem {
|
||||
return {
|
||||
kind: row.kind,
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
event_date: row.event_date,
|
||||
project_id: row.project_id,
|
||||
project_title: row.project_title,
|
||||
project_reference: row.project_reference,
|
||||
};
|
||||
function startOfWeek(d: Date): Date {
|
||||
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
const offset = (out.getDay() + 6) % 7; // Mon=0
|
||||
out.setDate(out.getDate() - offset);
|
||||
return out;
|
||||
}
|
||||
|
||||
function shift(d: Date, view: CalView, dir: number): Date {
|
||||
if (view === "month") return new Date(d.getFullYear(), d.getMonth() + dir, 1);
|
||||
if (view === "week") return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir * 7);
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + dir);
|
||||
}
|
||||
|
||||
function isToday(d: Date): boolean {
|
||||
const now = new Date();
|
||||
return d.getFullYear() === now.getFullYear()
|
||||
&& d.getMonth() === now.getMonth()
|
||||
&& d.getDate() === now.getDate();
|
||||
}
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function formatRangeLabel(view: CalView, anchor: Date): string {
|
||||
const lang = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
if (view === "month") {
|
||||
return anchor.toLocaleDateString(lang, { month: "long", year: "numeric" });
|
||||
}
|
||||
if (view === "week") {
|
||||
const start = startOfWeek(anchor);
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
return formatWeekHeader(start, end, lang);
|
||||
}
|
||||
return anchor.toLocaleDateString(lang, {
|
||||
weekday: "short", year: "numeric", month: "long", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatWeekHeader(start: Date, end: Date, lang: string): string {
|
||||
const startStr = start.toLocaleDateString(lang, { day: "numeric", month: "short" });
|
||||
const endStr = end.toLocaleDateString(lang, { day: "numeric", month: "short", year: "numeric" });
|
||||
return `${startStr} – ${endStr}`;
|
||||
}
|
||||
|
||||
// --- URL state -----------------------------------------------------------
|
||||
|
||||
function readView(defaultView: CalView | undefined): CalView {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(VIEW_PARAM);
|
||||
if (raw === "month" || raw === "week" || raw === "day") return raw;
|
||||
return defaultView ?? "month";
|
||||
}
|
||||
|
||||
function readAnchor(rows: ViewRow[]): Date {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get(DATE_PARAM);
|
||||
if (raw) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
|
||||
if (m) {
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
if (!isNaN(d.getTime())) return d;
|
||||
}
|
||||
}
|
||||
// No URL anchor — pick the first row's date, or today.
|
||||
for (const row of rows) {
|
||||
const d = new Date(row.event_date);
|
||||
if (!isNaN(d.getTime())) return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
}
|
||||
|
||||
function writeURL(view: CalView, anchor: Date): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(VIEW_PARAM, view);
|
||||
url.searchParams.set(DATE_PARAM, isoDate(anchor));
|
||||
history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
deadlineCardHtml,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
// Regression tests for the editable→click-to-edit wiring on timeline date
|
||||
// cells (m/paliad#59). When CardOpts.editable=true the card renderer must
|
||||
// emit `class="… frist-date-edit"` with `data-rule-code` + `data-current-
|
||||
// date` on the date span. Pages then attach a delegated click handler that
|
||||
// resolves that selector to swap in an inline `<input type="date">`. If a
|
||||
// future refactor drops the attrs, /tools/verfahrensablauf and
|
||||
// /tools/fristenrechner both silently lose click-to-edit (no script error,
|
||||
// nothing happens on click). These tests pin the contract.
|
||||
//
|
||||
// Fixture leaves ruleRef/legalSource* empty so deadlineCardHtml stays
|
||||
// inside its non-DOM code paths (escHtml is DOM-backed and bun test runs
|
||||
// in plain Node without jsdom).
|
||||
|
||||
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
|
||||
code: "upc-rop-12",
|
||||
name: "Klageerwiderung",
|
||||
nameEN: "Statement of Defence",
|
||||
party: "defendant",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: "2026-07-15",
|
||||
originalDate: "2026-07-15",
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
test("date span carries frist-date-edit class + data-rule-code + data-current-date", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true, editable: true });
|
||||
expect(html).toContain('class="timeline-date frist-date-edit"');
|
||||
expect(html).toContain('data-rule-code="upc-rop-12"');
|
||||
expect(html).toContain('data-current-date="2026-07-15"');
|
||||
expect(html).toContain('role="button"');
|
||||
expect(html).toContain('tabindex="0"');
|
||||
});
|
||||
|
||||
test("editable=false (default) emits the date span without click-to-edit attrs", () => {
|
||||
const html = deadlineCardHtml(dl(), { showParty: true });
|
||||
expect(html).toContain("timeline-date");
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
expect(html).not.toContain('role="button"');
|
||||
});
|
||||
|
||||
test("root event suppresses editable even when editable=true (root has no override semantic)", () => {
|
||||
const html = deadlineCardHtml(dl({ isRootEvent: true }), { showParty: true, editable: true });
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
|
||||
test("isCourtSet renders the court-set placeholder with click-to-edit so users can pin a real date", () => {
|
||||
const html = deadlineCardHtml(dl({ isCourtSet: true }), { showParty: true, editable: true });
|
||||
expect(html).toContain("timeline-court-set frist-date-edit");
|
||||
expect(html).toContain('data-rule-code="upc-rop-12"');
|
||||
});
|
||||
|
||||
test("empty rule code with editable=true still suppresses click-to-edit (no anchor target)", () => {
|
||||
const html = deadlineCardHtml(dl({ code: "" }), { showParty: true, editable: true });
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
});
|
||||
@@ -95,21 +95,8 @@ export function priorityRendering(
|
||||
export interface DeadlineResponse {
|
||||
proceedingType: string;
|
||||
proceedingName: string;
|
||||
// proceedingNameEN: English label of the picked proceeding. Empty
|
||||
// when not populated server-side; frontend falls back to
|
||||
// proceedingName. Used for the "Trigger event" fallback when the
|
||||
// timeline has no root rule. (m/paliad#58)
|
||||
proceedingNameEN?: string;
|
||||
triggerDate: string;
|
||||
deadlines: CalculatedDeadline[];
|
||||
// contextualNote / contextualNoteEN render as a banner above the
|
||||
// timeline. Populated when the picked proceeding is a sub-track of
|
||||
// another proceeding (e.g. upc.ccr.cfi runs inside upc.inf.cfi with
|
||||
// with_ccr) — the server routes to the parent's rules but keeps the
|
||||
// picked proceeding's identity in the response, and the note
|
||||
// explains the framing. (m/paliad#58)
|
||||
contextualNote?: string;
|
||||
contextualNoteEN?: string;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -312,87 +299,6 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
||||
${notesBlock}`;
|
||||
}
|
||||
|
||||
// ─── inline date editor (click-to-edit per-rule due date) ────────────────
|
||||
//
|
||||
// The renderer emits `<span class="frist-date-edit" data-rule-code="…"
|
||||
// data-current-date="YYYY-MM-DD" role="button" tabindex="0">…</span>` when
|
||||
// CardOpts.editable is true. Pages call wireDateEditClicks() on their
|
||||
// result container once, and the delegated click/keydown handlers swap a
|
||||
// clicked span for a `<input type="date">` editor via openInlineDateEditor.
|
||||
// The caller's onCommit callback receives (ruleCode, newValue) — an empty
|
||||
// newValue means "revert" (clear the anchor override and let the calculator
|
||||
// re-project). The actual recompute is the caller's job — they own the
|
||||
// anchor-overrides map + the calc dispatch.
|
||||
|
||||
export function openInlineDateEditor(
|
||||
span: HTMLElement,
|
||||
onCommit: (ruleCode: string, newValue: string) => void,
|
||||
): void {
|
||||
const ruleCode = span.dataset.ruleCode || "";
|
||||
if (!ruleCode) return;
|
||||
const current = span.dataset.currentDate || "";
|
||||
const editor = document.createElement("input");
|
||||
editor.type = "date";
|
||||
editor.className = "frist-date-edit-input";
|
||||
editor.value = current;
|
||||
|
||||
let done = false;
|
||||
const cancel = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
editor.replaceWith(span);
|
||||
};
|
||||
const commit = (newValue: string) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
onCommit(ruleCode, newValue);
|
||||
};
|
||||
|
||||
editor.addEventListener("blur", () => {
|
||||
if (editor.value !== current) commit(editor.value);
|
||||
else cancel();
|
||||
});
|
||||
editor.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key === "Enter") {
|
||||
e.preventDefault();
|
||||
editor.blur();
|
||||
} else if (ke.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
});
|
||||
|
||||
span.replaceWith(editor);
|
||||
editor.focus();
|
||||
if (editor.value) editor.select();
|
||||
}
|
||||
|
||||
// wireDateEditClicks attaches delegated click + keyboard handlers to the
|
||||
// timeline result container so click-to-edit survives every innerHTML
|
||||
// rewrite the page does on recalc. Idempotent — re-calling on the same
|
||||
// container does nothing (the dataset flag short-circuits).
|
||||
export function wireDateEditClicks(
|
||||
container: HTMLElement,
|
||||
onCommit: (ruleCode: string, newValue: string) => void,
|
||||
): void {
|
||||
if (container.dataset.dateEditWired === "1") return;
|
||||
container.dataset.dateEditWired = "1";
|
||||
container.addEventListener("click", (e) => {
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
openInlineDateEditor(target, onCommit);
|
||||
});
|
||||
container.addEventListener("keydown", (e) => {
|
||||
const ke = e as KeyboardEvent;
|
||||
if (ke.key !== "Enter" && ke.key !== " ") return;
|
||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||
if (!target || !target.dataset.ruleCode) return;
|
||||
e.preventDefault();
|
||||
openInlineDateEditor(target, onCommit);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
|
||||
@@ -22,7 +22,6 @@ export function ProjectFormFields(): string {
|
||||
<option value="patent" data-i18n="projects.type.patent">Patent</option>
|
||||
<option value="case" data-i18n="projects.type.case">Verfahren</option>
|
||||
<option value="project" data-i18n="projects.type.project">Projekt (generisch)</option>
|
||||
<option value="other" data-i18n="projects.type.other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -140,24 +139,6 @@ export function ProjectFormFields(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Litigation-specific */}
|
||||
<div className="projekt-fields projekt-fields-litigation" id="fields-litigation" style="display:none">
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-opponent-code" data-i18n="projects.field.opponent_code">Gegner-Kürzel</label>
|
||||
<input
|
||||
type="text"
|
||||
id="project-opponent-code"
|
||||
maxLength={16}
|
||||
pattern="[A-Z0-9-]{1,16}"
|
||||
placeholder="OPNT"
|
||||
data-i18n-placeholder="projects.field.opponent_code.placeholder"
|
||||
/>
|
||||
<p className="form-hint" data-i18n="projects.field.opponent_code.hint">
|
||||
Kurzes Kürzel der Gegenseite (Grossbuchstaben, Ziffern, Bindestriche, max. 16 Zeichen). Wird als mittleres Segment in automatisch abgeleiteten Projekt-Codes verwendet (z.B. EXMPL.OPNT.567.INF.CFI).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Case-specific */}
|
||||
<div className="projekt-fields projekt-fields-case" id="fields-case" style="display:none">
|
||||
<div className="form-field-row">
|
||||
@@ -170,29 +151,20 @@ export function ProjectFormFields(): string {
|
||||
<input type="text" id="project-case-number" placeholder="UPC_CFI_123/2026" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-our-side" data-i18n="projects.field.client_role">Mandantenrolle</label>
|
||||
<select id="project-our-side">
|
||||
<option value="" data-i18n="projects.field.client_role.unset">Unbekannt</option>
|
||||
<optgroup data-i18n-label="projects.field.client_role.group.active" label="Aktiv (wir greifen an)">
|
||||
<option value="claimant" data-i18n="projects.field.client_role.claimant">Klägerseite</option>
|
||||
<option value="applicant" data-i18n="projects.field.client_role.applicant">Antragsteller</option>
|
||||
<option value="appellant" data-i18n="projects.field.client_role.appellant">Berufungsführer</option>
|
||||
</optgroup>
|
||||
<optgroup data-i18n-label="projects.field.client_role.group.reactive" label="Reaktiv (wir verteidigen)">
|
||||
<option value="defendant" data-i18n="projects.field.client_role.defendant">Beklagtenseite</option>
|
||||
<option value="respondent" data-i18n="projects.field.client_role.respondent">Antragsgegner</option>
|
||||
</optgroup>
|
||||
<optgroup data-i18n-label="projects.field.client_role.group.other" label="Dritte / Sonstige">
|
||||
<option value="third_party" data-i18n="projects.field.client_role.third_party">Streithelfer / Dritter</option>
|
||||
<option value="other" data-i18n="projects.field.client_role.other">Sonstige Beteiligte</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="projects.field.client_role.hint">
|
||||
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator: Aktiv → Klägerseite, Reaktiv → Beklagtenseite. Lässt sich dort jederzeit überschreiben.
|
||||
</p>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-our-side" data-i18n="projects.field.our_side">Wir vertreten</label>
|
||||
<select id="project-our-side">
|
||||
<option value="" data-i18n="projects.field.our_side.unset">Unbekannt / nicht gesetzt</option>
|
||||
<option value="claimant" data-i18n="projects.field.our_side.claimant">Klägerseite</option>
|
||||
<option value="defendant" data-i18n="projects.field.our_side.defendant">Beklagtenseite</option>
|
||||
<option value="court" data-i18n="projects.field.our_side.court">Gericht / Tribunal</option>
|
||||
<option value="both" data-i18n="projects.field.our_side.both">Beide Seiten</option>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="projects.field.our_side.hint">
|
||||
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
|
||||
@@ -5,14 +5,12 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// The three /* __PALIAD_DASHBOARD_*__ */ tokens below are replaced at
|
||||
// request time by the Go handler (internal/handlers/dashboard_shell.go)
|
||||
// with JSON blobs assigned to window.__PALIAD_DASHBOARD__,
|
||||
// window.__PALIAD_DASHBOARD_LAYOUT__, and window.__PALIAD_DASHBOARD_CATALOG__.
|
||||
// Keep each token intact and exactly once in the output. The latter two
|
||||
// power the per-user configurable layout (t-paliad-219).
|
||||
// The /* __PALIAD_DASHBOARD_DATA__ */ token below is replaced at request time
|
||||
// by the Go handler (internal/handlers/dashboard_shell.go) with a JSON blob
|
||||
// assigned to window.__PALIAD_DASHBOARD__. Keep the token intact and exactly
|
||||
// once in the output.
|
||||
const HYDRATION_SCRIPT =
|
||||
"/*__PALIAD_DASHBOARD_DATA__*//*__PALIAD_DASHBOARD_LAYOUT__*//*__PALIAD_DASHBOARD_CATALOG__*/";
|
||||
"/*__PALIAD_DASHBOARD_DATA__*/";
|
||||
|
||||
// Chevron used as the collapsible-section disclosure indicator. CSS rotates
|
||||
// it 90deg clockwise when the section is open via the
|
||||
@@ -25,13 +23,12 @@ const ICON_CHEVRON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
// renders all sections expanded so unstyled fallback is sensible.
|
||||
function CollapsibleSection(props: {
|
||||
id: string;
|
||||
widgetKey: string;
|
||||
headingI18n: string;
|
||||
headingDe: string;
|
||||
children: any;
|
||||
}): string {
|
||||
return (
|
||||
<section className="dashboard-section" data-collapse-key={props.id} data-widget-key={props.widgetKey} aria-expanded="true">
|
||||
<section className="dashboard-section" data-collapse-key={props.id} aria-expanded="true">
|
||||
<button type="button" className="dashboard-section-toggle" aria-expanded="true">
|
||||
<h3 className="dashboard-section-heading" data-i18n={props.headingI18n}>{props.headingDe}</h3>
|
||||
<span className="dashboard-section-chevron" aria-hidden="true"
|
||||
@@ -76,17 +73,6 @@ export function renderDashboard(): string {
|
||||
<span className="dashboard-date" id="dashboard-date"></span>
|
||||
</p>
|
||||
</div>
|
||||
{/* "Anpassen" toggle (t-paliad-219 Slice B). Off by
|
||||
default — when on, body.dashboard-editing reveals
|
||||
drag handles / ↑↓ / x / ⚙ chrome on each widget plus
|
||||
the edit-footer below the widget stack. */}
|
||||
<button
|
||||
type="button"
|
||||
id="dashboard-edit-toggle"
|
||||
className="btn btn-ghost dashboard-edit-toggle"
|
||||
aria-pressed="false"
|
||||
data-i18n="dashboard.edit.toggle"
|
||||
>Anpassen</button>
|
||||
</div>
|
||||
|
||||
<div id="dashboard-unavailable" className="dashboard-unavailable" style="display:none">
|
||||
@@ -101,184 +87,105 @@ export function renderDashboard(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Configurable widget grid (t-paliad-227 overhaul). All
|
||||
widgets live as direct children of the single
|
||||
.dashboard-grid container so applyLayout can place them
|
||||
via grid-column/grid-row inline styles. Pre-overhaul
|
||||
this stack had nested wrappers (.dashboard-columns,
|
||||
standalone <section>s) that fought the layout engine
|
||||
and made cross-row drags appear to fail. */}
|
||||
<div className="dashboard-grid" id="dashboard-grid">
|
||||
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
|
||||
<CollapsibleSection id="summary" widgetKey="deadline-summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
|
||||
<div className="dashboard-summary-grid">
|
||||
<a href="/deadlines?status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
|
||||
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.overdue">Überfällig</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=today" className="dashboard-card dashboard-card-today" id="dashboard-card-today">
|
||||
<div className="dashboard-card-count" id="dashboard-count-today">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.today">Heute</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=this_week" className="dashboard-card dashboard-card-amber" id="dashboard-card-thisweek">
|
||||
<div className="dashboard-card-count" id="dashboard-count-this-week">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.this_week">Diese Woche</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=next_week" className="dashboard-card dashboard-card-green" id="dashboard-card-nextweek">
|
||||
<div className="dashboard-card-count" id="dashboard-count-next-week">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.next_week">Nächste Woche</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=later" className="dashboard-card dashboard-card-later" id="dashboard-card-later">
|
||||
<div className="dashboard-card-count" id="dashboard-count-later">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Später</div>
|
||||
</a>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Matter summary — uses CollapsibleSection now so it
|
||||
participates in the grid like every other widget. The
|
||||
inner card heading was redundant with the section
|
||||
heading; we keep the stats grid + the projects link. */}
|
||||
<CollapsibleSection id="matters" widgetKey="matter-summary" headingI18n="dashboard.matters.heading" headingDe="Meine Akten">
|
||||
<a href="/projects" className="dashboard-matter-card">
|
||||
<div className="dashboard-matter-stats">
|
||||
<div>
|
||||
<div className="dashboard-matter-num" id="dashboard-matter-active">0</div>
|
||||
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.active">Aktiv</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-matter-num" id="dashboard-matter-archived">0</div>
|
||||
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.archived">Archiviert</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-matter-num" id="dashboard-matter-total">0</div>
|
||||
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.total">Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Traffic-light deadline summary (4+1: Überfällig conditional + 4 universal — t-paliad-110) */}
|
||||
<CollapsibleSection id="summary" headingI18n="dashboard.summary.heading" headingDe="Fristen auf einen Blick">
|
||||
<div className="dashboard-summary-grid">
|
||||
<a href="/deadlines?status=overdue" className="dashboard-card dashboard-card-red" id="dashboard-card-overdue">
|
||||
<div className="dashboard-card-count" id="dashboard-count-overdue">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.overdue">Überfällig</div>
|
||||
</a>
|
||||
</CollapsibleSection>
|
||||
<a href="/deadlines?status=today" className="dashboard-card dashboard-card-today" id="dashboard-card-today">
|
||||
<div className="dashboard-card-count" id="dashboard-count-today">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.today">Heute</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=this_week" className="dashboard-card dashboard-card-amber" id="dashboard-card-thisweek">
|
||||
<div className="dashboard-card-count" id="dashboard-count-this-week">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.this_week">Diese Woche</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=next_week" className="dashboard-card dashboard-card-green" id="dashboard-card-nextweek">
|
||||
<div className="dashboard-card-count" id="dashboard-count-next-week">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.next_week">Nächste Woche</div>
|
||||
</a>
|
||||
<a href="/deadlines?status=later" className="dashboard-card dashboard-card-later" id="dashboard-card-later">
|
||||
<div className="dashboard-card-count" id="dashboard-count-later">0</div>
|
||||
<div className="dashboard-card-label" data-i18n="dashboard.summary.later">Später</div>
|
||||
</a>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection id="deadlines" widgetKey="upcoming-deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
|
||||
{/* Matter summary card — single tappable card, kept outside the
|
||||
collapsible scaffold because its h3 is internal to the card
|
||||
and doubles as the navigation affordance. */}
|
||||
<section className="dashboard-matters">
|
||||
<a href="/projects" className="dashboard-matter-card">
|
||||
<div className="dashboard-matter-header">
|
||||
<h3 data-i18n="dashboard.matters.heading">Meine Akten</h3>
|
||||
<span className="dashboard-matter-arrow" aria-hidden="true">→</span>
|
||||
</div>
|
||||
<div className="dashboard-matter-stats">
|
||||
<div>
|
||||
<div className="dashboard-matter-num" id="dashboard-matter-active">0</div>
|
||||
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.active">Aktiv</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-matter-num" id="dashboard-matter-archived">0</div>
|
||||
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.archived">Archiviert</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="dashboard-matter-num" id="dashboard-matter-total">0</div>
|
||||
<div className="dashboard-matter-lbl" data-i18n="dashboard.matters.total">Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
{/* Two-column lists — each column is its own collapsible section
|
||||
so users can hide deadlines or appointments independently.
|
||||
The .dashboard-columns wrapper is preserved so the grid
|
||||
layout still applies; collapse hides the body of each col
|
||||
but leaves the heading row in the grid. */}
|
||||
<div className="dashboard-columns">
|
||||
<CollapsibleSection id="deadlines" headingI18n="dashboard.deadlines.heading" headingDe="Kommende Fristen">
|
||||
<ul className="dashboard-list" id="dashboard-deadlines-list"></ul>
|
||||
<div className="dashboard-calendar" id="dashboard-deadlines-calendar" style="display:none"></div>
|
||||
<p className="dashboard-empty" id="dashboard-deadlines-empty" style="display:none" data-i18n="dashboard.deadlines.empty">
|
||||
Keine Fristen in den nächsten 7 Tagen.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection id="appointments" widgetKey="upcoming-appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
|
||||
<CollapsibleSection id="appointments" headingI18n="dashboard.appointments.heading" headingDe="Kommende Termine">
|
||||
<ul className="dashboard-list" id="dashboard-appointments-list"></ul>
|
||||
<div className="dashboard-calendar" id="dashboard-appointments-calendar" style="display:none"></div>
|
||||
<p className="dashboard-empty" id="dashboard-appointments-empty" style="display:none" data-i18n="dashboard.appointments.empty">
|
||||
Keine Termine in den nächsten 7 Tagen.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* Inline Agenda (t-paliad-162). Same item shape as the
|
||||
standalone /agenda page, rendered via the shared
|
||||
agenda-render module. The dashboard variant is read-only:
|
||||
no chip filters, no URL state — a 30-day window of
|
||||
upcoming items grouped by day. The standalone /agenda
|
||||
route is unchanged for direct-link compatibility. */}
|
||||
<CollapsibleSection id="agenda" widgetKey="inline-agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
|
||||
<div className="dashboard-agenda">
|
||||
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
|
||||
<ul className="dashboard-list" id="dashboard-agenda-list" style="display:none"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
|
||||
Keine Fälligkeiten in den nächsten 30 Tagen.
|
||||
</p>
|
||||
<p className="dashboard-agenda-link">
|
||||
<a href="/agenda" data-i18n="dashboard.agenda.full_link">Vollständige Agenda öffnen →</a>
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Inbox-approvals widget (t-paliad-219 — new in v1). The
|
||||
list mirrors /inbox's "Approver" axis but capped at the
|
||||
widget's count setting. Renders the empty state when
|
||||
the user has no open approvals to review. */}
|
||||
<CollapsibleSection id="inbox-approvals" widgetKey="inbox-approvals" headingI18n="dashboard.inbox.heading" headingDe="Offene Freigaben">
|
||||
<div className="dashboard-inbox">
|
||||
<p className="dashboard-inbox-summary" id="dashboard-inbox-summary" style="display:none"></p>
|
||||
<ul className="dashboard-list" id="dashboard-inbox-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-inbox-empty" style="display:none" data-i18n="dashboard.inbox.empty">
|
||||
Keine offenen Freigaben.
|
||||
</p>
|
||||
<p className="dashboard-agenda-link">
|
||||
<a href="/inbox" data-i18n="dashboard.inbox.full_link">Vollständigen Posteingang öffnen →</a>
|
||||
</p>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Activity feed — moved under Agenda per m's design call
|
||||
(t-paliad-162). */}
|
||||
<CollapsibleSection id="activity" widgetKey="recent-activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
|
||||
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
|
||||
Noch keine Aktivität erfasst.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Pinned-projects widget (t-paliad-219 Slice C). Reads
|
||||
PinService via DashboardData.pinned_projects (server-
|
||||
joined to titles + refs). Default-hidden — users opt
|
||||
in via the picker. */}
|
||||
<CollapsibleSection id="pinned-projects" widgetKey="pinned-projects" headingI18n="dashboard.pinned.heading" headingDe="Angepinnte Akten">
|
||||
<ul className="dashboard-list" id="dashboard-pinned-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-pinned-empty" style="display:none" data-i18n="dashboard.pinned.empty">
|
||||
Noch keine Akten angepinnt.
|
||||
{/* Inline Agenda (t-paliad-162). Same item shape as the
|
||||
standalone /agenda page, rendered via the shared
|
||||
agenda-render module. The dashboard variant is read-only:
|
||||
no chip filters, no URL state — a 30-day window of
|
||||
upcoming items grouped by day. The standalone /agenda
|
||||
route is unchanged for direct-link compatibility. */}
|
||||
<CollapsibleSection id="agenda" headingI18n="dashboard.agenda.heading" headingDe="Agenda">
|
||||
<div className="dashboard-agenda">
|
||||
<div className="agenda-timeline" id="dashboard-agenda-timeline" />
|
||||
<p className="dashboard-empty" id="dashboard-agenda-empty" style="display:none" data-i18n="dashboard.agenda.empty">
|
||||
Keine Fälligkeiten in den nächsten 30 Tagen.
|
||||
</p>
|
||||
<p className="dashboard-agenda-link">
|
||||
<a href="/projects" data-i18n="dashboard.pinned.full_link">Alle Akten öffnen →</a>
|
||||
<a href="/agenda" data-i18n="dashboard.agenda.full_link">Vollständige Agenda öffnen →</a>
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Quick-actions widget (t-paliad-219 Slice C). Pure UI;
|
||||
no backend data path. Default-hidden — surfaced via the
|
||||
picker. */}
|
||||
<CollapsibleSection id="quick-actions" widgetKey="quick-actions" headingI18n="dashboard.quick.heading" headingDe="Schnellzugriff">
|
||||
<div className="dashboard-quick-actions">
|
||||
<a href="/projects/new" className="btn btn-primary dashboard-quick-btn" data-i18n="dashboard.quick.new_project">+ Akte</a>
|
||||
<a href="/deadlines/new" className="btn btn-secondary dashboard-quick-btn" data-i18n="dashboard.quick.new_deadline">+ Frist</a>
|
||||
<a href="/appointments/new" className="btn btn-secondary dashboard-quick-btn" data-i18n="dashboard.quick.new_appointment">+ Termin</a>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
{/* Edit-mode footer (t-paliad-219 Slice B). Hidden via CSS
|
||||
unless body.dashboard-editing — see dashboard.ts.
|
||||
Slice C added the admin "Promote to firm default"
|
||||
button — it stays hidden unless data.user.global_role
|
||||
is 'global_admin'; dashboard.ts toggles it. */}
|
||||
<div id="dashboard-edit-footer" className="dashboard-edit-footer">
|
||||
<button
|
||||
type="button"
|
||||
id="dashboard-edit-add"
|
||||
className="btn btn-secondary dashboard-edit-add"
|
||||
data-i18n="dashboard.edit.add_widget"
|
||||
>Widget hinzufügen</button>
|
||||
<button
|
||||
type="button"
|
||||
id="dashboard-edit-promote"
|
||||
className="btn btn-ghost dashboard-edit-promote"
|
||||
style="display:none"
|
||||
data-i18n="dashboard.edit.promote"
|
||||
>Als Firmen-Standard speichern</button>
|
||||
<button
|
||||
type="button"
|
||||
id="dashboard-edit-reset"
|
||||
className="dashboard-edit-reset-link"
|
||||
data-i18n="dashboard.edit.reset"
|
||||
>Auf Standard zurücksetzen</button>
|
||||
</div>
|
||||
|
||||
{/* Save toast slot — managed by dashboard.ts. */}
|
||||
<div
|
||||
id="dashboard-save-toast"
|
||||
className="dashboard-save-toast"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
{/* Activity feed — moved under Agenda per m's design call
|
||||
(t-paliad-162). */}
|
||||
<CollapsibleSection id="activity" headingI18n="dashboard.activity.heading" headingDe="Letzte Aktivität">
|
||||
<ul className="dashboard-activity-list" id="dashboard-activity-list"></ul>
|
||||
<p className="dashboard-empty" id="dashboard-activity-empty" style="display:none" data-i18n="dashboard.activity.empty">
|
||||
Noch keine Aktivität erfasst.
|
||||
</p>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
84
frontend/src/deadlines-calendar.tsx
Normal file
84
frontend/src/deadlines-calendar.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderDeadlinesCalendar(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="deadlines.kalender.title">Fristenkalender — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/events?type=deadline" />
|
||||
<BottomNav currentPath="/events?type=deadline" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div className="entity-header-row">
|
||||
<div>
|
||||
<h1 data-i18n="deadlines.kalender.heading">Fristenkalender</h1>
|
||||
<p className="tool-subtitle" data-i18n="deadlines.kalender.subtitle">
|
||||
Monatsübersicht aller Fristen Ihrer Akten.
|
||||
</p>
|
||||
</div>
|
||||
<div className="fristen-header-actions">
|
||||
<a href="/events?type=deadline" className="btn-secondary" data-i18n="deadlines.kalender.list">Listenansicht</a>
|
||||
<a href="/deadlines/new" className="btn-primary btn-cta-lime" data-i18n="deadlines.list.new">Neue Frist</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="frist-calendar-controls">
|
||||
<button type="button" id="cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">←</button>
|
||||
<h2 id="cal-month-label" className="frist-cal-month-label" />
|
||||
<button type="button" id="cal-next" className="btn-secondary btn-small" aria-label="Nächster Monat">→</button>
|
||||
<button type="button" id="cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
|
||||
</div>
|
||||
|
||||
<div className="frist-calendar" id="deadline-calendar">
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
|
||||
<div id="deadline-cal-grid" className="frist-cal-grid" />
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="deadline-cal-empty" style="display:none" data-i18n="deadlines.kalender.empty">
|
||||
Keine Fristen im ausgewählten Zeitraum.
|
||||
</p>
|
||||
|
||||
<div className="modal-overlay" id="cal-popup" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="cal-popup-date" />
|
||||
<button className="modal-close" id="cal-popup-close" type="button">×</button>
|
||||
</div>
|
||||
<ul className="frist-cal-popup-list" id="cal-popup-list" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/deadlines-calendar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -82,21 +82,15 @@ export function renderDeadlinesDetail(): string {
|
||||
<input type="date" id="deadline-due-edit" style="display:none" />
|
||||
</dd>
|
||||
|
||||
{/* m/paliad#56 — Verfahrenshandlung block.
|
||||
Event type (parent concept) renders first; rule
|
||||
sits beneath as the citation under that event
|
||||
type. Editor splits them back into separate
|
||||
pickers but the read-only stack reads as one
|
||||
compound "Typ — Regel" surface. */}
|
||||
<dt data-i18n="deadlines.detail.rule">Regel</dt>
|
||||
<dd id="deadline-rule-display">—</dd>
|
||||
|
||||
<dt data-i18n="deadlines.field.event_type">Typ (optional)</dt>
|
||||
<dd>
|
||||
<span id="deadline-event-types-display">—</span>
|
||||
<div id="deadline-event-types-edit" className="event-type-picker-host" style="display:none" />
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.rule">Regel</dt>
|
||||
<dd id="deadline-rule-display">—</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.source">Quelle</dt>
|
||||
<dd id="deadline-source-display" />
|
||||
|
||||
|
||||
@@ -101,19 +101,18 @@ export function renderDeadlinesNew(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* m/paliad#56 — Regel sits directly beneath the Typ
|
||||
picker so the parent/child relationship reads at a
|
||||
glance. Due date is its own row below. */}
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
|
||||
<select id="deadline-rule">
|
||||
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-due" data-i18n="deadlines.field.due">Fälligkeitsdatum</label>
|
||||
<input type="date" id="deadline-due" required />
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-due" data-i18n="deadlines.field.due">Fälligkeitsdatum</label>
|
||||
<input type="date" id="deadline-due" required />
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
|
||||
<select id="deadline-rule">
|
||||
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
|
||||
@@ -236,10 +236,37 @@ export function renderEvents(): string {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Calendar host — mountCalendar() (t-paliad-224) builds the
|
||||
month/week/day grid + toolbar into this container when
|
||||
the Kalender view chip is active. Empty until then. */}
|
||||
<div id="events-calendar-wrap" className="events-calendar-wrap" hidden />
|
||||
<div id="events-calendar-wrap" className="events-calendar-wrap" hidden>
|
||||
<div className="frist-calendar-controls">
|
||||
<button type="button" id="events-cal-prev" className="btn-secondary btn-small" aria-label="Vorheriger Monat">←</button>
|
||||
<h2 id="events-cal-month-label" className="frist-cal-month-label" />
|
||||
<button type="button" id="events-cal-next" className="btn-secondary btn-small" aria-label="Nächster Monat">→</button>
|
||||
<button type="button" id="events-cal-today" className="btn-secondary btn-small" data-i18n="deadlines.kalender.today">Heute</button>
|
||||
</div>
|
||||
<div className="frist-calendar">
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.mon">Mo</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.tue">Di</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.wed">Mi</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.thu">Do</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.fri">Fr</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.sat">Sa</div>
|
||||
<div className="frist-cal-weekday" data-i18n="cal.day.sun">So</div>
|
||||
<div id="events-cal-grid" className="frist-cal-grid" />
|
||||
</div>
|
||||
<p className="entity-events-empty" id="events-cal-empty" hidden data-i18n="events.calendar.empty">
|
||||
Keine Einträge im ausgewählten Zeitraum.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="modal-overlay" id="events-cal-popup" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="events-cal-popup-date" />
|
||||
<button className="modal-close" id="events-cal-popup-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<ul className="frist-cal-popup-list" id="events-cal-popup-list" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="events-empty" style="display:none">
|
||||
<h2 data-i18n="events.empty.title">Keine Einträge vorhanden</h2>
|
||||
|
||||
@@ -161,19 +161,19 @@ export function renderFristenrechner(): string {
|
||||
<div className="fristen-adhoc-chips" role="group" aria-label="Ad-hoc proceeding">
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="upc"
|
||||
data-i18n="deadlines.step1.adhoc.upc">
|
||||
UPC proceeding
|
||||
Custom UPC proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="de"
|
||||
data-i18n="deadlines.step1.adhoc.de">
|
||||
DE proceeding
|
||||
Custom DE proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="epa"
|
||||
data-i18n="deadlines.step1.adhoc.epa">
|
||||
EPA proceeding
|
||||
Custom EPA proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="dpma"
|
||||
data-i18n="deadlines.step1.adhoc.dpma">
|
||||
DPMA proceeding
|
||||
Custom DPMA proceeding
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,10 +485,7 @@ export function renderFristenrechner(): string {
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
{/* Read-only caption labelling the value <span>. Not a
|
||||
<label htmlFor> — m/paliad#60: <label for=…> must
|
||||
point at a labelable form control, never a span. */}
|
||||
<span className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</span>
|
||||
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</label>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
|
||||
@@ -440,23 +440,7 @@ export type I18nKey =
|
||||
| "admin.section.planned"
|
||||
| "admin.subtitle"
|
||||
| "admin.team.add.direct"
|
||||
| "admin.team.add.full"
|
||||
| "admin.team.add.invite"
|
||||
| "admin.team.add_full.body"
|
||||
| "admin.team.add_full.cancel"
|
||||
| "admin.team.add_full.email"
|
||||
| "admin.team.add_full.error.email_exists"
|
||||
| "admin.team.add_full.error.generic"
|
||||
| "admin.team.add_full.error.unavailable"
|
||||
| "admin.team.add_full.feedback.added"
|
||||
| "admin.team.add_full.job_title"
|
||||
| "admin.team.add_full.lang"
|
||||
| "admin.team.add_full.name"
|
||||
| "admin.team.add_full.office"
|
||||
| "admin.team.add_full.profession"
|
||||
| "admin.team.add_full.send_welcome"
|
||||
| "admin.team.add_full.submit"
|
||||
| "admin.team.add_full.title"
|
||||
| "admin.team.col.actions"
|
||||
| "admin.team.col.additional"
|
||||
| "admin.team.col.created"
|
||||
@@ -502,13 +486,6 @@ export type I18nKey =
|
||||
| "agenda.appointment_type.deadline_hearing"
|
||||
| "agenda.appointment_type.hearing"
|
||||
| "agenda.appointment_type.meeting"
|
||||
| "agenda.day.di"
|
||||
| "agenda.day.do"
|
||||
| "agenda.day.fr"
|
||||
| "agenda.day.mi"
|
||||
| "agenda.day.mo"
|
||||
| "agenda.day.sa"
|
||||
| "agenda.day.so"
|
||||
| "agenda.day.today"
|
||||
| "agenda.day.tomorrow"
|
||||
| "agenda.empty.hint"
|
||||
@@ -578,6 +555,12 @@ export type I18nKey =
|
||||
| "appointments.filter.type"
|
||||
| "appointments.filter.type.all"
|
||||
| "appointments.form.approval_hint"
|
||||
| "appointments.kalender.empty"
|
||||
| "appointments.kalender.heading"
|
||||
| "appointments.kalender.list"
|
||||
| "appointments.kalender.subtitle"
|
||||
| "appointments.kalender.title"
|
||||
| "appointments.list.calendar"
|
||||
| "appointments.list.heading"
|
||||
| "appointments.list.new"
|
||||
| "appointments.list.subtitle"
|
||||
@@ -675,7 +658,6 @@ export type I18nKey =
|
||||
| "approvals.suggest.note_placeholder"
|
||||
| "approvals.suggest.section.context"
|
||||
| "approvals.suggest.section.editable"
|
||||
| "approvals.suggest.section.event_type_rule"
|
||||
| "approvals.suggest.submit"
|
||||
| "approvals.suggest.submit_disabled_hint"
|
||||
| "approvals.suggest.unsupported_lifecycle"
|
||||
@@ -722,49 +704,11 @@ export type I18nKey =
|
||||
| "cal.month.9"
|
||||
| "cal.month.next"
|
||||
| "cal.month.prev"
|
||||
| "cal.today"
|
||||
| "cal.view.day"
|
||||
| "cal.view.month"
|
||||
| "cal.view.week"
|
||||
| "cal.week.next"
|
||||
| "cal.week.prev"
|
||||
| "caldav.bindings.add"
|
||||
| "caldav.bindings.card.edit"
|
||||
| "caldav.bindings.card.enabled"
|
||||
| "caldav.bindings.card.remove"
|
||||
| "caldav.bindings.delete.confirm"
|
||||
| "caldav.bindings.delete.failed"
|
||||
| "caldav.bindings.empty"
|
||||
| "caldav.bindings.error.create_name_required"
|
||||
| "caldav.bindings.error.create_name_taken"
|
||||
| "caldav.bindings.error.create_unsupported"
|
||||
| "caldav.bindings.error.path"
|
||||
| "caldav.bindings.error.scope"
|
||||
| "caldav.bindings.error.scope_project"
|
||||
| "caldav.bindings.heading"
|
||||
| "caldav.bindings.hint"
|
||||
| "caldav.bindings.modal.add_title"
|
||||
| "caldav.bindings.modal.display_name"
|
||||
| "caldav.bindings.modal.display_name.placeholder"
|
||||
| "caldav.bindings.modal.edit_title"
|
||||
| "caldav.bindings.modal.scope"
|
||||
| "caldav.bindings.modal.scope.all_visible"
|
||||
| "caldav.bindings.modal.scope.personal_only"
|
||||
| "caldav.bindings.modal.scope.project"
|
||||
| "caldav.bindings.modal.scope.project.loading"
|
||||
| "caldav.bindings.modal.source"
|
||||
| "caldav.bindings.modal.source.create"
|
||||
| "caldav.bindings.modal.source.custom"
|
||||
| "caldav.bindings.modal.source.degrade"
|
||||
| "caldav.bindings.modal.source.discover_empty"
|
||||
| "caldav.bindings.modal.source.discover_failed"
|
||||
| "caldav.bindings.modal.source.existing"
|
||||
| "caldav.bindings.modal.source.loading"
|
||||
| "caldav.bindings.modal.submit_add"
|
||||
| "caldav.bindings.modal.submit_edit"
|
||||
| "caldav.bindings.scope.all_visible"
|
||||
| "caldav.bindings.scope.personal_only"
|
||||
| "caldav.bindings.scope.project"
|
||||
| "caldav.delete"
|
||||
| "caldav.delete.confirm"
|
||||
| "caldav.delete.done"
|
||||
@@ -807,54 +751,7 @@ export type I18nKey =
|
||||
| "changelog.tag.feature"
|
||||
| "changelog.tag.fix"
|
||||
| "changelog.title"
|
||||
| "checklisten.author.cancel"
|
||||
| "checklisten.author.error.generic"
|
||||
| "checklisten.author.error.no_groups"
|
||||
| "checklisten.author.error.notfound"
|
||||
| "checklisten.author.error.title"
|
||||
| "checklisten.author.field.court"
|
||||
| "checklisten.author.field.deadline"
|
||||
| "checklisten.author.field.description"
|
||||
| "checklisten.author.field.lang"
|
||||
| "checklisten.author.field.reference"
|
||||
| "checklisten.author.field.regime"
|
||||
| "checklisten.author.field.title"
|
||||
| "checklisten.author.field.title.hint"
|
||||
| "checklisten.author.field.visibility"
|
||||
| "checklisten.author.group.remove"
|
||||
| "checklisten.author.group.title"
|
||||
| "checklisten.author.groups.add"
|
||||
| "checklisten.author.groups.heading"
|
||||
| "checklisten.author.heading.edit"
|
||||
| "checklisten.author.heading.new"
|
||||
| "checklisten.author.item.add"
|
||||
| "checklisten.author.item.label"
|
||||
| "checklisten.author.item.note"
|
||||
| "checklisten.author.item.remove"
|
||||
| "checklisten.author.item.rule"
|
||||
| "checklisten.author.save"
|
||||
| "checklisten.author.saving"
|
||||
| "checklisten.author.subtitle"
|
||||
| "checklisten.author.title"
|
||||
| "checklisten.author.title.edit"
|
||||
| "checklisten.author.visibility.firm.hint"
|
||||
| "checklisten.author.visibility.private.hint"
|
||||
| "checklisten.back"
|
||||
| "checklisten.detail.authored.by"
|
||||
| "checklisten.detail.delete"
|
||||
| "checklisten.detail.delete.confirm"
|
||||
| "checklisten.detail.delete.error"
|
||||
| "checklisten.detail.demote"
|
||||
| "checklisten.detail.demote.confirm"
|
||||
| "checklisten.detail.edit"
|
||||
| "checklisten.detail.promote"
|
||||
| "checklisten.detail.promote.confirm"
|
||||
| "checklisten.detail.promote.error"
|
||||
| "checklisten.detail.share"
|
||||
| "checklisten.detail.visibility"
|
||||
| "checklisten.detail.visibility.error"
|
||||
| "checklisten.detail.visibility.set.firm"
|
||||
| "checklisten.detail.visibility.set.private"
|
||||
| "checklisten.disclaimer"
|
||||
| "checklisten.empty"
|
||||
| "checklisten.feedback.btn"
|
||||
@@ -872,23 +769,11 @@ export type I18nKey =
|
||||
| "checklisten.feedback.type"
|
||||
| "checklisten.filter.all"
|
||||
| "checklisten.filter.de"
|
||||
| "checklisten.filter.other"
|
||||
| "checklisten.gallery.empty"
|
||||
| "checklisten.heading"
|
||||
| "checklisten.instance.akte.open"
|
||||
| "checklisten.instance.back"
|
||||
| "checklisten.instance.diff.added"
|
||||
| "checklisten.instance.diff.changed"
|
||||
| "checklisten.instance.diff.close"
|
||||
| "checklisten.instance.diff.empty"
|
||||
| "checklisten.instance.diff.error"
|
||||
| "checklisten.instance.diff.removed"
|
||||
| "checklisten.instance.diff.title"
|
||||
| "checklisten.instance.loading"
|
||||
| "checklisten.instance.notfound"
|
||||
| "checklisten.instance.outdated.badge"
|
||||
| "checklisten.instance.outdated.diff"
|
||||
| "checklisten.instance.outdated.note"
|
||||
| "checklisten.instance.rename"
|
||||
| "checklisten.instance.rename.error"
|
||||
| "checklisten.instance.rename.save"
|
||||
@@ -911,18 +796,6 @@ export type I18nKey =
|
||||
| "checklisten.instances.heading"
|
||||
| "checklisten.instances.loading"
|
||||
| "checklisten.instances.sub"
|
||||
| "checklisten.mine.delete"
|
||||
| "checklisten.mine.delete.confirm"
|
||||
| "checklisten.mine.delete.error"
|
||||
| "checklisten.mine.edit"
|
||||
| "checklisten.mine.empty"
|
||||
| "checklisten.mine.loading"
|
||||
| "checklisten.mine.new"
|
||||
| "checklisten.mine.origin.authored"
|
||||
| "checklisten.mine.visibility.firm"
|
||||
| "checklisten.mine.visibility.global"
|
||||
| "checklisten.mine.visibility.private"
|
||||
| "checklisten.mine.visibility.shared"
|
||||
| "checklisten.newInstance"
|
||||
| "checklisten.newInstance.akte"
|
||||
| "checklisten.newInstance.akte.hint"
|
||||
@@ -939,31 +812,8 @@ export type I18nKey =
|
||||
| "checklisten.reset"
|
||||
| "checklisten.reset.confirm"
|
||||
| "checklisten.reset.error"
|
||||
| "checklisten.share.cancel"
|
||||
| "checklisten.share.error.generic"
|
||||
| "checklisten.share.error.pick"
|
||||
| "checklisten.share.grants.empty"
|
||||
| "checklisten.share.grants.heading"
|
||||
| "checklisten.share.grants.recipient.office"
|
||||
| "checklisten.share.grants.recipient.partner_unit"
|
||||
| "checklisten.share.grants.recipient.project"
|
||||
| "checklisten.share.grants.recipient.user"
|
||||
| "checklisten.share.grants.revoke"
|
||||
| "checklisten.share.grants.revoke.confirm"
|
||||
| "checklisten.share.grants.revoke.error"
|
||||
| "checklisten.share.kind"
|
||||
| "checklisten.share.kind.office"
|
||||
| "checklisten.share.kind.partner_unit"
|
||||
| "checklisten.share.kind.project"
|
||||
| "checklisten.share.kind.user"
|
||||
| "checklisten.share.pick"
|
||||
| "checklisten.share.submit"
|
||||
| "checklisten.share.success"
|
||||
| "checklisten.share.title"
|
||||
| "checklisten.subtitle"
|
||||
| "checklisten.tab.gallery"
|
||||
| "checklisten.tab.instances"
|
||||
| "checklisten.tab.mine"
|
||||
| "checklisten.tab.templates"
|
||||
| "checklisten.title"
|
||||
| "common.cancel"
|
||||
@@ -1039,54 +889,12 @@ export type I18nKey =
|
||||
| "dashboard.appointments.heading"
|
||||
| "dashboard.deadlines.empty"
|
||||
| "dashboard.deadlines.heading"
|
||||
| "dashboard.edit.add_widget"
|
||||
| "dashboard.edit.drag"
|
||||
| "dashboard.edit.exit"
|
||||
| "dashboard.edit.hide"
|
||||
| "dashboard.edit.move_down"
|
||||
| "dashboard.edit.move_up"
|
||||
| "dashboard.edit.promote"
|
||||
| "dashboard.edit.promote_confirm"
|
||||
| "dashboard.edit.promoted"
|
||||
| "dashboard.edit.reset"
|
||||
| "dashboard.edit.reset_confirm"
|
||||
| "dashboard.edit.resize"
|
||||
| "dashboard.edit.save_failed"
|
||||
| "dashboard.edit.saved"
|
||||
| "dashboard.edit.setting.count"
|
||||
| "dashboard.edit.setting.count.custom"
|
||||
| "dashboard.edit.setting.horizon"
|
||||
| "dashboard.edit.setting.horizon.custom"
|
||||
| "dashboard.edit.setting.horizon.days"
|
||||
| "dashboard.edit.setting.position"
|
||||
| "dashboard.edit.setting.size"
|
||||
| "dashboard.edit.setting.view"
|
||||
| "dashboard.edit.settings"
|
||||
| "dashboard.edit.toggle"
|
||||
| "dashboard.greeting.prefix"
|
||||
| "dashboard.inbox.empty"
|
||||
| "dashboard.inbox.entity.appointment"
|
||||
| "dashboard.inbox.entity.deadline"
|
||||
| "dashboard.inbox.full_link"
|
||||
| "dashboard.inbox.heading"
|
||||
| "dashboard.matters.active"
|
||||
| "dashboard.matters.archived"
|
||||
| "dashboard.matters.heading"
|
||||
| "dashboard.matters.total"
|
||||
| "dashboard.onboarding"
|
||||
| "dashboard.picker.close"
|
||||
| "dashboard.picker.empty"
|
||||
| "dashboard.picker.status.absent"
|
||||
| "dashboard.picker.status.active"
|
||||
| "dashboard.picker.status.hidden"
|
||||
| "dashboard.picker.title"
|
||||
| "dashboard.pinned.empty"
|
||||
| "dashboard.pinned.full_link"
|
||||
| "dashboard.pinned.heading"
|
||||
| "dashboard.quick.heading"
|
||||
| "dashboard.quick.new_appointment"
|
||||
| "dashboard.quick.new_deadline"
|
||||
| "dashboard.quick.new_project"
|
||||
| "dashboard.section.collapse"
|
||||
| "dashboard.section.expand"
|
||||
| "dashboard.summary.completed"
|
||||
@@ -1121,9 +929,7 @@ export type I18nKey =
|
||||
| "deadlines.card.calc.flag.with_cci"
|
||||
| "deadlines.card.calc.flag.with_ccr"
|
||||
| "deadlines.card.calc.flags.label"
|
||||
| "deadlines.card.calc.pill_picker.change"
|
||||
| "deadlines.card.calc.pill_picker.label"
|
||||
| "deadlines.card.calc.pill_picker.locked_label"
|
||||
| "deadlines.card.calc.result.calculating"
|
||||
| "deadlines.card.calc.result.court_set"
|
||||
| "deadlines.card.calc.result.due"
|
||||
@@ -1269,6 +1075,13 @@ export type I18nKey =
|
||||
| "deadlines.inbox.label"
|
||||
| "deadlines.inbox.posteingang"
|
||||
| "deadlines.inbox.posteingang.title"
|
||||
| "deadlines.kalender.empty"
|
||||
| "deadlines.kalender.heading"
|
||||
| "deadlines.kalender.list"
|
||||
| "deadlines.kalender.subtitle"
|
||||
| "deadlines.kalender.title"
|
||||
| "deadlines.kalender.today"
|
||||
| "deadlines.list.calendar"
|
||||
| "deadlines.list.heading"
|
||||
| "deadlines.list.new"
|
||||
| "deadlines.list.subtitle"
|
||||
@@ -1596,6 +1409,7 @@ export type I18nKey =
|
||||
| "event_types.picker.no_match"
|
||||
| "event_types.picker.remove"
|
||||
| "event_types.picker.search"
|
||||
| "events.calendar.empty"
|
||||
| "events.col.appointment_type"
|
||||
| "events.col.date"
|
||||
| "events.col.location"
|
||||
@@ -2116,7 +1930,6 @@ export type I18nKey =
|
||||
| "projects.chip.type.case"
|
||||
| "projects.chip.type.client"
|
||||
| "projects.chip.type.litigation"
|
||||
| "projects.chip.type.other"
|
||||
| "projects.chip.type.patent"
|
||||
| "projects.chip.type.project"
|
||||
| "projects.col.clientmatter"
|
||||
@@ -2153,8 +1966,6 @@ export type I18nKey =
|
||||
| "projects.detail.edit"
|
||||
| "projects.detail.edit.modal.title"
|
||||
| "projects.detail.edit.type_change_warning.title"
|
||||
| "projects.detail.export.button"
|
||||
| "projects.detail.export.tooltip"
|
||||
| "projects.detail.firmwide.off"
|
||||
| "projects.detail.firmwide.on"
|
||||
| "projects.detail.kinder.add"
|
||||
@@ -2304,19 +2115,6 @@ export type I18nKey =
|
||||
| "projects.field.billing_reference"
|
||||
| "projects.field.case_number"
|
||||
| "projects.field.client_number"
|
||||
| "projects.field.client_role"
|
||||
| "projects.field.client_role.appellant"
|
||||
| "projects.field.client_role.applicant"
|
||||
| "projects.field.client_role.claimant"
|
||||
| "projects.field.client_role.defendant"
|
||||
| "projects.field.client_role.group.active"
|
||||
| "projects.field.client_role.group.other"
|
||||
| "projects.field.client_role.group.reactive"
|
||||
| "projects.field.client_role.hint"
|
||||
| "projects.field.client_role.other"
|
||||
| "projects.field.client_role.respondent"
|
||||
| "projects.field.client_role.third_party"
|
||||
| "projects.field.client_role.unset"
|
||||
| "projects.field.clientmatter.hint"
|
||||
| "projects.field.collaborators"
|
||||
| "projects.field.collaborators.hint"
|
||||
@@ -2334,21 +2132,13 @@ export type I18nKey =
|
||||
| "projects.field.matter_number"
|
||||
| "projects.field.netdocuments_url"
|
||||
| "projects.field.office"
|
||||
| "projects.field.opponent_code"
|
||||
| "projects.field.opponent_code.hint"
|
||||
| "projects.field.opponent_code.placeholder"
|
||||
| "projects.field.our_side"
|
||||
| "projects.field.our_side.appellant"
|
||||
| "projects.field.our_side.applicant"
|
||||
| "projects.field.our_side.both"
|
||||
| "projects.field.our_side.claimant"
|
||||
| "projects.field.our_side.court"
|
||||
| "projects.field.our_side.defendant"
|
||||
| "projects.field.our_side.hint"
|
||||
| "projects.field.our_side.none"
|
||||
| "projects.field.our_side.other"
|
||||
| "projects.field.our_side.respondent"
|
||||
| "projects.field.our_side.third_party"
|
||||
| "projects.field.our_side.unset"
|
||||
| "projects.field.parent"
|
||||
| "projects.field.parent.hint"
|
||||
@@ -2395,9 +2185,6 @@ export type I18nKey =
|
||||
| "projects.team.derived.from"
|
||||
| "projects.team.derived.visibility"
|
||||
| "projects.team.direct"
|
||||
| "projects.team.error.forbidden"
|
||||
| "projects.team.error.generic"
|
||||
| "projects.team.error.last_admin"
|
||||
| "projects.team.inherited.hint"
|
||||
| "projects.team.profession.associate"
|
||||
| "projects.team.profession.hint"
|
||||
@@ -2408,8 +2195,6 @@ export type I18nKey =
|
||||
| "projects.team.profession.paralegal"
|
||||
| "projects.team.profession.partner"
|
||||
| "projects.team.profession.senior_pa"
|
||||
| "projects.team.responsibility.admin"
|
||||
| "projects.team.responsibility.admin.hint"
|
||||
| "projects.team.responsibility.external"
|
||||
| "projects.team.responsibility.lead"
|
||||
| "projects.team.responsibility.member"
|
||||
@@ -2458,7 +2243,6 @@ export type I18nKey =
|
||||
| "projects.type.case"
|
||||
| "projects.type.client"
|
||||
| "projects.type.litigation"
|
||||
| "projects.type.other"
|
||||
| "projects.type.patent"
|
||||
| "projects.type.project"
|
||||
| "projects.unavailable"
|
||||
@@ -2525,11 +2309,6 @@ export type I18nKey =
|
||||
| "team.role.senior_associate"
|
||||
| "team.role.trainee"
|
||||
| "team.search.placeholder"
|
||||
| "team.selection.clear"
|
||||
| "team.selection.count"
|
||||
| "team.selection.select_all"
|
||||
| "team.selection.send"
|
||||
| "team.selection.toggle_card"
|
||||
| "team.subtitle"
|
||||
| "team.title"
|
||||
| "theme.toggle.auto"
|
||||
|
||||
@@ -50,14 +50,6 @@ export function renderProjectsDetail(): string {
|
||||
<div className="entity-detail-meta">
|
||||
<span id="project-type-chip" className="entity-type-chip" />
|
||||
<span className="entity-ref" id="project-ref-display" />
|
||||
{/* Auto-derived project code (t-paliad-222 / m/paliad#50).
|
||||
Rendered as a separate badge so the user can still
|
||||
distinguish a custom reference (left badge) from a
|
||||
tree-derived code (right badge); when reference is
|
||||
blank, the derived code IS reference and only this
|
||||
badge shows. Hidden via inline style until the
|
||||
client populates it. */}
|
||||
<span className="entity-ref entity-ref-code" id="project-code-display" style="display:none" title="Auto-derived project code" />
|
||||
<span id="project-clientmatter" className="entity-ref" />
|
||||
<span id="project-status-chip" className="entity-status-chip" />
|
||||
<a id="project-netdocs" className="netdocs-link" target="_blank" rel="noopener" style="display:none">netDocuments ↗</a>
|
||||
@@ -89,20 +81,6 @@ export function renderProjectsDetail(): string {
|
||||
<a className="entity-tab" data-tab="notes" href="#" data-i18n="projects.detail.tab.notizen">Notizen</a>
|
||||
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
|
||||
<a className="entity-tab" data-tab="submissions" href="#" data-i18n="projects.detail.tab.submissions">Schriftsätze</a>
|
||||
{/* t-paliad-214 Slice 2 — project-subtree export button.
|
||||
Sits at the end of the tab nav. Hidden by default; the
|
||||
client unhides it after /api/me confirms the caller can
|
||||
extract (responsibility ∈ {lead, member} OR global_admin). */}
|
||||
<button
|
||||
type="button"
|
||||
id="project-export-btn"
|
||||
className="entity-tab entity-tab-action"
|
||||
style="display:none"
|
||||
title=""
|
||||
data-i18n-title="projects.detail.export.tooltip"
|
||||
data-i18n="projects.detail.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
@@ -270,7 +248,6 @@ export function renderProjectsDetail(): string {
|
||||
<div className="form-field">
|
||||
<label htmlFor="team-responsibility" data-i18n="projects.detail.team.form.responsibility">Rolle im Projekt</label>
|
||||
<select id="team-responsibility">
|
||||
<option value="admin" data-i18n="projects.team.responsibility.admin">Admin</option>
|
||||
<option value="lead" data-i18n="projects.team.responsibility.lead">Lead</option>
|
||||
<option value="member" selected data-i18n="projects.team.responsibility.member">Mitglied</option>
|
||||
<option value="observer" data-i18n="projects.team.responsibility.observer">Beobachter</option>
|
||||
|
||||
@@ -127,8 +127,7 @@ export function renderProjects(): string {
|
||||
<label><input type="checkbox" value="litigation" /><span data-i18n="projects.chip.type.litigation">Streitsache</span></label>
|
||||
<label><input type="checkbox" value="patent" /><span data-i18n="projects.chip.type.patent">Patent</span></label>
|
||||
<label><input type="checkbox" value="case" /><span data-i18n="projects.chip.type.case">Verfahren</span></label>
|
||||
<label><input type="checkbox" value="project" /><span data-i18n="projects.chip.type.project">Projekt</span></label>
|
||||
<label><input type="checkbox" value="other" /><span data-i18n="projects.chip.type.other">Sonstiges</span></label>
|
||||
<label><input type="checkbox" value="project" data-i18n-text="projects.chip.type.project"><span data-i18n="projects.chip.type.project">Projekt</span></input></label>
|
||||
</div>
|
||||
</details>
|
||||
<button type="button" className="projects-chip" data-chip="has_open_deadlines" data-i18n="projects.chip.has_open_deadlines">Mit aktiven Fristen</button>
|
||||
|
||||
@@ -323,25 +323,6 @@ export function renderSettings(): string {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* t-paliad-212 Slice 2b — multi-calendar bindings.
|
||||
Each card is one (calendar, scope) binding layered on the
|
||||
single CalDAV server connection above. */}
|
||||
<div className="caldav-bindings-section" id="caldav-bindings-section" style="display:none">
|
||||
<div className="caldav-bindings-header">
|
||||
<h2 data-i18n="caldav.bindings.heading">Kalender</h2>
|
||||
<button type="button" id="caldav-bindings-add-btn" className="btn-secondary" data-i18n="caldav.bindings.add">
|
||||
+ Kalender hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-hint" data-i18n="caldav.bindings.hint">
|
||||
Verbinde mehrere Kalender mit Paliad — einen Master für alles oder eigene Kalender pro Projekt.
|
||||
</p>
|
||||
<div id="caldav-bindings-list" className="caldav-bindings-list" />
|
||||
<p className="entity-events-empty" id="caldav-bindings-empty" data-i18n="caldav.bindings.empty" style="display:none">
|
||||
Noch keine Kalender konfiguriert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="caldav-log-card">
|
||||
<h2 data-i18n="caldav.log.heading">Letzte Synchronisationen</h2>
|
||||
<table className="entity-table entity-table--readonly caldav-log-table">
|
||||
@@ -411,89 +392,6 @@ export function renderSettings(): string {
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
{/* t-paliad-212 Slice 2b — single-step Add/Edit modal for
|
||||
calendar bindings. Source picker (existing dropdown or
|
||||
custom URL) + scope radio + display name. Edit mode hides
|
||||
the source picker (path is fixed). */}
|
||||
<div id="caldav-binding-modal" className="modal-backdrop" style="display:none">
|
||||
<div className="modal-dialog">
|
||||
<div className="modal-header">
|
||||
<h2 id="caldav-binding-modal-title" data-i18n="caldav.bindings.modal.add_title">Kalender hinzufügen</h2>
|
||||
<button type="button" className="modal-close" id="caldav-binding-modal-close" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<form id="caldav-binding-form" className="entity-form modal-body" autocomplete="off">
|
||||
<div className="form-field" id="caldav-binding-source-field">
|
||||
<label data-i18n="caldav.bindings.modal.source">Kalender</label>
|
||||
<div className="caldav-binding-source-modes" id="caldav-binding-source-modes">
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="existing" checked />
|
||||
<span data-i18n="caldav.bindings.modal.source.existing">Vorhandenen Kalender wählen</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label" id="caldav-binding-source-mode-create-row" style="display:none">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="create" />
|
||||
<span data-i18n="caldav.bindings.modal.source.create">Neuen Kalender erstellen</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-source-mode" value="custom" />
|
||||
<span data-i18n="caldav.bindings.modal.source.custom">Eigene URL eingeben</span>
|
||||
</label>
|
||||
</div>
|
||||
<select id="caldav-binding-discover-select">
|
||||
<option value="" data-i18n="caldav.bindings.modal.source.loading">Lädt…</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
id="caldav-binding-custom-path"
|
||||
placeholder="https://..."
|
||||
style="display:none"
|
||||
/>
|
||||
{/* Slice 2c — Google-degrade notice. Shown when
|
||||
supports_mkcalendar=false; the create-new radio is
|
||||
hidden in that state, so users are nudged to the
|
||||
custom-URL path. */}
|
||||
<p className="form-hint caldav-binding-degrade-notice" id="caldav-binding-degrade-notice" style="display:none" data-i18n="caldav.bindings.modal.source.degrade">
|
||||
Dieser Anbieter erlaubt das Erstellen neuer Kalender nicht via CalDAV.
|
||||
Erstelle den Kalender direkt in der Anbieter-Oberfläche und füge ihn hier per URL hinzu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="caldav-binding-display-name" data-i18n="caldav.bindings.modal.display_name">Anzeigename (optional)</label>
|
||||
<input type="text" id="caldav-binding-display-name" data-i18n-placeholder="caldav.bindings.modal.display_name.placeholder" placeholder="z.B. Projekt Acme v Bosch" />
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label data-i18n="caldav.bindings.modal.scope">Inhalt</label>
|
||||
<div className="caldav-binding-scope-radios">
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-scope" value="all_visible" checked />
|
||||
<span data-i18n="caldav.bindings.modal.scope.all_visible">Alles, was ich sehe</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-scope" value="personal_only" />
|
||||
<span data-i18n="caldav.bindings.modal.scope.personal_only">Nur persönliche Termine</span>
|
||||
</label>
|
||||
<label className="caldav-toggle-label">
|
||||
<input type="radio" name="caldav-binding-scope" value="project" />
|
||||
<span data-i18n="caldav.bindings.modal.scope.project">Ein Projekt:</span>
|
||||
<select id="caldav-binding-project-select" disabled>
|
||||
<option value="" data-i18n="caldav.bindings.modal.scope.project.loading">Lädt…</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="form-msg" id="caldav-binding-msg" />
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-secondary" id="caldav-binding-cancel-btn" data-i18n="common.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" id="caldav-binding-submit-btn" data-i18n="caldav.bindings.modal.submit_add">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,14 +75,6 @@ export function renderTeam(): string {
|
||||
<div className="team-broadcast-wrap" id="team-broadcast-wrap" style="display:none">
|
||||
</div>
|
||||
|
||||
{/* t-paliad-223 (#53) — master "select all visible" checkbox. */}
|
||||
<div className="team-select-master-row">
|
||||
<label className="team-select-master-label">
|
||||
<input type="checkbox" id="team-select-master" />
|
||||
<span data-i18n="team.selection.select_all">Alle sichtbaren auswählen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="team-list" id="team-list" />
|
||||
|
||||
<div className="glossar-empty" id="team-empty" style="display:none">
|
||||
|
||||
@@ -163,10 +163,7 @@ export function renderVerfahrensablauf(): string {
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
{/* Read-only caption labelling the value <span>. Not a
|
||||
<label htmlFor> — m/paliad#60: <label for=…> must
|
||||
point at a labelable form control, never a span. */}
|
||||
<span className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</span>
|
||||
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</label>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
|
||||
7
go.mod
7
go.mod
@@ -4,21 +4,20 @@ go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/xuri/excelize/v2 v2.10.1
|
||||
golang.org/x/text v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.6 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.6 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.2 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/excelize/v2 v2.10.1 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
)
|
||||
|
||||
58
go.sum
58
go.sum
@@ -1,11 +1,39 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
@@ -15,14 +43,26 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
|
||||
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
|
||||
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
|
||||
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
|
||||
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
|
||||
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
|
||||
@@ -31,12 +71,22 @@ github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzx
|
||||
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -1,78 +1,46 @@
|
||||
// Package db owns the Paliad Postgres connection and embedded schema migrations.
|
||||
//
|
||||
// Migrations are NNN_description.up.sql / .down.sql files in the migrations/
|
||||
// subdirectory, embedded into the binary so a single artifact ships with its
|
||||
// schema. The server applies pending migrations at startup before binding
|
||||
// the HTTP listener.
|
||||
//
|
||||
// The runner tracks applied state as a set, not a counter: every applied
|
||||
// migration gets its own row in paliad.applied_migrations(version PK, name,
|
||||
// applied_at, checksum). On every deploy, pending = on_disk \ applied, in
|
||||
// ascending version order. Gaps in the version space are first-class — a
|
||||
// version that's missing from applied_migrations runs on the next deploy,
|
||||
// regardless of which higher versions are already applied.
|
||||
//
|
||||
// This is what closes the parallel-merge skip-hole that the single-counter
|
||||
// tracker (golang-migrate) silently fell into on 2026-05-20 (m/paliad#44).
|
||||
// Background and design: docs/design-migration-runner-applied-set-2026-05-20.md.
|
||||
//
|
||||
// .down.sql files ship in the embedded FS as reference material but are not
|
||||
// auto-applied — there are no call sites for rolling back, and operator
|
||||
// recovery (psql .down.sql + DELETE FROM paliad.applied_migrations WHERE
|
||||
// version=N) is the documented path. If a real call site for auto-rollback
|
||||
// materializes later, add it as a focused follow-up.
|
||||
// Migrations are golang-migrate format (NNN_description.up.sql / .down.sql) and
|
||||
// live in the migrations/ subdirectory, embedded into the binary so a single
|
||||
// artifact ships with its schema. The server applies pending migrations at
|
||||
// startup before binding the HTTP listener.
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
// advisoryLockID is the Postgres advisory-lock id the runner takes around
|
||||
// the apply loop. Derived once from the table name so the value is stable
|
||||
// across processes — two concurrent deploys (rolling Dokploy update, dev
|
||||
// laptop hitting the same scratch DB as CI) serialize on this id rather
|
||||
// than racing on the pending set.
|
||||
// migrationsTable is the name of the golang-migrate tracking table. We use a
|
||||
// uniquely-named table (not the default "schema_migrations") because the
|
||||
// production Supabase instance hosts multiple apps in the `public` schema,
|
||||
// and a differently-shaped `public.schema_migrations` already exists there.
|
||||
// Using "paliad_schema_migrations" prevents collision at startup.
|
||||
//
|
||||
// FNV-1a-64 is good enough: the id only has to be a stable int64, not
|
||||
// cryptographically uniform. Process-wide constant.
|
||||
var advisoryLockID = func() int64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte("paliad.applied_migrations"))
|
||||
return int64(h.Sum64())
|
||||
}()
|
||||
// The table lives in the `public` schema (golang-migrate's default) rather
|
||||
// than `paliad`. Rationale: migration 001's down-step is
|
||||
// DROP SCHEMA IF EXISTS paliad CASCADE
|
||||
// which would take the tracking table with it — breaking any subsequent
|
||||
// migrate.Up() call. Keeping the tracker in `public` makes the down-path
|
||||
// safe and idempotent.
|
||||
const migrationsTable = "paliad_schema_migrations"
|
||||
|
||||
// migration is one *.up.sql file from the embedded FS.
|
||||
type migration struct {
|
||||
version int
|
||||
name string
|
||||
filename string
|
||||
}
|
||||
|
||||
// ApplyMigrations applies every pending up-migration to the given database.
|
||||
// ApplyMigrations runs all pending up-migrations against the given database
|
||||
// URL. Returns nil if no migrations were pending. Safe to call repeatedly.
|
||||
//
|
||||
// Safe to call repeatedly; a fully-applied tree is a no-op. Returns the
|
||||
// first error encountered (with the offending migration filename wrapped
|
||||
// in the message) and leaves the rest of pending unapplied — same fail-fast
|
||||
// posture as the previous golang-migrate runner.
|
||||
//
|
||||
// On first deploy of this code path against a database that still has the
|
||||
// legacy paliad.paliad_schema_migrations counter at version N, the runner
|
||||
// seeds paliad.applied_migrations with rows 1..N (checksum NULL) before
|
||||
// applying anything new. The first deploy is therefore effectively a
|
||||
// no-op against the schema — the bootstrap just relabels existing state.
|
||||
// Pre-creates the `paliad` schema before invoking golang-migrate because the
|
||||
// first migration creates it and golang-migrate's tracking table would
|
||||
// otherwise be created in whatever `current_schema()` happens to be.
|
||||
func ApplyMigrations(databaseURL string) error {
|
||||
if databaseURL == "" {
|
||||
return errors.New("database URL is empty")
|
||||
@@ -83,250 +51,39 @@ func ApplyMigrations(databaseURL string) error {
|
||||
return fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if err := conn.Ping(); err != nil {
|
||||
return fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
// Ensure the paliad schema exists. Mig 001 also creates it; the
|
||||
// applied_migrations table lives in paliad.* and gets created before
|
||||
// any migrations run, so the schema must exist first.
|
||||
// Bootstrap the paliad schema so later migrations can target it cleanly.
|
||||
// This duplicates migration 001, but is idempotent via IF NOT EXISTS and
|
||||
// ensures the schema exists before golang-migrate touches the DB.
|
||||
if _, err := conn.Exec(`CREATE SCHEMA IF NOT EXISTS paliad`); err != nil {
|
||||
return fmt.Errorf("ensure paliad schema: %w", err)
|
||||
}
|
||||
|
||||
if _, err := conn.Exec(`SELECT pg_advisory_lock($1)`, advisoryLockID); err != nil {
|
||||
return fmt.Errorf("acquire advisory lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.Exec(`SELECT pg_advisory_unlock($1)`, advisoryLockID)
|
||||
}()
|
||||
|
||||
if _, err := conn.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS paliad.applied_migrations (
|
||||
version int NOT NULL PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
applied_at timestamptz NOT NULL DEFAULT now(),
|
||||
checksum text NULL
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("create applied_migrations: %w", err)
|
||||
}
|
||||
|
||||
onDisk, err := scanEmbeddedMigrations()
|
||||
source, err := iofs.New(migrationFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan embedded migrations: %w", err)
|
||||
return fmt.Errorf("open migration source: %w", err)
|
||||
}
|
||||
|
||||
if err := bootstrapFromLegacyTracker(conn, onDisk); err != nil {
|
||||
return fmt.Errorf("bootstrap from legacy tracker: %w", err)
|
||||
}
|
||||
|
||||
applied, err := readAppliedMigrations(conn)
|
||||
driver, err := postgres.WithInstance(conn, &postgres.Config{
|
||||
// Unique tracking-table name avoids collision with pre-existing
|
||||
// public.schema_migrations owned by other apps on this Postgres.
|
||||
MigrationsTable: migrationsTable,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("read applied_migrations: %w", err)
|
||||
return fmt.Errorf("create migration driver: %w", err)
|
||||
}
|
||||
|
||||
if err := checkNameAgreement(onDisk, applied); err != nil {
|
||||
return err
|
||||
m, err := migrate.NewWithInstance("iofs", source, "postgres", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create migrator: %w", err)
|
||||
}
|
||||
|
||||
for _, m := range onDisk {
|
||||
if _, ok := applied[m.version]; ok {
|
||||
continue
|
||||
}
|
||||
if err := applyOne(conn, m); err != nil {
|
||||
return fmt.Errorf("apply %s: %w", m.filename, err)
|
||||
}
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("apply migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanEmbeddedMigrations returns every NNN_*.up.sql in the embedded FS,
|
||||
// sorted by version ascending. Hard-fails on two files sharing the same
|
||||
// version prefix — that's the failure mode the parallel-merge incident
|
||||
// exposed, and the runner refuses to start rather than silently picking one.
|
||||
func scanEmbeddedMigrations() ([]migration, error) {
|
||||
entries, err := migrationFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
seen := map[int]string{}
|
||||
var out []migration
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".up.sql") {
|
||||
continue
|
||||
}
|
||||
v, n, ok := parseMigrationFilename(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unparseable migration filename %q "+
|
||||
"(expected NNN_description.up.sql)", name)
|
||||
}
|
||||
if prior, dup := seen[v]; dup {
|
||||
return nil, fmt.Errorf("two migrations at version %d: %q and %q — "+
|
||||
"rename one and redeploy", v, prior, name)
|
||||
}
|
||||
seen[v] = name
|
||||
out = append(out, migration{version: v, name: n, filename: name})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseMigrationFilename splits "NNN_description.up.sql" into (NNN, description).
|
||||
// Returns ok=false on any deviation from that shape.
|
||||
func parseMigrationFilename(filename string) (version int, name string, ok bool) {
|
||||
base := strings.TrimSuffix(filename, ".up.sql")
|
||||
if base == filename {
|
||||
return 0, "", false
|
||||
}
|
||||
underscore := strings.IndexByte(base, '_')
|
||||
if underscore <= 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
v, err := strconv.Atoi(base[:underscore])
|
||||
if err != nil {
|
||||
return 0, "", false
|
||||
}
|
||||
return v, base[underscore+1:], true
|
||||
}
|
||||
|
||||
// readAppliedMigrations returns a map version → name from
|
||||
// paliad.applied_migrations. Returns an empty map (no error) if the table
|
||||
// is missing — that's the fresh-DB path before the CREATE TABLE in
|
||||
// ApplyMigrations runs against it.
|
||||
func readAppliedMigrations(conn *sql.DB) (map[int]string, error) {
|
||||
rows, err := conn.Query(`SELECT version, name FROM paliad.applied_migrations`)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
return map[int]string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int]string{}
|
||||
for rows.Next() {
|
||||
var v int
|
||||
var n string
|
||||
if err := rows.Scan(&v, &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[v] = n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// bootstrapFromLegacyTracker seeds paliad.applied_migrations from
|
||||
// paliad.paliad_schema_migrations on the first deploy of the new runner
|
||||
// against a DB that previously ran golang-migrate.
|
||||
//
|
||||
// Behavior:
|
||||
// - applied_migrations already has rows → no-op (idempotent).
|
||||
// - applied_migrations empty AND legacy tracker missing → no-op
|
||||
// (virgin DB; the apply loop will run everything from scratch).
|
||||
// - applied_migrations empty AND legacy tracker present, clean, version N
|
||||
// → INSERT rows for every on-disk version ≤ N with checksum NULL.
|
||||
// - applied_migrations empty AND legacy tracker dirty → hard-fail.
|
||||
// The operator must recover the legacy tracker first (it being dirty
|
||||
// means a prior golang-migrate run crashed mid-flight); we will not
|
||||
// paper over an unknown state by guessing what landed.
|
||||
//
|
||||
// Backfilled rows have checksum NULL because the legacy runner didn't hash
|
||||
// anything — we can't fabricate a provenance hash today without falsely
|
||||
// claiming we know the byte-identity of what shipped historically.
|
||||
func bootstrapFromLegacyTracker(conn *sql.DB, onDisk []migration) error {
|
||||
var count int
|
||||
if err := conn.QueryRow(`SELECT count(*) FROM paliad.applied_migrations`).Scan(&count); err != nil {
|
||||
return fmt.Errorf("count applied_migrations: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var legacyVer int
|
||||
var legacyDirty bool
|
||||
err := conn.QueryRow(`SELECT version, dirty FROM paliad.paliad_schema_migrations LIMIT 1`).
|
||||
Scan(&legacyVer, &legacyDirty)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read legacy tracker: %w", err)
|
||||
}
|
||||
if legacyDirty {
|
||||
return fmt.Errorf("legacy paliad.paliad_schema_migrations is dirty at version %d — "+
|
||||
"recover manually before deploying", legacyVer)
|
||||
}
|
||||
|
||||
for _, m := range onDisk {
|
||||
if m.version > legacyVer {
|
||||
continue
|
||||
}
|
||||
if _, err := conn.Exec(`
|
||||
INSERT INTO paliad.applied_migrations(version, name, applied_at, checksum)
|
||||
VALUES ($1, $2, now(), NULL)
|
||||
ON CONFLICT (version) DO NOTHING
|
||||
`, m.version, m.name); err != nil {
|
||||
return fmt.Errorf("backfill version %d: %w", m.version, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkNameAgreement hard-fails if a version that's already applied has a
|
||||
// different name on disk than in the DB. Catches the post-merge rename
|
||||
// accident where someone renames `098_foo.up.sql` to `098_bar.up.sql` —
|
||||
// the SQL has already run on prod with the old name, so the rename is a
|
||||
// lie about history. Operator recovery: revert the rename, or update the
|
||||
// DB row if the rename is intentional.
|
||||
//
|
||||
// Backfilled rows have a name pulled from the on-disk filename, so an
|
||||
// out-of-the-box backfill never trips this check.
|
||||
func checkNameAgreement(onDisk []migration, applied map[int]string) error {
|
||||
for _, m := range onDisk {
|
||||
dbName, ok := applied[m.version]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if dbName != m.name {
|
||||
return fmt.Errorf("migration %d: disk name %q != DB name %q "+
|
||||
"(renamed after apply? revert the rename, or UPDATE paliad.applied_migrations "+
|
||||
"SET name=%q WHERE version=%d if the rename is intentional)",
|
||||
m.version, m.name, dbName, m.name, m.version)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyOne runs one migration's .up.sql plus its INSERT row in a single
|
||||
// transaction. All-or-nothing per migration: if the SQL fails, the row
|
||||
// isn't inserted and the next deploy re-tries from the same point. If
|
||||
// the INSERT fails (e.g. PK violation because the lock wasn't held), the
|
||||
// SQL rolls back too.
|
||||
func applyOne(conn *sql.DB, m migration) error {
|
||||
body, err := migrationFS.ReadFile("migrations/" + m.filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", m.filename, err)
|
||||
}
|
||||
checksum := fmt.Sprintf("%x", sha256.Sum256(body))
|
||||
|
||||
tx, err := conn.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(string(body)); err != nil {
|
||||
return fmt.Errorf("exec sql: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO paliad.applied_migrations(version, name, applied_at, checksum)
|
||||
VALUES ($1, $2, now(), $3)
|
||||
`, m.version, m.name, checksum); err != nil {
|
||||
return fmt.Errorf("record applied: %w", err)
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -1,49 +1,60 @@
|
||||
// Package db tests — migration dry-run gate.
|
||||
//
|
||||
// This is the test that catches mig-N crash-loops before they reach prod.
|
||||
// The new runner tracks applied state as a set in paliad.applied_migrations
|
||||
// (one row per migration; see migrate.go). A migration that compiles cleanly
|
||||
// but fails on apply (typo, missing column, wrong CHECK shape) crashes the
|
||||
// Dokploy container loop before paliad.de finishes binding :8080, and the
|
||||
// only way to learn about it today is to watch the deploy log.
|
||||
// The convention since t-paliad-098/099 is that paliad migrations land in
|
||||
// numeric order on a single trunk; the next deploy runs whichever ones are
|
||||
// pending against the live `public.paliad_schema_migrations` tracker. A
|
||||
// migration that compiles cleanly but fails on apply (typo, missing column,
|
||||
// wrong CHECK shape) crashes the Dokploy container loop before paliad.de
|
||||
// finishes binding :8080, and the only way to learn about it today is to
|
||||
// watch the deploy log.
|
||||
//
|
||||
// TestMigrations_DryRun closes that gap: for every *.up.sql in this
|
||||
// directory whose version is NOT present in paliad.applied_migrations on
|
||||
// the scratch DB, it opens a transaction, runs the SQL, and ROLLBACKs.
|
||||
// Any error fails the test with the file name + Postgres error. Always
|
||||
// non-destructive — the ROLLBACK runs even on success, so the scratch DB
|
||||
// stays at its starting set.
|
||||
//
|
||||
// "Pending" means: a version that's on disk but not in applied_migrations.
|
||||
// In CI against a fresh scratch DB (where applied_migrations either
|
||||
// doesn't exist or is empty), every migration is pending and gets
|
||||
// verified. On a developer laptop whose scratch DB is already at HEAD,
|
||||
// no migrations are pending and the test logs and passes — the protection
|
||||
// only kicks in the moment a new *.up.sql lands in the tree before the
|
||||
// developer runs `db.ApplyMigrations` against the same scratch DB.
|
||||
// directory whose version is greater than the scratch DB's current tracker
|
||||
// version, it opens a transaction, runs the SQL, and ROLLBACKs. Any error
|
||||
// fails the test with the file name + Postgres error. Always non-destructive
|
||||
// — the ROLLBACK runs even on success, so the scratch DB stays at its
|
||||
// starting version.
|
||||
//
|
||||
// Requires TEST_DATABASE_URL (same pattern as the rest of the live-DB
|
||||
// tests). Skipped without it.
|
||||
//
|
||||
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1 and
|
||||
// docs/design-migration-runner-applied-set-2026-05-20.md §6.
|
||||
// Design: docs/design-paliad-test-strategy-2026-05-19.md §5 Slice 1.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// migration is one *.up.sql file from the embedded migrations FS.
|
||||
type migration struct {
|
||||
version int
|
||||
name string
|
||||
filename string
|
||||
}
|
||||
|
||||
// TestMigrations_DryRun walks every pending *.up.sql in numeric order,
|
||||
// applies each inside its own BEGIN/ROLLBACK against the scratch DB, and
|
||||
// fails the test on the first SQL error. Reports per-file as a sub-test so
|
||||
// `go test -v` shows which migration failed.
|
||||
//
|
||||
// What "pending" means: greater than the scratch DB's current tracker
|
||||
// version (or 0 if the tracker doesn't exist yet). In CI against a fresh
|
||||
// scratch DB, every migration is pending and gets verified. On a developer
|
||||
// laptop whose scratch DB is already at HEAD, no migrations are pending and
|
||||
// the test logs the start version and passes — the protection only kicks in
|
||||
// the moment a new *.up.sql lands in the tree before the developer runs
|
||||
// `db.ApplyMigrations` against the same scratch DB.
|
||||
func TestMigrations_DryRun(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
@@ -68,32 +79,28 @@ func TestMigrations_DryRun(t *testing.T) {
|
||||
t.Fatalf("ensure paliad schema: %v", err)
|
||||
}
|
||||
|
||||
applied, err := readAppliedVersions(conn)
|
||||
startVersion, dirty, err := currentTrackerVersion(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read applied_migrations: %v", err)
|
||||
t.Fatalf("read tracker: %v", err)
|
||||
}
|
||||
if dirty {
|
||||
t.Fatalf("tracker is dirty at version %d — fix that first (DROP the tracker row "+
|
||||
"or restore from backup); the dry-run cannot trust a dirty starting state",
|
||||
startVersion)
|
||||
}
|
||||
t.Logf("scratch DB tracker at version %d; walking pending migrations from %d upward",
|
||||
startVersion, startVersion+1)
|
||||
|
||||
onDisk, err := scanEmbeddedMigrations()
|
||||
migs, err := loadPendingMigrations(startVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("scan embedded migrations: %v", err)
|
||||
t.Fatalf("load migrations: %v", err)
|
||||
}
|
||||
|
||||
var pending []migration
|
||||
for _, m := range onDisk {
|
||||
if !applied[m.version] {
|
||||
pending = append(pending, m)
|
||||
}
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
t.Logf("no pending migrations — scratch DB applied set covers every on-disk version (%d total)",
|
||||
len(onDisk))
|
||||
if len(migs) == 0 {
|
||||
t.Logf("no pending migrations — scratch DB is at HEAD (%d)", startVersion)
|
||||
return
|
||||
}
|
||||
t.Logf("scratch DB has %d/%d on-disk migrations applied; walking %d pending",
|
||||
len(applied), len(onDisk), len(pending))
|
||||
|
||||
for _, m := range pending {
|
||||
for _, m := range migs {
|
||||
t.Run(fmt.Sprintf("%03d_%s", m.version, m.name), func(t *testing.T) {
|
||||
body, err := migrationFS.ReadFile("migrations/" + m.filename)
|
||||
if err != nil {
|
||||
@@ -103,10 +110,10 @@ func TestMigrations_DryRun(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
// Always rollback; the dry-run must not leave the scratch
|
||||
// DB at a different applied set than where it started.
|
||||
// Rollback is safe after a failed Exec — Postgres aborts
|
||||
// the transaction internally on the first error.
|
||||
// Always rollback; the dry-run must not leave the scratch DB
|
||||
// at a different version than where it started. Rollback is
|
||||
// safe to call even after a failed Exec — Postgres aborts the
|
||||
// transaction internally on the first error.
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.Exec(string(body)); err != nil {
|
||||
@@ -116,30 +123,76 @@ func TestMigrations_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// readAppliedVersions returns the set of versions present in
|
||||
// paliad.applied_migrations on the scratch DB. Missing table → empty set
|
||||
// (fresh-DB path; the table only exists after the runner has been called).
|
||||
// currentTrackerVersion reads the latest version + dirty flag from the
|
||||
// `public.paliad_schema_migrations` tracker. Returns (0, false, nil) when the
|
||||
// tracker doesn't exist yet — that's the "fresh scratch DB" path.
|
||||
//
|
||||
// We don't pre-create the table here because the dry-run is supposed to be
|
||||
// a passive observer — it must not mutate the scratch DB outside of its
|
||||
// own per-mig BEGIN/ROLLBACK probes. A "table doesn't exist" outcome is
|
||||
// the right read against a virgin scratch DB.
|
||||
func readAppliedVersions(conn *sql.DB) (map[int]bool, error) {
|
||||
rows, err := conn.Query(`SELECT version FROM paliad.applied_migrations`)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") {
|
||||
return map[int]bool{}, nil
|
||||
// We don't use golang-migrate's API to read this because golang-migrate's
|
||||
// driver locks the tracker row on read; a test runner that calls this while
|
||||
// the developer has paliad running locally would race. A plain SELECT is
|
||||
// race-safe and matches what `psql` would show.
|
||||
func currentTrackerVersion(conn *sql.DB) (version int, dirty bool, err error) {
|
||||
const q = `SELECT version, dirty FROM public.paliad_schema_migrations LIMIT 1`
|
||||
row := conn.QueryRow(q)
|
||||
if scanErr := row.Scan(&version, &dirty); scanErr != nil {
|
||||
// Missing table → fresh DB → start at 0. lib/pq surfaces this
|
||||
// as `pq.Error.Code = "42P01"` (undefined_table); the simpler
|
||||
// sql.ErrNoRows fires if the table exists but is empty (also
|
||||
// fresh-DB-shaped).
|
||||
if errors.Is(scanErr, sql.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int]bool{}
|
||||
for rows.Next() {
|
||||
var v int
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, err
|
||||
if strings.Contains(scanErr.Error(), "does not exist") {
|
||||
return 0, false, nil
|
||||
}
|
||||
out[v] = true
|
||||
return 0, false, scanErr
|
||||
}
|
||||
return out, rows.Err()
|
||||
return version, dirty, nil
|
||||
}
|
||||
|
||||
// loadPendingMigrations returns every *.up.sql in the embedded FS whose
|
||||
// version is greater than startVersion, sorted by version ascending. A
|
||||
// filename like "098_submission_codes_prefix_and_rename.up.sql" yields
|
||||
// version=98, name="submission_codes_prefix_and_rename".
|
||||
func loadPendingMigrations(startVersion int) ([]migration, error) {
|
||||
entries, err := migrationFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
var out []migration
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".up.sql") {
|
||||
continue
|
||||
}
|
||||
v, n, ok := parseMigrationName(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unparseable migration filename: %s "+
|
||||
"(expected NNN_description.up.sql)", name)
|
||||
}
|
||||
if v <= startVersion {
|
||||
continue
|
||||
}
|
||||
out = append(out, migration{version: v, name: n, filename: name})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].version < out[j].version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseMigrationName splits "NNN_description.up.sql" into (NNN, description).
|
||||
// Returns ok=false on any deviation from that shape.
|
||||
func parseMigrationName(filename string) (version int, name string, ok bool) {
|
||||
base := strings.TrimSuffix(filename, ".up.sql")
|
||||
if base == filename { // suffix wasn't present
|
||||
return 0, "", false
|
||||
}
|
||||
underscore := strings.IndexByte(base, '_')
|
||||
if underscore <= 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
v, err := strconv.Atoi(base[:underscore])
|
||||
if err != nil {
|
||||
return 0, "", false
|
||||
}
|
||||
return v, base[underscore+1:], true
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Reverse of 107: drop the binding_id column from caldav_sync_log.
|
||||
-- The associated index drops automatically with the column.
|
||||
|
||||
ALTER TABLE paliad.caldav_sync_log
|
||||
DROP COLUMN IF EXISTS binding_id;
|
||||
@@ -1,53 +0,0 @@
|
||||
-- t-paliad-212 — Slice 2a of CalDAV multi-calendar.
|
||||
--
|
||||
-- Adds paliad.caldav_sync_log.binding_id so the per-tick sync log
|
||||
-- records which binding the entry belongs to. NULL for legacy rows
|
||||
-- and for "global" log entries that aren't per-binding (Slice 2a
|
||||
-- still writes one row per user per tick — Slice 2b's sync rewrite
|
||||
-- moves to one row per (user, binding) per tick).
|
||||
--
|
||||
-- FK uses ON DELETE SET NULL so deleting a binding doesn't blow away
|
||||
-- its historical sync log (audit trail wins over referential tidiness).
|
||||
--
|
||||
-- Idempotent: column added via DO block with information_schema check.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 107: add caldav_sync_log.binding_id for per-binding sync log entries (t-paliad-212 Slice 2a)',
|
||||
true);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'caldav_sync_log'
|
||||
AND column_name = 'binding_id'
|
||||
) THEN
|
||||
ALTER TABLE paliad.caldav_sync_log
|
||||
ADD COLUMN binding_id uuid
|
||||
REFERENCES paliad.user_calendar_bindings(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS caldav_sync_log_binding_idx
|
||||
ON paliad.caldav_sync_log (binding_id, occurred_at DESC)
|
||||
WHERE binding_id IS NOT NULL;
|
||||
|
||||
-- Assertion: column exists and is nullable.
|
||||
DO $$
|
||||
DECLARE
|
||||
col_nullable text;
|
||||
BEGIN
|
||||
SELECT is_nullable INTO col_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'caldav_sync_log'
|
||||
AND column_name = 'binding_id';
|
||||
IF col_nullable IS NULL THEN
|
||||
RAISE EXCEPTION 'mig 107 assertion failed: caldav_sync_log.binding_id missing';
|
||||
END IF;
|
||||
IF col_nullable <> 'YES' THEN
|
||||
RAISE EXCEPTION 'mig 107 assertion failed: caldav_sync_log.binding_id is NOT NULL (must be nullable)';
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Reverse of 108: drop the capability columns.
|
||||
|
||||
ALTER TABLE paliad.user_caldav_config
|
||||
DROP COLUMN IF EXISTS supports_mkcalendar,
|
||||
DROP COLUMN IF EXISTS mkcalendar_probed_at;
|
||||
@@ -1,67 +0,0 @@
|
||||
-- t-paliad-212 — Slice 2c of CalDAV multi-calendar.
|
||||
--
|
||||
-- Adds the MKCALENDAR-capability tri-state to paliad.user_caldav_config:
|
||||
-- * supports_mkcalendar = NULL → unprobed (probe runs lazily on
|
||||
-- the first /api/caldav-discover or
|
||||
-- /api/caldav-mkcalendar call).
|
||||
-- * supports_mkcalendar = TRUE → server accepts MKCALENDAR; the
|
||||
-- "Create new calendar" affordance
|
||||
-- in the picker is visible.
|
||||
-- * supports_mkcalendar = FALSE → Google-style degrade; UI hides the
|
||||
-- create button and surfaces the
|
||||
-- "create it in your provider's UI"
|
||||
-- notice with a manual-URL input.
|
||||
-- The probed_at timestamp lets us re-probe stale-cached results when
|
||||
-- the user changes credentials (SaveConfig invalidates by SetNull in
|
||||
-- the Go service layer; the column is here so the next round of
|
||||
-- probing has somewhere to land).
|
||||
--
|
||||
-- Idempotent (column-exists DO block) + assertion at the bottom.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 108: add user_caldav_config.supports_mkcalendar tri-state for t-paliad-212 Slice 2c capability probe',
|
||||
true);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'user_caldav_config'
|
||||
AND column_name = 'supports_mkcalendar'
|
||||
) THEN
|
||||
ALTER TABLE paliad.user_caldav_config
|
||||
ADD COLUMN supports_mkcalendar boolean;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad'
|
||||
AND table_name = 'user_caldav_config'
|
||||
AND column_name = 'mkcalendar_probed_at'
|
||||
) THEN
|
||||
ALTER TABLE paliad.user_caldav_config
|
||||
ADD COLUMN mkcalendar_probed_at timestamptz;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Assertion — both columns present and nullable.
|
||||
DO $$
|
||||
DECLARE
|
||||
sup_nullable text;
|
||||
probed_nullable text;
|
||||
BEGIN
|
||||
SELECT is_nullable INTO sup_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad' AND table_name = 'user_caldav_config'
|
||||
AND column_name = 'supports_mkcalendar';
|
||||
SELECT is_nullable INTO probed_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'paliad' AND table_name = 'user_caldav_config'
|
||||
AND column_name = 'mkcalendar_probed_at';
|
||||
IF sup_nullable <> 'YES' OR probed_nullable <> 'YES' THEN
|
||||
RAISE EXCEPTION
|
||||
'mig 108 assertion failed: expected both columns nullable, got supports=% probed=%',
|
||||
sup_nullable, probed_nullable;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Reverse of 109_user_dashboard_layouts.up.sql.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.user_dashboard_layouts;
|
||||
@@ -1,29 +0,0 @@
|
||||
-- t-paliad-219 Slice A1: per-user dashboard layout.
|
||||
--
|
||||
-- Design: docs/design-dashboard-configurable-2026-05-20.md §5.1 (newton,
|
||||
-- m-locked 2026-05-20: single layout per user, Q2).
|
||||
--
|
||||
-- Stores one configurable dashboard layout per user as a single jsonb
|
||||
-- column. The layout is an ordered list of (widget_key, visible, settings)
|
||||
-- triples; see internal/services/dashboard_layout_spec.go DashboardLayoutSpec.
|
||||
--
|
||||
-- Single-row-per-user PK because m's Q2 pick is one layout per user (v1) —
|
||||
-- no named-layout switcher. Forward path to named layouts (drop the PK, add
|
||||
-- id+name+is_default columns) stays open if m later changes course.
|
||||
--
|
||||
-- RLS owner-only mirrors user_card_layouts / user_views — personal working
|
||||
-- state, not auditable infrastructure. global_admin gets no override.
|
||||
|
||||
CREATE TABLE paliad.user_dashboard_layouts (
|
||||
user_id uuid PRIMARY KEY REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
layout_json jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE paliad.user_dashboard_layouts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY user_dashboard_layouts_owner_all
|
||||
ON paliad.user_dashboard_layouts FOR ALL
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
@@ -1,22 +0,0 @@
|
||||
-- mig 110 (down) — revert 'other' addition to paliad.projects.type
|
||||
--
|
||||
-- Coerces any 'other' rows back to 'project' (the historical catch-all)
|
||||
-- so the narrower CHECK constraint can re-attach. This is a lossy
|
||||
-- rollback: rows that were genuinely 'other' lose that distinction.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 110 (down): revert ''other'' from projects.type CHECK; coerce rows to ''project''',
|
||||
true);
|
||||
|
||||
UPDATE paliad.projects
|
||||
SET type = 'project'
|
||||
WHERE type = 'other';
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT IF EXISTS projects_type_check;
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_type_check
|
||||
CHECK (type IN (
|
||||
'client', 'litigation', 'patent', 'case', 'project'
|
||||
));
|
||||
@@ -1,33 +0,0 @@
|
||||
-- mig 110 — add 'other' as a sixth paliad.projects.type value
|
||||
--
|
||||
-- m/paliad#51 (t-paliad-221): the type chip filter on /projects used to
|
||||
-- treat unclassified projects as a synthetic "Empty" bucket. We replace
|
||||
-- that with a real 'other' type so every row carries a meaningful label
|
||||
-- and the filter UI stops needing a NULL/Empty shim.
|
||||
--
|
||||
-- Defensive backfill: NOT NULL + the original IN-list CHECK already
|
||||
-- forbid NULL rows, but we coerce any stray rows just in case a future
|
||||
-- migration ever relaxed the constraint. As of 2026-05-20 production
|
||||
-- carries zero rows that would change here (live query confirmed).
|
||||
--
|
||||
-- The Go-side source of truth lives in
|
||||
-- internal/services/project_service.go (ProjectType constants +
|
||||
-- isValidProjectType); this migration keeps the DB in sync.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 110: add ''other'' to projects.type CHECK + backfill NULLs (m/paliad#51)',
|
||||
true);
|
||||
|
||||
-- Backfill first so the new CHECK never rejects a pre-existing row.
|
||||
UPDATE paliad.projects
|
||||
SET type = 'other'
|
||||
WHERE type IS NULL;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT IF EXISTS projects_type_check;
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_type_check
|
||||
CHECK (type IN (
|
||||
'client', 'litigation', 'patent', 'case', 'project', 'other'
|
||||
));
|
||||
@@ -1,65 +0,0 @@
|
||||
-- Reverse of 111_project_admin_and_select.up.sql.
|
||||
--
|
||||
-- Drops effective_project_admin, restores the original RLS policies,
|
||||
-- and shrinks the responsibility CHECK back to four values. Any rows
|
||||
-- still carrying responsibility='admin' would violate the restored
|
||||
-- CHECK; the down-migration backfills them to 'lead' (the closest
|
||||
-- existing role) before re-adding the constraint.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Backfill any responsibility='admin' rows to 'lead'.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.project_teams
|
||||
SET responsibility = 'lead'
|
||||
WHERE responsibility = 'admin';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. Restore the original CHECK (lead/member/observer/external).
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.project_teams
|
||||
DROP CONSTRAINT IF EXISTS project_teams_responsibility_check;
|
||||
|
||||
ALTER TABLE paliad.project_teams
|
||||
ADD CONSTRAINT project_teams_responsibility_check
|
||||
CHECK (responsibility IN ('lead', 'member', 'observer', 'external'));
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. Restore the pre-110 RLS policies.
|
||||
-- ============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS project_teams_update ON paliad.project_teams;
|
||||
CREATE POLICY project_teams_update
|
||||
ON paliad.project_teams FOR UPDATE
|
||||
USING (paliad.can_see_project(project_id))
|
||||
WITH CHECK (paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS project_teams_insert ON paliad.project_teams;
|
||||
CREATE POLICY project_teams_insert
|
||||
ON paliad.project_teams FOR INSERT
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
OR paliad.can_see_project(project_id)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS project_teams_delete ON paliad.project_teams;
|
||||
CREATE POLICY project_teams_delete
|
||||
ON paliad.project_teams FOR DELETE
|
||||
USING (
|
||||
paliad.can_see_project(project_id)
|
||||
AND (
|
||||
user_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. Drop the predicate function.
|
||||
-- ============================================================================
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.effective_project_admin(uuid, uuid);
|
||||
@@ -1,152 +0,0 @@
|
||||
-- t-paliad-223 Slice A: Project Admin role on project_teams.responsibility +
|
||||
-- inheritable role-edit gate.
|
||||
--
|
||||
-- Design: docs/design-team-admin-rework-2026-05-20.md (gauss, m-locked
|
||||
-- 2026-05-20 via head's "all R approved").
|
||||
--
|
||||
-- Adds a fifth 'admin' value to the project_teams.responsibility enum
|
||||
-- (orthogonal to the profession-driven approval ladder — admin does NOT
|
||||
-- open the 4-Augen gate by itself). Introduces paliad.effective_project_admin
|
||||
-- which mirrors paliad.can_see_project's shape and walks the ltree path
|
||||
-- to compute inheritance. Replaces the three write-side RLS policies on
|
||||
-- paliad.project_teams so role edits are gated on the new predicate
|
||||
-- instead of "anyone with visibility".
|
||||
--
|
||||
-- Day-1 deploy = no behaviour change for callers who never use the admin
|
||||
-- value: existing lead/member/observer/external rows keep their meaning,
|
||||
-- and the global_admin shortcut + self-join INSERT / self-DELETE remain
|
||||
-- intact.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. ALTER project_teams.responsibility CHECK to include 'admin'.
|
||||
-- 2. CREATE paliad.effective_project_admin(uuid, uuid).
|
||||
-- 3. Replace project_teams_update policy: gated on effective_project_admin.
|
||||
-- 4. Replace project_teams_insert policy: self-join OR effective_project_admin.
|
||||
-- 5. Replace project_teams_delete policy: self / global_admin / effective_project_admin.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Extend responsibility CHECK to include 'admin'.
|
||||
--
|
||||
-- 'admin' inherits down the project tree (see effective_project_admin in §2).
|
||||
-- A user marked admin on a Mandant-level project is implicitly admin on
|
||||
-- every Litigation / Patent / Case descendant — same shape as how 'lead'
|
||||
-- already inherits.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.project_teams
|
||||
DROP CONSTRAINT IF EXISTS project_teams_responsibility_check;
|
||||
|
||||
ALTER TABLE paliad.project_teams
|
||||
ADD CONSTRAINT project_teams_responsibility_check
|
||||
CHECK (responsibility IN ('admin', 'lead', 'member', 'observer', 'external'));
|
||||
|
||||
COMMENT ON COLUMN paliad.project_teams.responsibility IS
|
||||
'Per-project responsibility. admin = can manage team + roles on this '
|
||||
'project and descendants (inherited via paliad.effective_project_admin). '
|
||||
'lead/member open the 4-Augen approval gate; observer/external close it. '
|
||||
'admin is orthogonal to the approval gate — it does NOT open it by itself.';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. paliad.effective_project_admin(_user_id, _project_id)
|
||||
--
|
||||
-- Mirrors paliad.can_see_project: STABLE SECURITY DEFINER, ltree path-walk
|
||||
-- against projects.path. Two branches:
|
||||
-- (a) global_admin short-circuit — firm-wide admins are always admin.
|
||||
-- (b) ancestor-or-self project_teams row with responsibility='admin'.
|
||||
--
|
||||
-- Used by the project_teams_update / _insert / _delete policies below
|
||||
-- and by ProjectService for the effective_admin payload field.
|
||||
--
|
||||
-- The ltree-array cast is the same pattern can_see_project uses; the
|
||||
-- existing GiST index on projects.path is the load-bearing index. No new
|
||||
-- index needed.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.effective_project_admin(_user_id uuid, _project_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = _user_id
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.projects target
|
||||
JOIN paliad.project_teams pt
|
||||
ON pt.user_id = _user_id
|
||||
AND pt.responsibility = 'admin'
|
||||
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
|
||||
WHERE target.id = _project_id
|
||||
);
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.effective_project_admin(uuid, uuid) IS
|
||||
'True iff the user is global_admin OR has responsibility=admin on the '
|
||||
'project itself or any ancestor in the materialised ltree path. '
|
||||
'Drives the role-edit gate on project_teams (UPDATE/INSERT/DELETE RLS).';
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. project_teams_update policy: gated on effective_project_admin.
|
||||
--
|
||||
-- Before: USING + CHECK = can_see_project (anyone with visibility could
|
||||
-- edit anyone's responsibility — the load-bearing gap that t-paliad-223
|
||||
-- closes).
|
||||
-- After: USING + CHECK = effective_project_admin (only project-admins
|
||||
-- and global_admins can change roles).
|
||||
-- ============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS project_teams_update ON paliad.project_teams;
|
||||
|
||||
CREATE POLICY project_teams_update
|
||||
ON paliad.project_teams FOR UPDATE
|
||||
USING (paliad.effective_project_admin(auth.uid(), project_id))
|
||||
WITH CHECK (paliad.effective_project_admin(auth.uid(), project_id));
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. project_teams_insert policy: self-join OR effective_project_admin.
|
||||
--
|
||||
-- The self-join branch (user_id = auth.uid()) preserves the legacy
|
||||
-- creator-as-lead INSERT in ProjectService.Create: the project creator
|
||||
-- auto-joins their own project with responsibility='lead' before any
|
||||
-- admin exists. Without this branch, the first-ever team row on a new
|
||||
-- project would fail because no admin has been granted yet.
|
||||
--
|
||||
-- For all other inserts (adding other users), the caller must be an
|
||||
-- effective_project_admin on the target project.
|
||||
-- ============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS project_teams_insert ON paliad.project_teams;
|
||||
|
||||
CREATE POLICY project_teams_insert
|
||||
ON paliad.project_teams FOR INSERT
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
OR paliad.effective_project_admin(auth.uid(), project_id)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. project_teams_delete policy: self / global_admin / effective_project_admin.
|
||||
--
|
||||
-- Additive: self-remove + global_admin still work; project-admin can now
|
||||
-- also remove members.
|
||||
-- ============================================================================
|
||||
|
||||
DROP POLICY IF EXISTS project_teams_delete ON paliad.project_teams;
|
||||
|
||||
CREATE POLICY project_teams_delete
|
||||
ON paliad.project_teams FOR DELETE
|
||||
USING (
|
||||
paliad.can_see_project(project_id)
|
||||
AND (
|
||||
user_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
OR paliad.effective_project_admin(auth.uid(), project_id)
|
||||
)
|
||||
);
|
||||
@@ -1,30 +0,0 @@
|
||||
-- Down migration for 112_client_role_rework.
|
||||
--
|
||||
-- Restores the original 4-value CHECK ('claimant','defendant',
|
||||
-- 'court','both', NULL) and backfills any rows that landed on a new
|
||||
-- sub-role value (applicant / appellant / respondent / third_party /
|
||||
-- other) to NULL so the schema is internally consistent after the
|
||||
-- step-down.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Backfill new sub-role values to NULL so the old CHECK doesn't reject.
|
||||
UPDATE paliad.projects
|
||||
SET our_side = NULL
|
||||
WHERE our_side IN ('applicant', 'appellant', 'respondent', 'third_party', 'other');
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT IF EXISTS projects_our_side_check;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_our_side_check
|
||||
CHECK (our_side IS NULL
|
||||
OR our_side IN ('claimant', 'defendant', 'court', 'both'));
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.our_side IS
|
||||
'Which side the firm represents on this project. Used by the '
|
||||
'Fristenrechner Determinator (Slice 3c) to predefine the '
|
||||
'perspective chip from the project context. Allowed: claimant, '
|
||||
'defendant, court, both.';
|
||||
|
||||
COMMIT;
|
||||
@@ -1,51 +0,0 @@
|
||||
-- mig 112 — t-paliad-222 / m/paliad#47 — Client Role rework.
|
||||
--
|
||||
-- Widens paliad.projects.our_side CHECK to seven sub-role values and
|
||||
-- drops the legacy 'court' / 'both' entries. The DB column name stays
|
||||
-- as 'our_side' (UI label changes only — see design doc §2.2 Q1).
|
||||
--
|
||||
-- New allowed sub-roles, grouped at display time:
|
||||
-- Active (we initiate) : claimant, applicant, appellant
|
||||
-- Reactive (we defend) : defendant, respondent
|
||||
-- Third Party / Other : third_party, other
|
||||
-- NULL : unknown / not set
|
||||
--
|
||||
-- Backfill: any rows still on 'court' / 'both' fall back to NULL.
|
||||
-- Verified 2026-05-20: all 12 production rows are NULL, so this is
|
||||
-- a no-op on prod; the UPDATE runs defensively for staging / test
|
||||
-- fixtures that may carry the legacy values.
|
||||
--
|
||||
-- Idempotent so re-runs against a partially-applied state stay safe.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Backfill any 'court' / 'both' rows to NULL.
|
||||
UPDATE paliad.projects
|
||||
SET our_side = NULL
|
||||
WHERE our_side IN ('court', 'both');
|
||||
|
||||
-- 2. Swap the CHECK constraint for the widened sub-role set.
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT IF EXISTS projects_our_side_check;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_our_side_check
|
||||
CHECK (our_side IS NULL OR our_side IN (
|
||||
'claimant', 'defendant',
|
||||
'applicant', 'appellant',
|
||||
'respondent',
|
||||
'third_party', 'other'
|
||||
));
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.our_side IS
|
||||
'Which side the firm represents on this case project (renamed in '
|
||||
'the UI to "Client Role" / "Mandantenrolle" — t-paliad-222 / '
|
||||
'm/paliad#47). Allowed sub-roles, grouped at display time: Active '
|
||||
'(claimant, applicant, appellant); Reactive (defendant, '
|
||||
'respondent); Third Party / Other (third_party, other). NULL = '
|
||||
'unknown. The form hides the field on non-case project types. '
|
||||
'Drives the Fristenrechner Determinator perspective chip — Active '
|
||||
'group → claimant-perspective, Reactive → defendant-perspective, '
|
||||
'Third Party / Other → null (chip free-pick).';
|
||||
|
||||
COMMIT;
|
||||
@@ -1,11 +0,0 @@
|
||||
-- Down migration for 113_projects_opponent_code.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP CONSTRAINT IF EXISTS projects_opponent_code_check;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP COLUMN IF EXISTS opponent_code;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,50 +0,0 @@
|
||||
-- mig 113 — t-paliad-222 / m/paliad#50 — auto-derived project codes.
|
||||
--
|
||||
-- Adds an opponent-code slug field on litigation projects. Used as
|
||||
-- the middle segment when BuildProjectCode assembles an auto-derived
|
||||
-- project code from the ancestor tree (e.g. EXMPL.OPNT.567.INF.CFI).
|
||||
--
|
||||
-- NULL = segment skipped silently. Existing litigation rows yield
|
||||
-- codes without an opponent segment until the user fills the field.
|
||||
-- No backfill from `title` — the litigation title is free-text
|
||||
-- ("Siemens AG ./. Huawei", "Mandant vs Gegner") and any regex would
|
||||
-- be brittle; the user enters the slug once at project creation /
|
||||
-- next edit.
|
||||
--
|
||||
-- Slug shape: uppercase letters / digits / dashes, max 16 chars.
|
||||
-- Constraint also gates on type='litigation' so a stray value on a
|
||||
-- non-litigation row is rejected at the DB level (defence in depth;
|
||||
-- the form already hides the field on other types).
|
||||
--
|
||||
-- Idempotent.
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS opponent_code text;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'projects_opponent_code_check'
|
||||
AND conrelid = 'paliad.projects'::regclass
|
||||
) THEN
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_opponent_code_check
|
||||
CHECK (opponent_code IS NULL
|
||||
OR (opponent_code ~ '^[A-Z0-9-]{1,16}$'
|
||||
AND type = 'litigation'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.opponent_code IS
|
||||
'Short slug for the opposing party on a litigation project '
|
||||
'(uppercase letters, digits, dashes, max 16 chars). Used as the '
|
||||
'middle segment when BuildProjectCode walks the ancestor tree to '
|
||||
'assemble a dotted project code — e.g. EXMPL.OPNT.567.INF.CFI '
|
||||
'(t-paliad-222 / m/paliad#50). NULL = segment skipped silently. '
|
||||
'Only meaningful on type=''litigation'' rows; the CHECK enforces '
|
||||
'that pairing.';
|
||||
|
||||
COMMIT;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Reverse of mig 114 — t-paliad-225 / m/paliad#61 Slice A.
|
||||
|
||||
ALTER TABLE paliad.checklist_instances
|
||||
DROP COLUMN IF EXISTS template_snapshot;
|
||||
|
||||
DROP POLICY IF EXISTS checklists_delete ON paliad.checklists;
|
||||
DROP POLICY IF EXISTS checklists_update ON paliad.checklists;
|
||||
DROP POLICY IF EXISTS checklists_insert ON paliad.checklists;
|
||||
DROP POLICY IF EXISTS checklists_select ON paliad.checklists;
|
||||
|
||||
DROP FUNCTION IF EXISTS paliad.can_see_checklist(uuid, uuid);
|
||||
|
||||
DROP TABLE IF EXISTS paliad.checklists;
|
||||
@@ -1,178 +0,0 @@
|
||||
-- mig 114 — t-paliad-225 / m/paliad#61 Slice A — user-authored checklists.
|
||||
--
|
||||
-- Design: docs/design-user-checklists-2026-05-20.md
|
||||
--
|
||||
-- Introduces paliad.checklists (the authored-template catalog), the
|
||||
-- paliad.can_see_checklist(uuid, uuid) visibility predicate, and a
|
||||
-- nullable template_snapshot column on paliad.checklist_instances so
|
||||
-- per-Akte instances stay decoupled from subsequent template edits.
|
||||
--
|
||||
-- Slice A ships with private + firm visibility only; the 'shared' and
|
||||
-- 'global' values are valid in the CHECK enum so Slice B can add the
|
||||
-- explicit-share path and admin-promotion without a second migration
|
||||
-- to the enum.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. CREATE TABLE paliad.checklists.
|
||||
-- 2. paliad.can_see_checklist(uuid, uuid) predicate.
|
||||
-- 3. RLS policies on paliad.checklists.
|
||||
-- 4. ALTER TABLE paliad.checklist_instances ADD COLUMN template_snapshot.
|
||||
--
|
||||
-- Idempotent throughout (CREATE … IF NOT EXISTS / CREATE OR REPLACE
|
||||
-- FUNCTION / DROP POLICY IF EXISTS + CREATE POLICY).
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.checklists — authored-template catalog.
|
||||
--
|
||||
-- The static Go catalog (internal/checklists/templates.go) stays the
|
||||
-- firm's curated source for legally-reviewed templates. This table holds
|
||||
-- user-authored templates that augment that catalog at read time via
|
||||
-- ChecklistCatalogService.
|
||||
--
|
||||
-- Slugs are author-facing and unique within this table. The application
|
||||
-- layer rejects slugs that collide with the static catalog (see
|
||||
-- ChecklistTemplateService.Create — applies a 'u-' prefix and falls back
|
||||
-- through a collision-retry loop).
|
||||
--
|
||||
-- body jsonb carries { "groups": [{ "title", "items": [{ "label", "note",
|
||||
-- "rule" }] }] } — the same shape as the static checklists.Template
|
||||
-- minus the metadata (which lives in dedicated columns).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.checklists (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL UNIQUE,
|
||||
owner_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
title text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
regime text NOT NULL DEFAULT 'OTHER',
|
||||
court text NOT NULL DEFAULT '',
|
||||
reference text NOT NULL DEFAULT '',
|
||||
deadline text NOT NULL DEFAULT '',
|
||||
lang text NOT NULL DEFAULT 'de',
|
||||
body jsonb NOT NULL,
|
||||
visibility text NOT NULL DEFAULT 'private'
|
||||
CHECK (visibility IN ('private', 'shared', 'firm', 'global')),
|
||||
promoted_at timestamptz,
|
||||
promoted_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS checklists_owner_idx
|
||||
ON paliad.checklists (owner_id);
|
||||
CREATE INDEX IF NOT EXISTS checklists_visibility_idx
|
||||
ON paliad.checklists (visibility)
|
||||
WHERE visibility IN ('firm', 'global');
|
||||
CREATE INDEX IF NOT EXISTS checklists_regime_idx
|
||||
ON paliad.checklists (regime);
|
||||
|
||||
COMMENT ON TABLE paliad.checklists IS
|
||||
'User-authored checklist templates. Augments the static Go catalog '
|
||||
'at read time via ChecklistCatalogService. Visibility levels: '
|
||||
'private (owner only), shared (Slice B), firm (all authenticated), '
|
||||
'global (admin-promoted into firm catalog — Slice B).';
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. paliad.can_see_checklist(_user_id, _checklist_id)
|
||||
--
|
||||
-- Pattern mirrors paliad.can_see_project / paliad.effective_project_admin
|
||||
-- (mig 111): STABLE SECURITY DEFINER, single-statement, predicate-friendly.
|
||||
--
|
||||
-- Slice A only relies on the owner + firm/global branches. The shared
|
||||
-- branch (matching against paliad.checklist_shares) is wired now so
|
||||
-- Slice B doesn't need to replace the function — a NULL row count just
|
||||
-- returns false. The table doesn't exist yet, so the EXISTS clause must
|
||||
-- be guarded; we inline a NOT EXISTS check on pg_class so the function
|
||||
-- body compiles cleanly on Slice A while staying ready for Slice B.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $$
|
||||
-- Owner can always see.
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id
|
||||
AND c.owner_id = _user_id
|
||||
)
|
||||
-- firm / global visibility: every authenticated user.
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id
|
||||
AND c.visibility IN ('firm', 'global')
|
||||
);
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.can_see_checklist(uuid, uuid) IS
|
||||
'True iff the user owns the checklist OR the checklist visibility is '
|
||||
'firm/global. Slice B extends this predicate with the explicit-share '
|
||||
'path over paliad.checklist_shares.';
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. RLS on paliad.checklists.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.checklists ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: owner OR visible via can_see_checklist.
|
||||
DROP POLICY IF EXISTS checklists_select ON paliad.checklists;
|
||||
CREATE POLICY checklists_select
|
||||
ON paliad.checklists FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_checklist(auth.uid(), id));
|
||||
|
||||
-- INSERT: caller can only create templates owned by themselves.
|
||||
DROP POLICY IF EXISTS checklists_insert ON paliad.checklists;
|
||||
CREATE POLICY checklists_insert
|
||||
ON paliad.checklists FOR INSERT TO authenticated
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
-- UPDATE: owner OR global_admin.
|
||||
DROP POLICY IF EXISTS checklists_update ON paliad.checklists;
|
||||
CREATE POLICY checklists_update
|
||||
ON paliad.checklists FOR UPDATE TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- DELETE: owner OR global_admin.
|
||||
DROP POLICY IF EXISTS checklists_delete ON paliad.checklists;
|
||||
CREATE POLICY checklists_delete
|
||||
ON paliad.checklists FOR DELETE TO authenticated
|
||||
USING (
|
||||
owner_id = auth.uid()
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. paliad.checklist_instances.template_snapshot — instance integrity column.
|
||||
--
|
||||
-- Captures the template body (groups + items) at instance create time so
|
||||
-- subsequent template edits / visibility narrowing don't affect existing
|
||||
-- per-Akte instances. NULL on rows created before this migration; the
|
||||
-- service layer falls back to live catalog lookup for those.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE paliad.checklist_instances
|
||||
ADD COLUMN IF NOT EXISTS template_snapshot jsonb;
|
||||
|
||||
COMMENT ON COLUMN paliad.checklist_instances.template_snapshot IS
|
||||
'Snapshot of the template body at instance create time. NULL for '
|
||||
'pre-mig-114 rows; service layer falls back to live catalog lookup '
|
||||
'in that case (legacy path; backfilled in Slice C).';
|
||||
@@ -1,26 +0,0 @@
|
||||
-- Reverse of mig 115 — t-paliad-225 / m/paliad#61 Slice B.
|
||||
--
|
||||
-- Restore the owner+firm/global-only body of paliad.can_see_checklist
|
||||
-- (matches the mig 114 definition) so a rollback of Slice B leaves the
|
||||
-- function pointing at the Slice A behaviour.
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id AND c.owner_id = _user_id
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id AND c.visibility IN ('firm', 'global')
|
||||
);
|
||||
$$;
|
||||
|
||||
DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares;
|
||||
DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares;
|
||||
DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.checklist_shares;
|
||||
@@ -1,211 +0,0 @@
|
||||
-- mig 115 — t-paliad-225 / m/paliad#61 Slice B — explicit sharing +
|
||||
-- admin-promotion plumbing for user-authored checklists.
|
||||
--
|
||||
-- Design: docs/design-user-checklists-2026-05-20.md §3.2 / §4.2 / §4.3
|
||||
-- / §4.5.
|
||||
--
|
||||
-- Introduces paliad.checklist_shares with the polymorphic recipient
|
||||
-- pattern (xor-check enforces exactly one recipient_* column populated
|
||||
-- per recipient_kind). Extends paliad.can_see_checklist with the
|
||||
-- explicit-share branches so the 'shared' visibility level actually
|
||||
-- gates anything.
|
||||
--
|
||||
-- Sections:
|
||||
-- 1. CREATE TABLE paliad.checklist_shares (+ indexes + RLS).
|
||||
-- 2. CREATE OR REPLACE paliad.can_see_checklist — adds 4 share
|
||||
-- branches (user / office / partner_unit / project).
|
||||
--
|
||||
-- Idempotent throughout.
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. paliad.checklist_shares — explicit grants for a single checklist.
|
||||
--
|
||||
-- recipient_kind disambiguates which recipient_* column is populated.
|
||||
-- The XOR check makes the constraint structurally enforce "exactly one
|
||||
-- recipient_<kind> non-null per row". Per-kind UNIQUE partial indexes
|
||||
-- prevent duplicate grants per (checklist, recipient).
|
||||
--
|
||||
-- Slice A's checklists.visibility CHECK already includes 'shared' so no
|
||||
-- ALTER is needed here.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.checklist_shares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
checklist_id uuid NOT NULL REFERENCES paliad.checklists(id) ON DELETE CASCADE,
|
||||
recipient_kind text NOT NULL
|
||||
CHECK (recipient_kind IN ('user', 'office', 'partner_unit', 'project')),
|
||||
recipient_user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
recipient_office text,
|
||||
recipient_partner_unit_id uuid REFERENCES paliad.partner_units(id) ON DELETE CASCADE,
|
||||
recipient_project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
granted_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
granted_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT checklist_shares_recipient_xor CHECK (
|
||||
(recipient_kind = 'user'
|
||||
AND recipient_user_id IS NOT NULL
|
||||
AND recipient_office IS NULL
|
||||
AND recipient_partner_unit_id IS NULL
|
||||
AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'office'
|
||||
AND recipient_office IS NOT NULL
|
||||
AND recipient_user_id IS NULL
|
||||
AND recipient_partner_unit_id IS NULL
|
||||
AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'partner_unit'
|
||||
AND recipient_partner_unit_id IS NOT NULL
|
||||
AND recipient_user_id IS NULL
|
||||
AND recipient_office IS NULL
|
||||
AND recipient_project_id IS NULL)
|
||||
OR (recipient_kind = 'project'
|
||||
AND recipient_project_id IS NOT NULL
|
||||
AND recipient_user_id IS NULL
|
||||
AND recipient_office IS NULL
|
||||
AND recipient_partner_unit_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Hot-path lookup for the visibility predicate.
|
||||
CREATE INDEX IF NOT EXISTS checklist_shares_lookup_idx
|
||||
ON paliad.checklist_shares (checklist_id);
|
||||
|
||||
-- Uniqueness per recipient kind. Partial indexes so a NULL recipient_<other>
|
||||
-- doesn't collide with another row's NULL recipient_<other>.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_user_uniq
|
||||
ON paliad.checklist_shares (checklist_id, recipient_user_id)
|
||||
WHERE recipient_kind = 'user';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_office_uniq
|
||||
ON paliad.checklist_shares (checklist_id, recipient_office)
|
||||
WHERE recipient_kind = 'office';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_partner_unit_uniq
|
||||
ON paliad.checklist_shares (checklist_id, recipient_partner_unit_id)
|
||||
WHERE recipient_kind = 'partner_unit';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS checklist_shares_project_uniq
|
||||
ON paliad.checklist_shares (checklist_id, recipient_project_id)
|
||||
WHERE recipient_kind = 'project';
|
||||
|
||||
COMMENT ON TABLE paliad.checklist_shares IS
|
||||
'Explicit grants for paliad.checklists. Polymorphic recipient '
|
||||
'(user/office/partner_unit/project) enforced by recipient_xor CHECK. '
|
||||
'Owner of the checklist grants and revokes; global_admin can revoke '
|
||||
'as well. Slice B (t-paliad-225) — see can_see_checklist body for '
|
||||
'the visibility branches that consume these rows.';
|
||||
|
||||
ALTER TABLE paliad.checklist_shares ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- SELECT: caller can see the row if they own the parent checklist OR
|
||||
-- they are the recipient (for user-kind grants — recipients shouldn't
|
||||
-- be surprised by who else can also see the checklist) OR global_admin.
|
||||
DROP POLICY IF EXISTS checklist_shares_select ON paliad.checklist_shares;
|
||||
CREATE POLICY checklist_shares_select
|
||||
ON paliad.checklist_shares FOR SELECT TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
|
||||
)
|
||||
OR (recipient_kind = 'user' AND recipient_user_id = auth.uid())
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- INSERT: only the checklist owner can grant; granted_by must be self.
|
||||
DROP POLICY IF EXISTS checklist_shares_insert ON paliad.checklist_shares;
|
||||
CREATE POLICY checklist_shares_insert
|
||||
ON paliad.checklist_shares FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
|
||||
)
|
||||
AND granted_by = auth.uid()
|
||||
);
|
||||
|
||||
-- DELETE: owner OR global_admin. No UPDATE policy — grants are
|
||||
-- immutable, revoke = DELETE + re-insert with the corrected recipient.
|
||||
DROP POLICY IF EXISTS checklist_shares_delete ON paliad.checklist_shares;
|
||||
CREATE POLICY checklist_shares_delete
|
||||
ON paliad.checklist_shares FOR DELETE TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = checklist_id AND c.owner_id = auth.uid()
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. paliad.can_see_checklist — extend with the 4 share branches.
|
||||
--
|
||||
-- Owner + firm/global branches stay as in mig 114. Share branches:
|
||||
-- - user — the row's recipient_user_id matches the caller
|
||||
-- - office — recipient_office matches caller's office OR is in
|
||||
-- their additional_offices array
|
||||
-- - partner_unit — caller is a member of the recipient partner_unit
|
||||
-- - project — caller can see the recipient project (reuses
|
||||
-- paliad.can_see_project, ltree-walked)
|
||||
--
|
||||
-- can_see_project reads auth.uid() through SECURITY DEFINER inheritance
|
||||
-- (same pattern effective_project_admin uses in mig 111).
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_checklist(_user_id uuid, _checklist_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path TO 'paliad', 'public'
|
||||
AS $$
|
||||
-- Owner
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id AND c.owner_id = _user_id
|
||||
)
|
||||
-- firm / global
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklists c
|
||||
WHERE c.id = _checklist_id AND c.visibility IN ('firm', 'global')
|
||||
)
|
||||
-- Explicit share: user
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklist_shares s
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'user'
|
||||
AND s.recipient_user_id = _user_id
|
||||
)
|
||||
-- Explicit share: office (caller's primary OR additional offices)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.users u ON u.id = _user_id
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'office'
|
||||
AND (s.recipient_office = u.office
|
||||
OR s.recipient_office = ANY(u.additional_offices))
|
||||
)
|
||||
-- Explicit share: partner_unit (caller is a member)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.checklist_shares s
|
||||
JOIN paliad.partner_unit_members pum
|
||||
ON pum.partner_unit_id = s.recipient_partner_unit_id
|
||||
AND pum.user_id = _user_id
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'partner_unit'
|
||||
)
|
||||
-- Explicit share: project (caller can see the project via existing predicate)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.checklist_shares s
|
||||
WHERE s.checklist_id = _checklist_id
|
||||
AND s.recipient_kind = 'project'
|
||||
AND paliad.can_see_project(s.recipient_project_id)
|
||||
);
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.can_see_checklist(uuid, uuid) IS
|
||||
'True iff the user owns the checklist OR firm/global visibility OR '
|
||||
'an explicit share row matches the caller (by user / office / '
|
||||
'partner_unit / project ancestry).';
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Reverse of mig 116 — t-paliad-225 / m/paliad#61 Slice C.
|
||||
|
||||
ALTER TABLE paliad.checklist_instances
|
||||
DROP COLUMN IF EXISTS template_version;
|
||||
|
||||
ALTER TABLE paliad.checklists
|
||||
DROP COLUMN IF EXISTS version;
|
||||
@@ -1,39 +0,0 @@
|
||||
-- mig 116 — t-paliad-225 / m/paliad#61 Slice C — template versioning.
|
||||
--
|
||||
-- Design: docs/design-user-checklists-2026-05-20.md §3.4 / §6.
|
||||
--
|
||||
-- Adds an integer version counter to paliad.checklists that bumps on
|
||||
-- every meaningful edit (body or title — see
|
||||
-- ChecklistTemplateService.Update). Adds a matching template_version
|
||||
-- column on paliad.checklist_instances so the instance detail page can
|
||||
-- surface "the template you instantiated from has been updated" and
|
||||
-- offer a diff view.
|
||||
--
|
||||
-- Existing rows backfill to version=1 / template_version=NULL. The
|
||||
-- NULL on instances means "we don't know which version was snapshotted"
|
||||
-- (pre-Slice-C row); the snapshot column is still authoritative for
|
||||
-- rendering, but the "outdated" badge stays off because we can't
|
||||
-- compare.
|
||||
--
|
||||
-- Idempotent throughout.
|
||||
|
||||
ALTER TABLE paliad.checklists
|
||||
ADD COLUMN IF NOT EXISTS version int NOT NULL DEFAULT 1;
|
||||
|
||||
-- Backfill any rows that somehow ended up at 0 (shouldn't happen with
|
||||
-- DEFAULT 1, but defensive — the column was added with default so this
|
||||
-- is a no-op on the live DB).
|
||||
UPDATE paliad.checklists SET version = 1 WHERE version < 1;
|
||||
|
||||
COMMENT ON COLUMN paliad.checklists.version IS
|
||||
'Monotonic version counter, bumps in ChecklistTemplateService.Update '
|
||||
'whenever body or title changes. Used by the instance detail page '
|
||||
'to show an "outdated" badge when the user''s snapshot is older.';
|
||||
|
||||
ALTER TABLE paliad.checklist_instances
|
||||
ADD COLUMN IF NOT EXISTS template_version int;
|
||||
|
||||
COMMENT ON COLUMN paliad.checklist_instances.template_version IS
|
||||
'Snapshot of paliad.checklists.version at instance create time. '
|
||||
'NULL for pre-Slice-C rows where the version wasn''t captured; the '
|
||||
'"outdated" badge stays off in that case.';
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS paliad.firm_dashboard_default;
|
||||
@@ -1,33 +0,0 @@
|
||||
-- t-paliad-219 Slice C: firm-wide dashboard default layout.
|
||||
--
|
||||
-- Design: docs/design-dashboard-configurable-2026-05-20.md §8.2 (firm-wide
|
||||
-- admin default, deferred to v1.1 — now activated by m's Slice C brief).
|
||||
--
|
||||
-- A single optional row that holds the firm's preferred dashboard layout.
|
||||
-- DashboardLayoutService.GetOrSeed reads this on first call for a new user
|
||||
-- (falling back to the code-resident FactoryDefaultLayout when null);
|
||||
-- ResetToDefault similarly prefers the firm default. Admins promote their
|
||||
-- own current layout into this row via POST /api/me/dashboard-layout/promote.
|
||||
--
|
||||
-- Single-row design via CHECK (id = 1) so there's no ambiguity about which
|
||||
-- row is "the default". RLS lets any authenticated user SELECT (so the
|
||||
-- service can read it during seed); only the application (service-role
|
||||
-- connection) writes — the admin gate sits on the HTTP handler.
|
||||
|
||||
CREATE TABLE paliad.firm_dashboard_default (
|
||||
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
layout_json jsonb NOT NULL,
|
||||
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE paliad.firm_dashboard_default ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- All authenticated users can SELECT — the dashboard seed path needs to
|
||||
-- read it for every new user. The HTTP handler enforces admin-only on the
|
||||
-- PUT/DELETE paths; the service runs under service-role so writes bypass
|
||||
-- RLS anyway. No INSERT/UPDATE policy means no Supabase-JWT-authenticated
|
||||
-- client can write, which is the desired posture.
|
||||
CREATE POLICY firm_dashboard_default_read
|
||||
ON paliad.firm_dashboard_default FOR SELECT
|
||||
USING (true);
|
||||
@@ -44,78 +44,6 @@ func handleAdminListUnonboarded(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /api/admin/users/full — create BOTH an auth.users row (via Supabase
|
||||
// Admin API) and a paliad.users row in one operation. t-paliad-223 Slice B
|
||||
// (#49). Lets a global_admin onboard a colleague without forcing them
|
||||
// through the email-invitation round-trip; the new user is visible in
|
||||
// dropdowns immediately and can log in via the emailed magic-link.
|
||||
//
|
||||
// Requires SUPABASE_SERVICE_ROLE_KEY at the server. Returns 503 when
|
||||
// unset so a deploy that hasn't provisioned the credential yet gets a
|
||||
// clear diagnostic instead of a cryptic 500.
|
||||
//
|
||||
// Error mapping:
|
||||
// - ErrSupabaseAdminUnavailable → 503
|
||||
// - ErrSupabaseEmailExists → 409 (hint to use "Onboard existing")
|
||||
// - ErrUserAlreadyOnboarded → 409 (paliad.users dup; should be unreachable)
|
||||
// - ErrInvalidInput → 400 (bad shape)
|
||||
// - email domain not on whitelist → 403 (mirrors handleAdminCreateUser)
|
||||
// - other → 500
|
||||
func handleAdminCreateFullUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input services.AdminCreateFullInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if !isAllowedEmailDomain(input.Email) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"error": "email domain not on the " + branding.Name + " allow-list",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Look up the inviter (the calling admin) so the welcome email and
|
||||
// audit row carry their identity. Failures here shouldn't block the
|
||||
// create; we just degrade to empty fields.
|
||||
inviter, err := dbSvc.users.GetByID(r.Context(), uid)
|
||||
if err == nil && inviter != nil {
|
||||
input.InviterID = inviter.ID
|
||||
input.InviterName = inviter.DisplayName
|
||||
input.InviterEmail = inviter.Email
|
||||
}
|
||||
|
||||
u, err := dbSvc.users.AdminCreateUserFull(r.Context(), input)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrSupabaseAdminUnavailable):
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "add-user flow requires SUPABASE_SERVICE_ROLE_KEY on the server",
|
||||
})
|
||||
case errors.Is(err, services.ErrSupabaseEmailExists):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "auth account already exists — please use 'Onboard existing' instead",
|
||||
})
|
||||
case errors.Is(err, services.ErrUserAlreadyOnboarded):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{
|
||||
"error": "user already onboarded",
|
||||
})
|
||||
case errors.Is(err, services.ErrInvalidInput):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
default:
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, u)
|
||||
}
|
||||
|
||||
// POST /api/admin/users — direct-create a paliad.users row for an existing
|
||||
// auth.users entry. The recipient email's domain must already match the
|
||||
// allowed-email policy (Supabase wouldn't have let them sign up otherwise),
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
@@ -312,226 +311,6 @@ func handleTestCalDAVConfig(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// GET /api/caldav-bindings — list the authenticated user's CalDAV
|
||||
// bindings (the (calendar, scope) entries layered on the single CalDAV
|
||||
// server connection). Read-only in Slice 2a; full CRUD lands in Slice 2b.
|
||||
func handleListCalDAVBindings(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.caldavBindings == nil {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]any{
|
||||
"error": "CalDAV bindings unavailable (CalDAV service not configured)",
|
||||
})
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.caldavBindings.ListForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []models.UserCalendarBinding{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /api/caldav-bindings — create a new binding for the
|
||||
// authenticated user and synchronously fire a first push so the modal
|
||||
// closes with events already landed. Returns 201 with the binding row.
|
||||
func handleCreateCalDAVBinding(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCalDAV(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.caldavBindings == nil {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]any{"error": "CalDAV bindings unavailable"})
|
||||
return
|
||||
}
|
||||
var input services.CreateBindingInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
// Default to enabled=true so the modal "Hinzufügen" button does the
|
||||
// expected thing without forcing the user to toggle anything.
|
||||
if !input.Enabled {
|
||||
input.Enabled = true
|
||||
}
|
||||
binding, err := dbSvc.caldavBindings.Create(r.Context(), uid, input)
|
||||
if err != nil {
|
||||
writeCalDAVError(w, err)
|
||||
return
|
||||
}
|
||||
// Synchronous first push per Q5 of the Slice 2 design (m's 2026-05-20
|
||||
// pick): block the request so the user sees events already landed
|
||||
// when the modal closes. PushBindingNow logs per-event failures and
|
||||
// returns; we only surface a hard config/cipher error.
|
||||
pushed, pushErr := dbSvc.caldav.PushBindingNow(r.Context(), uid, binding)
|
||||
if pushErr != nil {
|
||||
// Binding was created; sync failed. Tell the UI both bits so it
|
||||
// can show "binding added, initial sync had a problem".
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"binding": binding,
|
||||
"initial_pushed": pushed,
|
||||
"initial_sync_error": pushErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Ensure the per-user goroutine is running so future ticks happen.
|
||||
dbSvc.caldav.EnsureLoop(uid)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"binding": binding,
|
||||
"initial_pushed": pushed,
|
||||
})
|
||||
}
|
||||
|
||||
// PATCH /api/caldav-bindings/{id} — partial update. Lazy scope cleanup
|
||||
// per Q6: stale targets get dropped on the next sync tick, not here.
|
||||
func handlePatchCalDAVBinding(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.caldavBindings == nil {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]any{"error": "CalDAV bindings unavailable"})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var input services.UpdateBindingInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
binding, err := dbSvc.caldavBindings.Update(r.Context(), uid, id, input)
|
||||
if err != nil {
|
||||
writeCalDAVError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, binding)
|
||||
}
|
||||
|
||||
// DELETE /api/caldav-bindings/{id} — best-effort remote cleanup of every
|
||||
// .ics this binding pushed, then drop the binding row. On partial remote
|
||||
// failure the binding is disabled (not deleted) so the next sync tick
|
||||
// can retry; the response is 202 Accepted in that case.
|
||||
func handleDeleteCalDAVBinding(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.caldav == nil {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]any{"error": "CalDAV bindings unavailable"})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
fully, err := dbSvc.caldav.RemoveBinding(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeCalDAVError(w, err)
|
||||
return
|
||||
}
|
||||
if !fully {
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"status": "partial",
|
||||
"message": "Binding disabled; some remote events could not be deleted. Retry on next sync tick.",
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/caldav-mkcalendar — creates a new calendar on the user's
|
||||
// CalDAV server via MKCALENDAR + a matching binding row in one logical
|
||||
// transaction. Slice 2c only — visible when /api/caldav-discover
|
||||
// reports supports_mkcalendar=true. Errors:
|
||||
// - 501 when supports_mkcalendar=false (caller should show the
|
||||
// Google-degrade UX with the manual-URL input).
|
||||
// - 409 when the slugified name + 3 retries all collide on the
|
||||
// server. UI should ask the user to type their own name.
|
||||
func handleCalDAVMakeCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCalDAV(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input services.CreateCalendarInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
result, err := dbSvc.caldav.MakeCalendar(r.Context(), uid, input)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrMKCalendarUnsupported):
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]any{
|
||||
"error": err.Error(),
|
||||
"supports_mkcalendar": false,
|
||||
})
|
||||
case errors.Is(err, services.ErrCalendarNameTaken):
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
default:
|
||||
// Binding-create / push errors carry the partial result so
|
||||
// the UI can surface "created remotely but binding failed".
|
||||
if result != nil {
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"calendar_path": result.CalendarPath,
|
||||
"binding": result.Binding,
|
||||
"initial_pushed": result.InitialPushed,
|
||||
"initial_sync_error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
writeCalDAVError(w, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
}
|
||||
|
||||
// GET /api/caldav-discover — walks the calendar-home-set chain on the
|
||||
// user's CalDAV server and returns the calendars they own. Cached
|
||||
// server-side for 5 minutes per user (Q4 of Slice 2 brief).
|
||||
func handleCalDAVDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCalDAV(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := dbSvc.caldav.DiscoverCalendars(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeCalDAVError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GET /api/caldav-config/log — last 5 sync attempts.
|
||||
func handleCalDAVSyncLog(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCalDAV(w) {
|
||||
|
||||
@@ -24,13 +24,8 @@ func handleAppointmentsDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/appointments-detail.html")
|
||||
}
|
||||
|
||||
// handleAppointmentsCalendarPage 301-redirects the legacy standalone
|
||||
// calendar route to the canonical /events Kalender tab (t-paliad-224 /
|
||||
// m/paliad#55). Counterpart of handleDeadlinesCalendarPage — same
|
||||
// reasoning: the standalone page was orphaned in navigation since
|
||||
// t-paliad-110, the canonical calendar lives inside /events.
|
||||
func handleAppointmentsCalendarPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/events?type=appointment&view=calendar", http.StatusMovedPermanently)
|
||||
http.ServeFile(w, r, "dist/appointments-calendar.html")
|
||||
}
|
||||
|
||||
// handleSettingsPage serves the unified settings page with tabs for
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/checklists/templates/{slug}/shares — list grants (owner/admin).
|
||||
func handleListChecklistShares(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
rows, err := dbSvc.checklistShare.ListGrants(r.Context(), uid, slug)
|
||||
if err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /api/checklists/templates/{slug}/shares — grant a share.
|
||||
func handleGrantChecklistShare(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
var input services.ShareGrantInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
share, err := dbSvc.checklistShare.Grant(r.Context(), uid, slug, input)
|
||||
if err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, share)
|
||||
}
|
||||
|
||||
// DELETE /api/checklists/shares/{id} — revoke a share by id.
|
||||
func handleRevokeChecklistShare(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.checklistShare.Revoke(r.Context(), uid, id); err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/admin/checklists/{slug}/promote — global_admin only.
|
||||
func handlePromoteChecklist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
if err := dbSvc.checklistPromotion.Promote(r.Context(), uid, slug); err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/admin/checklists/{slug}/demote — global_admin only.
|
||||
func handleDemoteChecklist(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
var body struct {
|
||||
Target string `json:"target"`
|
||||
}
|
||||
// Body is optional — Demote defaults to 'firm' when empty.
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if err := dbSvc.checklistPromotion.Demote(r.Context(), uid, slug, body.Target); err != nil {
|
||||
writeChecklistShareError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// writeChecklistShareError maps the share/promotion service errors.
|
||||
// Same as the templates handler: ErrInvalidInput → 400, ErrForbidden →
|
||||
// 403, ErrNotVisible → 404, fall through to writeServiceError.
|
||||
func writeChecklistShareError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrNotVisible) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "checklist not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/checklists/templates/mine — list authored templates owned by caller.
|
||||
func handleListMyChecklistTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.checklistTemplate.ListOwnedBy(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// POST /api/checklists/templates — create a new authored template.
|
||||
func handleCreateChecklistTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input services.CreateTemplateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
t, err := dbSvc.checklistTemplate.Create(r.Context(), uid, input)
|
||||
if err != nil {
|
||||
writeChecklistTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
// PATCH /api/checklists/templates/{slug} — update authored template (owner only).
|
||||
func handleUpdateChecklistTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
var input services.UpdateTemplateInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
t, err := dbSvc.checklistTemplate.Update(r.Context(), uid, slug, input)
|
||||
if err != nil {
|
||||
writeChecklistTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
// PATCH /api/checklists/templates/{slug}/visibility — toggle private↔firm.
|
||||
func handleSetChecklistTemplateVisibility(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
var body struct {
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
t, err := dbSvc.checklistTemplate.SetVisibility(r.Context(), uid, slug, body.Visibility)
|
||||
if err != nil {
|
||||
writeChecklistTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
// DELETE /api/checklists/templates/{slug} — delete authored template.
|
||||
func handleDeleteChecklistTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slug := r.PathValue("slug")
|
||||
if err := dbSvc.checklistTemplate.Delete(r.Context(), uid, slug); err != nil {
|
||||
writeChecklistTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// writeChecklistTemplateError maps service errors to HTTP status. Falls
|
||||
// through to writeServiceError for unknown errors so the generic
|
||||
// ErrNotVisible / ErrInvalidInput / ErrForbidden mappings still apply.
|
||||
func writeChecklistTemplateError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": strings.TrimPrefix(err.Error(), "invalid input: ")})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrForbidden) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, services.ErrNotVisible) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "checklist not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
@@ -24,13 +24,6 @@ func handleChecklistsPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/checklists.html")
|
||||
}
|
||||
|
||||
// handleChecklistsAuthorPage serves the authoring wizard (new + edit
|
||||
// share the same bundle; the client reads location.pathname to decide
|
||||
// create vs edit mode).
|
||||
func handleChecklistsAuthorPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/checklists-author.html")
|
||||
}
|
||||
|
||||
func handleChecklistDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if _, ok := checklists.Find(slug); !ok {
|
||||
@@ -44,105 +37,18 @@ func handleChecklistInstancePage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/checklists-instance.html")
|
||||
}
|
||||
|
||||
// handleChecklistsAPI returns the merged catalog: static templates
|
||||
// (always) plus authored DB templates the caller can see (mig 114).
|
||||
// Each entry carries origin + visibility + author metadata so the
|
||||
// frontend can render provenance.
|
||||
//
|
||||
// Falls back to the bare static catalog when DB is unavailable so the
|
||||
// knowledge-platform-only deploy stays functional without DATABASE_URL.
|
||||
func handleChecklistsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.checklistCatalog == nil {
|
||||
// Fall back to static summaries shape so the existing frontend
|
||||
// keeps working in the no-DB deploy.
|
||||
writeJSON(w, http.StatusOK, checklists.Summaries())
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := dbSvc.checklistCatalog.ListVisible(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
// Frontend expects the existing Summary shape on the index list; map
|
||||
// the merged entries to Summary + origin/visibility/author fields.
|
||||
type Summary struct {
|
||||
checklists.Summary
|
||||
Origin string `json:"origin"`
|
||||
Visibility string `json:"visibility"`
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
OwnerDisplayName string `json:"owner_display_name,omitempty"`
|
||||
}
|
||||
out := make([]Summary, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, Summary{
|
||||
Summary: checklists.Summary{
|
||||
Slug: e.Template.Slug,
|
||||
TitleDE: e.Template.TitleDE,
|
||||
TitleEN: e.Template.TitleEN,
|
||||
DescriptionDE: e.Template.DescriptionDE,
|
||||
DescriptionEN: e.Template.DescriptionEN,
|
||||
Regime: e.Template.Regime,
|
||||
CourtDE: e.Template.CourtDE,
|
||||
CourtEN: e.Template.CourtEN,
|
||||
ItemCount: checklists.TotalItems(e.Template),
|
||||
},
|
||||
Origin: e.Origin,
|
||||
Visibility: e.Visibility,
|
||||
OwnerEmail: e.OwnerEmail,
|
||||
OwnerDisplayName: e.OwnerDisplayName,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
writeJSON(w, http.StatusOK, checklists.Summaries())
|
||||
}
|
||||
|
||||
// handleChecklistAPI returns one template by slug. Looks up static
|
||||
// catalog first (always visible), then authored DB rows via the
|
||||
// catalog with visibility check.
|
||||
func handleChecklistAPI(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
// Static-first path keeps the no-DB deploy functional and is the
|
||||
// common case for the curated templates.
|
||||
if c, ok := checklists.Find(slug); ok {
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
return
|
||||
}
|
||||
if dbSvc == nil || dbSvc.checklistCatalog == nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Checkliste nicht gefunden."})
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
c, ok := checklists.Find(slug)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry, err := dbSvc.checklistCatalog.Find(r.Context(), uid, slug)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Checkliste nicht gefunden."})
|
||||
return
|
||||
}
|
||||
// Re-render as the bilingual Template shape plus a thin meta block.
|
||||
// Version is included so the instance detail page can decide whether
|
||||
// to show the "template updated since this instance was created"
|
||||
// badge (Slice C).
|
||||
type templateWithMeta struct {
|
||||
checklists.Template
|
||||
Origin string `json:"origin"`
|
||||
Visibility string `json:"visibility"`
|
||||
OwnerEmail string `json:"owner_email,omitempty"`
|
||||
OwnerDisplayName string `json:"owner_display_name,omitempty"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
writeJSON(w, http.StatusOK, templateWithMeta{
|
||||
Template: entry.Template,
|
||||
Origin: entry.Origin,
|
||||
Visibility: entry.Visibility,
|
||||
OwnerEmail: entry.OwnerEmail,
|
||||
OwnerDisplayName: entry.OwnerDisplayName,
|
||||
Version: entry.Version,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
}
|
||||
|
||||
func handleChecklistsFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/auth"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/dashboard — returns the DashboardData JSON for the logged-in user.
|
||||
@@ -25,29 +24,21 @@ func handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
// GET /dashboard — protected shell page. The client boots, reads three
|
||||
// initial payloads inlined by the server (data, layout, catalog), and
|
||||
// renders without a second round-trip (audit §2.3: no skeleton→fetch
|
||||
// waterfall). Each inline is best-effort: if any read fails the
|
||||
// corresponding blob is left null and the client falls back to fetch.
|
||||
// GET /dashboard — protected shell page. The client boots, reads the initial
|
||||
// payload inlined by the server into window.__PALIAD_DASHBOARD__, and renders
|
||||
// without a second round-trip (audit §2.3: no skeleton→fetch waterfall).
|
||||
func handleDashboardPage(w http.ResponseWriter, r *http.Request) {
|
||||
uid, hasUser := auth.UserIDFromContext(r.Context())
|
||||
var payload, layout []byte
|
||||
var payload []byte
|
||||
if hasUser && dbSvc != nil {
|
||||
// Best-effort server-render. If the DB read fails we still serve the
|
||||
// shell; the client will show the inline error state instead of the
|
||||
// zero-count cards.
|
||||
if data, err := dbSvc.dashboard.Get(r.Context(), uid); err == nil {
|
||||
payload = mustJSON(data)
|
||||
}
|
||||
if dbSvc.dashboardLayout != nil {
|
||||
if spec, err := dbSvc.dashboardLayout.GetOrSeed(r.Context(), uid); err == nil {
|
||||
layout = mustJSON(spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Catalog is code-resident — always inline it so the widget picker
|
||||
// and dispatch logic can boot without an extra fetch even on
|
||||
// knowledge-platform-only deployments without DATABASE_URL.
|
||||
catalog := mustJSON(services.WidgetCatalog())
|
||||
serveDashboardShell(w, r, payload, layout, catalog)
|
||||
serveDashboardShell(w, r, payload)
|
||||
}
|
||||
|
||||
// handleRootPage is the public `/` route. Unauthenticated visitors get the
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
package handlers
|
||||
|
||||
// HTTP handlers for the per-user dashboard layout (t-paliad-219 Slice A2).
|
||||
//
|
||||
// Design: docs/design-dashboard-configurable-2026-05-20.md §9.
|
||||
//
|
||||
// Four endpoints:
|
||||
// GET /api/me/dashboard-layout → read (auto-seeds factory default)
|
||||
// PUT /api/me/dashboard-layout → replace (validates against catalog)
|
||||
// POST /api/me/dashboard-layout/reset → overwrite with factory default
|
||||
// GET /api/dashboard-widget-catalog → catalog metadata for the picker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/me/dashboard-layout — returns the caller's layout, seeding the
|
||||
// factory default on first call. Always returns 200 with a valid
|
||||
// DashboardLayoutSpec.
|
||||
func handleGetDashboardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dashboardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
|
||||
return
|
||||
}
|
||||
spec, err := dbSvc.dashboardLayout.GetOrSeed(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, spec)
|
||||
}
|
||||
|
||||
// PUT /api/me/dashboard-layout — replaces the caller's layout. Body must
|
||||
// be a complete DashboardLayoutSpec; the service validates against the
|
||||
// catalog and 400s on a bad spec.
|
||||
func handlePutDashboardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dashboardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
|
||||
return
|
||||
}
|
||||
var spec services.DashboardLayoutSpec
|
||||
if err := json.NewDecoder(r.Body).Decode(&spec); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.dashboardLayout.Update(r.Context(), uid, spec)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// POST /api/me/dashboard-layout/reset — overwrites the caller's layout
|
||||
// with the factory default. The previous layout is discarded.
|
||||
func handleResetDashboardLayout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dashboardLayout == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
|
||||
return
|
||||
}
|
||||
spec, err := dbSvc.dashboardLayout.ResetToDefault(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, spec)
|
||||
}
|
||||
|
||||
// GET /api/dashboard-widget-catalog — returns the widget catalog. Auth-
|
||||
// gated only because the catalog includes user-facing copy; nothing
|
||||
// security-sensitive is exposed. The handler is DB-independent (the
|
||||
// catalog is code-resident) so the requireDB gate is intentionally
|
||||
// skipped — knowledge-platform-only deployments can still surface the
|
||||
// catalog and we never want this endpoint to 503.
|
||||
func handleGetWidgetCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, services.WidgetCatalog())
|
||||
}
|
||||
@@ -11,15 +11,10 @@ import (
|
||||
)
|
||||
|
||||
// The dashboard shell is pre-rendered by bun (`renderDashboard()` → dist/dashboard.html)
|
||||
// and contains three placeholder tokens (data, layout, catalog). On each
|
||||
// request we splice in JSON blobs as window.__PALIAD_DASHBOARD__ /
|
||||
// __PALIAD_DASHBOARD_LAYOUT__ / __PALIAD_DASHBOARD_CATALOG__ so the client
|
||||
// can paint the real data on first frame — no skeleton + /api/* waterfall.
|
||||
const (
|
||||
dashboardDataPlaceholder = "/*__PALIAD_DASHBOARD_DATA__*/"
|
||||
dashboardLayoutPlaceholder = "/*__PALIAD_DASHBOARD_LAYOUT__*/"
|
||||
dashboardCatalogPlaceholder = "/*__PALIAD_DASHBOARD_CATALOG__*/"
|
||||
)
|
||||
// and contains the placeholder token below. On each request we splice in a
|
||||
// JSON blob as `window.__PALIAD_DASHBOARD__` so the client can paint the real
|
||||
// data on first frame — no skeleton + /api/dashboard waterfall.
|
||||
const dashboardDataPlaceholder = "/*__PALIAD_DASHBOARD_DATA__*/"
|
||||
|
||||
var (
|
||||
dashboardShellOnce sync.Once
|
||||
@@ -43,19 +38,28 @@ func loadDashboardShell() ([]byte, error) {
|
||||
return dashboardShellBytes, dashboardShellErr
|
||||
}
|
||||
|
||||
// serveDashboardShell writes dist/dashboard.html with three JSON blobs
|
||||
// spliced in (data, layout, catalog). A nil payload disables server-side
|
||||
// hydration of that slot; the client falls back to fetching the
|
||||
// corresponding /api/* endpoint on mount.
|
||||
func serveDashboardShell(w http.ResponseWriter, _ *http.Request, payload, layout, catalog []byte) {
|
||||
// serveDashboardShell writes dist/dashboard.html with the JSON payload spliced
|
||||
// into the placeholder. A nil payload disables server-side hydration; the
|
||||
// client then falls back to fetching /api/dashboard on mount.
|
||||
func serveDashboardShell(w http.ResponseWriter, _ *http.Request, payload []byte) {
|
||||
shell, err := loadDashboardShell()
|
||||
if err != nil {
|
||||
http.Error(w, "dashboard shell unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body := splicePlaceholder(shell, dashboardDataPlaceholder, "window.__PALIAD_DASHBOARD__=", payload)
|
||||
body = splicePlaceholder(body, dashboardLayoutPlaceholder, "window.__PALIAD_DASHBOARD_LAYOUT__=", layout)
|
||||
body = splicePlaceholder(body, dashboardCatalogPlaceholder, "window.__PALIAD_DASHBOARD_CATALOG__=", catalog)
|
||||
var body []byte
|
||||
if len(payload) > 0 {
|
||||
// JSON is wrapped so the script block is self-contained even when the
|
||||
// payload contains `</script>` sequences (defensive: our data is
|
||||
// server-owned, but future event.description fields could contain
|
||||
// arbitrary text).
|
||||
inline := append([]byte("window.__PALIAD_DASHBOARD__="), escapeForScript(payload)...)
|
||||
inline = append(inline, ';')
|
||||
body = bytes.Replace(shell, []byte(dashboardDataPlaceholder), inline, 1)
|
||||
} else {
|
||||
body = bytes.Replace(shell, []byte(dashboardDataPlaceholder),
|
||||
[]byte("window.__PALIAD_DASHBOARD__=null;"), 1)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
@@ -63,22 +67,6 @@ func serveDashboardShell(w http.ResponseWriter, _ *http.Request, payload, layout
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// splicePlaceholder replaces a single placeholder token with a JS
|
||||
// assignment of the given JSON payload to a window.X global. A nil
|
||||
// payload assigns `null` so the client can detect "no server-side
|
||||
// hydration" and fall back to fetch.
|
||||
func splicePlaceholder(shell []byte, placeholder, prefix string, payload []byte) []byte {
|
||||
var inline []byte
|
||||
if len(payload) > 0 {
|
||||
inline = append(inline, []byte(prefix)...)
|
||||
inline = append(inline, escapeForScript(payload)...)
|
||||
inline = append(inline, ';')
|
||||
} else {
|
||||
inline = append(inline, []byte(prefix+"null;")...)
|
||||
}
|
||||
return bytes.Replace(shell, []byte(placeholder), inline, 1)
|
||||
}
|
||||
|
||||
// escapeForScript makes a JSON blob safe to embed directly in an inline
|
||||
// <script>. JSON strings may contain `</script>` or U+2028/U+2029, both of
|
||||
// which terminate script blocks in some parsers.
|
||||
|
||||
@@ -23,13 +23,6 @@ func handleDeadlinesDetailPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/deadlines-detail.html")
|
||||
}
|
||||
|
||||
// handleDeadlinesCalendarPage 301-redirects the legacy standalone
|
||||
// calendar route to the canonical /events Kalender tab (t-paliad-224 /
|
||||
// m/paliad#55). The standalone page was orphaned in navigation since
|
||||
// t-paliad-110 — Sidebar/BottomNav already point at /events?type=…, and
|
||||
// the canonical calendar lives inside that page's view chip. The
|
||||
// redirect preserves bookmarks and external links without a duplicate
|
||||
// rendering pipeline.
|
||||
func handleDeadlinesCalendarPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/events?type=deadline&view=calendar", http.StatusMovedPermanently)
|
||||
http.ServeFile(w, r, "dist/deadlines-calendar.html")
|
||||
}
|
||||
|
||||
@@ -2,19 +2,16 @@ package handlers
|
||||
|
||||
// Data-export handlers (t-paliad-214).
|
||||
//
|
||||
// Slice 1: personal scope
|
||||
// Slice 1 ships the personal scope only:
|
||||
//
|
||||
// GET /api/me/export → streams a personal-scope export .zip
|
||||
//
|
||||
// Slice 2: project subtree scope
|
||||
// GET /api/projects/{id}/export?direct_only=0|1 → streams a project-subtree
|
||||
// export .zip
|
||||
//
|
||||
// Slice 3 (org, async) lands in a follow-up.
|
||||
// Slices 2 + 3 (project + org) layer onto this file when they ship.
|
||||
//
|
||||
// Authentication: the existing protected mux middleware (auth.Middleware +
|
||||
// auth.WithUserID) populates the user UUID in the context. Slice 1 gates
|
||||
// only on authentication; Slice 2 adds a §4 responsibility + global_admin
|
||||
// check via handleProjectExportGate.
|
||||
// auth.WithUserID) populates the user UUID in the context. We do not gate
|
||||
// on global_role here — personal export is available to every authenticated
|
||||
// user.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -25,8 +22,6 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
@@ -107,7 +102,7 @@ func handleMeExport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := services.ExportFilename(services.ExportScopePersonal, "", uuid.Nil, spec.GeneratedAt)
|
||||
filename := services.ExportFilename(services.ExportScopePersonal, "", spec.GeneratedAt)
|
||||
size := int64(buf.Len())
|
||||
|
||||
if err := dbSvc.export.PatchAuditRowSuccess(ctx, auditID, meta, size); err != nil {
|
||||
@@ -128,163 +123,3 @@ func handleMeExport(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("export: response write failed for %s (audit=%s): %v", uid, auditID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleProjectExport streams the project-subtree export .zip for the
|
||||
// project named in the URL path.
|
||||
//
|
||||
// Authorization (Slice 2 §4):
|
||||
//
|
||||
// - caller must be authenticated (handled by the mux middleware),
|
||||
// - caller must pass paliad.can_see_project(rootID) — enforced via
|
||||
// ProjectService.GetByID returning ErrNotVisible → 404,
|
||||
// - caller must be on paliad.project_teams for the root with
|
||||
// responsibility ∈ {lead, member}, OR be a global_admin.
|
||||
// Observers + Externals see but cannot extract — 403 bilingual.
|
||||
//
|
||||
// Query params:
|
||||
// - ?direct_only=1 narrows the export to the root project only (no
|
||||
// descendants). Default = subtree-inclusive.
|
||||
func handleProjectExport(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.export == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "export service not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
rootID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid project id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
directOnly := false
|
||||
if q := r.URL.Query().Get("direct_only"); q == "1" || q == "true" {
|
||||
directOnly = true
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), exportRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Visibility gate (a + b): GetByID returns ErrNotVisible when the
|
||||
// caller can't see the project, which we map to 404. The handler
|
||||
// stays oblivious to whether the project doesn't exist or simply
|
||||
// isn't visible — that's by design (RLS-style opacity).
|
||||
project, err := dbSvc.projects.GetByID(ctx, uid, rootID)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Authority gate (c): direct-team responsibility ∈ {lead, member} OR
|
||||
// global_admin. Derived-only-via-partner-unit users (DerivedPeer)
|
||||
// don't qualify for extraction — m's Q1 lock-in.
|
||||
allowed, err := callerCanExportProject(ctx, uid, rootID)
|
||||
if err != nil {
|
||||
log.Printf("export: authority check failed for user=%s project=%s: %v", uid, rootID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "authority check failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
// Bilingual 403 per Q7. Pattern matches mapApprovalError style.
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{
|
||||
"code": "export_not_authorized",
|
||||
"message": "Datenexport ist nur Team-Mitgliedern (Lead / Member) vorbehalten. / Data export is restricted to project team members (lead / member).",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := dbSvc.users.GetByID(ctx, uid)
|
||||
if err != nil || user == nil {
|
||||
log.Printf("export: user lookup failed for %s: %v", uid, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "user lookup failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
spec := services.ExportSpec{
|
||||
Scope: services.ExportScopeProject,
|
||||
ScopeRoot: &rootID,
|
||||
ScopeRootLabel: project.Title,
|
||||
ScopeRootPath: project.Path,
|
||||
DirectOnly: directOnly,
|
||||
ActorID: uid,
|
||||
ActorEmail: user.Email,
|
||||
ActorLabel: user.DisplayName,
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
auditID, err := dbSvc.export.WriteAuditRow(ctx, spec)
|
||||
if err != nil {
|
||||
log.Printf("export: audit insert failed for %s/project=%s: %v", uid, rootID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "audit write failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
meta, err := dbSvc.export.WriteProject(ctx, &buf, spec)
|
||||
if err != nil {
|
||||
dbSvc.export.PatchAuditRowFailure(context.Background(), auditID, err.Error())
|
||||
log.Printf("export: WriteProject failed for %s/project=%s (audit=%s): %v", uid, rootID, auditID, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "export generation failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
filename := services.ExportFilename(services.ExportScopeProject, project.Title, rootID, spec.GeneratedAt)
|
||||
size := int64(buf.Len())
|
||||
|
||||
if err := dbSvc.export.PatchAuditRowSuccess(ctx, auditID, meta, size); err != nil {
|
||||
log.Printf("export: audit patch failed for %s/project=%s (audit=%s): %v", uid, rootID, auditID, err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.Header().Set("X-Paliad-Export-Audit-Id", auditID.String())
|
||||
if _, err := w.Write(buf.Bytes()); err != nil {
|
||||
log.Printf("export: response write failed for %s/project=%s (audit=%s): %v", uid, rootID, auditID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// callerCanExportProject is the §4 authority check:
|
||||
//
|
||||
// - global_admin can extract anything anywhere.
|
||||
// - else: caller must be on paliad.project_teams for the root with
|
||||
// responsibility ∈ {lead, member}.
|
||||
//
|
||||
// One query, parameterised; returns the boolean. Errors surface to the
|
||||
// handler as 500.
|
||||
func callerCanExportProject(ctx context.Context, userID, projectID uuid.UUID) (bool, error) {
|
||||
const q = `
|
||||
SELECT
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = $1 AND u.global_role = 'global_admin'
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.project_id = $2
|
||||
AND pt.responsibility IN ('lead', 'member')
|
||||
)
|
||||
`
|
||||
var ok bool
|
||||
if err := dbSvc.projects.DB().QueryRowContext(ctx, q, userID, projectID).Scan(&ok); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
package handlers
|
||||
|
||||
// HTTP handlers for the firm-wide dashboard default layout (t-paliad-219
|
||||
// Slice C). All four endpoints sit behind the adminGate so only
|
||||
// global_admin can read or mutate. The per-user GetOrSeed/ResetToDefault
|
||||
// path consumes the firm default via DashboardLayoutService — the read
|
||||
// surface here is just the admin's read-back of the current row.
|
||||
//
|
||||
// GET /api/admin/firm-dashboard-default — current row, or 204
|
||||
// PUT /api/admin/firm-dashboard-default — replace
|
||||
// DELETE /api/admin/firm-dashboard-default — clear (revert to factory)
|
||||
// POST /api/me/dashboard-layout/promote — promote caller's own
|
||||
// current layout to firm
|
||||
// default. Admin convenience.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// GET /api/admin/firm-dashboard-default — returns the current firm-wide
|
||||
// default layout, or 204 when none is set. Admins read this on the
|
||||
// firm-default admin surface to verify the active layout.
|
||||
func handleGetFirmDashboardDefault(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.firmDashboardDefault == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-dashboard-default service not configured"})
|
||||
return
|
||||
}
|
||||
spec, ok, err := dbSvc.firmDashboardDefault.Get(r.Context())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
// Empty firm default — the caller can fall back to the factory
|
||||
// shape via GET /api/dashboard-widget-catalog + FactoryDefault-
|
||||
// Layout logic mirrored client-side. 204 is cheaper than
|
||||
// shipping an "is_set: false" wrapper.
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, spec)
|
||||
}
|
||||
|
||||
// PUT /api/admin/firm-dashboard-default — replace the firm-wide default.
|
||||
// Body must be a complete DashboardLayoutSpec. The admin is recorded as
|
||||
// updated_by for audit.
|
||||
func handlePutFirmDashboardDefault(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.firmDashboardDefault == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-dashboard-default service not configured"})
|
||||
return
|
||||
}
|
||||
var spec services.DashboardLayoutSpec
|
||||
if err := json.NewDecoder(r.Body).Decode(&spec); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.firmDashboardDefault.Set(r.Context(), spec, uid)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// DELETE /api/admin/firm-dashboard-default — clear the firm default so
|
||||
// future seeds/resets revert to the code-resident FactoryDefaultLayout.
|
||||
// Idempotent.
|
||||
func handleDeleteFirmDashboardDefault(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if dbSvc.firmDashboardDefault == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-dashboard-default service not configured"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.firmDashboardDefault.Clear(r.Context()); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// POST /api/me/dashboard-layout/promote — admin convenience. Takes the
|
||||
// caller's current layout (whatever's in user_dashboard_layouts for
|
||||
// them) and promotes it to the firm-wide default. Saves an admin the
|
||||
// step of crafting a layout in a JSON editor — they edit their own
|
||||
// dashboard via the standard /dashboard editor, then promote one click.
|
||||
//
|
||||
// Admin-only at the route level (handlers.go wires this under adminGate).
|
||||
// The handler itself does not re-check admin — that's the gate's job.
|
||||
func handlePromoteDashboardLayoutToFirmDefault(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.dashboardLayout == nil || dbSvc.firmDashboardDefault == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "dashboard-layout service not configured"})
|
||||
return
|
||||
}
|
||||
// Read the admin's own current layout (seeding the factory if they
|
||||
// somehow lack a row — vanishingly unlikely for an admin who's
|
||||
// logging in to promote, but the safety belt costs nothing).
|
||||
spec, err := dbSvc.dashboardLayout.GetOrSeed(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.firmDashboardDefault.Set(r.Context(), spec, uid)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
@@ -57,7 +57,6 @@ type Services struct {
|
||||
Deadline *services.DeadlineService
|
||||
Appointment *services.AppointmentService
|
||||
CalDAV *services.CalDAVService
|
||||
CalDAVBindings *services.CalendarBindingService
|
||||
Rules *services.DeadlineRuleService
|
||||
Calculator *services.DeadlineCalculator
|
||||
Users *services.UserService
|
||||
@@ -70,11 +69,7 @@ type Services struct {
|
||||
EventType *services.EventTypeService
|
||||
Dashboard *services.DashboardService
|
||||
Note *services.NoteService
|
||||
ChecklistInst *services.ChecklistInstanceService
|
||||
ChecklistCatalog *services.ChecklistCatalogService
|
||||
ChecklistTemplate *services.ChecklistTemplateService
|
||||
ChecklistShare *services.ChecklistShareService
|
||||
ChecklistPromotion *services.ChecklistPromotionService
|
||||
ChecklistInst *services.ChecklistInstanceService
|
||||
Mail *services.MailService
|
||||
Invite *services.InviteService
|
||||
Agenda *services.AgendaService
|
||||
@@ -88,15 +83,9 @@ type Services struct {
|
||||
UserView *services.UserViewService
|
||||
Broadcast *services.BroadcastService
|
||||
Pin *services.PinService
|
||||
CardLayout *services.CardLayoutService
|
||||
DashboardLayout *services.DashboardLayoutService
|
||||
// FirmDashboardDefault is the firm-wide /dashboard default layout
|
||||
// (Slice C). Admin-only writes; per-user seed/reset reads it via
|
||||
// DashboardLayoutService.defaultLayout(). Nil-safe — falls back to
|
||||
// the code-resident FactoryDefaultLayout.
|
||||
FirmDashboardDefault *services.FirmDashboardDefaultService
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
CardLayout *services.CardLayoutService
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
|
||||
// Submission generator (t-paliad-215) — Klageerwiderung &
|
||||
// friends. Three coordinated services: registry fetches templates
|
||||
@@ -140,7 +129,6 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
deadline: svc.Deadline,
|
||||
appointment: svc.Appointment,
|
||||
caldav: svc.CalDAV,
|
||||
caldavBindings: svc.CalDAVBindings,
|
||||
rules: svc.Rules,
|
||||
calc: svc.Calculator,
|
||||
users: svc.Users,
|
||||
@@ -153,11 +141,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
eventType: svc.EventType,
|
||||
dashboard: svc.Dashboard,
|
||||
note: svc.Note,
|
||||
checklistInst: svc.ChecklistInst,
|
||||
checklistCatalog: svc.ChecklistCatalog,
|
||||
checklistTemplate: svc.ChecklistTemplate,
|
||||
checklistShare: svc.ChecklistShare,
|
||||
checklistPromotion: svc.ChecklistPromotion,
|
||||
checklistInst: svc.ChecklistInst,
|
||||
mail: svc.Mail,
|
||||
invite: svc.Invite,
|
||||
agenda: svc.Agenda,
|
||||
@@ -171,11 +155,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
userView: svc.UserView,
|
||||
broadcast: svc.Broadcast,
|
||||
pin: svc.Pin,
|
||||
cardLayout: svc.CardLayout,
|
||||
dashboardLayout: svc.DashboardLayout,
|
||||
firmDashboardDefault: svc.FirmDashboardDefault,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
cardLayout: svc.CardLayout,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,25 +244,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/tools/gebuehrentabellen/lookup", handleGebuehrentabellenLookup)
|
||||
protected.HandleFunc("POST /api/tools/gebuehrentabellen/feedback", handleGebuehrentabellenFeedback)
|
||||
protected.HandleFunc("GET /checklists", handleChecklistsPage)
|
||||
protected.HandleFunc("GET /checklists/new", handleChecklistsAuthorPage)
|
||||
protected.HandleFunc("GET /checklists/instances/{id}", handleChecklistInstancePage)
|
||||
protected.HandleFunc("GET /checklists/templates/{slug}/edit", handleChecklistsAuthorPage)
|
||||
protected.HandleFunc("GET /checklists/{slug}", handleChecklistDetailPage)
|
||||
protected.HandleFunc("GET /api/checklists", handleChecklistsAPI)
|
||||
protected.HandleFunc("GET /api/checklists/{slug}", handleChecklistAPI)
|
||||
protected.HandleFunc("POST /api/checklists/feedback", handleChecklistsFeedback)
|
||||
// t-paliad-225 Slice A — user-authored templates (paliad.checklists).
|
||||
protected.HandleFunc("GET /api/checklists/templates/mine", handleListMyChecklistTemplates)
|
||||
protected.HandleFunc("POST /api/checklists/templates", handleCreateChecklistTemplate)
|
||||
protected.HandleFunc("PATCH /api/checklists/templates/{slug}", handleUpdateChecklistTemplate)
|
||||
protected.HandleFunc("PATCH /api/checklists/templates/{slug}/visibility", handleSetChecklistTemplateVisibility)
|
||||
protected.HandleFunc("DELETE /api/checklists/templates/{slug}", handleDeleteChecklistTemplate)
|
||||
// t-paliad-225 Slice B — explicit sharing + admin promotion.
|
||||
protected.HandleFunc("GET /api/checklists/templates/{slug}/shares", handleListChecklistShares)
|
||||
protected.HandleFunc("POST /api/checklists/templates/{slug}/shares", handleGrantChecklistShare)
|
||||
protected.HandleFunc("DELETE /api/checklists/shares/{id}", handleRevokeChecklistShare)
|
||||
protected.HandleFunc("POST /api/admin/checklists/{slug}/promote", handlePromoteChecklist)
|
||||
protected.HandleFunc("POST /api/admin/checklists/{slug}/demote", handleDemoteChecklist)
|
||||
protected.HandleFunc("GET /api/checklists/{slug}/instances", handleListChecklistInstancesForTemplate)
|
||||
protected.HandleFunc("POST /api/checklists/{slug}/instances", handleCreateChecklistInstance)
|
||||
protected.HandleFunc("GET /api/checklist-instances", handleListAllChecklistInstances)
|
||||
@@ -316,10 +284,6 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/projects/{id}/timeline", handleGetProjectTimeline)
|
||||
// t-paliad-177 Slice 2 — iCal feed (deadlines + appointments only).
|
||||
protected.HandleFunc("GET /api/projects/{id}/timeline.ics", handleGetProjectTimelineICS)
|
||||
// t-paliad-214 Slice 2 — project-subtree data export. ?direct_only=1
|
||||
// narrows to the root project only; default = root + descendants.
|
||||
// Permission gate: responsibility ∈ {lead, member} OR global_admin.
|
||||
protected.HandleFunc("GET /api/projects/{id}/export", handleProjectExport)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/milestone", handleCreateProjectTimelineMilestone)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/anchor", handleProjectTimelineAnchor)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/skip", handleProjectTimelineSkip)
|
||||
@@ -342,18 +306,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("PATCH /api/user-card-layouts/{id}", handleUpdateCardLayout)
|
||||
protected.HandleFunc("DELETE /api/user-card-layouts/{id}", handleDeleteCardLayout)
|
||||
protected.HandleFunc("POST /api/user-card-layouts/{id}/set-default", handleSetDefaultCardLayout)
|
||||
// t-paliad-219 — per-user configurable dashboard layout.
|
||||
protected.HandleFunc("GET /api/me/dashboard-layout", handleGetDashboardLayout)
|
||||
protected.HandleFunc("PUT /api/me/dashboard-layout", handlePutDashboardLayout)
|
||||
protected.HandleFunc("POST /api/me/dashboard-layout/reset", handleResetDashboardLayout)
|
||||
protected.HandleFunc("GET /api/dashboard-widget-catalog", handleGetWidgetCatalog)
|
||||
protected.HandleFunc("GET /api/projects/{id}/ancestors", handleListProjectAncestors)
|
||||
protected.HandleFunc("GET /api/projects/{id}/parties", handleListParties)
|
||||
protected.HandleFunc("POST /api/projects/{id}/parties", handleCreateParty)
|
||||
// Team membership endpoints for Project detail "Team" tab.
|
||||
protected.HandleFunc("GET /api/projects/{id}/team", handleListProjectTeam)
|
||||
protected.HandleFunc("POST /api/projects/{id}/team", handleAddProjectTeamMember)
|
||||
protected.HandleFunc("PATCH /api/projects/{id}/team/{user_id}", handleChangeProjectTeamMemberResponsibility)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/team/{user_id}", handleRemoveProjectTeamMember)
|
||||
// t-paliad-139 — sub-team aggregation surfaces for the Team tab.
|
||||
protected.HandleFunc("GET /api/projects/{id}/team/derived", handleListDerivedTeam)
|
||||
@@ -392,15 +350,6 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("DELETE /api/caldav-config", handleDeleteCalDAVConfig)
|
||||
protected.HandleFunc("POST /api/caldav-config/test", handleTestCalDAVConfig)
|
||||
protected.HandleFunc("GET /api/caldav-config/log", handleCalDAVSyncLog)
|
||||
// t-paliad-212 Slice 2a/2b — multi-calendar binding CRUD.
|
||||
protected.HandleFunc("GET /api/caldav-bindings", handleListCalDAVBindings)
|
||||
protected.HandleFunc("POST /api/caldav-bindings", handleCreateCalDAVBinding)
|
||||
protected.HandleFunc("PATCH /api/caldav-bindings/{id}", handlePatchCalDAVBinding)
|
||||
protected.HandleFunc("DELETE /api/caldav-bindings/{id}", handleDeleteCalDAVBinding)
|
||||
// /api/caldav-discover — calendar-home-set walk (RFC 6764) for picker.
|
||||
protected.HandleFunc("GET /api/caldav-discover", handleCalDAVDiscover)
|
||||
// Slice 2c — MKCALENDAR ("Create new calendar" affordance in picker).
|
||||
protected.HandleFunc("POST /api/caldav-mkcalendar", handleCalDAVMakeCalendar)
|
||||
|
||||
// t-paliad-088 — Event Types (categorization for Deadlines).
|
||||
protected.HandleFunc("GET /api/event-types", handleListEventTypes)
|
||||
@@ -537,17 +486,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /admin/event-types", adminGate(users, gateOnboarded(handleAdminEventTypesPage)))
|
||||
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
|
||||
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
|
||||
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
|
||||
protected.HandleFunc("GET /api/admin/users/unonboarded", adminGate(users, handleAdminListUnonboarded))
|
||||
protected.HandleFunc("PATCH /api/admin/users/{id}", adminGate(users, handleAdminUpdateUser))
|
||||
protected.HandleFunc("DELETE /api/admin/users/{id}", adminGate(users, handleAdminDeleteUser))
|
||||
protected.HandleFunc("GET /api/audit-log", adminGate(users, handleListAuditLog))
|
||||
// t-paliad-219 Slice C — firm-wide dashboard default + admin promote.
|
||||
protected.HandleFunc("GET /api/admin/firm-dashboard-default", adminGate(users, handleGetFirmDashboardDefault))
|
||||
protected.HandleFunc("PUT /api/admin/firm-dashboard-default", adminGate(users, handlePutFirmDashboardDefault))
|
||||
protected.HandleFunc("DELETE /api/admin/firm-dashboard-default", adminGate(users, handleDeleteFirmDashboardDefault))
|
||||
protected.HandleFunc("POST /api/me/dashboard-layout/promote", adminGate(users, handlePromoteDashboardLayoutToFirmDefault))
|
||||
|
||||
protected.HandleFunc("GET /api/admin/email-templates", adminGate(users, handleAdminListEmailTemplates))
|
||||
protected.HandleFunc("GET /api/admin/email-templates/{key}/variables", adminGate(users, handleAdminEmailTemplateVariables))
|
||||
protected.HandleFunc("GET /api/admin/email-templates/{key}/{lang}", adminGate(users, handleAdminGetEmailTemplate))
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/auth"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
@@ -25,7 +24,6 @@ type dbServices struct {
|
||||
deadline *services.DeadlineService
|
||||
appointment *services.AppointmentService
|
||||
caldav *services.CalDAVService
|
||||
caldavBindings *services.CalendarBindingService
|
||||
rules *services.DeadlineRuleService
|
||||
calc *services.DeadlineCalculator
|
||||
users *services.UserService
|
||||
@@ -38,11 +36,7 @@ type dbServices struct {
|
||||
eventType *services.EventTypeService
|
||||
dashboard *services.DashboardService
|
||||
note *services.NoteService
|
||||
checklistInst *services.ChecklistInstanceService
|
||||
checklistCatalog *services.ChecklistCatalogService
|
||||
checklistTemplate *services.ChecklistTemplateService
|
||||
checklistShare *services.ChecklistShareService
|
||||
checklistPromotion *services.ChecklistPromotionService
|
||||
checklistInst *services.ChecklistInstanceService
|
||||
mail *services.MailService
|
||||
invite *services.InviteService
|
||||
agenda *services.AgendaService
|
||||
@@ -57,8 +51,6 @@ type dbServices struct {
|
||||
broadcast *services.BroadcastService
|
||||
pin *services.PinService
|
||||
cardLayout *services.CardLayoutService
|
||||
dashboardLayout *services.DashboardLayoutService
|
||||
firmDashboardDefault *services.FirmDashboardDefaultService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
}
|
||||
@@ -110,8 +102,6 @@ func writeServiceError(w http.ResponseWriter, err error) {
|
||||
})
|
||||
case errors.Is(err, services.ErrEventTypeSlugTaken):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrLastProjectAdmin):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
default:
|
||||
log.Printf("ERROR service: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
||||
@@ -327,24 +317,7 @@ func handleGetProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
// t-paliad-223: piggyback effective_project_admin onto the project
|
||||
// payload so the frontend can drive the inline role-edit affordance
|
||||
// without a second round-trip. JSON-merge via a small wrapper that
|
||||
// embeds the existing Project shape — every existing caller keeps
|
||||
// reading the same fields and gains effective_admin as additive.
|
||||
effAdmin, err := dbSvc.team.IsEffectiveProjectAdmin(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
type projectWithPermissions struct {
|
||||
*models.Project
|
||||
EffectiveAdmin bool `json:"effective_admin"`
|
||||
}
|
||||
writeJSON(w, http.StatusOK, projectWithPermissions{
|
||||
Project: p,
|
||||
EffectiveAdmin: effAdmin,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
// GET /api/projects/{id}/children — direct children.
|
||||
@@ -376,7 +349,7 @@ func handleListProjectChildren(w http.ResponseWriter, r *http.Request) {
|
||||
// Query parameters (all optional, additive):
|
||||
// ?scope=all|mine|pinned — chip-driven scope (default "all")
|
||||
// ?status=active,archived,closed — status whitelist (CSV; default = no narrowing)
|
||||
// ?type=client,litigation,patent,case,project,other — type whitelist
|
||||
// ?type=client,litigation,patent,case,project — type whitelist
|
||||
// ?has_open_deadlines=true|false — narrow by deadline activity
|
||||
// ?q=<term> — search title / reference / clientmatter
|
||||
// ?subtree_counts=true|false — populate *_subtree fields (default true)
|
||||
|
||||
@@ -32,28 +32,3 @@ func TestRegisterLegacyRedirects_SubProjectsAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-224: /deadlines/calendar and /appointments/calendar 301 to
|
||||
// the canonical /events Kalender tab. Pins the redirect target so a
|
||||
// future refactor doesn't silently break the bookmark contract.
|
||||
func TestStandaloneCalendarHandlers_RedirectToEventsKalender(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
handler http.HandlerFunc
|
||||
want string
|
||||
}{
|
||||
{"deadlines", handleDeadlinesCalendarPage, "/events?type=deadline&view=calendar"},
|
||||
{"appointments", handleAppointmentsCalendarPage, "/events?type=appointment&view=calendar"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil) // path ignored — handler is direct
|
||||
w := httptest.NewRecorder()
|
||||
tc.handler(w, req)
|
||||
if w.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("%s: status = %d, want %d", tc.name, w.Code, http.StatusMovedPermanently)
|
||||
}
|
||||
if got := w.Header().Get("Location"); got != tc.want {
|
||||
t.Fatalf("%s: Location = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,8 +473,6 @@ func humanProjectType(t string) string {
|
||||
return "Verfahren"
|
||||
case services.ProjectTypeProject:
|
||||
return "Projekt"
|
||||
case services.ProjectTypeOther:
|
||||
return "Sonstiges"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
@@ -93,53 +93,6 @@ func handleListMembershipsIndex(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// PATCH /api/projects/{id}/team/{user_id} — change a direct member's
|
||||
// responsibility. Body: {"responsibility": "<admin|lead|member|observer|external>"}.
|
||||
//
|
||||
// Authorisation is RLS-enforced (project_teams_update gated on
|
||||
// effective_project_admin in mig 111). Non-admins get a pq permission
|
||||
// error from the UPDATE; we surface that as 404 to avoid leaking that
|
||||
// the row exists. The last-admin guard runs inside the service tx and
|
||||
// returns ErrLastProjectAdmin (mapped to 409 by writeServiceError).
|
||||
func handleChangeProjectTeamMemberResponsibility(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
userID, err := uuid.Parse(r.PathValue("user_id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Responsibility string `json:"responsibility"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
m, err := dbSvc.team.ChangeResponsibility(r.Context(), uid, projectID, userID, body.Responsibility)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||
"error": "no direct membership found",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
// DELETE /api/projects/{id}/team/{user_id} — remove a direct member.
|
||||
// Inherited memberships can't be removed at the child level.
|
||||
func handleRemoveProjectTeamMember(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -159,35 +159,10 @@ type Project struct {
|
||||
// OurSide is which side the firm represents on this project. Used
|
||||
// by the Fristenrechner Determinator to predefine the perspective
|
||||
// chip from the project context (t-paliad-164). NULL = unknown /
|
||||
// not set; Determinator falls back to free-pick.
|
||||
//
|
||||
// Allowed sub-roles (mig 112, t-paliad-222):
|
||||
// Active : claimant, applicant, appellant
|
||||
// Reactive : defendant, respondent
|
||||
// Other : third_party, other
|
||||
//
|
||||
// The DB column name stays as `our_side`; the UI label has moved
|
||||
// to "Client Role" / "Mandantenrolle" on case projects and is
|
||||
// hidden on every other project type.
|
||||
// not set; Determinator falls back to free-pick. Allowed values:
|
||||
// claimant, defendant, court, both.
|
||||
OurSide *string `db:"our_side" json:"our_side,omitempty"`
|
||||
|
||||
// OpponentCode is the short slug for the opposing party on a
|
||||
// litigation project (uppercase letters / digits / dashes, max 16
|
||||
// chars). Used as the middle segment when services.BuildProjectCode
|
||||
// assembles an auto-derived project code from the ancestor tree —
|
||||
// e.g. EXMPL.OPNT.567.INF.CFI (t-paliad-222 / m/paliad#50). NULL
|
||||
// → segment skipped silently. Only meaningful on type='litigation'
|
||||
// rows; CHECK constraint (mig 113) enforces the pairing.
|
||||
OpponentCode *string `db:"opponent_code" json:"opponent_code,omitempty"`
|
||||
|
||||
// Code is the auto-derived (or override) project code, computed at
|
||||
// projection time by services.BuildProjectCode. Not a DB column —
|
||||
// no `db:` tag — populated by service-layer projection helpers
|
||||
// after the row is loaded. Empty on rows for which the helper has
|
||||
// not run (e.g. raw fixtures in tests, internal projection paths
|
||||
// that don't call the helper).
|
||||
Code string `db:"-" json:"code,omitempty"`
|
||||
|
||||
// CounterclaimOf is the parent project this row is a counterclaim
|
||||
// (CCR) against (t-paliad-174 SmartTimeline Slice 3). NULL on
|
||||
// regular projects; non-NULL rows are CCR sub-projects rendered as
|
||||
@@ -421,32 +396,22 @@ type Note struct {
|
||||
AuthorEmail *string `db:"author_email" json:"author_email,omitempty"`
|
||||
}
|
||||
|
||||
// ChecklistInstance is one user's instantiation of a checklist template
|
||||
// (static catalog in internal/checklists OR authored row in
|
||||
// paliad.checklists). Checkbox state lives in the `state` jsonb column.
|
||||
// ChecklistInstance is one user's instantiation of a static checklist
|
||||
// template (defined in internal/checklists). Checkbox state lives in the
|
||||
// `state` jsonb column.
|
||||
//
|
||||
// Visibility mirrors Appointment: project_id nullable. Personal instances
|
||||
// (project_id NULL) are creator-only; Project-linked instances follow
|
||||
// paliad.can_see_project.
|
||||
//
|
||||
// TemplateSnapshot captures the template body at instance create time so
|
||||
// subsequent template edits / visibility narrowing don't affect existing
|
||||
// instances (t-paliad-225 Slice A). NULL on pre-mig-114 rows; the
|
||||
// service layer falls back to live catalog lookup in that case.
|
||||
type ChecklistInstance struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
TemplateSlug string `db:"template_slug" json:"template_slug"`
|
||||
Name string `db:"name" json:"name"`
|
||||
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||
State json.RawMessage `db:"state" json:"state"`
|
||||
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
TemplateSnapshot NullableJSON `db:"template_snapshot" json:"template_snapshot,omitempty"`
|
||||
// TemplateVersion is the checklists.version at instance create time.
|
||||
// NULL on pre-Slice-C rows where versioning wasn't captured; the
|
||||
// "outdated" badge stays off in that case.
|
||||
TemplateVersion *int `db:"template_version" json:"template_version,omitempty"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
TemplateSlug string `db:"template_slug" json:"template_slug"`
|
||||
Name string `db:"name" json:"name"`
|
||||
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||
State json.RawMessage `db:"state" json:"state"`
|
||||
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// ChecklistInstanceWithProject enriches an instance with its parent Project
|
||||
@@ -457,109 +422,31 @@ type ChecklistInstanceWithProject struct {
|
||||
ProjectTitle *string `db:"project_title" json:"project_title,omitempty"`
|
||||
}
|
||||
|
||||
// Checklist is one authored template row in paliad.checklists. Augments
|
||||
// the static Go catalog (internal/checklists/templates.go) at read time
|
||||
// via ChecklistCatalogService. Body holds the groups + items as JSONB.
|
||||
type Checklist struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Slug string `db:"slug" json:"slug"`
|
||||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Description string `db:"description" json:"description"`
|
||||
Regime string `db:"regime" json:"regime"`
|
||||
Court string `db:"court" json:"court"`
|
||||
Reference string `db:"reference" json:"reference"`
|
||||
Deadline string `db:"deadline" json:"deadline"`
|
||||
Lang string `db:"lang" json:"lang"`
|
||||
Body json.RawMessage `db:"body" json:"body"`
|
||||
Visibility string `db:"visibility" json:"visibility"`
|
||||
PromotedAt *time.Time `db:"promoted_at" json:"promoted_at,omitempty"`
|
||||
PromotedBy *uuid.UUID `db:"promoted_by" json:"promoted_by,omitempty"`
|
||||
Version int `db:"version" json:"version"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// ChecklistWithOwner enriches a Checklist row with author display fields
|
||||
// for list views (Meine Vorlagen, Gallery).
|
||||
type ChecklistWithOwner struct {
|
||||
Checklist
|
||||
OwnerEmail string `db:"owner_email" json:"owner_email"`
|
||||
OwnerDisplayName string `db:"owner_display_name" json:"owner_display_name"`
|
||||
}
|
||||
|
||||
// UserCalDAVConfig holds one user's external CalDAV connection. The password
|
||||
// is never returned in API responses; only the public fields are exposed.
|
||||
type UserCalDAVConfig struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
URL string `db:"url" json:"url"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordEncrypted []byte `db:"password_encrypted" json:"-"`
|
||||
CalendarPath string `db:"calendar_path" json:"calendar_path"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
|
||||
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
// MKCALENDAR-capability tri-state (mig 108, Slice 2c). NULL = unprobed.
|
||||
SupportsMKCalendar *bool `db:"supports_mkcalendar" json:"supports_mkcalendar,omitempty"`
|
||||
MKCalendarProbedAt *time.Time `db:"mkcalendar_probed_at" json:"mkcalendar_probed_at,omitempty"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
URL string `db:"url" json:"url"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordEncrypted []byte `db:"password_encrypted" json:"-"`
|
||||
CalendarPath string `db:"calendar_path" json:"calendar_path"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
|
||||
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// CalDAVSyncLogEntry is one historical sync record. BindingID is populated
|
||||
// for per-binding sync entries written by the post-Slice-2a sync engine;
|
||||
// older rows have it NULL and the entry covers the user's default binding.
|
||||
// CalDAVSyncLogEntry is one historical sync record.
|
||||
type CalDAVSyncLogEntry struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
OccurredAt time.Time `db:"occurred_at" json:"occurred_at"`
|
||||
Direction string `db:"direction" json:"direction"`
|
||||
ItemsPushed int `db:"items_pushed" json:"items_pushed"`
|
||||
ItemsPulled int `db:"items_pulled" json:"items_pulled"`
|
||||
Error *string `db:"error" json:"error,omitempty"`
|
||||
DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"`
|
||||
BindingID *uuid.UUID `db:"binding_id" json:"binding_id,omitempty"`
|
||||
}
|
||||
|
||||
// UserCalendarBinding is one of N (calendar, scope) bindings a user can
|
||||
// configure on top of their single CalDAV server connection. The same
|
||||
// Appointment can land in multiple bindings (e.g. master + per-project),
|
||||
// with per-binding push state living in AppointmentCalDAVTarget.
|
||||
type UserCalendarBinding struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
CalendarPath string `db:"calendar_path" json:"calendar_path"`
|
||||
DisplayName string `db:"display_name" json:"display_name"`
|
||||
ScopeKind string `db:"scope_kind" json:"scope_kind"`
|
||||
ScopeID *uuid.UUID `db:"scope_id" json:"scope_id,omitempty"`
|
||||
IncludePersonal bool `db:"include_personal" json:"include_personal"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
|
||||
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// Scope-kind enum mirrored from paliad.user_calendar_bindings_scope_kind_chk.
|
||||
const (
|
||||
BindingScopeAllVisible = "all_visible"
|
||||
BindingScopePersonalOnly = "personal_only"
|
||||
BindingScopeProject = "project"
|
||||
BindingScopeClient = "client"
|
||||
BindingScopeLitigation = "litigation"
|
||||
BindingScopePatent = "patent"
|
||||
BindingScopeCase = "case"
|
||||
)
|
||||
|
||||
// AppointmentCalDAVTarget is the per-(appointment, binding) push state.
|
||||
// The caldav_uid is canonical per Appointment (same value across all of
|
||||
// an appointment's targets); caldav_etag varies per binding.
|
||||
type AppointmentCalDAVTarget struct {
|
||||
AppointmentID uuid.UUID `db:"appointment_id" json:"appointment_id"`
|
||||
BindingID uuid.UUID `db:"binding_id" json:"binding_id"`
|
||||
CalDAVUID string `db:"caldav_uid" json:"caldav_uid"`
|
||||
CalDAVEtag *string `db:"caldav_etag" json:"caldav_etag,omitempty"`
|
||||
LastPushedAt time.Time `db:"last_pushed_at" json:"last_pushed_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
OccurredAt time.Time `db:"occurred_at" json:"occurred_at"`
|
||||
Direction string `db:"direction" json:"direction"`
|
||||
ItemsPushed int `db:"items_pushed" json:"items_pushed"`
|
||||
ItemsPulled int `db:"items_pulled" json:"items_pulled"`
|
||||
Error *string `db:"error" json:"error,omitempty"`
|
||||
DurationMS *int `db:"duration_ms" json:"duration_ms,omitempty"`
|
||||
}
|
||||
|
||||
// Party is a party to a Project (Kläger, Beklagter, etc. — typically on
|
||||
|
||||
@@ -753,86 +753,6 @@ func (s *AppointmentService) AllForUser(ctx context.Context, userID uuid.UUID) (
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ErrUnsupportedScope is returned by ForBinding when the binding's
|
||||
// scope_kind is one of the hierarchy scopes (client / litigation /
|
||||
// patent / case) — those land in Slice 3 of t-paliad-212. Slice 2
|
||||
// only supports all_visible / personal_only / project.
|
||||
var ErrUnsupportedScope = errors.New("binding scope_kind not yet supported")
|
||||
|
||||
// ForBinding returns the slice of the user's appointments that belongs
|
||||
// in this binding's calendar. Implements the §2.3 scope filter from
|
||||
// docs/design-caldav-slice-2-2026-05-20.md.
|
||||
//
|
||||
// - all_visible → AllForUser(userID)
|
||||
// - personal_only → personal (project_id IS NULL) appointments
|
||||
// created by this user
|
||||
// - project → appointments attached to scope_id, gated by the
|
||||
// same visibility predicate as AllForUser. Hidden
|
||||
// projects return an empty slice (the binding stays
|
||||
// in place but receives no events). If
|
||||
// include_personal is true, the user's personal
|
||||
// appointments are unioned in.
|
||||
//
|
||||
// Hierarchy scopes (client / litigation / patent / case) return
|
||||
// ErrUnsupportedScope; Slice 3 wires them via the existing path-based
|
||||
// descendant predicate.
|
||||
func (s *AppointmentService) ForBinding(ctx context.Context, userID uuid.UUID, b *models.UserCalendarBinding) ([]models.Appointment, error) {
|
||||
if b == nil {
|
||||
return nil, fmt.Errorf("%w: nil binding", ErrInvalidInput)
|
||||
}
|
||||
switch b.ScopeKind {
|
||||
case models.BindingScopeAllVisible:
|
||||
return s.AllForUser(ctx, userID)
|
||||
|
||||
case models.BindingScopePersonalOnly:
|
||||
rows := []models.Appointment{}
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+appointmentColumns+`
|
||||
FROM paliad.appointments t
|
||||
WHERE t.project_id IS NULL
|
||||
AND t.created_by = $1`, userID); err != nil {
|
||||
return nil, fmt.Errorf("for-binding personal_only: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
|
||||
case models.BindingScopeProject:
|
||||
if b.ScopeID == nil {
|
||||
return nil, fmt.Errorf("%w: project binding missing scope_id", ErrInvalidInput)
|
||||
}
|
||||
var query string
|
||||
if b.IncludePersonal {
|
||||
query = `
|
||||
SELECT ` + appointmentColumns + `
|
||||
FROM paliad.appointments t
|
||||
LEFT JOIN paliad.projects p ON p.id = t.project_id
|
||||
WHERE (
|
||||
t.project_id = $2
|
||||
AND ` + visibilityPredicatePositional("p", 1) + `
|
||||
) OR (
|
||||
t.project_id IS NULL AND t.created_by = $1
|
||||
)`
|
||||
} else {
|
||||
query = `
|
||||
SELECT ` + appointmentColumns + `
|
||||
FROM paliad.appointments t
|
||||
JOIN paliad.projects p ON p.id = t.project_id
|
||||
WHERE t.project_id = $2
|
||||
AND ` + visibilityPredicatePositional("p", 1)
|
||||
}
|
||||
rows := []models.Appointment{}
|
||||
if err := s.db.SelectContext(ctx, &rows, query, userID, *b.ScopeID); err != nil {
|
||||
return nil, fmt.Errorf("for-binding project: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
|
||||
case models.BindingScopeClient, models.BindingScopeLitigation, models.BindingScopePatent, models.BindingScopeCase:
|
||||
return nil, ErrUnsupportedScope
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unknown scope_kind %q", ErrInvalidInput, b.ScopeKind)
|
||||
}
|
||||
}
|
||||
|
||||
// FindByCalDAVUID resolves a Appointment from its external UID.
|
||||
func (s *AppointmentService) FindByCalDAVUID(ctx context.Context, uid string) (*models.Appointment, error) {
|
||||
var t models.Appointment
|
||||
|
||||
@@ -25,14 +25,7 @@ const (
|
||||
|
||||
// Project-level responsibility values on paliad.project_teams.responsibility.
|
||||
// Open the ladder gate (lead/member) or close it (observer/external).
|
||||
//
|
||||
// ResponsibilityAdmin (t-paliad-223) is orthogonal to the approval gate —
|
||||
// it grants role-edit authority on the project + descendants via the
|
||||
// paliad.effective_project_admin predicate, but does NOT by itself open
|
||||
// the 4-Augen approval gate. An Admin who has no profession set is still
|
||||
// not an approver. Use responsibilityOpensGate to test the approval axis.
|
||||
const (
|
||||
ResponsibilityAdmin = "admin"
|
||||
ResponsibilityLead = "lead"
|
||||
ResponsibilityMember = "member"
|
||||
ResponsibilityObserver = "observer"
|
||||
@@ -150,7 +143,7 @@ func IsValidProfession(p string) bool {
|
||||
// recognised project-responsibility enum values. Used by TeamService.
|
||||
func IsValidResponsibility(r string) bool {
|
||||
switch r {
|
||||
case ResponsibilityAdmin, ResponsibilityLead, ResponsibilityMember,
|
||||
case ResponsibilityLead, ResponsibilityMember,
|
||||
ResponsibilityObserver, ResponsibilityExternal:
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -190,8 +190,7 @@ func TestIsValidProfession(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIsValidResponsibility(t *testing.T) {
|
||||
// t-paliad-223 added 'admin'; the four legacy values stay valid.
|
||||
for _, r := range []string{"admin", "lead", "member", "observer", "external"} {
|
||||
for _, r := range []string{"lead", "member", "observer", "external"} {
|
||||
t.Run(r, func(t *testing.T) {
|
||||
if !IsValidResponsibility(r) {
|
||||
t.Errorf("IsValidResponsibility(%q) must be true", r)
|
||||
@@ -207,30 +206,6 @@ func TestIsValidResponsibility(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-223: admin maps to legacy 'lead' for the deprecated shadow
|
||||
// column. The other mappings are unchanged from t-paliad-148. Pin them
|
||||
// so a future refactor doesn't silently flip them.
|
||||
func TestLegacyRoleFromResponsibility(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{ResponsibilityAdmin, "lead"},
|
||||
{ResponsibilityLead, "lead"},
|
||||
{ResponsibilityObserver, "observer"},
|
||||
{ResponsibilityExternal, "local_counsel"},
|
||||
{ResponsibilityMember, "associate"},
|
||||
{"", "associate"}, // unknown / empty falls through to associate
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.in, func(t *testing.T) {
|
||||
got := legacyRoleFromResponsibility(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("legacyRoleFromResponsibility(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovalEventType(t *testing.T) {
|
||||
cases := []struct {
|
||||
entity, step, want string
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// CalendarBindingService — CRUD on paliad.user_calendar_bindings.
|
||||
//
|
||||
// Each row is one of N (calendar, scope) bindings layered on top of the
|
||||
// user's single CalDAV server connection in paliad.user_caldav_config.
|
||||
// Slice 1 (t-paliad-212) introduced the table + an auto-backfilled
|
||||
// 'all_visible' binding per existing user; Slice 2a wires the service
|
||||
// that owns the rows. The sync engine (CalDAVService) drives off
|
||||
// ListEnabled to discover where to push.
|
||||
//
|
||||
// Validation of (scope_kind, scope_id) combinatorics is enforced both
|
||||
// here (so the API returns a useful 400) and by the table's CHECK
|
||||
// constraints (so direct SQL or older clients can't slip a bad row in).
|
||||
type CalendarBindingService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewCalendarBindingService(db *sqlx.DB) *CalendarBindingService {
|
||||
return &CalendarBindingService{db: db}
|
||||
}
|
||||
|
||||
const bindingColumns = `
|
||||
id, user_id, calendar_path, display_name,
|
||||
scope_kind, scope_id, include_personal, enabled,
|
||||
last_sync_at, last_sync_error, created_at, updated_at`
|
||||
|
||||
// ListForUser returns every binding owned by the user, ordered by
|
||||
// scope_kind then created_at so the all_visible / personal_only roots
|
||||
// always sort to the top.
|
||||
func (s *CalendarBindingService) ListForUser(ctx context.Context, userID uuid.UUID) ([]models.UserCalendarBinding, error) {
|
||||
rows := []models.UserCalendarBinding{}
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+bindingColumns+`
|
||||
FROM paliad.user_calendar_bindings
|
||||
WHERE user_id = $1
|
||||
ORDER BY
|
||||
CASE scope_kind
|
||||
WHEN 'all_visible' THEN 0
|
||||
WHEN 'personal_only' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
created_at`, userID); err != nil {
|
||||
return nil, fmt.Errorf("list bindings: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ListEnabled returns the user's bindings with enabled = true.
|
||||
// Used by the CalDAVService sync loop.
|
||||
func (s *CalendarBindingService) ListEnabled(ctx context.Context, userID uuid.UUID) ([]models.UserCalendarBinding, error) {
|
||||
rows := []models.UserCalendarBinding{}
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+bindingColumns+`
|
||||
FROM paliad.user_calendar_bindings
|
||||
WHERE user_id = $1 AND enabled = true
|
||||
ORDER BY created_at`, userID); err != nil {
|
||||
return nil, fmt.Errorf("list enabled bindings: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ListAllEnabled returns every enabled binding across all users.
|
||||
// Used at server boot to spawn one sync goroutine per (user) that
|
||||
// owns at least one enabled binding.
|
||||
func (s *CalendarBindingService) ListAllEnabled(ctx context.Context) ([]models.UserCalendarBinding, error) {
|
||||
rows := []models.UserCalendarBinding{}
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+bindingColumns+`
|
||||
FROM paliad.user_calendar_bindings
|
||||
WHERE enabled = true
|
||||
ORDER BY user_id, created_at`); err != nil {
|
||||
return nil, fmt.Errorf("list all enabled bindings: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Get returns one binding scoped to the user; ErrNotVisible when the row
|
||||
// doesn't exist or belongs to someone else.
|
||||
func (s *CalendarBindingService) Get(ctx context.Context, userID, bindingID uuid.UUID) (*models.UserCalendarBinding, error) {
|
||||
var b models.UserCalendarBinding
|
||||
err := s.db.GetContext(ctx, &b,
|
||||
`SELECT `+bindingColumns+`
|
||||
FROM paliad.user_calendar_bindings
|
||||
WHERE id = $1 AND user_id = $2`, bindingID, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get binding: %w", err)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// CreateInput is the payload for POST /api/caldav-bindings. Slice 2b
|
||||
// wires this; Slice 2a exposes Create for tests + SQL-equivalent
|
||||
// integration tests.
|
||||
type CreateBindingInput struct {
|
||||
CalendarPath string `json:"calendar_path"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ScopeKind string `json:"scope_kind"`
|
||||
ScopeID *uuid.UUID `json:"scope_id,omitempty"`
|
||||
IncludePersonal bool `json:"include_personal"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Create inserts a new binding. Validates scope_kind / scope_id
|
||||
// combinatorics; returns ErrInvalidInput on a bad payload.
|
||||
func (s *CalendarBindingService) Create(ctx context.Context, userID uuid.UUID, in CreateBindingInput) (*models.UserCalendarBinding, error) {
|
||||
if err := validateScope(in.ScopeKind, in.ScopeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.CalendarPath == "" {
|
||||
return nil, fmt.Errorf("%w: calendar_path is required", ErrInvalidInput)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var b models.UserCalendarBinding
|
||||
err := s.db.GetContext(ctx, &b,
|
||||
`INSERT INTO paliad.user_calendar_bindings
|
||||
(user_id, calendar_path, display_name, scope_kind, scope_id,
|
||||
include_personal, enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
||||
RETURNING `+bindingColumns,
|
||||
userID, in.CalendarPath, in.DisplayName, in.ScopeKind, in.ScopeID,
|
||||
in.IncludePersonal, in.Enabled, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert binding: %w", err)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// UpdateInput captures the PATCH-shaped fields. Pointer fields = "leave
|
||||
// as-is when nil".
|
||||
type UpdateBindingInput struct {
|
||||
DisplayName *string `json:"display_name,omitempty"`
|
||||
ScopeKind *string `json:"scope_kind,omitempty"`
|
||||
ScopeID *uuid.UUID `json:"scope_id,omitempty"`
|
||||
IncludePersonal *bool `json:"include_personal,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// Update mutates the binding. Validates the resulting (scope_kind, scope_id)
|
||||
// combinatorics if either field changes.
|
||||
func (s *CalendarBindingService) Update(ctx context.Context, userID, bindingID uuid.UUID, in UpdateBindingInput) (*models.UserCalendarBinding, error) {
|
||||
existing, err := s.Get(ctx, userID, bindingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.ScopeKind != nil || in.ScopeID != nil {
|
||||
kind := existing.ScopeKind
|
||||
if in.ScopeKind != nil {
|
||||
kind = *in.ScopeKind
|
||||
}
|
||||
var sid *uuid.UUID
|
||||
if in.ScopeID != nil {
|
||||
sid = in.ScopeID
|
||||
} else {
|
||||
sid = existing.ScopeID
|
||||
}
|
||||
if err := validateScope(kind, sid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sets := []string{"updated_at = NOW()"}
|
||||
args := []any{}
|
||||
next := 1
|
||||
addSet := func(col string, val any) {
|
||||
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
|
||||
args = append(args, val)
|
||||
next++
|
||||
}
|
||||
if in.DisplayName != nil {
|
||||
addSet("display_name", *in.DisplayName)
|
||||
}
|
||||
if in.ScopeKind != nil {
|
||||
addSet("scope_kind", *in.ScopeKind)
|
||||
}
|
||||
if in.ScopeID != nil {
|
||||
addSet("scope_id", *in.ScopeID)
|
||||
}
|
||||
if in.IncludePersonal != nil {
|
||||
addSet("include_personal", *in.IncludePersonal)
|
||||
}
|
||||
if in.Enabled != nil {
|
||||
addSet("enabled", *in.Enabled)
|
||||
}
|
||||
// Append WHERE clause args last.
|
||||
args = append(args, bindingID, userID)
|
||||
q := fmt.Sprintf(`UPDATE paliad.user_calendar_bindings
|
||||
SET %s
|
||||
WHERE id = $%d AND user_id = $%d
|
||||
RETURNING %s`, strings.Join(sets, ", "), next, next+1, bindingColumns)
|
||||
var b models.UserCalendarBinding
|
||||
if err := s.db.GetContext(ctx, &b, q, args...); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
return nil, fmt.Errorf("update binding: %w", err)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// Delete removes the binding row. Caller is responsible for the remote
|
||||
// .ics cleanup (CalDAVService handles that via §2.6 of the Slice 2 brief)
|
||||
// before invoking this; this method is the bare DB delete.
|
||||
func (s *CalendarBindingService) Delete(ctx context.Context, userID, bindingID uuid.UUID) error {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM paliad.user_calendar_bindings
|
||||
WHERE id = $1 AND user_id = $2`, bindingID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete binding: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotVisible
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetSyncStatus is called by CalDAVService after each sync attempt for
|
||||
// this binding. last_sync_error nil clears the previous error.
|
||||
func (s *CalendarBindingService) SetSyncStatus(ctx context.Context, bindingID uuid.UUID, errStr *string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.user_calendar_bindings
|
||||
SET last_sync_at = NOW(), last_sync_error = $1, updated_at = NOW()
|
||||
WHERE id = $2`, errStr, bindingID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update binding sync status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateScope mirrors the table's CHECK constraints — we duplicate
|
||||
// the rule here so the API can return a useful 400 instead of letting
|
||||
// Postgres reject the row with a generic check_violation.
|
||||
func validateScope(kind string, scopeID *uuid.UUID) error {
|
||||
switch kind {
|
||||
case models.BindingScopeAllVisible, models.BindingScopePersonalOnly:
|
||||
if scopeID != nil {
|
||||
return fmt.Errorf("%w: scope_id must be NULL when scope_kind = %q", ErrInvalidInput, kind)
|
||||
}
|
||||
case models.BindingScopeProject, models.BindingScopeClient, models.BindingScopeLitigation, models.BindingScopePatent, models.BindingScopeCase:
|
||||
if scopeID == nil {
|
||||
return fmt.Errorf("%w: scope_id is required when scope_kind = %q", ErrInvalidInput, kind)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown scope_kind %q", ErrInvalidInput, kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user