Compare commits
104 Commits
mai/gauss/
...
mai/hermes
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e66da8def | |||
| ef21e43375 | |||
| 4cb99fb627 | |||
| 452ccdf127 | |||
| 045accc6d9 | |||
| e6b61b4d2e | |||
| 940df95418 | |||
| 538c2d2da9 | |||
| a9a9adbd2a | |||
| f24a90b722 | |||
| 55bfe439f2 | |||
| 0ac26fe0ee | |||
| 72b64140e9 | |||
| 50cd80a4a6 | |||
| 716f6d7ece | |||
| 1bf62c78e3 | |||
| 9a774ba3ad | |||
| 8caaf6a631 | |||
| 228ae1b263 | |||
| cdd3747c2b | |||
| 02255c4234 | |||
| 206f2917ea | |||
| 5df87f4129 | |||
| 898348a64a | |||
| 1714b788d2 | |||
| db8335253b | |||
| 5589cbb477 | |||
| 0059e3f15b | |||
| a911a2d0ee | |||
| b26f04ffe0 | |||
| 8e195cb497 | |||
| 1f7de99493 | |||
| 0adcc2c826 | |||
| 2c7ac6423f | |||
| 436c1b41bb | |||
| 2c5f85b802 | |||
| d3aade5aac | |||
| 17d2fff661 | |||
| c6a5416611 | |||
| d590be4bb7 | |||
| f7374a67cd | |||
| 3ff1b23238 | |||
| a88269c7c1 | |||
| 3d85ce5444 | |||
| 903225b593 | |||
| 4cd2f05d33 | |||
| b4f5af7f70 | |||
| 83b00d13fe | |||
| 34372ca4c8 | |||
| 65308651dd | |||
| d088de95eb | |||
| becf4f0ce3 | |||
| 924dbd9768 | |||
| 6c40823038 | |||
| 007ebc2794 | |||
| cdd27d674e | |||
| 28de2e56d0 | |||
| af073f87da | |||
| f22e918048 | |||
| 79d98cfeb8 | |||
| 19d95d6f5b | |||
| 17d149c09e | |||
| 7c7030c5bf | |||
| da8389b6e3 | |||
| 7967839f78 | |||
| d86cac0b53 | |||
| 69f45893a3 | |||
| 9f339747e5 | |||
| 7c3c84454d | |||
| 61210943d9 | |||
| 74783e7a89 | |||
| 062afb6cc5 | |||
| 47b869dddf | |||
| c4c4fa267f | |||
| d555d5f679 | |||
| 875d0c149a | |||
| 92d0340d74 | |||
| f8c6206afe | |||
| f8245a06a6 | |||
| ca71162543 | |||
| 6b565be830 | |||
| 0857c1c078 | |||
| 4bf0a719b0 | |||
| 15ce176ebd | |||
| e56cb3b210 | |||
| fffddcc71a | |||
| b850eb755c | |||
| a93277a072 | |||
| c3cd51eb85 | |||
| 6b634207c2 | |||
| 794617cbfd | |||
| b418705775 | |||
| 7a1fd81d23 | |||
| a4e2f3526d | |||
| 1c8cdd3079 | |||
| 82ecbe3b8e | |||
| badbffa6e0 | |||
| 0f98d2cd39 | |||
| d0f732d0ec | |||
| e83b150eda | |||
| 2320cb765d | |||
| 668558380d | |||
| 9dd47a0591 | |||
| 3d3a4fa36d |
@@ -128,6 +128,20 @@ 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
|
||||
@@ -137,11 +151,25 @@ func main() {
|
||||
|
||||
eventTypeSvc := services.NewEventTypeService(pool, users)
|
||||
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
|
||||
partySvc := services.NewPartyService(pool, projectSvc)
|
||||
// t-paliad-238 — dedicated submission draft editor. The variable
|
||||
// bag service is shared between the renderer (export) and the
|
||||
// preview HTML path. Resurrected from t-paliad-215 Slice 1 backend
|
||||
// (commits 3677c81 + 1765d5e + 8ea3509).
|
||||
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
|
||||
submissionRenderer := services.NewSubmissionRenderer()
|
||||
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
|
||||
// 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,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: services.NewPartyService(pool, projectSvc),
|
||||
Party: partySvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
@@ -165,7 +193,11 @@ func main() {
|
||||
EventType: eventTypeSvc,
|
||||
Dashboard: services.NewDashboardService(pool, users),
|
||||
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
|
||||
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
|
||||
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc, checklistCatalogSvc),
|
||||
ChecklistCatalog: checklistCatalogSvc,
|
||||
ChecklistTemplate: checklistTemplateSvc,
|
||||
ChecklistShare: services.NewChecklistShareService(pool, checklistTemplateSvc, sysAuditSvc, users),
|
||||
ChecklistPromotion: services.NewChecklistPromotionService(pool, checklistTemplateSvc, sysAuditSvc, users),
|
||||
Mail: mailSvc,
|
||||
Invite: inviteSvc,
|
||||
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
|
||||
@@ -178,8 +210,9 @@ 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),
|
||||
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),
|
||||
// t-paliad-214 Slice 1 — personal-scope data export. firm name
|
||||
// is captured into __meta of every export and printed in the
|
||||
@@ -194,20 +227,21 @@ func main() {
|
||||
// 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
|
||||
// the placeholder map from project + parties + rule, renderer
|
||||
// merges {{placeholder}} tokens into the .docx.
|
||||
svcBundle.SubmissionRegistry = services.NewTemplateRegistry(giteaToken, branding.Name)
|
||||
svcBundle.SubmissionVars = services.NewSubmissionVarsService(
|
||||
pool,
|
||||
svcBundle.Project,
|
||||
svcBundle.Party,
|
||||
svcBundle.Users,
|
||||
)
|
||||
svcBundle.SubmissionRenderer = services.NewSubmissionRenderer()
|
||||
// t-paliad-230 — submission generator (format-only). No
|
||||
// service wiring needed: handlers/submissions.go reuses the
|
||||
// existing files.go HL Patents Style cache and calls
|
||||
// services.ConvertDotmToDocx (stateless function).
|
||||
|
||||
// Paliadin backend selection.
|
||||
//
|
||||
|
||||
@@ -8,6 +8,7 @@ services:
|
||||
- SUPABASE_URL=${SUPABASE_URL}
|
||||
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
|
||||
- SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY:-}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY}
|
||||
|
||||
448
docs/design-calendar-view-align-2026-05-20.md
Normal file
448
docs/design-calendar-view-align-2026-05-20.md
Normal file
@@ -0,0 +1,448 @@
|
||||
# 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).
|
||||
|
||||
---
|
||||
668
docs/design-submission-page-2026-05-22.md
Normal file
668
docs/design-submission-page-2026-05-22.md
Normal file
@@ -0,0 +1,668 @@
|
||||
# Design — Dedicated Submission/Schriftsätze page (t-paliad-238)
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-22
|
||||
**Issue:** m/paliad (mai task t-paliad-238)
|
||||
**Branch:** `mai/cronus/inventor-dedicated`
|
||||
**Status:** DESIGN READY FOR REVIEW
|
||||
**Prior art:** `docs/design-submission-generator-2026-05-19.md` (t-paliad-215). This doc deepens that design rather than replacing it — every section below references the corresponding §x there when the shape is reused.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
Today's "Schriftsätze" tab on the project detail page lists each filing-type rule and offers a one-click [Generieren] that streams a clean (format-only) `.docx` of the universal HL Patents Style template. There is no customization — variables, parties, dates, optional passages: none of it is filled in. The lawyer downloads the firm style, opens it in Word, and types everything by hand.
|
||||
|
||||
This design adds a **dedicated submission page** at `/projects/{id}/submissions/{code}/draft` where the lawyer:
|
||||
|
||||
1. Picks (or creates) a named **draft** for one (project, submission_code).
|
||||
2. Sees a sidebar with every `{{placeholder}}` the merge engine knows, pre-filled from the project's data (parties, court, case number, dates, legal_source) — editable inline. Auto-saved.
|
||||
3. Sees a read-only preview pane showing the merged document body as HTML.
|
||||
4. Clicks **Export → .docx** to download a fully-merged Word file (template + project + lawyer overrides), ready to edit.
|
||||
|
||||
Old [Generieren] button stays as the one-click "quick export with empty placeholders" path; the new [Bearbeiten] button next to it deep-links to the draft editor. Drafts persist as `paliad.submission_drafts` rows so the lawyer can come back next week, multiple drafts per submission code, RLS through `paliad.can_see_project`.
|
||||
|
||||
**Reuses** the deleted Slice 1 backend (`SubmissionVarsService` from commit `3677c81`, in-house `SubmissionRenderer` from `8ea3509`, `patent_number_upc` helper from `1765d5e`) wholesale — those files are salvageable from git history and slot back in with one new service (`SubmissionDraftService`) and the new schema. **Reuses** the `internal/handlers/files.go` Gitea proxy pattern for per-submission_code templates in `m/mWorkRepo/templates/{FIRM_NAME}/{code}.docx` (chain: firm → base/code → base/family → skeleton) — same fallback chain m locked in the 2026-05-19 design (§5).
|
||||
|
||||
Three slices: **A** = schema + new page + variables-only export against the universal `.dotm` (one slice; ships the editor end-to-end); **B** = per-`submission_code` `.docx` templates with the fallback-chain registry (template authoring is the bottleneck, not code); **C** = toggleable passages (boilerplate sections the lawyer can include/exclude before export).
|
||||
|
||||
Read-only inventor design. Implementation gate is m's go/no-go on this doc through head.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-22)
|
||||
|
||||
Anchored against the running paliad codebase + youpc Supabase, not against CLAUDE.md or memory. Every claim that load-bears the design was checked against the live system.
|
||||
|
||||
| Claim | Verification |
|
||||
|---|---|
|
||||
| Today's `/api/projects/{id}/submissions` is **format-only**; no variables. | `internal/handlers/submissions.go:155-245`: handler fetches the universal `hl-patents-style.dotm` from the in-process `fileRegistry` cache, calls `services.ConvertDotmToDocx`, writes one `system_audit_log` row, streams. No project data merged. `frontend/src/client/submissions.ts` confirms the client side: POST → blob → download. The richer engine (`SubmissionVarsService` + `TemplateRegistry` + `SubmissionRenderer`) was reverted to format-only in commit `d86cac0` (t-paliad-230). |
|
||||
| Original Slice 1 backend is preserved in git history and salvageable. | `git show 3677c81:internal/services/submission_vars.go` (484 LoC, 7-namespace placeholder bag), `git show 8ea3509:internal/services/submission_render.go` (in-house run-fragmentation-aware merger, ~315 LoC), `git show 3677c81:internal/services/submission_templates.go` (442 LoC fallback-chain registry), `git show 1765d5e:internal/services/submission_vars.go` (Slice 2 added `{{project.patent_number_upc}}` helper). All four files compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`) — no API drift since 2026-05-20. |
|
||||
| `paliad.projects` carries everything the variable bag needs. | `internal/models/project.go:123` — `Title, Reference, CaseNumber, Court, PatentNumber, FilingDate, GrantDate, OurSide, InstanceLevel, ProceedingTypeID, ClientNumber, MatterNumber`. Unchanged since the original design. |
|
||||
| `paliad.parties` carries party data scoped per project. | `internal/models/project.go:567` (`Party{ID, ProjectID, Name, Role, Representative, ContactInfo}`); `PartyService.ListForProject(ctx, userID, projectID)` already exists at `internal/services/party_service.go`. Visibility flows from `ProjectService.GetByID` → `can_see_project`. |
|
||||
| `paliad.deadline_rules` published rows resolve by `submission_code`. | The same query the format-only handler uses (`internal/handlers/submissions.go:255-280`) — `lifecycle_state='published' AND is_active=true ORDER BY sequence_order LIMIT 1`. Today the corpus carries ~254 rules, ~214 published; covers DE-inf-LG, DE-inf-OLG, DE-inf-BGH, UPC-inf-CFI, DE-PatG-DPMA, DE-PatG-BPatG, EPO oppositions. |
|
||||
| Migration tracker is at 106; file list extends to 118 (other-branch work) on the worktree. | `SELECT version FROM paliad.paliad_schema_migrations ORDER BY version DESC LIMIT 1` → 106. `ls internal/db/migrations/` → `…118_paliadin_aichat_conversation`. The next free number for *this* branch's migration is **119** (collisions only if another worktree commits 119 first, in which case the coder picks the next unused). |
|
||||
| `paliad.can_see_project(uuid)` is the canonical RLS predicate. | Mig 055; every other table that gates on project visibility uses it. The new `submission_drafts` table follows the same pattern. |
|
||||
| The Schriftsätze tab already exists on project detail. | `frontend/src/projects-detail.tsx:91` (`data-tab="submissions"`), section `#tab-submissions` at line 629. Empty / no-proceeding / table-of-rules states already wired. The page-level route `GET /projects/{id}/submissions` exists at `internal/handlers/handlers.go:472` and renders the same project detail page with the submissions tab pre-selected (`#tab-submissions` URL fragment + tab activation). **No new top-level route needed**; this design adds a *deeper* route `/projects/{id}/submissions/{code}/draft` and `/draft/{draftID}`. |
|
||||
| `internal/handlers/files.go` carries the Gitea proxy + SHA-cache pattern. | Same template the original Slice 1 design lifted (in `templates/_base/de.inf.lg.erwidg.docx @ SHA 7f97b7f9` per memory). 5-min refresh, in-process cache, single-replica deployment. Reusable wholesale. |
|
||||
| `lukasjarosch/go-docx` is NOT a deal we made. | The original 2026-05-19 design recommended it, but the shipped Slice 1 (commit `8ea3509`) went with an **in-house renderer** because the library refuses to replace sibling `{{a}} ./. {{b}}` placeholders in the same run. The in-house engine handles cross-run fragmentation in ~315 LoC. **This design reuses the in-house engine, no new Go dependency.** Memory entry `ca6de586` corroborates the engine decision verbatim. |
|
||||
| `FIRM_NAME` defaults to "HLC", overridable. | `internal/branding.Name` (read once at process start). Templates land under `templates/HLC/...` for the default; the fallback chain handles per-firm overrides without code change. |
|
||||
| The PoC Paliadin is owner-gated; the submission page is NOT. | `internal/services/paliadin.go:52` — `PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"`. This is the LLM-shell-out boundary, irrelevant to template-merge. Every paliad user who can see a project can edit its submission drafts. |
|
||||
| The `entity-table` row contract is enforced. | `.claude/CLAUDE.md` → "Whole-card / whole-row click → use a JS row handler". The new draft list (when a submission_code has multiple drafts) follows the same pattern. |
|
||||
|
||||
**Doc-vs-live conflicts found:** none material. `docs/project-status.md` doesn't mention t-paliad-215 or t-paliad-230 yet — that's a documentation lag, not a design risk.
|
||||
|
||||
---
|
||||
|
||||
## §2 m's decisions (2026-05-22)
|
||||
|
||||
The task brief (mai task t-paliad-238 description) carries inventor recommendations (R) for ten open questions. Per project CLAUDE.md inventor → head escalation policy: inventor defaults to (R) unless the pick is materially expensive or risk-bearing, in which case the head escalates to m. The matrix below records the (R) defaults this design adopts; the four genuinely-material picks are escalated to head in §11.
|
||||
|
||||
| # | Question (from task brief) | Default adopted | Source |
|
||||
|---|---|---|---|
|
||||
| Q1 | Page location | **Deep page under project — `/projects/{id}/submissions/{code}/draft` and `…/draft/{draftID}`** | (R) — keeps URL self-describing and shareable with the project. |
|
||||
| Q2 | State persistence | **Server-side draft, `paliad.submission_drafts` keyed on `(project_id, submission_code, user_id, name)` with autosave** | (R) — multiple drafts per code, named; resumable across sessions. |
|
||||
| Q3 | Variable layer | **Resurrect `submission_vars.go` from commit `3677c81` + `1765d5e` (incl. `patent_number_upc`); resolve at export time** | (R) — proven shape, ~30 placeholders, 7 namespaces. |
|
||||
| Q4 | Customization surface (UI) | **Structured sidebar (variable list, editable values) + read-only HTML preview pane** | (R) — sidebar drives the merge; preview reflects the result. |
|
||||
| Q5 | Template source | **(a) per-`submission_code` `.docx` in `m/mWorkRepo/templates/{FIRM_NAME}/...` via Gitea proxy + fallback chain** | (R) — Word is the authoring surface lawyers know; mWorkRepo is the existing vehicle. |
|
||||
| Q6 | Customization options beyond variables | **v1: variables only. Toggleable passages = Slice C** | (R) — citation insertion waits for the sources system. |
|
||||
| Q7 | Migration / data shape | **See §4** | (R) — followed task brief's column list, refined for RLS + cascade + index. |
|
||||
| Q8 | Export endpoint | **`POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export` → `.docx`** | (R) — reuses `ConvertDotmToDocx` strip-macros path but adds merge step. |
|
||||
| Q9 | Schriftsätze tab integration | **Keep list + existing "Generieren" (one-click format-only); add new "Bearbeiten" button per row that deep-links to `/projects/{id}/submissions/{code}/draft`** | (R) — additive, no churn for users who only want the firm style. |
|
||||
| Q10 | Variable-merge library | **In-house renderer from commit `8ea3509` (no new Go module)** | (R) — the 2026-05-19 design recommended `lukasjarosch/go-docx`, but the shipped Slice 1 reverted to an in-house engine because the library refused sibling placeholders. The in-house engine handles run-fragmentation in ~315 LoC and is already battle-tested against the corpus. |
|
||||
|
||||
Inventor-defaulted (not in the (R) matrix; clear right answer):
|
||||
|
||||
| # | Topic | Default | Reasoning |
|
||||
|---|---|---|---|
|
||||
| D1 | Authorization | `paliad.can_see_project(project_id)` only. No profession floor. | Matches every other write surface on a project. Draft is a Word doc; lawyer's substantive review happens downstream. |
|
||||
| D2 | Missing-placeholder behaviour | `[KEIN WERT: {key}]` / `[NO VALUE: {key}]` in the rendered preview AND in the exported .docx, per `DefaultMissingMarker(lang)` from `8ea3509` | Same call the original design made. Lawyer sees the gap in Word, fixes in paliad, regenerates. Better than 400ing. |
|
||||
| D3 | Editor surface for templates | Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo). | Per the original design §5. A paliad-side uploader is a Slice C+ affordance only if Gitea round-trip is friction. |
|
||||
| D4 | Audit trail | One `paliad.system_audit_log` row per export (`event_type='submission.exported'`) + one `paliad.project_events` row (`event_type='submission_exported'`) so the export surfaces in Verlauf / SmartTimeline. **Draft create/update do NOT audit** — autosave noise would dominate the log. | Mirrors the existing `submission.generated` row from `internal/handlers/submissions.go:333-339`. The Verlauf entry is the user-visible footprint; the system_audit_log entry is the admin-visible audit footprint. |
|
||||
| D5 | Preview engine | Server-side merge → render to HTML for preview pane. Same `SubmissionRenderer` walks the .docx, but for the preview it strips `<w:r>` / `<w:p>` to plain HTML paragraphs (no styling beyond paragraph breaks + bold/italic carry-through). The export endpoint produces the real .docx with all formatting preserved. | Cheaper than client-side OOXML parsing; matches the read-only Q4 contract. The preview is a fidelity guide, not a WYSIWYG editor — final formatting comes from Word. |
|
||||
| D6 | Draft autosave cadence | Debounce 500ms after the lawyer stops typing in a variable field; PATCH `…/drafts/{draftID}` with the diff. No optimistic locking — last-write-wins per (project, submission_code, user) draft, and we never multi-user a single draft (one row per `user_id`). | Standard textarea autosave; the data is the lawyer's own draft, not a shared object. |
|
||||
| D7 | Variable contract surfacing | Each placeholder in the sidebar shows: dotted key (e.g. `project.case_number`), human label (DE/EN), current resolved value (from project state), and an editable override field. Override empty → fall back to project state at export. Override filled → carry the lawyer's value into the merge. | Lawyer never has to leave the page to fix a project-level field; AND lawyer can locally override (e.g. "Court is wrong on this draft, but I don't want to edit the project") without polluting project state. |
|
||||
| D8 | Draft naming | First draft per (project, submission_code, user) auto-named "Entwurf 1" (DE) / "Draft 1" (EN). Lawyer can rename inline. Subsequent drafts auto-name "Entwurf 2", etc. The (project_id, submission_code, user_id, name) tuple is the unique constraint — two drafts can't share a name for the same submission of the same project. | Lets the lawyer keep a "submitted version" and a "scratch" version side-by-side. |
|
||||
|
||||
---
|
||||
|
||||
## §3 Architecture overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Project detail (existing) — /projects/{id} with #tab-submissions │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Schriftsätze tab │ │
|
||||
│ │ Klageerwiderung [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ │ Schriftsatz der Klägerin (SoC) [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ │ Replik [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ └────────┬──────────────────────────────────────────┬─────────────────────┘ │
|
||||
│ │ "Bearbeiten" deep-link │ "Generieren" = │
|
||||
│ │ │ existing one-click │
|
||||
│ ▼ │ format-only export │
|
||||
└───────────┼───────────────────────────────────────────┼──────────────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ NEW Submission draft page — /projects/{id}/submissions/{code}/draft │
|
||||
│ (lands on most-recent draft for this user, or creates "Entwurf 1") │
|
||||
│ │
|
||||
│ ┌──── Sidebar (sticky left) ────────┐ ┌──── Preview pane (right) ───────┐ │
|
||||
│ │ Schriftsatz: Klageerwiderung │ │ [HTML-rendered merge of │ │
|
||||
│ │ Entwurf 1 ▼ [+ Neuer Entwurf] │ │ template + variables + │ │
|
||||
│ │ ───────────────────────────────── │ │ overrides] │ │
|
||||
│ │ project.case_number │ │ │ │
|
||||
│ │ 2 O 123/25 [override?] │ │ Klage gegen die │ │
|
||||
│ │ parties.claimant.name │ │ Beklagte BMW AG, vertreten │ │
|
||||
│ │ BMW AG [override?] │ │ durch … │ │
|
||||
│ │ deadline.due_date │ │ │ │
|
||||
│ │ 2026-06-12 [override?] │ │ Sehr geehrte Damen und Herren, │ │
|
||||
│ │ ... │ │ │ │
|
||||
│ │ │ │ [KEIN WERT: project.our_side] │ │
|
||||
│ │ [✎ Bearbeiten] inline │ │ │ │
|
||||
│ └───────────────────────────────────┘ └──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [⬇ Als .docx exportieren] │
|
||||
└──────────────────────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ handlers/submission_drafts.go (NEW) │
|
||||
│ 1. Auth: ProjectService.GetByID → can_see_project │
|
||||
│ 2. Load draft row (RLS via project visibility) │
|
||||
│ 3. SubmissionVarsService.Build (project + parties + rule + next-Frist) │
|
||||
│ 4. Apply draft.overrides on top of bag │
|
||||
│ 5. TemplateRegistry.Resolve(code) — fallback chain → bytes + SHA │
|
||||
│ (Slice A: skips registry, fetches the universal .dotm directly) │
|
||||
│ 6. SubmissionRenderer.Render(bytes, bag, missingMarker) → .docx bytes │
|
||||
│ 7. Audit: system_audit_log + project_events │
|
||||
│ 8. Stream .docx with Content-Disposition: attachment │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**No backend changes to today's Schriftsätze tab** — its list endpoint + one-click generate stay exactly as they are. The new page is additive.
|
||||
|
||||
---
|
||||
|
||||
## §4 Schema (`paliad.submission_drafts`)
|
||||
|
||||
Migration `119_submission_drafts.up.sql` (next free number on this branch; coder bumps if 119 is taken at write time).
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.submission_drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
submission_code text NOT NULL,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL, -- "Entwurf 1", lawyer-renameable
|
||||
|
||||
overrides jsonb NOT NULL DEFAULT '{}'::jsonb, -- { "project.case_number": "2 O 999/25", ... }
|
||||
-- empty value = "don't override, use bag"
|
||||
-- present key = "use this verbatim"
|
||||
|
||||
last_exported_at timestamptz, -- NULL until first export
|
||||
last_exported_sha text, -- template SHA at last export (audit aid)
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT submission_drafts_unique_per_user
|
||||
UNIQUE (project_id, submission_code, user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX submission_drafts_project_user_idx
|
||||
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
|
||||
|
||||
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY submission_drafts_visible
|
||||
ON paliad.submission_drafts
|
||||
FOR ALL
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
-- updated_at trigger pattern (same shape as paliad.notizen, etc.).
|
||||
CREATE TRIGGER submission_drafts_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_drafts
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
```
|
||||
|
||||
**No changes to `paliad.deadline_rules`.** The task brief floats a `template_body_de/_en` Markdown column as alternative (b) — rejected per Q5 default. Templates stay in Gitea.
|
||||
|
||||
**No changes to `paliad.documents`.** The original Slice 1 design wrote an `audit-only` row (`file_path NULL`) per generation; this design **does not** — `system_audit_log` + `project_events` carry the audit trail, and `paliad.documents` is reserved for actually-uploaded documents (a Phase 2 affordance per §13.5 of the 2026-05-19 design). If m wants the `documents` row for symmetry with future "I uploaded my edited version" UX, the coder can land it in a follow-up migration; it's not load-bearing for this design.
|
||||
|
||||
### 4.1 RLS read-vs-write
|
||||
|
||||
`can_see_project` is the only gate — the policy applies to FOR ALL operations. Anyone who can see a project can create / read / update / export drafts under that project, for their own user_id. Inter-user draft visibility (paralegal sees associate's drafts) is **NOT** a requirement in v1 — the unique constraint includes `user_id` and we don't expose a "drafts by other users on this project" endpoint. Multi-user collaboration on a single draft is out of scope.
|
||||
|
||||
### 4.2 Down migration
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS paliad.submission_drafts;
|
||||
```
|
||||
|
||||
No data loss concern at design time — feature ships without legacy drafts.
|
||||
|
||||
---
|
||||
|
||||
## §5 Service layer
|
||||
|
||||
### 5.1 Resurrect from git (no new code)
|
||||
|
||||
```
|
||||
internal/services/submission_vars.go RESURRECT from 3677c81 + Slice 2 patch from 1765d5e (patent_number_upc)
|
||||
internal/services/submission_render.go REPLACE the format-only convert with the in-house renderer from 8ea3509.
|
||||
KEEP the convert helper (ConvertDotmToDocx) — Slice A still needs it to
|
||||
strip macros from the universal .dotm before the merge step runs.
|
||||
internal/services/submission_templates.go RESURRECT from 3677c81 — Gitea-backed TemplateRegistry with fallback chain.
|
||||
NOT wired in Slice A (universal .dotm only); wired in Slice B.
|
||||
```
|
||||
|
||||
The three files were ~926 LoC + 350 LoC + 35 LoC patch when shipped. They compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`); zero API drift since their deletion. The resurrection is a copy-paste from `git show`, plus a one-line wiring in `cmd/server/main.go` + `internal/handlers/handlers.go`.
|
||||
|
||||
### 5.2 New service — `SubmissionDraftService`
|
||||
|
||||
```go
|
||||
// internal/services/submission_draft_service.go (NEW, ~300 LoC)
|
||||
|
||||
type SubmissionDraftService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
}
|
||||
|
||||
type SubmissionDraft struct {
|
||||
ID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
SubmissionCode string
|
||||
UserID uuid.UUID
|
||||
Name string
|
||||
Overrides PlaceholderMap // jsonb → map[string]string
|
||||
LastExportedAt *time.Time
|
||||
LastExportedSHA *string
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// List returns every draft for (project, submission_code, user) ordered by updated_at DESC.
|
||||
// Visibility flows through projects.GetByID.
|
||||
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error)
|
||||
|
||||
// Get returns a single draft by ID, gated on project visibility.
|
||||
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error)
|
||||
|
||||
// EnsureLatest returns the user's most-recently-updated draft for (project, submission_code).
|
||||
// Creates "Entwurf 1" / "Draft 1" if none exists. Idempotent on repeat calls.
|
||||
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
|
||||
|
||||
// Create makes a new draft with an auto-incremented "Entwurf N" name (lawyer can rename via Update).
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
|
||||
|
||||
// Update patches the draft. Permitted fields: name, overrides. last_exported_* is set by the export handler.
|
||||
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error)
|
||||
|
||||
// Delete archives the draft. ON DELETE CASCADE from project takes care of project-archival fallout.
|
||||
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error
|
||||
|
||||
// MarkExported updates last_exported_at + last_exported_sha after a successful export.
|
||||
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, sha string) error
|
||||
```
|
||||
|
||||
`DraftPatch` is a `struct { Name *string; Overrides *PlaceholderMap }` — nil pointer = "no change", non-nil = "set to this". `Overrides` is replace-semantics (lawyer's sidebar sends the full map); the service does not merge.
|
||||
|
||||
### 5.3 Wiring
|
||||
|
||||
```go
|
||||
// cmd/server/main.go (additions, no replacements)
|
||||
draftSvc := services.NewSubmissionDraftService(db, projectSvc)
|
||||
varsSvc := services.NewSubmissionVarsService(db, projectSvc, partySvc, userSvc)
|
||||
// Slice B only:
|
||||
tplRegistry := services.NewTemplateRegistry(os.Getenv("GITEA_TOKEN"), branding.Name)
|
||||
```
|
||||
|
||||
No new env var. `GITEA_TOKEN` is already documented in CLAUDE.md and used by `internal/handlers/files.go`.
|
||||
|
||||
---
|
||||
|
||||
## §6 UI surface
|
||||
|
||||
### 6.1 Page layout
|
||||
|
||||
`/projects/{id}/submissions/{code}/draft` lands on the user's latest draft for that (project, code). `/projects/{id}/submissions/{code}/draft/{draftID}` opens a specific draft (e.g. "Entwurf 2"). Both routes call the same renderer + client bundle; the difference is which draft `EnsureLatest` vs `Get` returns.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ← Zurück zum Projekt: BMW AG ./. Bosch GmbH │
|
||||
│ Schriftsatz: Klageerwiderung (DE.ZPO.276.1) • Entwurf 1 │
|
||||
│ [⬇ Als .docx exportieren] │
|
||||
├────────── Sidebar (sticky) ────────────────┬─────── Preview ────────────────┤
|
||||
│ Entwurf 1 ▼ │ [HTML-rendered merge] │
|
||||
│ • Entwurf 1 (zuletzt 23 Mai 2026) │ │
|
||||
│ • Entwurf 2 (zuletzt 20 Mai 2026) │ An das Landgericht München I │
|
||||
│ • [+ Neuer Entwurf] │ Pacellistr. 5 │
|
||||
│ │ 80333 München │
|
||||
│ ───────────────────────────────────────── │ │
|
||||
│ Variablen │ In der Sache │
|
||||
│ firm.name HLC │ │
|
||||
│ project.case_number 2 O 123/25 [✎] │ BMW AG, vertreten durch … │
|
||||
│ project.court LG München I [✎] │ — Klägerin — │
|
||||
│ parties.claimant.name BMW AG [✎] │ │
|
||||
│ parties.defendant.name Bosch GmbH [✎] │ gegen │
|
||||
│ parties.defendant.representative │ │
|
||||
│ Dr. Maria Schmidt [✎] │ Bosch GmbH … │
|
||||
│ deadline.due_date 2026-06-12 [✎] │ — Beklagte — │
|
||||
│ rule.legal_source_pretty │ │
|
||||
│ § 276 Abs. 1 ZPO ✓ │ Sehr geehrte Damen und Herren,│
|
||||
│ … │ │
|
||||
│ │ [KEIN WERT: project.our_side] │
|
||||
│ ───────────────────────────────────────── │ │
|
||||
│ [Entwurf umbenennen] [Entwurf löschen] │ │
|
||||
└────────────────────────────────────────────┴────────────────────────────────┘
|
||||
```
|
||||
|
||||
Sidebar grouping (top-to-bottom, locale-aware labels):
|
||||
|
||||
1. **Schriftsatz** (rule.* — read-only metadata: name, legal_source_pretty, primary_party)
|
||||
2. **Mandanten & Parteien** (parties.*)
|
||||
3. **Verfahren** (project.* — case_number, court, patent_number, patent_number_upc, our_side, …)
|
||||
4. **Frist** (deadline.* — due_date, computed_from)
|
||||
5. **Kanzlei & Datum** (firm.*, user.*, today.*)
|
||||
|
||||
Each placeholder row shows: human label (DE/EN), resolved value, edit icon. Click [✎] expands an inline text input pre-filled with the current value. Blur or Enter → debounced autosave (500ms). Empty override → revert to bag value.
|
||||
|
||||
### 6.2 Preview pane
|
||||
|
||||
Read-only HTML. The same `SubmissionRenderer.Render(...)` call that produces the .docx for export ALSO produces a sidecar HTML preview (the in-house renderer walks `<w:p>` / `<w:r>` runs and emits `<p>` / inline `<strong>` / `<em>` based on `<w:b>` / `<w:i>` flags). Preview re-renders on every autosave round-trip (cheap: server-side merge, ~10ms for a 5-page brief). Loading state: ghost-skeleton paragraphs during the round-trip.
|
||||
|
||||
This is the SLIGHTLY non-trivial coder piece: the in-house renderer today emits .docx; the coder adds a parallel `RenderHTML` path that walks the same tree but emits HTML. Same regex, same run-merge logic, different writer. Coder estimates ~120 LoC on top of the resurrected `submission_render.go`.
|
||||
|
||||
### 6.3 Routing + handlers
|
||||
|
||||
```
|
||||
GET /projects/{id}/submissions/{code}/draft → page (lands on latest, creates if none)
|
||||
GET /projects/{id}/submissions/{code}/draft/{draftID} → page (specific draft)
|
||||
GET /api/projects/{id}/submissions/{code}/drafts → list drafts for current user
|
||||
POST /api/projects/{id}/submissions/{code}/drafts → create new draft → returns row + redirect target
|
||||
GET /api/projects/{id}/submissions/{code}/drafts/{draftID} → single draft + resolved bag + HTML preview
|
||||
PATCH /api/projects/{id}/submissions/{code}/drafts/{draftID} → update name / overrides; returns new preview
|
||||
DELETE /api/projects/{id}/submissions/{code}/drafts/{draftID} → delete
|
||||
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
|
||||
→ .docx download (application/vnd.openxmlformats-officedocument.wordprocessingml.document)
|
||||
```
|
||||
|
||||
Page-route handler is `handleSubmissionDraftPage` in `internal/handlers/submission_drafts.go` — calls `handleProjectsDetailPage` shape (returns HTML for an SSR layout) with a deep-route flag, OR ships an entirely new TSX page module. Inventor default: **new TSX page** at `frontend/src/submission-draft.tsx` rendering its own layout. Lighter than retrofitting the existing project-detail page with conditional panels, and the URL semantics demand it (`#tab-submissions` is the tab; `/draft/{id}` is a distinct page).
|
||||
|
||||
### 6.4 Schriftsätze tab — additive change
|
||||
|
||||
```diff
|
||||
- // Each row: [Generieren ↓]
|
||||
+ // Each row: [Bearbeiten ↗] [Generieren ↓]
|
||||
```
|
||||
|
||||
`[Bearbeiten ↗]` → `window.location.href = "/projects/{id}/submissions/{code}/draft"`. `[Generieren ↓]` stays as today (one-click format-only export of the universal .dotm). For users who want zero-config "give me a clean firm style template", `[Generieren]` is the path; for users who want a merged draft, `[Bearbeiten]` is the path.
|
||||
|
||||
Per the `.entity-table` row contract in CLAUDE.md, the row itself becomes clickable (navigates to `/draft`), with the `Generieren` button stopping propagation. The `entity-table--readonly` modifier is removed.
|
||||
|
||||
---
|
||||
|
||||
## §7 Variable contract (v1 placeholder set)
|
||||
|
||||
Reproduced from the resurrected `submission_vars.go` (commits `3677c81` + `1765d5e`). The sidebar's "Variablen" section enumerates this list in the exact same order as `addProjectVars` / `addPartyVars` / etc., grouped per §6.1.
|
||||
|
||||
```
|
||||
firm.name — branding.Name (HLC or FIRM_NAME override)
|
||||
firm.signature_block — empty in v1 (Phase 2 affordance)
|
||||
|
||||
today — 2026-05-22 (ISO, Europe/Berlin)
|
||||
today.iso — ISO short
|
||||
today.long_de — "22. Mai 2026"
|
||||
today.long_en — "22 May 2026"
|
||||
|
||||
user.display_name — paliad.users.display_name
|
||||
user.email — paliad.users.email
|
||||
user.office — paliad.users.office
|
||||
|
||||
project.title — paliad.projects.title
|
||||
project.reference — paliad.projects.reference
|
||||
project.case_number — paliad.projects.case_number
|
||||
project.court — paliad.projects.court
|
||||
project.patent_number — DE/inline form "EP 1 234 567 B1"
|
||||
project.patent_number_upc — UPC parenthesised form "EP 1 234 567 (B1)" (Slice 2 helper, 1765d5e)
|
||||
project.filing_date — ISO date
|
||||
project.grant_date — ISO date
|
||||
project.our_side — claimant | defendant
|
||||
project.our_side_de — "Klägerin" | "Beklagte"
|
||||
project.our_side_en — "Claimant" | "Defendant"
|
||||
project.instance_level — lg | olg | bgh | cfi | …
|
||||
project.client_number — paliad.projects.client_number
|
||||
project.matter_number — paliad.projects.matter_number
|
||||
project.proceeding.code — e.g. "de.inf.lg"
|
||||
project.proceeding.name — locale-aware (DE: Verletzungsklage am Landgericht)
|
||||
project.proceeding.name_de — explicit DE
|
||||
project.proceeding.name_en — explicit EN
|
||||
|
||||
parties.claimant.name — first paliad.parties row with role='claimant'
|
||||
parties.claimant.representative
|
||||
parties.defendant.name — first row with role='defendant'
|
||||
parties.defendant.representative
|
||||
parties.other.name — first non-claimant/defendant row
|
||||
parties.other.representative
|
||||
|
||||
rule.submission_code — "de.inf.lg.erwidg"
|
||||
rule.name — locale-aware ("Klageerwiderung" / "Statement of Defence")
|
||||
rule.name_de
|
||||
rule.name_en
|
||||
rule.legal_source — "DE.ZPO.276.1"
|
||||
rule.legal_source_pretty — "§ 276 Abs. 1 ZPO" / "Section 276(1) ZPO"
|
||||
rule.primary_party — claimant | defendant | court | both
|
||||
rule.event_type — filing | hearing | decision
|
||||
|
||||
deadline.due_date — ISO of next pending deadline for this rule on this project
|
||||
deadline.due_date_long_de — "12. Juni 2026"
|
||||
deadline.due_date_long_en — "12 June 2026"
|
||||
deadline.original_due_date — ISO if extended
|
||||
deadline.computed_from — anchor description (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen")
|
||||
deadline.title — deadline.title
|
||||
deadline.source — "rule" | "manual" | …
|
||||
```
|
||||
|
||||
Variable bag construction is `SubmissionVarsService.Build(ctx, in)` — exactly the function in `3677c81`'s `submission_vars.go`, no changes.
|
||||
|
||||
### 7.1 Override semantics
|
||||
|
||||
The lawyer's `overrides` map (jsonb in `submission_drafts`) shadows the bag at export time:
|
||||
|
||||
```go
|
||||
bag := varsSvc.Build(ctx, ...).Placeholders // ~30 keys, resolved from project state
|
||||
for k, v := range draft.Overrides { // lawyer's edits
|
||||
if v == "" {
|
||||
delete(bag, k) // empty override means "force missing marker"
|
||||
} else {
|
||||
bag[k] = v // non-empty override replaces
|
||||
}
|
||||
}
|
||||
docx := renderer.Render(templateBytes, bag, missingMarker(lang))
|
||||
```
|
||||
|
||||
Edge case: lawyer types empty string into a field that was resolved from the project. Decision: empty override forces the `[KEIN WERT: …]` marker. Lawyer's intent ("blank this out, I'll fill manually in Word") is honoured rather than silently falling back to project state. Sidebar UX: empty override field is annotated "→ [KEIN WERT: …]" so the lawyer sees the consequence before exporting.
|
||||
|
||||
---
|
||||
|
||||
## §8 Template authoring (mWorkRepo layout, naming, fallback chain)
|
||||
|
||||
### 8.1 Slice A — universal .dotm only
|
||||
|
||||
Slice A merges the variable bag into the same universal HL Patents Style .dotm that today's format-only convert ships. **The .dotm body must carry `{{placeholder}}` tokens** — currently it doesn't (it's a firm style template, not a per-submission template). m has two ways to seed Slice A:
|
||||
|
||||
- **8.1.a** — author one universal template (`m/mWorkRepo/templates/_base/_universal.docx` or similar) with `{{firm.name}}`, `{{rule.name}}`, `{{project.case_number}}`, `{{parties.claimant.name}}`, etc. The merge engine fills these and outputs a draft that's still a generic letter shape but pre-populated.
|
||||
- **8.1.b** — author one Klageerwiderung-shaped template (`m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`) and route Slice A's export to that path for `submission_code='de.inf.lg.erwidg'`, with a hard-coded fall-back to `_universal.docx` for any other code. This is essentially Slice A + B's first template — wins both rounds.
|
||||
|
||||
Inventor recommendation: **8.1.b**. Strictly more useful, identical engine code, identical mWorkRepo round-trip. The Slice A → Slice B transition is then "add more templates", not "rewire the resolver".
|
||||
|
||||
### 8.2 Slice B — fallback-chain registry
|
||||
|
||||
Layout reproduced from the 2026-05-19 design §5.1:
|
||||
|
||||
```
|
||||
m/mWorkRepo (existing repo, already proxied)
|
||||
└── templates/
|
||||
├── HLC/ # FIRM_NAME-keyed override dir
|
||||
│ ├── de.inf.lg.erwidg.docx # Slice A target (per 8.1.b above)
|
||||
│ ├── de.inf.lg.klage.docx # Slice B addition
|
||||
│ ├── upc.inf.cfi.soc.docx # Slice B addition
|
||||
│ └── upc.inf.cfi.sod.docx # Slice B addition
|
||||
├── _base/ # Cross-firm baseline
|
||||
│ ├── de.inf.lg.erwidg.docx # base equivalent (Slice C+)
|
||||
│ ├── de.inf.lg.docx # proceeding-family fallback
|
||||
│ ├── upc.inf.cfi.docx
|
||||
│ ├── _skeleton.docx # ultra-generic fallback
|
||||
│ └── _universal.docx # the v1 Slice A "any code" template
|
||||
└── README.md # placeholder reference for template authors
|
||||
```
|
||||
|
||||
Naming: `{submission_code}.docx`. Family fallback uses the first three dot-segments (`de.inf.lg` from `de.inf.lg.erwidg`). Skeleton is the ultra-generic fallback (letterhead + party block + court address + signature stub).
|
||||
|
||||
### 8.3 Lookup algorithm
|
||||
|
||||
```go
|
||||
// services/submission_templates.go (resurrected from 3677c81)
|
||||
func (r *TemplateRegistry) candidates(submissionCode string) []string {
|
||||
family := familyOf(submissionCode)
|
||||
out := []string{
|
||||
fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
|
||||
fmt.Sprintf("templates/_base/%s.docx", submissionCode),
|
||||
}
|
||||
if family != "" && family != submissionCode {
|
||||
out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
|
||||
}
|
||||
out = append(out, "templates/_base/_skeleton.docx")
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
Gitea proxy: same `internal/handlers/files.go` shape. 5-min SHA refresh, in-process cache, `GITEA_TOKEN` for auth. The original `submission_templates.go` already implements this end-to-end; the coder re-applies it from `git show 3677c81`.
|
||||
|
||||
### 8.4 No template at all — Slice A vs Slice B
|
||||
|
||||
Slice A: the universal template always resolves; `ErrNoTemplate` is impossible.
|
||||
|
||||
Slice B: if every candidate in the fallback chain 404s, the handler returns 503 + `"Vorlagen-Repository nicht erreichbar"` in the UI (same handling as the original Slice 1 design §5.4). Since the chain ends at `_skeleton.docx`, this only fires when the mWorkRepo itself is misconfigured.
|
||||
|
||||
### 8.5 Template authoring task lands outside this design
|
||||
|
||||
Inventor flags but does not assign: HLC must author the per-submission_code `.docx` templates. Slice A's `_universal.docx` is one document. Slice B adds Klageerwiderung, Klageerhebung, SoC, SoD, … iteratively. **Template authoring runs in parallel with engine code**; the coder ships the engine, m + HLC ships the templates. The two converge before the slice closes.
|
||||
|
||||
This is the m-escalated piece (see §11): without per-submission templates, Slice B is engine-only.
|
||||
|
||||
---
|
||||
|
||||
## §9 Slice plan
|
||||
|
||||
### Slice A — schema + new page + variables-only export against universal .docx
|
||||
|
||||
Ships the editor end-to-end with one template.
|
||||
|
||||
| Deliverable | Files |
|
||||
|---|---|
|
||||
| Migration 119 — `submission_drafts` table + RLS + trigger | `internal/db/migrations/119_submission_drafts.{up,down}.sql` |
|
||||
| `SubmissionVarsService` resurrected | `internal/services/submission_vars.go` (from `3677c81` + Slice 2 patch `1765d5e`) |
|
||||
| `SubmissionRenderer` resurrected with new `RenderHTML` | `internal/services/submission_render.go` (from `8ea3509`); adds `RenderHTML(...) string` for preview |
|
||||
| `SubmissionDraftService` | `internal/services/submission_draft_service.go` (NEW) |
|
||||
| Handlers (page + 7 API endpoints) | `internal/handlers/submission_drafts.go` (NEW) |
|
||||
| Wiring | `cmd/server/main.go`, `internal/handlers/handlers.go` |
|
||||
| Page TSX | `frontend/src/submission-draft.tsx` (NEW) |
|
||||
| Client bundle | `frontend/src/client/submission-draft.ts` (NEW) |
|
||||
| Schriftsätze tab update | `frontend/src/projects-detail.tsx` (rows get [Bearbeiten]), `frontend/src/client/submissions.ts` (handler) |
|
||||
| i18n | new keys under `projects.detail.submissions.draft.*` and `submissions.draft.*` (page-level) |
|
||||
| One template at `m/mWorkRepo/templates/_base/_universal.docx` (8.1.b → also `templates/HLC/de.inf.lg.erwidg.docx`) | mWorkRepo, separate PR by m |
|
||||
| Tests | `internal/services/submission_render_test.go` (resurrected + RenderHTML), `internal/services/submission_vars_test.go` (round-trip), handler smoke |
|
||||
|
||||
Acceptance:
|
||||
|
||||
1. Opening `/projects/{id}/submissions/de.inf.lg.erwidg/draft` lands on the user's latest draft (or creates "Entwurf 1").
|
||||
2. Sidebar renders ~30 placeholders, pre-filled from project state.
|
||||
3. Editing a sidebar value autosaves within 500ms and updates the preview pane.
|
||||
4. Multiple drafts per (project, code, user) supported; switcher in sidebar.
|
||||
5. Clicking "Als .docx exportieren" downloads a merged `.docx` (universal template + project + lawyer overrides).
|
||||
6. `system_audit_log` row appears on export (`event_type='submission.exported'`).
|
||||
7. `project_events` row appears on export and surfaces in Verlauf.
|
||||
8. RLS: caller without `can_see_project` gets 404 on the page and 404 on every draft API.
|
||||
9. Schriftsätze tab on project detail shows [Bearbeiten] alongside [Generieren].
|
||||
10. `go build ./... && go vet ./... && go test ./... && bun run build` clean.
|
||||
|
||||
### Slice B — per-submission_code templates + fallback chain
|
||||
|
||||
Engine is unchanged from Slice A; this slice wires `TemplateRegistry` into the export endpoint and lights up per-code templates.
|
||||
|
||||
| Deliverable | Files |
|
||||
|---|---|
|
||||
| `TemplateRegistry` resurrected | `internal/services/submission_templates.go` (from `3677c81`) |
|
||||
| Handler swaps Slice A's `fetchHLPatentsStyleBytes` for `templateRegistry.Resolve(code)` | `internal/handlers/submission_drafts.go` |
|
||||
| `has_template` boolean per row in Schriftsätze tab list (today: unconditionally true; under Slice B: depends on registry probe) | `internal/handlers/submissions.go` |
|
||||
| Templates authored in mWorkRepo: at least Klageerwiderung + Klageerhebung + SoC + SoD | mWorkRepo PR by m |
|
||||
| Tests for fallback chain | `internal/services/submission_templates_test.go` (resurrect from history if it existed; otherwise new) |
|
||||
|
||||
Acceptance:
|
||||
|
||||
1. Pushing `m/mWorkRepo/templates/HLC/upc.inf.cfi.soc.docx` makes the SoC draft page resolve that template within 5 min (or instantly via `POST /api/files/refresh`).
|
||||
2. `has_template=false` rows in the Schriftsätze tab show [Keine Vorlage] instead of [Bearbeiten]/[Generieren]. Existing list ordering preserved.
|
||||
3. `last_exported_sha` on `submission_drafts` records which SHA the lawyer exported against.
|
||||
4. Misconfigured repo (every fallback 404s) → 503 with clear error.
|
||||
|
||||
### Slice C — toggleable passages
|
||||
|
||||
Lawyer can include/exclude boilerplate sections before export.
|
||||
|
||||
| Deliverable | Notes |
|
||||
|---|---|
|
||||
| `passages` jsonb column on `submission_drafts` | `migration 120` (or whatever's free at land time): `passages jsonb NOT NULL DEFAULT '{}'::jsonb` — `{"intro": true, "patent_validity_attack": false, "non_infringement": true}`. |
|
||||
| Template syntax for passage blocks | `{{#passage intro}}…{{/passage}}` — start/end markers, merger drops the block when the corresponding `passages.{key}` is false. The in-house renderer's run-fragmentation handling extends to the new tokens cleanly. |
|
||||
| Sidebar UI | "Passagen" group above "Variablen", per-passage toggle (on by default), help text per passage. |
|
||||
| Template author API | `templates/README.md` documents the passage syntax + a worked example. |
|
||||
|
||||
Acceptance: turning off `non_infringement` in the sidebar of a Klageerwiderung draft removes the corresponding section from the exported .docx; preview reflects immediately.
|
||||
|
||||
Slices D+ (not detailed here): citation insertion from the sources system (waits for that surface), per-firm template overrides (registry already supports this), `/admin/submission-templates` variable contract sidebar.
|
||||
|
||||
---
|
||||
|
||||
## §10 Out of scope
|
||||
|
||||
- AI-drafted prose (the 2026-05-19 design §11 sketch; still deferred).
|
||||
- PDF export. v1 ships `.docx` only; the lawyer's Word does the PDF step.
|
||||
- Multi-user collaboration on a single draft. Each draft is owner-scoped (`user_id`).
|
||||
- Real-time co-editing. Last-write-wins per draft; no operational transforms.
|
||||
- An in-paliad WYSIWYG editor for `.docx` content. Preview is read-only; final edits happen in Word.
|
||||
- A paliad-side template uploader. Gitea stays as the editor for templates until lawyers complain about the round-trip.
|
||||
- Translation of templates DE↔EN. Templates are mono-locale; the variable bag is bilingual.
|
||||
- Citation insertion from the sources system. Waits for the sources surface m parked.
|
||||
- Frist-detail "Exportieren" button. The submission page is reachable only from the project's Schriftsätze tab in v1; a Frist-level deep-link is a Slice D+ affordance.
|
||||
- Validation of the rendered draft against any legal rule. The engine produces text; the lawyer's substantive review is downstream.
|
||||
- Sending the draft to court / e-filing. The lawyer downloads and handles transmission outside paliad.
|
||||
|
||||
---
|
||||
|
||||
## §11 Material picks escalated to head
|
||||
|
||||
Per project CLAUDE.md inventor → head policy, the four picks below carry enough cost or risk to deserve head's read. Head ratifies (or escalates to m) before the coder shift starts.
|
||||
|
||||
### Q-E1 — Template authoring effort
|
||||
|
||||
Slice A needs at least one custom-authored template (`_universal.docx` or `de.inf.lg.erwidg.docx`) carrying `{{placeholder}}` tokens. Slice B needs four more (Klageerhebung, SoC, SoD, Erwiderung). The engine ships independently of template content, but the feature is unfinished without lawyer-authored templates.
|
||||
|
||||
**Inventor pick:** ship Slice A with **one** lawyer-authored template (8.1.b: `templates/HLC/de.inf.lg.erwidg.docx`) + the universal fallback. m + HLC owns the authoring; the coder owns the engine. Slices A and template-1 land together.
|
||||
|
||||
**Material because:** without a template, the feature looks broken in user testing. Head decides: does m commit to authoring or reviewing the first template before Slice A merges, or does Slice A merge engine-only and we accept the "format-only export with placeholders" intermediate state for a week?
|
||||
|
||||
### Q-E2 — `paliad.documents` row on export
|
||||
|
||||
The original Slice 1 design wrote an audit-only `paliad.documents` row (`file_path NULL`, `doc_type='generated_submission'`) per generation, on the theory that "Documents" would become the canonical listing UI. This design defers that.
|
||||
|
||||
**Inventor pick:** **no** `paliad.documents` write. `system_audit_log` + `project_events` carry the audit trail. The `documents` table is reserved for actually-uploaded documents (Phase 2 of the broader docs roadmap).
|
||||
|
||||
**Material because:** if head agrees, we skip a column repurpose (`ai_extracted` jsonb being used for generation provenance — the 2026-05-19 design noted this was ugly). If head disagrees, the coder lands the row inside Slice A.
|
||||
|
||||
### Q-E3 — Preview render — server or client?
|
||||
|
||||
Server-side: `RenderHTML(...)` on the in-house renderer, round-trip per autosave. Cheaper to build, costs ~10ms server-side per keystroke (debounced 500ms).
|
||||
|
||||
Client-side: ship the merged document body as JSON of paragraph runs, render in TS. Faster preview, harder to build (parallel render path in TS), and **diverges** the preview from the export shape (export still goes server-side).
|
||||
|
||||
**Inventor pick:** **server-side**. Single source of truth for the merge logic. The 500ms debounce already absorbs the round-trip; a 10ms server merge plus 50ms HTTP RTT is sub-perceptible.
|
||||
|
||||
**Material because:** if head wants the client-side preview for fully-offline draft editing, the coder needs a TS port of `substituteInDocumentXML`. Bigger build, but no round-trip latency on every keystroke.
|
||||
|
||||
### Q-E4 — Inter-user draft visibility
|
||||
|
||||
Today's design: each user sees only their own drafts. If two associates on the same project both draft a Klageerwiderung, they don't see each other's drafts (each has their own row).
|
||||
|
||||
**Inventor pick:** **owner-scoped (status quo of this design)**. The unique constraint includes `user_id`; the `List` endpoint filters by current user.
|
||||
|
||||
**Material because:** if head wants project-team visibility ("paralegal sees associate's draft for review"), the unique constraint shifts to `(project_id, submission_code, name)` (drop `user_id`), the RLS already covers the read path (`can_see_project`), and `submission_drafts` becomes a project-team resource. **This is a Phase-shape change** — the lawyer model differs. Inventor flags it because the change is cheap to make now (one column + one constraint) and expensive to make later (drafts already accumulate per-user). Head's call.
|
||||
|
||||
---
|
||||
|
||||
## §12 Implementation notes
|
||||
|
||||
For the coder, not for head.
|
||||
|
||||
- **Resurrection is `git show`, not "re-write".** The four file revisions (`3677c81:internal/services/submission_vars.go`, `1765d5e:internal/services/submission_vars.go` for the Slice 2 patch, `8ea3509:internal/services/submission_render.go`, `3677c81:internal/services/submission_templates.go`) can be applied via `git checkout 3677c81 -- internal/services/submission_vars.go` etc. The coder should verify each compiles against today's `cmd/server/main.go` wiring before applying.
|
||||
- **Renderer's `RenderHTML` is new.** The .docx walker today emits OOXML bytes; the HTML emitter walks the same tree and emits `<p>` / `<strong>` / `<em>` / `<br>`. ~120 LoC on top of the resurrected file. Same regex (`placeholderRegex`), same run-merge logic, different writer.
|
||||
- **Sidebar variable schema needs a label table.** The variable contract from §7 is keyed by dotted paths; the sidebar UI needs DE/EN labels per key. Coder adds `services/submission_var_labels.go` with a `map[string]struct{LabelDE, LabelEN, HelpDE, HelpEN}` for the ~30 keys. (Mirrors `internal/services/email_template_variables.go` shape — same lawyer-facing pattern paliad already ships at `/admin/email-templates`.)
|
||||
- **Autosave race.** The lawyer types fast → multiple PATCHes in flight. Coder uses a request-ID-debouncing pattern on the client (cancel in-flight PATCH when a new one starts) and last-write-wins on the server. No version column on the draft row in v1.
|
||||
- **Empty-override semantics in the jsonb.** `overrides = {"project.case_number": ""}` means "force missing marker". `overrides = {}` (key absent) means "fall back to bag". The service code distinguishes — careful with `omitempty`.
|
||||
- **i18n key audit.** Add `projects.detail.submissions.action.edit`, `submissions.draft.title`, `submissions.draft.export`, `submissions.draft.sidebar.{firm,project,parties,deadline,user}.group`, `submissions.draft.rename`, `submissions.draft.delete`, `submissions.draft.new`, etc. Roughly 35 new keys in DE + EN.
|
||||
- **`entity-table` row contract.** Schriftsätze tab today carries `entity-table--readonly`. Slice A removes that modifier and adds a row-click handler that navigates to `/projects/{id}/submissions/{code}/draft`, skipping clicks on the inner [Generieren] button. Matches the pattern in `frontend/src/client/checklists.ts`, `client/projects-detail.ts`, `client/deadlines.ts`.
|
||||
- **Migration 119 may collide.** Other worktrees (paliadin aichat, mig 118) may land 119 before this branch merges. Coder verifies at land time; bump to the next free number if needed.
|
||||
|
||||
---
|
||||
|
||||
## §13 Acceptance gate
|
||||
|
||||
Per inventor SKILL.md + project CLAUDE.md: this design needs head's go/no-go before any coder is hired. After head ratifies (with or without escalating §11 to m):
|
||||
|
||||
- The head decides whether to hire the same worker as `/mai-coder` with this design as the brief, or a fresh coder.
|
||||
- A coder shift takes this doc as the spec, ships Slice A, opens a PR (no self-merge).
|
||||
- Slices B and C are SEPARATE tasks — not auto-spawned.
|
||||
|
||||
Inventor parks here.
|
||||
918
docs/design-user-checklists-2026-05-20.md
Normal file
918
docs/design-user-checklists-2026-05-20.md
Normal file
@@ -0,0 +1,918 @@
|
||||
# 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.
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -17,13 +18,14 @@ import { renderProjects } from "./src/projects";
|
||||
import { renderProjectsNew } from "./src/projects-new";
|
||||
import { renderProjectsDetail } from "./src/projects-detail";
|
||||
import { renderProjectsChart } from "./src/projects-chart";
|
||||
import { renderSubmissionDraft } from "./src/submission-draft";
|
||||
import { renderSubmissionsIndex } from "./src/submissions-index";
|
||||
import { renderSubmissionsNew } from "./src/submissions-new";
|
||||
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";
|
||||
@@ -245,6 +247,7 @@ 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"),
|
||||
@@ -252,13 +255,14 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/projects-new.ts"),
|
||||
join(import.meta.dir, "src/client/projects-detail.ts"),
|
||||
join(import.meta.dir, "src/client/projects-chart.ts"),
|
||||
join(import.meta.dir, "src/client/submission-draft.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-index.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-new.ts"),
|
||||
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"),
|
||||
@@ -370,6 +374,7 @@ 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());
|
||||
@@ -377,6 +382,9 @@ async function build() {
|
||||
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
|
||||
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
||||
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
|
||||
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
|
||||
await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex());
|
||||
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
|
||||
// t-paliad-115 — shared EventsPage at the canonical /events URL.
|
||||
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
|
||||
// Termine entries point at /events?type=… and events.ts re-highlights
|
||||
@@ -384,10 +392,8 @@ 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,6 +33,9 @@ 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>
|
||||
@@ -132,6 +135,67 @@ 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>
|
||||
|
||||
@@ -1,103 +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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
120
frontend/src/checklists-author.tsx
Normal file
120
frontend/src/checklists-author.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
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,12 +39,28 @@ 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>
|
||||
@@ -122,6 +138,65 @@ 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,6 +58,10 @@ 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>
|
||||
@@ -118,6 +122,21 @@ 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,6 +34,8 @@ 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>
|
||||
|
||||
@@ -49,6 +51,36 @@ 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,11 +468,125 @@ 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();
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
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();
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initNotes } from "./notes";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
@@ -25,6 +26,9 @@ interface PendingApprovalRequest {
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
// t-paliad-252 — used by the withdraw warning modal to pick the right
|
||||
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
|
||||
lifecycle_event?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -43,6 +47,10 @@ let project: Project | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
let me: Me | null = null;
|
||||
// t-paliad-252 — see deadlines-detail.ts. Routes Save to the new
|
||||
// /api/approval-requests/{id}/edit-entity endpoint when the user picked
|
||||
// "Termin bearbeiten" in the withdraw warning modal.
|
||||
let pendingEditMode = false;
|
||||
|
||||
function parseAppointmentID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -207,10 +215,14 @@ function renderHeader() {
|
||||
}
|
||||
|
||||
// Freeze the edit form + delete button while a request is in flight.
|
||||
// t-paliad-252 — when the user picked "Termin bearbeiten" in the
|
||||
// withdraw modal, pendingEditMode unfreezes the form so Save can route
|
||||
// to /edit-entity (which keeps the request pending + merges payload).
|
||||
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
|
||||
if (form) {
|
||||
const freeze = isPending && !pendingEditMode;
|
||||
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
|
||||
.forEach((el) => { el.disabled = isPending; });
|
||||
.forEach((el) => { el.disabled = freeze; });
|
||||
}
|
||||
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
|
||||
if (deleteBtn) deleteBtn.disabled = isPending;
|
||||
@@ -263,6 +275,39 @@ async function saveEdit(ev: Event) {
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
// t-paliad-252 — pending-edit mode routes through /edit-entity which
|
||||
// keeps the request pending + merges fields into payload. clear_project
|
||||
// and project_id are NOT in the counter-allowlist (yet) — the requester
|
||||
// can't move projects on a pending request from this surface.
|
||||
if (pendingEditMode && pendingRequest) {
|
||||
const editFields = { ...payload };
|
||||
delete editFields.clear_project;
|
||||
const resp = await fetch(
|
||||
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: editFields }),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/appointments/${appointment.id}`);
|
||||
if (fresh.ok) appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
// Exit pending-edit mode so the form re-freezes (still pending).
|
||||
pendingEditMode = false;
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
msg.textContent = t("appointments.detail.saved");
|
||||
msg.className = "form-msg form-msg-ok";
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
|
||||
msg.textContent = data.message || data.error || t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(`/api/appointments/${appointment.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -312,12 +357,37 @@ async function deleteAppointment() {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-252 — withdraw warning modal replaces the old confirm().
|
||||
// Returns:
|
||||
// "edit" → unfreeze the edit form (pending-edit mode); Save will
|
||||
// route through /api/approval-requests/{id}/edit-entity
|
||||
// "withdraw" → destructive: the existing /revoke endpoint
|
||||
// null → user cancelled
|
||||
async function withdrawAppointmentRequest() {
|
||||
if (!appointment || !pendingRequest) return;
|
||||
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const action = await openWithdrawWarningModal({
|
||||
entityType: "appointment",
|
||||
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
|
||||
});
|
||||
if (action === null) {
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
pendingEditMode = true;
|
||||
if (btn) btn.disabled = false;
|
||||
// renderHeader re-evaluates the freeze and unfreezes the form now
|
||||
// that pendingEditMode is set. Focus the first editable field so the
|
||||
// user can type immediately.
|
||||
renderHeader();
|
||||
const titleEl = document.getElementById("appointment-title-edit") as HTMLInputElement | null;
|
||||
titleEl?.focus();
|
||||
return;
|
||||
}
|
||||
// action === "withdraw" → destructive path.
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -328,9 +398,12 @@ async function withdrawAppointmentRequest() {
|
||||
if (fresh.ok) {
|
||||
appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
// CREATE lifecycle: entity gone → back to the list.
|
||||
window.location.href = "/events?type=appointment";
|
||||
}
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
|
||||
135
frontend/src/client/calendar/mount-calendar.test.ts
Normal file
135
frontend/src/client/calendar/mount-calendar.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
579
frontend/src/client/calendar/mount-calendar.ts
Normal file
579
frontend/src/client/calendar/mount-calendar.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
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());
|
||||
}
|
||||
365
frontend/src/client/checklists-author.ts
Normal file
365
frontend/src/client/checklists-author.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
// 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,6 +30,37 @@ 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 {
|
||||
@@ -371,13 +402,320 @@ 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 loadTemplate();
|
||||
void (async () => {
|
||||
me = await loadMe();
|
||||
await loadTemplate();
|
||||
applyOwnerControls();
|
||||
})();
|
||||
void loadInstances();
|
||||
void loadAkten();
|
||||
});
|
||||
|
||||
@@ -40,6 +40,16 @@ 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;
|
||||
@@ -155,6 +165,119 @@ 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() {
|
||||
@@ -389,6 +512,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initPrint();
|
||||
initRename();
|
||||
initFeedback();
|
||||
initDiffModal();
|
||||
onLangChange(renderAll);
|
||||
void bootstrap();
|
||||
});
|
||||
|
||||
@@ -11,6 +11,26 @@ 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 {
|
||||
@@ -26,15 +46,20 @@ interface ChecklistInstance {
|
||||
project_title?: string | null;
|
||||
}
|
||||
|
||||
type TabId = "templates" | "instances";
|
||||
type TabId = "templates" | "mine" | "gallery" | "instances";
|
||||
|
||||
const VALID_TABS: TabId[] = ["templates", "instances"];
|
||||
const VALID_TABS: TabId[] = ["templates", "mine", "gallery", "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 {
|
||||
@@ -208,7 +233,10 @@ function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
|
||||
el.style.display = el.id === `tab-${tab}` ? "" : "none";
|
||||
});
|
||||
if (opts.pushHistory ?? true) {
|
||||
const newURL = tab === "instances" ? "/checklists?tab=instances" : "/checklists";
|
||||
let newURL = "/checklists";
|
||||
if (tab === "instances") newURL = "/checklists?tab=instances";
|
||||
if (tab === "mine") newURL = "/checklists?tab=mine";
|
||||
if (tab === "gallery") newURL = "/checklists?tab=gallery";
|
||||
if (window.location.pathname + window.location.search !== newURL) {
|
||||
window.history.replaceState({}, "", newURL);
|
||||
}
|
||||
@@ -216,6 +244,155 @@ 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() {
|
||||
@@ -234,11 +411,15 @@ 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 });
|
||||
});
|
||||
|
||||
149
frontend/src/client/components/withdraw-warning-modal.ts
Normal file
149
frontend/src/client/components/withdraw-warning-modal.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// t-paliad-252 / m/paliad#83 — withdraw warning modal.
|
||||
//
|
||||
// Before t-paliad-252 the deadline + appointment detail pages did a
|
||||
// confirm() dialog before POSTing to /api/approval-requests/{id}/revoke.
|
||||
// For pending CREATE lifecycles that endpoint silently DELETES the
|
||||
// underlying entity row — m's "withdrawing the approval deletes the event"
|
||||
// surprise.
|
||||
//
|
||||
// This modal replaces the confirm() with three explicit paths:
|
||||
//
|
||||
// 1. Cancel — does nothing
|
||||
// 2. Termin bearbeiten (primary) — opens the edit form; saving routes
|
||||
// through POST /approval-requests/{id}/
|
||||
// edit-entity which keeps the request
|
||||
// pending and merges the new fields
|
||||
// into approval_request.payload
|
||||
// 3. Endgültig zurückziehen + — destructive; current /revoke
|
||||
// löschen behaviour (delete for CREATE, revert
|
||||
// for UPDATE/COMPLETE, cancel for
|
||||
// DELETE-lifecycle requests)
|
||||
//
|
||||
// Built on the unified openModal() primitive (t-paliad-217 Slice A) so the
|
||||
// three-button row sits cleanly inside the body — the primitive only
|
||||
// supports one secondary action, but we paint the destructive button as a
|
||||
// separate row above the footer.
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { openModal } from "./modal";
|
||||
|
||||
export type WithdrawAction = "edit" | "withdraw";
|
||||
|
||||
export interface WithdrawWarningArgs {
|
||||
// entityType drives the copy ("event" vs "appointment" labels).
|
||||
entityType: "deadline" | "appointment";
|
||||
// lifecycleEvent of the pending request; copy adapts (CREATE warns about
|
||||
// deletion; UPDATE/COMPLETE warn about revert; DELETE warns about
|
||||
// cancelling the deletion request).
|
||||
lifecycleEvent: "create" | "update" | "complete" | "delete" | string;
|
||||
}
|
||||
|
||||
// openWithdrawWarningModal resolves with the chosen action, or null if the
|
||||
// user dismissed via Cancel / Esc / backdrop / browser back-button.
|
||||
export async function openWithdrawWarningModal(
|
||||
args: WithdrawWarningArgs,
|
||||
): Promise<WithdrawAction | null> {
|
||||
const body = document.createElement("div");
|
||||
body.className = "withdraw-warning-body";
|
||||
|
||||
// Lead paragraph + sub-paragraph adapt to lifecycle so the user always
|
||||
// knows what the destructive button will actually do. The /revoke
|
||||
// backend behaviour:
|
||||
// - create → DELETE the entity (the "surprise" m flagged)
|
||||
// - update → revert to pre_image
|
||||
// - complete → revert to pre-complete state
|
||||
// - delete → cancel the delete request (entity stays alive)
|
||||
const intro = document.createElement("p");
|
||||
intro.className = "withdraw-warning-intro";
|
||||
intro.textContent = leadCopyFor(args);
|
||||
body.appendChild(intro);
|
||||
|
||||
const sub = document.createElement("p");
|
||||
sub.className = "withdraw-warning-sub muted";
|
||||
sub.textContent = subCopyFor(args);
|
||||
body.appendChild(sub);
|
||||
|
||||
// The destructive button lives inside the body — the openModal primitive
|
||||
// only exposes one secondary button slot, and we want the safe "Edit"
|
||||
// path to be the primary CTA. Painting it in red here, separated from
|
||||
// the footer, signals "this is the dangerous option" without competing
|
||||
// visually with the primary CTA.
|
||||
const destructiveRow = document.createElement("div");
|
||||
destructiveRow.className = "withdraw-warning-destructive-row";
|
||||
const destructiveBtn = document.createElement("button");
|
||||
destructiveBtn.type = "button";
|
||||
destructiveBtn.className = "btn btn-danger withdraw-warning-destructive-btn";
|
||||
destructiveBtn.textContent = t("approvals.withdraw.destructive.label");
|
||||
destructiveRow.appendChild(destructiveBtn);
|
||||
body.appendChild(destructiveRow);
|
||||
|
||||
return new Promise<WithdrawAction | null>((resolve) => {
|
||||
let chosen: WithdrawAction | null = null;
|
||||
|
||||
// The destructive button has to close the modal and return "withdraw".
|
||||
// We need access to the modal's internal close() — fortunately openModal
|
||||
// exposes it via the primary handler's first arg. We pass through the
|
||||
// outer resolve and let the primary handler (Edit) own the close-fn
|
||||
// route. For the destructive button we resolve the outer promise
|
||||
// directly and then synthesise an ESC keypress so the modal dismisses
|
||||
// — or, simpler, set chosen and use the secondary "Cancel" path that
|
||||
// the modal already supports. (openModal's onClose fires on every
|
||||
// dismiss path including the primary handler resolution.)
|
||||
destructiveBtn.addEventListener("click", () => {
|
||||
chosen = "withdraw";
|
||||
// The unified openModal primitive (modal.ts) wires its dismiss path
|
||||
// through the native <dialog>'s `cancel` event. Dispatching it on
|
||||
// the parent <dialog> runs the same finish() → onClose → resolve
|
||||
// sequence as ESC / backdrop. We then map the resolved `null` back
|
||||
// to "withdraw" via the captured `chosen` in onClose below.
|
||||
const dialogEl = body.closest("dialog");
|
||||
dialogEl?.dispatchEvent(new Event("cancel"));
|
||||
});
|
||||
|
||||
void openModal<WithdrawAction>({
|
||||
title: t("approvals.withdraw.modal.title"),
|
||||
body,
|
||||
size: "md",
|
||||
classNames: "withdraw-warning-modal",
|
||||
primary: {
|
||||
label: t("approvals.withdraw.primary.label"),
|
||||
handler: (close) => {
|
||||
chosen = "edit";
|
||||
close("edit");
|
||||
},
|
||||
},
|
||||
secondary: { label: t("approvals.withdraw.cancel") },
|
||||
onClose: () => {
|
||||
// Resolves whatever was chosen via the destructive button OR the
|
||||
// primary handler. ESC / backdrop / secondary clear `chosen` to
|
||||
// null which is the right "cancel" semantics.
|
||||
resolve(chosen);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function leadCopyFor(args: WithdrawWarningArgs): string {
|
||||
switch (args.lifecycleEvent) {
|
||||
case "create":
|
||||
return args.entityType === "appointment"
|
||||
? t("approvals.withdraw.lead.create.appointment")
|
||||
: t("approvals.withdraw.lead.create.deadline");
|
||||
case "delete":
|
||||
return t("approvals.withdraw.lead.delete");
|
||||
default:
|
||||
// update / complete / unknown → revert semantics
|
||||
return t("approvals.withdraw.lead.update");
|
||||
}
|
||||
}
|
||||
|
||||
function subCopyFor(args: WithdrawWarningArgs): string {
|
||||
switch (args.lifecycleEvent) {
|
||||
case "create":
|
||||
return t("approvals.withdraw.sub.create");
|
||||
case "delete":
|
||||
return t("approvals.withdraw.sub.delete");
|
||||
default:
|
||||
return t("approvals.withdraw.sub.update");
|
||||
}
|
||||
}
|
||||
285
frontend/src/client/dashboard-grid.test.ts
Normal file
285
frontend/src/client/dashboard-grid.test.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
GRID_COLUMNS,
|
||||
clampH,
|
||||
clampW,
|
||||
placeWidgets,
|
||||
type WidgetPlacementInput,
|
||||
} from "./dashboard-grid";
|
||||
|
||||
// Regression suite for m/paliad#70 (t-paliad-228): the post-#69 edit
|
||||
// mode produced overlapping widgets when a 2-col widget sat next to a
|
||||
// 1-col widget on the same row, when a drag swapped widgets of
|
||||
// different widths, and when a resize grew a widget into a sibling. The
|
||||
// fix moved the placement math into ./dashboard-grid + made it
|
||||
// collision-aware. These tests pin the no-overlap invariant.
|
||||
|
||||
function spec(
|
||||
key: string,
|
||||
x: number | undefined,
|
||||
y: number | undefined,
|
||||
w: number,
|
||||
h = 1,
|
||||
visible = true,
|
||||
): WidgetPlacementInput {
|
||||
return { key, visible, x, y, w, h };
|
||||
}
|
||||
|
||||
// hasOverlap returns true if any placed pair shares a cell. O(n²) is
|
||||
// fine — layouts cap at 32 widgets and the tests stay tiny.
|
||||
function hasOverlap(rects: Map<string, { x: number; y: number; w: number; h: number }>): string | null {
|
||||
const list = Array.from(rects.entries());
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const [ka, a] = list[i];
|
||||
for (let j = i + 1; j < list.length; j++) {
|
||||
const [kb, b] = list[j];
|
||||
const xOverlap = a.x < b.x + b.w && b.x < a.x + a.w;
|
||||
const yOverlap = a.y < b.y + b.h && b.y < a.y + a.h;
|
||||
if (xOverlap && yOverlap) return `${ka} ↔ ${kb} at (${a.x},${a.y},${a.w}x${a.h}) vs (${b.x},${b.y},${b.w}x${b.h})`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("placeWidgets — basic auto-flow", () => {
|
||||
test("places two 6-wide widgets side by side on row 0", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", undefined, undefined, 6),
|
||||
spec("b", undefined, undefined, 6),
|
||||
]);
|
||||
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("b")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("wraps when row doesn't fit", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", undefined, undefined, 8),
|
||||
spec("b", undefined, undefined, 8),
|
||||
]);
|
||||
expect(out.get("a")!.y).toBe(0);
|
||||
expect(out.get("b")!.y).toBeGreaterThan(0);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("hidden widgets are skipped and reserve no cells", () => {
|
||||
const out = placeWidgets([
|
||||
spec("hidden", 0, 0, 12, 1, false),
|
||||
spec("visible", undefined, undefined, 6),
|
||||
]);
|
||||
expect(out.has("hidden")).toBe(false);
|
||||
expect(out.get("visible")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — explicit positions, no collision", () => {
|
||||
test("trusts non-colliding explicit positions exactly", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 6),
|
||||
spec("b", 6, 0, 6),
|
||||
spec("c", 0, 1, 12),
|
||||
]);
|
||||
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("b")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("c")).toEqual({ x: 0, y: 1, w: 12, h: 1 });
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — mixed-width collision (m/paliad#70 regression)", () => {
|
||||
test("1-col + 2-col on same row do not overlap when both explicit", () => {
|
||||
// Half-width left + half-width right is the canonical 'two widgets per
|
||||
// row' layout; pre-fix this was fine but the next regression below
|
||||
// exercises the actual bug.
|
||||
const out = placeWidgets([
|
||||
spec("left", 0, 0, 6),
|
||||
spec("right", 6, 0, 6),
|
||||
]);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("4-col + 8-col both claiming (0,0) end up non-overlapping", () => {
|
||||
// Simulates a post-#69 layout where a 4-wide widget sits at (0, 0)
|
||||
// and an 8-wide widget got accidentally placed at (0, 0) too (e.g.
|
||||
// a buggy reset path or a stale spec from before #70). Placer must
|
||||
// honour the first one's position and fit the second somewhere
|
||||
// free — landing it on the same row at x=4 is acceptable (better
|
||||
// density) as long as nothing overlaps.
|
||||
const out = placeWidgets([
|
||||
spec("first", 0, 0, 4),
|
||||
spec("colliding", 0, 0, 8),
|
||||
]);
|
||||
expect(out.get("first")).toEqual({ x: 0, y: 0, w: 4, h: 1 });
|
||||
expect(out.get("colliding")!.w).toBe(8);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("drag-drop swap of 12-wide onto 6-wide does not overlap", () => {
|
||||
// Setup before swap:
|
||||
// A at (0, 0, w=12) — full width row 0
|
||||
// B at (0, 1, w=6) — half row 1 left
|
||||
// C at (6, 1, w=6) — half row 1 right
|
||||
// User drags A onto B. reorderViaDnd swaps (x, y):
|
||||
// A.x=0, A.y=1
|
||||
// B.x=0, B.y=0
|
||||
// Result must not overlap C.
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 1, 12),
|
||||
spec("b", 0, 0, 6),
|
||||
spec("c", 6, 1, 6),
|
||||
]);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("auto-flow widget steps past explicit blocker on same row", () => {
|
||||
// Explicit widget at (6, 0, w=6); auto-flow widget would pack into
|
||||
// (0, 0, w=6) which is fine — but the next auto-flow widget at w=6
|
||||
// would want (6, 0) which is taken. Placer must wrap it.
|
||||
const out = placeWidgets([
|
||||
spec("flow-a", undefined, undefined, 6),
|
||||
spec("anchored", 6, 0, 6),
|
||||
spec("flow-b", undefined, undefined, 6),
|
||||
]);
|
||||
expect(out.get("flow-a")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("anchored")).toEqual({ x: 6, y: 0, w: 6, h: 1 });
|
||||
expect(out.get("flow-b")!.y).toBeGreaterThan(0);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — resize-grow shifts siblings", () => {
|
||||
test("growing a 6-wide to 12-wide bumps the sibling on the same row", () => {
|
||||
// Pre-resize state:
|
||||
// A at (0, 0, w=6)
|
||||
// B at (6, 0, w=6)
|
||||
// User resizes A to w=12. resizeWidget() updates A.w but leaves B
|
||||
// at (6, 0). Placer must shift B down.
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 12),
|
||||
spec("b", 6, 0, 6),
|
||||
]);
|
||||
expect(out.get("a")).toEqual({ x: 0, y: 0, w: 12, h: 1 });
|
||||
expect(out.get("b")!.y).toBeGreaterThan(0);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("growing widget pushes only the first colliding sibling", () => {
|
||||
// A grows to 12-wide; B and C on row 0 are both colliding. Both must
|
||||
// move; their relative order on row 0 is preserved (B at x=0, C at
|
||||
// x=6) on row 1.
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 12),
|
||||
spec("b", 0, 0, 4),
|
||||
spec("c", 4, 0, 4),
|
||||
]);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
expect(out.get("a")!.y).toBe(0);
|
||||
expect(out.get("b")!.y).toBeGreaterThan(0);
|
||||
expect(out.get("c")!.y).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — explicit position overflow clamp", () => {
|
||||
test("x+w > GRID_COLUMNS is clamped not rejected", () => {
|
||||
// A 12-wide widget with x=6 would extend past col 11. Placer must
|
||||
// clamp x to 0 (or wherever fits) so the widget renders inside the
|
||||
// grid.
|
||||
const out = placeWidgets([
|
||||
spec("wide", 6, 0, 12),
|
||||
]);
|
||||
const r = out.get("wide")!;
|
||||
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
|
||||
expect(r.w).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — vertical (multi-row) widgets", () => {
|
||||
test("a 2-row-tall widget reserves both rows", () => {
|
||||
const out = placeWidgets([
|
||||
spec("tall", 0, 0, 6, 2),
|
||||
spec("collides-on-row-1", 0, 1, 6, 1),
|
||||
]);
|
||||
expect(out.get("tall")).toEqual({ x: 0, y: 0, w: 6, h: 2 });
|
||||
// The colliding widget must move because tall covers cols 0..5
|
||||
// on both row 0 and row 1. The placer may shift it to the right
|
||||
// half of row 1 (cols 6..11) or to a later row — either is fine
|
||||
// as long as nothing overlaps.
|
||||
const other = out.get("collides-on-row-1")!;
|
||||
expect(other.x >= 6 || other.y >= 2).toBe(true);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("placeWidgets — includeHidden (edit mode)", () => {
|
||||
test("hidden widgets are skipped by default", () => {
|
||||
const out = placeWidgets([
|
||||
spec("visible", 0, 0, 6),
|
||||
spec("hidden", 0, 0, 6, 1, false),
|
||||
]);
|
||||
expect(out.has("visible")).toBe(true);
|
||||
expect(out.has("hidden")).toBe(false);
|
||||
});
|
||||
|
||||
test("includeHidden:true places hidden widgets after visible ones", () => {
|
||||
// Regression for m/paliad#73 / t-paliad-238: in edit mode hidden
|
||||
// widgets MUST receive a placement, otherwise applyLayout leaves
|
||||
// their inline grid-column empty and CSS Grid auto-flows them as
|
||||
// 1×1 slivers ("super slim greyed-out column").
|
||||
const out = placeWidgets([
|
||||
spec("active", 0, 0, 12),
|
||||
spec("hidden", 0, 0, 6, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.has("hidden")).toBe(true);
|
||||
const h = out.get("hidden")!;
|
||||
// Must keep its requested width (6), not collapse to 1.
|
||||
expect(h.w).toBe(6);
|
||||
// Must land below the visible widget — never overlap or steal cells.
|
||||
expect(h.y).toBeGreaterThanOrEqual(1);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("includeHidden two-pass: visible widgets keep priority over hidden", () => {
|
||||
// Hidden widget stored at (0, 0) shouldn't displace a visible
|
||||
// widget that wants (0, 0). The visible pass runs first, claims
|
||||
// (0, 0); the hidden widget is then placed wherever free — the
|
||||
// placer happily fits it next to the visible widget on the same
|
||||
// row if there's room. The hard invariant is just no-overlap.
|
||||
const out = placeWidgets([
|
||||
spec("active", 0, 0, 6),
|
||||
spec("hidden-at-origin", 0, 0, 6, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.get("active")).toEqual({ x: 0, y: 0, w: 6, h: 1 });
|
||||
expect(out.has("hidden-at-origin")).toBe(true);
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
|
||||
test("multiple hidden widgets all receive valid placements", () => {
|
||||
const out = placeWidgets([
|
||||
spec("a", 0, 0, 12),
|
||||
spec("h1", undefined, undefined, 6, 1, false),
|
||||
spec("h2", undefined, undefined, 6, 1, false),
|
||||
spec("h3", undefined, undefined, 12, 1, false),
|
||||
], { includeHidden: true });
|
||||
expect(out.size).toBe(4);
|
||||
for (const r of out.values()) {
|
||||
expect(r.w).toBeGreaterThanOrEqual(1);
|
||||
expect(r.x + r.w).toBeLessThanOrEqual(GRID_COLUMNS);
|
||||
}
|
||||
expect(hasOverlap(out)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clamp helpers", () => {
|
||||
test("clampW respects min/max bounds", () => {
|
||||
expect(clampW(2, { min_w: 4, max_w: 12 })).toBe(4);
|
||||
expect(clampW(20, { min_w: 4, max_w: 12 })).toBe(12);
|
||||
expect(clampW(0, { default_w: 6 })).toBe(6);
|
||||
expect(clampW(NaN, { default_w: 8 })).toBe(8);
|
||||
});
|
||||
|
||||
test("clampH respects min/max bounds and MAX_ROW_SPAN", () => {
|
||||
expect(clampH(0, { default_h: 2 })).toBe(2);
|
||||
expect(clampH(99, undefined)).toBe(5); // MAX_ROW_SPAN
|
||||
expect(clampH(1, { min_h: 3 })).toBe(3);
|
||||
});
|
||||
});
|
||||
264
frontend/src/client/dashboard-grid.ts
Normal file
264
frontend/src/client/dashboard-grid.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
// dashboard-grid — pure layout math for the dashboard widget grid.
|
||||
//
|
||||
// Lives outside dashboard.ts so the placement logic is importable from
|
||||
// tests without dragging in the DOM-side rendering code. The grid is a
|
||||
// 12-column CSS Grid matching internal/services/dashboard_layout_spec.go;
|
||||
// rows grow vertically as widgets are placed.
|
||||
//
|
||||
// The core invariant is no-overlap: after placeWidgets() returns, every
|
||||
// pair of widgets occupies disjoint cells. Pre-overhaul callers wrote
|
||||
// computePlacements() to trust explicit (x, y) without checking — that
|
||||
// produced visual overlap whenever a drag or resize landed a widget on
|
||||
// cells another widget already covered (m/paliad#70). The collision-
|
||||
// aware placer below shifts colliding widgets to the next free row so
|
||||
// the rendered grid never overlaps regardless of the input spec.
|
||||
|
||||
export const GRID_COLUMNS = 12;
|
||||
export const MAX_ROW_SPAN = 5;
|
||||
|
||||
// Hard cap on the row-scan depth in findFreeSlot. The widget cap on a
|
||||
// single layout is 32 (LayoutWidgetCap on the Go side); each row holds
|
||||
// at least one widget, so 256 rows is an order-of-magnitude buffer
|
||||
// against runaway loops on pathological inputs.
|
||||
const MAX_SCAN_ROWS = 256;
|
||||
|
||||
export interface PlacedRect {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
// WidgetSizeBound captures the per-widget min/max/default clamps the
|
||||
// catalog publishes. Optional fields keep callers from having to
|
||||
// synthesize zeroes when the catalog entry is missing.
|
||||
export interface WidgetSizeBound {
|
||||
default_w?: number;
|
||||
default_h?: number;
|
||||
min_w?: number;
|
||||
max_w?: number;
|
||||
min_h?: number;
|
||||
max_h?: number;
|
||||
}
|
||||
|
||||
// WidgetPlacementInput is the per-widget data the placer consumes. The
|
||||
// catalog bound is optional — when missing, defaults fall back to a
|
||||
// full-width 1-row widget.
|
||||
export interface WidgetPlacementInput {
|
||||
key: string;
|
||||
visible: boolean;
|
||||
x?: number;
|
||||
y?: number;
|
||||
w?: number;
|
||||
h?: number;
|
||||
bound?: WidgetSizeBound;
|
||||
}
|
||||
|
||||
export function clampW(w: number, bound: WidgetSizeBound | undefined): number {
|
||||
let v = Math.round(w);
|
||||
if (!Number.isFinite(v) || v <= 0) v = bound?.default_w ?? GRID_COLUMNS;
|
||||
v = Math.max(1, Math.min(GRID_COLUMNS, v));
|
||||
if (bound?.min_w && v < bound.min_w) v = bound.min_w;
|
||||
if (bound?.max_w && v > bound.max_w) v = bound.max_w;
|
||||
return v;
|
||||
}
|
||||
|
||||
export function clampH(h: number, bound: WidgetSizeBound | undefined): number {
|
||||
let v = Math.round(h);
|
||||
if (!Number.isFinite(v) || v <= 0) v = bound?.default_h ?? 1;
|
||||
v = Math.max(1, Math.min(MAX_ROW_SPAN, v));
|
||||
if (bound?.min_h && v < bound.min_h) v = bound.min_h;
|
||||
if (bound?.max_h && v > bound.max_h) v = bound.max_h;
|
||||
return v;
|
||||
}
|
||||
|
||||
// Occupancy bitmap: one row → Uint8Array of GRID_COLUMNS bits. Rows are
|
||||
// created lazily so the map only stores rows the layout actually
|
||||
// reaches. Cell value 1 = occupied.
|
||||
class Occupancy {
|
||||
private rows = new Map<number, Uint8Array>();
|
||||
|
||||
row(y: number): Uint8Array {
|
||||
let r = this.rows.get(y);
|
||||
if (!r) {
|
||||
r = new Uint8Array(GRID_COLUMNS);
|
||||
this.rows.set(y, r);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
free(x: number, y: number, w: number, h: number): boolean {
|
||||
if (x < 0 || y < 0 || x + w > GRID_COLUMNS) return false;
|
||||
for (let yy = y; yy < y + h; yy++) {
|
||||
const row = this.row(yy);
|
||||
for (let xx = x; xx < x + w; xx++) {
|
||||
if (row[xx]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
mark(x: number, y: number, w: number, h: number): void {
|
||||
for (let yy = y; yy < y + h; yy++) {
|
||||
const row = this.row(yy);
|
||||
for (let xx = x; xx < x + w; xx++) row[xx] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findFreeSlot scans for the first (x, y) where a w×h block fits without
|
||||
// collision, starting at row startY. At each row preferX is tried first
|
||||
// — that keeps a widget close to its requested column when only the row
|
||||
// is blocked. Falls back to left-to-right scan within the row, then to
|
||||
// the next row. Caller guarantees w ≤ GRID_COLUMNS.
|
||||
function findFreeSlot(
|
||||
occ: Occupancy,
|
||||
startY: number,
|
||||
w: number,
|
||||
h: number,
|
||||
preferX: number,
|
||||
): { x: number; y: number } {
|
||||
for (let y = startY; y < startY + MAX_SCAN_ROWS; y++) {
|
||||
if (preferX >= 0 && preferX + w <= GRID_COLUMNS && occ.free(preferX, y, w, h)) {
|
||||
return { x: preferX, y };
|
||||
}
|
||||
for (let x = 0; x + w <= GRID_COLUMNS; x++) {
|
||||
if (x === preferX) continue;
|
||||
if (occ.free(x, y, w, h)) return { x, y };
|
||||
}
|
||||
}
|
||||
// Pathological fallback — caller's widget cap (32) makes this
|
||||
// unreachable in practice. Snap to the bottom-left so the widget at
|
||||
// least renders somewhere visible instead of vanishing.
|
||||
return { x: 0, y: startY + MAX_SCAN_ROWS };
|
||||
}
|
||||
|
||||
// PlaceOptions tunes the placer for the caller's render-vs-persist
|
||||
// needs.
|
||||
export interface PlaceOptions {
|
||||
// When true, hidden widgets are placed too — for edit-mode rendering
|
||||
// where the user can see + un-hide them inline. The two-pass order
|
||||
// (visible first, then hidden) guarantees hidden widgets never
|
||||
// displace visible ones: they get whatever cells are left below the
|
||||
// active layout. Default false matches view-mode behaviour and the
|
||||
// persistence path (materializePositions) where hidden widgets
|
||||
// retain their stored coordinates instead of being repacked.
|
||||
//
|
||||
// Without this option, hidden widgets in edit mode were left without
|
||||
// an explicit grid-column inline style by applyLayout(), so CSS Grid
|
||||
// auto-flowed them into the next free cell at 1×1 — the "super slim
|
||||
// greyed-out column" symptom of m/paliad#73 / t-paliad-238.
|
||||
includeHidden?: boolean;
|
||||
}
|
||||
|
||||
// placeWidgets assigns no-overlap grid coordinates to widgets. By
|
||||
// default only visible widgets receive placements; pass
|
||||
// {includeHidden:true} to also place hidden widgets after the visible
|
||||
// pass (used by applyLayout in edit mode).
|
||||
//
|
||||
// Algorithm — per pass:
|
||||
// 1. Clamp w/h against catalog bounds.
|
||||
// 2. If the spec carries explicit x and y, try that slot. On a
|
||||
// collision, search downward starting at the requested y for the
|
||||
// first free w×h block (preferring the requested x).
|
||||
// 3. If only x is explicit, search from y=0 at that x.
|
||||
// 4. Otherwise auto-flow: pack left-to-right under a running cursor;
|
||||
// when the row doesn't fit or is blocked by an explicitly-placed
|
||||
// widget, wrap to the next free row.
|
||||
//
|
||||
// The mixed-spec case (some widgets explicit, others auto-flow) is the
|
||||
// real-world layout — placing the explicit widgets first would change
|
||||
// the visual order, so we keep input order and let auto-flow widgets
|
||||
// step around any explicit blockers via the same collision search.
|
||||
//
|
||||
// Two-pass behaviour for hidden widgets: the visible pass owns its
|
||||
// own auto-flow cursor; the hidden pass continues from where the
|
||||
// visible pass left off so the hidden widgets stack right under the
|
||||
// active layout. The shared Occupancy bitmap guarantees the second
|
||||
// pass can never overlap a placed visible widget.
|
||||
export function placeWidgets(
|
||||
widgets: WidgetPlacementInput[],
|
||||
options: PlaceOptions = {},
|
||||
): Map<string, PlacedRect> {
|
||||
const out = new Map<string, PlacedRect>();
|
||||
const occ = new Occupancy();
|
||||
|
||||
// Auto-flow cursor — advances as we place flowed widgets. cursorY
|
||||
// tracks the row currently being filled; rowMaxH is the tallest
|
||||
// widget in that row so wrapping advances past it (not just past the
|
||||
// new widget's height — that would let taller previous neighbours
|
||||
// overlap into the wrap row).
|
||||
let cursorX = 0;
|
||||
let cursorY = 0;
|
||||
let rowMaxH = 0;
|
||||
|
||||
const placeOne = (w: WidgetPlacementInput): void => {
|
||||
const dw = clampW(w.w ?? w.bound?.default_w ?? GRID_COLUMNS, w.bound);
|
||||
const dh = clampH(w.h ?? w.bound?.default_h ?? 1, w.bound);
|
||||
|
||||
const hasX = typeof w.x === "number";
|
||||
const hasY = typeof w.y === "number";
|
||||
let placed: { x: number; y: number };
|
||||
|
||||
if (hasX && hasY) {
|
||||
// Clamp x so the widget never overflows the right edge — drag/
|
||||
// resize gestures can produce x+w > GRID_COLUMNS otherwise.
|
||||
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
|
||||
const prefY = Math.max(0, w.y as number);
|
||||
if (occ.free(prefX, prefY, dw, dh)) {
|
||||
placed = { x: prefX, y: prefY };
|
||||
} else {
|
||||
placed = findFreeSlot(occ, prefY, dw, dh, prefX);
|
||||
}
|
||||
} else if (hasX) {
|
||||
const prefX = Math.max(0, Math.min(GRID_COLUMNS - dw, w.x as number));
|
||||
placed = findFreeSlot(occ, 0, dw, dh, prefX);
|
||||
} else {
|
||||
// Auto-flow. Wrap the cursor when the widget wouldn't fit in the
|
||||
// remaining columns of the current row, then ask findFreeSlot to
|
||||
// honour the cursor's preferred (x, y) — that lets it step past
|
||||
// any explicit widget that already claimed cells under the
|
||||
// cursor.
|
||||
if (cursorX + dw > GRID_COLUMNS) {
|
||||
cursorY += rowMaxH || 1;
|
||||
cursorX = 0;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
placed = findFreeSlot(occ, cursorY, dw, dh, cursorX);
|
||||
if (placed.y > cursorY) {
|
||||
// Wrap was forced by a collision deeper than the current row.
|
||||
cursorY = placed.y;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
cursorX = placed.x + dw;
|
||||
if (dh > rowMaxH) rowMaxH = dh;
|
||||
}
|
||||
|
||||
occ.mark(placed.x, placed.y, dw, dh);
|
||||
out.set(w.key, { x: placed.x, y: placed.y, w: dw, h: dh });
|
||||
};
|
||||
|
||||
// Pass 1: visible widgets. They own the active layout.
|
||||
for (const w of widgets) {
|
||||
if (!w.visible) continue;
|
||||
placeOne(w);
|
||||
}
|
||||
|
||||
// Pass 2: hidden widgets (edit-mode only). Wrap the cursor to the
|
||||
// start of the next row before the second pass so the hidden tray
|
||||
// visually separates from the active layout — even if the last
|
||||
// visible widget left half a row open.
|
||||
if (options.includeHidden) {
|
||||
if (cursorX > 0) {
|
||||
cursorY += rowMaxH || 1;
|
||||
cursorX = 0;
|
||||
rowMaxH = 0;
|
||||
}
|
||||
for (const w of widgets) {
|
||||
if (w.visible) continue;
|
||||
placeOne(w);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,181 +0,0 @@
|
||||
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();
|
||||
});
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type EventType,
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
|
||||
import { formatRuleLabel, formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
interface Deadline {
|
||||
id: string;
|
||||
@@ -20,6 +22,9 @@ interface Deadline {
|
||||
source: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
// t-paliad-258 — lawyer's free-text rule label when the deadline was
|
||||
// saved in Custom mode. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
@@ -38,6 +43,9 @@ interface PendingApprovalRequest {
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
// t-paliad-252 — used by the withdraw warning modal to pick the right
|
||||
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
|
||||
lifecycle_event?: string;
|
||||
}
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
@@ -54,7 +62,21 @@ interface DeadlineRule {
|
||||
id: string;
|
||||
code?: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
rule_code?: string;
|
||||
legal_source?: string | null;
|
||||
// t-paliad-258 — canonical event_type for Auto-mode rule resolution
|
||||
// when the user flips to Auto on the edit form.
|
||||
concept_default_event_type_id?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
}
|
||||
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
jurisdiction: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -70,6 +92,30 @@ let me: Me | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
|
||||
// t-paliad-258 — Auto/Custom rule editor state. Mirrors the create form.
|
||||
// On enterEdit we initialise the mode from the persisted deadline:
|
||||
// rule_id set → "auto"
|
||||
// custom_rule_text set, no rule_id → "custom"
|
||||
// neither set → "auto" (so the Type-driven
|
||||
// resolver fills in immediately).
|
||||
type RuleMode = "auto" | "custom";
|
||||
let ruleMode: RuleMode = "auto";
|
||||
let allRules: DeadlineRule[] = [];
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
let proceedingTypesByID = new Map<number, ProceedingType>();
|
||||
// t-paliad-252 — when the user chose "Edit event" in the withdraw warning
|
||||
// modal, the entity is still in approval_status='pending'. Save must POST
|
||||
// to /api/approval-requests/{id}/edit-entity (which keeps the request
|
||||
// pending + merges the new fields into payload) instead of the regular
|
||||
// PATCH /api/deadlines/{id} (which 409s during pending). Cleared on exit
|
||||
// from edit mode + after a successful save.
|
||||
let pendingEditMode = false;
|
||||
|
||||
// pendingEnterEdit — late-bound by initEdit() so the withdraw warning
|
||||
// modal handler (initWithdraw) can route into pending-edit mode without
|
||||
// duplicating the edit-mode toggle logic.
|
||||
let pendingEnterEdit: (() => void) | null = null;
|
||||
|
||||
function parseDeadlineID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] !== "deadlines" || !parts[1]) return null;
|
||||
@@ -165,17 +211,66 @@ function populateProjectPicker() {
|
||||
sel.value = deadline.project_id;
|
||||
}
|
||||
|
||||
async function loadRule(ruleID: string) {
|
||||
async function loadAllRules() {
|
||||
try {
|
||||
const resp = await fetch(`/api/deadline-rules`);
|
||||
if (!resp.ok) return;
|
||||
const all: DeadlineRule[] = await resp.json();
|
||||
rule = all.find((r) => r.id === ruleID) || null;
|
||||
allRules = (await resp.json()) as DeadlineRule[];
|
||||
rulesByID = new Map(allRules.map((r) => [r.id, r]));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProceedingTypes() {
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db");
|
||||
if (!resp.ok) return;
|
||||
const types: ProceedingType[] = await resp.json();
|
||||
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function lookupRule(ruleID: string): DeadlineRule | null {
|
||||
return rulesByID.get(ruleID) || null;
|
||||
}
|
||||
|
||||
// resolveAutoRuleForType mirrors the create-form resolver: pick the
|
||||
// canonical rule for the chosen event_type, prioritising the project's
|
||||
// proceeding then jurisdiction match.
|
||||
function resolveAutoRuleForType(eventTypeID: string): DeadlineRule | null {
|
||||
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
const projID = deadline?.project_id;
|
||||
const proj = projID ? allProjects.find((p) => p.id === projID) as (Project & { proceeding_type_id?: number | null }) | undefined : undefined;
|
||||
if (proj && proj.proceeding_type_id) {
|
||||
const exact = candidates.find((r) => r.proceeding_type_id === proj.proceeding_type_id);
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
const et = eventTypeByID.get(eventTypeID);
|
||||
if (et?.jurisdiction && et.jurisdiction !== "any") {
|
||||
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
|
||||
const jurMatch = candidates.find((r) => {
|
||||
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
|
||||
return pt?.jurisdiction === want;
|
||||
});
|
||||
if (jurMatch) return jurMatch;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function currentAutoRule(): DeadlineRule | null {
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
if (picked.length !== 1) return null;
|
||||
return resolveAutoRuleForType(picked[0]);
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
@@ -227,9 +322,15 @@ function render() {
|
||||
}
|
||||
|
||||
const ruleEl = document.getElementById("deadline-rule-display")!;
|
||||
// t-paliad-258 — display priority:
|
||||
// 1. catalog rule (canonical Name · Citation pattern)
|
||||
// 2. custom_rule_text + Custom badge
|
||||
// 3. legacy rule_code-only (Fristenrechner saves)
|
||||
// 4. "—"
|
||||
if (rule) {
|
||||
const code = rule.rule_code || rule.code || "";
|
||||
ruleEl.textContent = code ? `${code} — ${rule.name}` : rule.name;
|
||||
ruleEl.innerHTML = formatRuleLabelHTML(rule, esc);
|
||||
} else if (deadline.custom_rule_text && deadline.custom_rule_text.trim()) {
|
||||
ruleEl.innerHTML = formatCustomRuleLabelHTML(deadline.custom_rule_text, esc);
|
||||
} else if (deadline.rule_code) {
|
||||
// Fristenrechner-saved deadlines carry rule_code directly without
|
||||
// a rule_id (no rule UUID round-trips through the public API).
|
||||
@@ -353,6 +454,48 @@ function render() {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshRuleAutoDisplay(): void {
|
||||
const panel = document.getElementById("deadline-rule-auto-display");
|
||||
const text = document.getElementById("deadline-rule-auto-text");
|
||||
if (!panel || !text) return;
|
||||
if (ruleMode !== "auto") {
|
||||
panel.style.display = "none";
|
||||
return;
|
||||
}
|
||||
panel.style.display = "";
|
||||
const r = currentAutoRule();
|
||||
if (r) {
|
||||
text.textContent = formatRuleLabel(r);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
const fallback = picked.length === 1
|
||||
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
|
||||
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
|
||||
text.textContent = fallback;
|
||||
text.classList.add("rule-auto-text--empty");
|
||||
}
|
||||
|
||||
function applyRuleModeUI(): void {
|
||||
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
const autoPanel = document.getElementById("deadline-rule-auto-display");
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
if (!toggleBtn || !autoPanel || !customInput) return;
|
||||
if (ruleMode === "auto") {
|
||||
autoPanel.style.display = "";
|
||||
customInput.style.display = "none";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
|
||||
} else {
|
||||
autoPanel.style.display = "none";
|
||||
customInput.style.display = "";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
|
||||
}
|
||||
refreshRuleAutoDisplay();
|
||||
}
|
||||
|
||||
function initEdit() {
|
||||
const titleDisplay = document.getElementById("deadline-title-display")!;
|
||||
const titleEdit = document.getElementById("deadline-title-edit") as HTMLInputElement;
|
||||
@@ -366,6 +509,11 @@ function initEdit() {
|
||||
const etEdit = document.getElementById("deadline-event-types-edit");
|
||||
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
|
||||
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
|
||||
const titleDefaultBtn = document.getElementById("deadline-title-default-btn") as HTMLButtonElement | null;
|
||||
const ruleDisplay = document.getElementById("deadline-rule-display");
|
||||
const ruleEdit = document.getElementById("deadline-rule-edit");
|
||||
const ruleCustomInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const ruleToggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
|
||||
function enterEdit() {
|
||||
titleDisplay.style.display = "none";
|
||||
@@ -381,6 +529,20 @@ function initEdit() {
|
||||
projectEdit.style.display = "";
|
||||
projectEdit.value = deadline.project_id;
|
||||
}
|
||||
if (titleDefaultBtn) titleDefaultBtn.style.display = "";
|
||||
// t-paliad-258 — show the Auto/Custom rule editor + initialise mode
|
||||
// from the persisted deadline. Display element stays visible so the
|
||||
// user keeps "before / after" context while editing.
|
||||
if (ruleEdit) ruleEdit.style.display = "";
|
||||
if (ruleDisplay) ruleDisplay.style.display = "none";
|
||||
if (deadline?.custom_rule_text && !deadline.rule_id) {
|
||||
ruleMode = "custom";
|
||||
if (ruleCustomInput) ruleCustomInput.value = deadline.custom_rule_text;
|
||||
} else {
|
||||
ruleMode = "auto";
|
||||
if (ruleCustomInput) ruleCustomInput.value = "";
|
||||
}
|
||||
applyRuleModeUI();
|
||||
saveBtn.style.display = "";
|
||||
editBtn.style.display = "none";
|
||||
titleEdit.focus();
|
||||
@@ -399,12 +561,71 @@ function initEdit() {
|
||||
projectEdit.style.display = "none";
|
||||
projectLink.style.display = "";
|
||||
}
|
||||
if (titleDefaultBtn) titleDefaultBtn.style.display = "none";
|
||||
if (ruleEdit) ruleEdit.style.display = "none";
|
||||
if (ruleDisplay) ruleDisplay.style.display = "";
|
||||
saveBtn.style.display = "none";
|
||||
editBtn.style.display = "";
|
||||
pendingEditMode = false;
|
||||
}
|
||||
|
||||
// Rule mode toggle (Auto ↔ Custom). The Auto resolver re-runs every
|
||||
// time the Type picker changes, so just-toggling-to-Auto immediately
|
||||
// surfaces a fresh resolution.
|
||||
ruleToggleBtn?.addEventListener("click", () => {
|
||||
ruleMode = ruleMode === "auto" ? "custom" : "auto";
|
||||
applyRuleModeUI();
|
||||
if (ruleMode === "custom") ruleCustomInput?.focus();
|
||||
});
|
||||
|
||||
// t-paliad-252 — expose enterEdit so the withdraw warning modal can
|
||||
// route into pending-edit mode without re-running the edit-button
|
||||
// visibility gate (which hides the button during pending).
|
||||
pendingEnterEdit = () => {
|
||||
pendingEditMode = true;
|
||||
enterEdit();
|
||||
};
|
||||
|
||||
editBtn.addEventListener("click", enterEdit);
|
||||
|
||||
// t-paliad-251 Part 4 — Standardtitel button.
|
||||
// Recipe (mirror of computeDefaultTitle in deadlines-new.ts):
|
||||
// head = event_type label (if exactly one Typ chip in edit)
|
||||
// || Auto-resolved rule's canonical label (Name · Citation)
|
||||
// || saved rule's canonical label
|
||||
// || custom_rule_text (when in Custom mode + non-empty)
|
||||
// || rule_code-only legacy fallback
|
||||
// || "Neue Frist" fallback
|
||||
// suffix = " — <project.reference>" when not already in head
|
||||
titleDefaultBtn?.addEventListener("click", () => {
|
||||
if (!deadline) return;
|
||||
let head = "";
|
||||
const ids = eventTypePicker?.getIDs() ?? deadline.event_type_ids ?? [];
|
||||
if (ids.length === 1) {
|
||||
const et = eventTypeByID.get(ids[0]);
|
||||
if (et) head = eventTypeLabel(et);
|
||||
}
|
||||
if (!head) {
|
||||
const r = ruleMode === "auto" ? (currentAutoRule() ?? rule) : null;
|
||||
if (r) head = formatRuleLabel(r);
|
||||
}
|
||||
if (!head && ruleMode === "custom") {
|
||||
const txt = ruleCustomInput?.value.trim() || "";
|
||||
if (txt) head = txt;
|
||||
}
|
||||
if (!head && rule) {
|
||||
head = formatRuleLabel(rule);
|
||||
}
|
||||
if (!head && deadline.rule_code) {
|
||||
head = deadline.rule_code;
|
||||
}
|
||||
if (!head) head = t("deadlines.field.title.default_fallback");
|
||||
const ref = project?.reference?.trim() || "";
|
||||
if (ref && !head.includes(ref)) head = `${head} — ${ref}`;
|
||||
titleEdit.value = head;
|
||||
titleEdit.focus();
|
||||
});
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!deadline) return;
|
||||
const newTitle = titleEdit.value.trim();
|
||||
@@ -424,6 +645,48 @@ function initEdit() {
|
||||
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
|
||||
payload.project_id = projectEdit.value;
|
||||
}
|
||||
// t-paliad-258 — rule_set discriminator tells the service this
|
||||
// PATCH carries an Auto/Custom rule change. Both columns are
|
||||
// mutually exclusive at the persistence boundary.
|
||||
payload.rule_set = true;
|
||||
if (ruleMode === "auto") {
|
||||
const r = currentAutoRule();
|
||||
payload.rule_id = r ? r.id : null;
|
||||
payload.custom_rule_text = null;
|
||||
} else {
|
||||
const txt = ruleCustomInput?.value.trim() || "";
|
||||
payload.rule_id = null;
|
||||
payload.custom_rule_text = txt || null;
|
||||
}
|
||||
|
||||
// t-paliad-252 — pending-edit mode routes through the new endpoint
|
||||
// that updates the entity + merges payload into the still-pending
|
||||
// approval_request. Outside pending-edit mode the regular PATCH
|
||||
// path remains the authoritative one (with its existing 409-on-
|
||||
// pending guard).
|
||||
if (pendingEditMode && pendingRequest) {
|
||||
const resp = await fetch(
|
||||
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: payload }),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (fresh.ok) deadline = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
const body = await resp.json().catch(() => null);
|
||||
const msg = (body && (body.message || body.error))
|
||||
|| (t("approvals.withdraw.error") || "Fehler");
|
||||
window.alert(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -501,19 +764,39 @@ function initReopen() {
|
||||
});
|
||||
}
|
||||
|
||||
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
|
||||
// /api/approval-requests/{id}/revoke endpoint (no new server route
|
||||
// needed). After the revoke lands, the entity goes back to
|
||||
// approval_status='approved' and the page reloads to refresh the
|
||||
// in-memory state cleanly.
|
||||
// initWithdraw — t-paliad-160 §C+E + t-paliad-252.
|
||||
//
|
||||
// Click flow: open the withdraw warning modal (replaces the old
|
||||
// confirm()). The modal returns one of:
|
||||
//
|
||||
// "edit" — open the edit form in pending-edit mode; Save calls
|
||||
// /api/approval-requests/{id}/edit-entity which keeps the
|
||||
// request pending + merges the new fields into payload
|
||||
// "withdraw" — destructive: call the existing /revoke endpoint
|
||||
// (DELETE entity for CREATE, revert for UPDATE/COMPLETE,
|
||||
// cancel-delete for DELETE lifecycle)
|
||||
// null — user cancelled; nothing happens
|
||||
function initWithdraw() {
|
||||
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!deadline || !pendingRequest) return;
|
||||
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const action = await openWithdrawWarningModal({
|
||||
entityType: "deadline",
|
||||
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
|
||||
});
|
||||
if (action === null) {
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
btn.disabled = false;
|
||||
pendingEnterEdit?.();
|
||||
return;
|
||||
}
|
||||
// action === "withdraw" → existing destructive path.
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -521,14 +804,16 @@ function initWithdraw() {
|
||||
});
|
||||
if (resp.ok) {
|
||||
// Re-fetch the entity so approval_status flips back to 'approved'
|
||||
// and the badge / buttons rerender accordingly.
|
||||
// and the badge / buttons rerender accordingly. For CREATE
|
||||
// lifecycle the entity is gone, so the 404 surfaces as a reload.
|
||||
const r = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (r.ok) {
|
||||
deadline = await r.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
window.location.reload();
|
||||
// CREATE lifecycle deleted the entity — bounce to the list.
|
||||
window.location.href = "/events?type=deadline";
|
||||
}
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
@@ -592,8 +877,14 @@ async function main() {
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
|
||||
if (deadline.rule_id) await loadRule(deadline.rule_id);
|
||||
await Promise.all([
|
||||
loadProject(deadline.project_id),
|
||||
loadAllProjects(),
|
||||
loadPendingRequest(),
|
||||
loadAllRules(),
|
||||
loadProceedingTypes(),
|
||||
]);
|
||||
if (deadline.rule_id) rule = lookupRule(deadline.rule_id);
|
||||
|
||||
// Load event types in parallel; render once ready (the picker re-renders
|
||||
// chips off the cached map, and the display element re-renders on the
|
||||
@@ -614,6 +905,11 @@ async function main() {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
initialIDs: deadline.event_type_ids ?? [],
|
||||
currentUserAdmin: me?.global_role === "global_admin",
|
||||
onChange: () => {
|
||||
// Type change shifts the Auto-resolved rule. Refresh the
|
||||
// read-only display panel (no-op outside edit mode / Custom).
|
||||
refreshRuleAutoDisplay();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initI18n, t, tDyn } from "./i18n";
|
||||
import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
@@ -8,22 +8,21 @@ import {
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { formatRuleLabel } from "./rule-label";
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let currentUserAdmin = false;
|
||||
let eventTypesByID = new Map<string, EventType>();
|
||||
// expandedOverride flips to true when the user clicks "Anderen Typ
|
||||
// wählen" on the collapsed inline summary. Sticky for the rest of the
|
||||
// form session — cleared only when the user reverts the rule to "Keine
|
||||
// Regel". When true, the picker stays visible regardless of whether
|
||||
// the chip matches the rule's canonical default.
|
||||
let expandedOverride = false;
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
// Used by the Type→Rule resolver to narrow rule candidates to the
|
||||
// project's own proceeding when one applies. Optional because clients
|
||||
// and matter-level projects don't carry a proceeding type.
|
||||
proceeding_type_id?: number | null;
|
||||
}
|
||||
|
||||
interface DeadlineRule {
|
||||
@@ -32,23 +31,37 @@ interface DeadlineRule {
|
||||
name: string;
|
||||
name_en: string;
|
||||
rule_code?: string;
|
||||
// t-paliad-165 — canonical event_type for this rule's concept,
|
||||
// hydrated server-side from paliad.deadline_concept_event_types.
|
||||
// Drives auto-fill of the Typ chip when the user picks this rule.
|
||||
legal_source?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
sequence_order?: number;
|
||||
// t-paliad-165 — canonical event_type for the rule's concept. The
|
||||
// catalog is indexed by it so we can resolve Type → canonical Rule.
|
||||
concept_default_event_type_id?: string | null;
|
||||
}
|
||||
|
||||
// Rules indexed by id so the Regel-change handler can look up the
|
||||
// concept's canonical event_type without re-fetching.
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
jurisdiction: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
// Last event_type the rule auto-filled. Tracked so we can tell whether
|
||||
// the picker still reflects the rule's suggestion (replace silently on
|
||||
// new rule pick) or whether the user has manually edited (leave alone,
|
||||
// surface the mismatch warning instead).
|
||||
let lastAutoFilledEventTypeID: string | null = null;
|
||||
// Rule mode (t-paliad-258 / m/paliad#89). The form has two states:
|
||||
// auto — rule_id resolved from the chosen event_type, rendered
|
||||
// read-only as "Auto: Name · Citation".
|
||||
// custom — free-text input; submits as custom_rule_text on the API.
|
||||
type RuleMode = "auto" | "custom";
|
||||
let ruleMode: RuleMode = "auto";
|
||||
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
let allRules: DeadlineRule[] = [];
|
||||
let proceedingTypesByID = new Map<number, ProceedingType>();
|
||||
let projectsByID = new Map<string, Project>();
|
||||
|
||||
let preselectedProjectID = "";
|
||||
let preselectedProjectIDLocal = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
@@ -62,6 +75,13 @@ function showError(msg: string) {
|
||||
el.className = "form-msg form-msg-error";
|
||||
}
|
||||
|
||||
function proceedingLabel(pt: ProceedingType | undefined): string {
|
||||
if (!pt) return "";
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && pt.name_en) ? pt.name_en : pt.name;
|
||||
return `${pt.jurisdiction} — ${name}`;
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
const sel = document.getElementById("deadline-project") as HTMLSelectElement;
|
||||
const hint = document.getElementById("deadline-project-empty-hint")!;
|
||||
@@ -69,6 +89,7 @@ async function loadProjects() {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (!resp.ok) return;
|
||||
const projects: Project[] = await resp.json();
|
||||
projectsByID = new Map(projects.map((p) => [p.id, p]));
|
||||
if (projects.length === 0) {
|
||||
hint.style.display = "";
|
||||
hint.innerHTML = `${esc(t("deadlines.field.akte.empty"))} <a href="/projects/new">${esc(t("deadlines.field.akte.empty.link"))}</a>`;
|
||||
@@ -82,7 +103,7 @@ async function loadProjects() {
|
||||
const ref = p.reference || "";
|
||||
const indent = projectIndent(p.path);
|
||||
options.push(
|
||||
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
|
||||
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} — ${esc(p.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = options.join("");
|
||||
@@ -91,122 +112,166 @@ async function loadProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProceedingTypes() {
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db");
|
||||
if (!resp.ok) return;
|
||||
const types: ProceedingType[] = await resp.json();
|
||||
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
// Optional: load rules so user can attach. We pull all rules; small set.
|
||||
const sel = document.getElementById("deadline-rule") as HTMLSelectElement;
|
||||
try {
|
||||
const resp = await fetch("/api/deadline-rules");
|
||||
if (!resp.ok) return;
|
||||
const rules: DeadlineRule[] = await resp.json();
|
||||
rulesByID = new Map(rules.map((r) => [r.id, r]));
|
||||
const opts: string[] = [
|
||||
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
||||
];
|
||||
for (const r of rules) {
|
||||
const code = r.rule_code || r.code || "";
|
||||
const label = code ? `${code} \u2014 ${r.name}` : r.name;
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(label)}</option>`);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
allRules = (await resp.json()) as DeadlineRule[];
|
||||
rulesByID = new Map(allRules.map((r) => [r.id, r]));
|
||||
} catch {
|
||||
/* non-fatal — rule select stays at "no rule" */
|
||||
/* non-fatal — rule display falls back to "—" */
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
|
||||
// picker. The two modes are mutually exclusive:
|
||||
// resolveAutoRuleForType picks the best-match catalog rule for the
|
||||
// chosen event type, scoring by:
|
||||
// 1. project's proceeding_type_id (if known) — exact match wins,
|
||||
// 2. otherwise event_type.jurisdiction matches the rule's proceeding's
|
||||
// jurisdiction (EPA→EPO canonicalised),
|
||||
// 3. otherwise the first candidate in canonical sequence_order.
|
||||
//
|
||||
// collapsed: rule selected + canonical event_type known + picker
|
||||
// contains exactly [default] + user hasn't clicked "Anderen Typ
|
||||
// wählen". Hides the chip cluster, surfaces a single inline
|
||||
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
|
||||
// override link.
|
||||
//
|
||||
// expanded: every other case — no rule, no default for the rule,
|
||||
// picker has been edited, or expandedOverride is sticky after the
|
||||
// user clicked the override link. Picker visible; mismatch warning
|
||||
// surfaces yellow when the rule expected a different event_type.
|
||||
function refreshRuleView(): void {
|
||||
const collapsed = document.getElementById("deadline-event-type-collapsed");
|
||||
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
const warn = document.getElementById("deadline-event-type-rule-mismatch");
|
||||
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
|
||||
// Returns null when no rule maps. Callers render that as "no Auto rule
|
||||
// available" so the user can flip to Custom or pick a different Type.
|
||||
function resolveAutoRuleForType(eventTypeID: string, projectID: string): DeadlineRule | null {
|
||||
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
|
||||
if (candidates.length === 0) return null;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const project = projectID ? projectsByID.get(projectID) : undefined;
|
||||
if (project?.proceeding_type_id) {
|
||||
const exact = candidates.find((r) => r.proceeding_type_id === project.proceeding_type_id);
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
const et = eventTypesByID.get(eventTypeID);
|
||||
if (et?.jurisdiction && et.jurisdiction !== "any") {
|
||||
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
|
||||
const jurMatch = candidates.find((r) => {
|
||||
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
|
||||
return pt?.jurisdiction === want;
|
||||
});
|
||||
if (jurMatch) return jurMatch;
|
||||
}
|
||||
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
// currentAutoRule returns the catalog rule the Auto mode would resolve
|
||||
// to for the current form state, or null when no Type is picked or no
|
||||
// rule maps. Centralised so the Auto display, submitForm, and the
|
||||
// Standardtitel button all agree on the same resolution.
|
||||
function currentAutoRule(): DeadlineRule | null {
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
if (picked.length !== 1) return null;
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
return resolveAutoRuleForType(picked[0], projectID);
|
||||
}
|
||||
|
||||
const pickerMatchesDefault =
|
||||
expected !== null && picked.length === 1 && picked[0] === expected;
|
||||
const wantsCollapsed =
|
||||
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
|
||||
|
||||
if (wantsCollapsed) {
|
||||
const et = eventTypesByID.get(expected!);
|
||||
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
|
||||
collapsed.style.display = "";
|
||||
pickerHost.style.display = "none";
|
||||
warn.style.display = "none";
|
||||
// refreshRuleAutoDisplay updates the read-only Auto display panel to
|
||||
// reflect the rule that would be saved in Auto mode. Hides itself when
|
||||
// the user is in Custom mode (the input takes its place).
|
||||
function refreshRuleAutoDisplay(): void {
|
||||
const panel = document.getElementById("deadline-rule-auto-display");
|
||||
const text = document.getElementById("deadline-rule-auto-text");
|
||||
if (!panel || !text) return;
|
||||
if (ruleMode !== "auto") {
|
||||
panel.style.display = "none";
|
||||
return;
|
||||
}
|
||||
panel.style.display = "";
|
||||
const rule = currentAutoRule();
|
||||
if (rule) {
|
||||
text.textContent = formatRuleLabel(rule);
|
||||
text.classList.remove("rule-auto-text--empty");
|
||||
return;
|
||||
}
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
const fallback = picked.length === 1
|
||||
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
|
||||
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
|
||||
text.textContent = fallback;
|
||||
text.classList.add("rule-auto-text--empty");
|
||||
}
|
||||
|
||||
collapsed.style.display = "none";
|
||||
pickerHost.style.display = "";
|
||||
// Mismatch warning: rule expected an event_type AND the picker
|
||||
// doesn't contain it. (When the picker is empty + no override, no
|
||||
// warning — user is free to leave it blank.)
|
||||
if (expected && picked.length > 0 && !picked.includes(expected)) {
|
||||
warn.style.display = "";
|
||||
function applyRuleModeUI(): void {
|
||||
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
|
||||
const autoPanel = document.getElementById("deadline-rule-auto-display");
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
if (!toggleBtn || !autoPanel || !customInput) return;
|
||||
if (ruleMode === "auto") {
|
||||
autoPanel.style.display = "";
|
||||
customInput.style.display = "none";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
|
||||
} else {
|
||||
warn.style.display = "none";
|
||||
autoPanel.style.display = "none";
|
||||
customInput.style.display = "";
|
||||
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
|
||||
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
|
||||
}
|
||||
refreshRuleAutoDisplay();
|
||||
}
|
||||
|
||||
function setRuleMode(mode: RuleMode): void {
|
||||
ruleMode = mode;
|
||||
applyRuleModeUI();
|
||||
if (mode === "custom") {
|
||||
const input = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
input?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// applyRuleAutoFill replaces the picker silently when it still reflects
|
||||
// the previous rule's suggestion (or is empty); leaves a manually-edited
|
||||
// picker alone. Called whenever the Regel select changes.
|
||||
function applyRuleAutoFill(): void {
|
||||
if (!eventTypePicker) return;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const current = eventTypePicker.getIDs();
|
||||
// computeDefaultTitle — t-paliad-251 Part 4. Priority order picks the head:
|
||||
// 1. event_type label (when exactly one Typ chip is set)
|
||||
// 2. canonical rule name (when Auto resolves to a rule)
|
||||
// 3. custom rule text (when in Custom mode)
|
||||
// 4. proceeding type name (when project carries one)
|
||||
// 5. fallback i18n key
|
||||
// Suffix: " — <project-reference>" when not already in head.
|
||||
function computeDefaultTitle(): string {
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
|
||||
const project = projectID ? projectsByID.get(projectID) : undefined;
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
|
||||
// Reset the override on transition to "Keine Regel" — fresh form
|
||||
// session. Otherwise expandedOverride stays sticky.
|
||||
if (ruleID === "") {
|
||||
expandedOverride = false;
|
||||
let head = "";
|
||||
if (picked.length === 1) {
|
||||
const et = eventTypesByID.get(picked[0]);
|
||||
if (et) head = eventTypeLabel(et);
|
||||
}
|
||||
|
||||
const pickerStillReflectsLastSuggestion =
|
||||
lastAutoFilledEventTypeID !== null &&
|
||||
current.length === 1 &&
|
||||
current[0] === lastAutoFilledEventTypeID;
|
||||
const pickerIsEmpty = current.length === 0;
|
||||
|
||||
if (expected) {
|
||||
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
|
||||
eventTypePicker.setIDs([expected]);
|
||||
lastAutoFilledEventTypeID = expected;
|
||||
if (!head) {
|
||||
if (ruleMode === "auto") {
|
||||
const rule = currentAutoRule();
|
||||
if (rule) head = formatRuleLabel(rule);
|
||||
} else {
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const txt = customInput?.value.trim() || "";
|
||||
if (txt) head = txt;
|
||||
}
|
||||
} else if (pickerStillReflectsLastSuggestion) {
|
||||
// New rule has no canonical event_type — clear the stale auto-fill
|
||||
// so the picker doesn't carry a chip from the old rule.
|
||||
eventTypePicker.setIDs([]);
|
||||
lastAutoFilledEventTypeID = null;
|
||||
}
|
||||
refreshRuleView();
|
||||
}
|
||||
if (!head && project?.proceeding_type_id) {
|
||||
const pt = proceedingTypesByID.get(project.proceeding_type_id);
|
||||
if (pt) head = proceedingLabel(pt);
|
||||
}
|
||||
if (!head) {
|
||||
head = t("deadlines.field.title.default_fallback");
|
||||
}
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
|
||||
back.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
const ref = project?.reference?.trim() || "";
|
||||
if (ref && !head.includes(ref)) {
|
||||
return `${head} — ${ref}`;
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
async function submitForm(e: Event) {
|
||||
@@ -217,7 +282,6 @@ async function submitForm(e: Event) {
|
||||
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement).value;
|
||||
const title = (document.getElementById("deadline-title") as HTMLInputElement).value.trim();
|
||||
const due = (document.getElementById("deadline-due") as HTMLInputElement).value;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement).value;
|
||||
const notes = (document.getElementById("deadline-notes") as HTMLTextAreaElement).value.trim();
|
||||
|
||||
if (!projectID || !title || !due) {
|
||||
@@ -234,7 +298,15 @@ async function submitForm(e: Event) {
|
||||
due_date: due,
|
||||
source: "manual",
|
||||
};
|
||||
if (ruleID) payload.rule_id = ruleID;
|
||||
// Rule field: Auto resolves to rule_id, Custom sends the free text.
|
||||
if (ruleMode === "auto") {
|
||||
const rule = currentAutoRule();
|
||||
if (rule) payload.rule_id = rule.id;
|
||||
} else {
|
||||
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
|
||||
const txt = customInput?.value.trim() || "";
|
||||
if (txt) payload.custom_rule_text = txt;
|
||||
}
|
||||
if (notes) payload.notes = notes;
|
||||
const eventTypeIDs = eventTypePicker?.getIDs() ?? [];
|
||||
if (eventTypeIDs.length > 0) payload.event_type_ids = eventTypeIDs;
|
||||
@@ -252,8 +324,8 @@ async function submitForm(e: Event) {
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
if (preselectedProjectID) {
|
||||
window.location.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
if (preselectedProjectIDLocal) {
|
||||
window.location.href = `/projects/${preselectedProjectIDLocal}/deadlines`;
|
||||
} else {
|
||||
window.location.href = `/deadlines/${created.id}`;
|
||||
}
|
||||
@@ -275,6 +347,16 @@ function detectPreselect() {
|
||||
if (fromQuery) preselectedProjectID = fromQuery;
|
||||
}
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
|
||||
back.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
}
|
||||
preselectedProjectIDLocal = preselectedProjectID;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
@@ -288,8 +370,6 @@ async function loadMe() {
|
||||
|
||||
// t-paliad-154 — fetch the effective approval policy for (project,
|
||||
// deadline, create) and reveal the form-time hint when it applies.
|
||||
// Hidden when no policy applies. Re-runs on project change so the hint
|
||||
// updates if the user picks a different project mid-form.
|
||||
async function refreshApprovalHint(): Promise<void> {
|
||||
const hint = document.getElementById("deadline-approval-hint");
|
||||
const text = document.getElementById("deadline-approval-hint-text");
|
||||
@@ -308,7 +388,6 @@ async function refreshApprovalHint(): Promise<void> {
|
||||
hint.style.display = "none";
|
||||
return;
|
||||
}
|
||||
// t-paliad-160 split-grammar (with M1 legacy fallback).
|
||||
const eff = await resp.json() as {
|
||||
requires_approval?: boolean;
|
||||
min_role?: string | null;
|
||||
@@ -343,44 +422,51 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Default due to today
|
||||
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
|
||||
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
|
||||
await Promise.all([loadProjects(), loadRules(), loadMe()]);
|
||||
|
||||
await Promise.all([loadProjects(), loadProceedingTypes(), loadRules(), loadMe()]);
|
||||
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
if (pickerHost) {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
currentUserAdmin,
|
||||
onChange: () => refreshRuleView(),
|
||||
onChange: () => {
|
||||
// Type change shifts which Auto rule resolves; re-render the
|
||||
// read-only Auto display panel.
|
||||
refreshRuleAutoDisplay();
|
||||
},
|
||||
});
|
||||
}
|
||||
// t-paliad-165 follow-up — preload event_types so the collapsed
|
||||
// summary can render the type's label inline without an extra round
|
||||
// trip when the user picks a Regel.
|
||||
|
||||
// Preload event_types for the Auto display + Standardtitel resolver.
|
||||
fetchEventTypes()
|
||||
.then((types) => {
|
||||
eventTypesByID = new Map(types.map((et) => [et.id, et]));
|
||||
refreshRuleView();
|
||||
refreshRuleAutoDisplay();
|
||||
})
|
||||
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
|
||||
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
|
||||
// concept's canonical event_type, when the picker hasn't been
|
||||
// manually edited away from the previous rule's suggestion.
|
||||
document.getElementById("deadline-rule")?.addEventListener("change", () => {
|
||||
applyRuleAutoFill();
|
||||
.catch(() => {/* non-fatal */});
|
||||
|
||||
// Rule mode toggle.
|
||||
document.getElementById("deadline-rule-mode-toggle")?.addEventListener("click", () => {
|
||||
setRuleMode(ruleMode === "auto" ? "custom" : "auto");
|
||||
});
|
||||
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
|
||||
// visible even when the chip still matches the rule's default.
|
||||
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
|
||||
expandedOverride = true;
|
||||
refreshRuleView();
|
||||
// Move focus into the picker's search box so the user can type
|
||||
// immediately without an extra click.
|
||||
const search = document.querySelector<HTMLInputElement>(
|
||||
"#deadline-event-types .event-type-search",
|
||||
);
|
||||
search?.focus();
|
||||
});
|
||||
// Wire approval-hint refresh: on first render + on project change.
|
||||
|
||||
applyRuleModeUI();
|
||||
|
||||
// Approval-hint refresh: on first render + on project change.
|
||||
void refreshApprovalHint();
|
||||
document.getElementById("deadline-project")?.addEventListener("change", () => {
|
||||
void refreshApprovalHint();
|
||||
// Project change can shift which Auto rule resolves (via the
|
||||
// project's proceeding_type_id).
|
||||
refreshRuleAutoDisplay();
|
||||
});
|
||||
|
||||
// t-paliad-251 Part 4 — Standardtitel button.
|
||||
document.getElementById("deadline-title-default-btn")?.addEventListener("click", () => {
|
||||
const titleInput = document.getElementById("deadline-title") as HTMLInputElement | null;
|
||||
if (!titleInput) return;
|
||||
const derived = computeDefaultTitle();
|
||||
if (derived) titleInput.value = derived;
|
||||
titleInput.focus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -686,6 +686,33 @@ export function openBrowseEventTypesModal(
|
||||
return new Promise<string[] | null>((resolve) => {
|
||||
let selected = new Set<string>(opts.initialIDs);
|
||||
let searchQuery = "";
|
||||
// t-paliad-251 — court-type filter chips. `null` = "Alle" (any
|
||||
// jurisdiction). Any non-null value matches event_types.jurisdiction;
|
||||
// "any" is mapped to NULL/missing rows via jurisdictionMatches().
|
||||
let activeJurisdiction: string | null = null;
|
||||
|
||||
// Surface every jurisdiction present in the data — "any" stays bucketed
|
||||
// separately so users still have a "show generic-only" chip. EPA is
|
||||
// canonicalised to EPO in event_types (see mig 074); the chip label
|
||||
// shows EPA to match the legal vocabulary the lawyers use.
|
||||
const jurisdictionsPresent = new Set<string>();
|
||||
for (const et of opts.types) {
|
||||
const j = (et.jurisdiction ?? "").trim();
|
||||
if (j) jurisdictionsPresent.add(j);
|
||||
}
|
||||
const JURISDICTION_ORDER = ["UPC", "EPO", "DPMA", "DE", "any"];
|
||||
const chipJurisdictions = JURISDICTION_ORDER.filter((j) => jurisdictionsPresent.has(j));
|
||||
// Any jurisdiction in the data that isn't in our ordered list lands at
|
||||
// the end so the chip row never silently drops a court flavour.
|
||||
for (const j of jurisdictionsPresent) {
|
||||
if (!chipJurisdictions.includes(j)) chipJurisdictions.push(j);
|
||||
}
|
||||
|
||||
function chipLabel(j: string): string {
|
||||
if (j === "EPO") return "EPA";
|
||||
if (j === "any") return t("event_types.browse.jurisdiction.none");
|
||||
return j;
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "modal-overlay event-type-browse-overlay";
|
||||
@@ -694,6 +721,15 @@ export function openBrowseEventTypesModal(
|
||||
<div class="event-type-browse-header">
|
||||
<h2 id="event-type-browse-title">${esc(t("event_types.browse.title"))}</h2>
|
||||
<input type="text" class="event-type-browse-search" data-role="search" placeholder="${esc(t("event_types.browse.search"))}" autocomplete="off" />
|
||||
<div class="event-type-browse-chips" data-role="chips" role="group" aria-label="${esc(t("event_types.browse.jurisdiction.filter_label"))}">
|
||||
<button type="button" class="event-type-browse-chip event-type-browse-chip--active" data-jurisdiction="" data-role="chip-all">${esc(t("event_types.browse.jurisdiction.all"))}</button>
|
||||
${chipJurisdictions
|
||||
.map(
|
||||
(j) =>
|
||||
`<button type="button" class="event-type-browse-chip" data-jurisdiction="${esc(j)}">${esc(chipLabel(j))}</button>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-type-browse-list" data-role="list" tabindex="-1"></div>
|
||||
<div class="event-type-browse-actions">
|
||||
@@ -711,6 +747,7 @@ export function openBrowseEventTypesModal(
|
||||
const countEl = overlay.querySelector<HTMLElement>("[data-role=count]")!;
|
||||
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
|
||||
const applyBtn = overlay.querySelector<HTMLButtonElement>("[data-role=apply]")!;
|
||||
const chipButtons = overlay.querySelectorAll<HTMLButtonElement>(".event-type-browse-chip");
|
||||
|
||||
const groups = groupByCategory(opts.types);
|
||||
|
||||
@@ -721,6 +758,12 @@ export function openBrowseEventTypesModal(
|
||||
return j;
|
||||
}
|
||||
|
||||
function jurisdictionMatches(et: EventType): boolean {
|
||||
if (activeJurisdiction === null) return true;
|
||||
const j = (et.jurisdiction ?? "").trim();
|
||||
return j === activeJurisdiction;
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
countEl.textContent = t("event_types.browse.selected_count").replace(
|
||||
"{n}",
|
||||
@@ -731,6 +774,7 @@ export function openBrowseEventTypesModal(
|
||||
function renderList() {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
const matches = (et: EventType) => {
|
||||
if (!jurisdictionMatches(et)) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
et.label_de.toLowerCase().includes(q) ||
|
||||
@@ -783,6 +827,16 @@ export function openBrowseEventTypesModal(
|
||||
renderList();
|
||||
});
|
||||
|
||||
chipButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const raw = btn.dataset.jurisdiction ?? "";
|
||||
activeJurisdiction = raw === "" ? null : raw;
|
||||
chipButtons.forEach((b) => b.classList.remove("event-type-browse-chip--active"));
|
||||
btn.classList.add("event-type-browse-chip--active");
|
||||
renderList();
|
||||
});
|
||||
});
|
||||
|
||||
function close(value: string[] | null) {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
overlay.remove();
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
type FilterHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
|
||||
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
// 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
|
||||
@@ -65,6 +67,9 @@ interface EventListItem {
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
// t-paliad-258 — free-text rule label when the deadline was created
|
||||
// via the Custom rule path. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
event_type_ids?: string[];
|
||||
|
||||
// appointment-only
|
||||
@@ -157,8 +162,10 @@ let me: Me | null = null;
|
||||
let eventTypeFilter: FilterHandle | null = null;
|
||||
let eventTypeByID: Map<string, EventType> = new Map();
|
||||
let loadedOK = false;
|
||||
let calYear = 0;
|
||||
let calMonth = 0;
|
||||
// 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;
|
||||
|
||||
function urlParams(): URLSearchParams {
|
||||
return new URLSearchParams(window.location.search);
|
||||
@@ -261,13 +268,26 @@ function urgencyClass(item: EventListItem): string {
|
||||
|
||||
function ruleDisplay(item: EventListItem): string {
|
||||
if (item.type !== "deadline") return "";
|
||||
// Prefer the saved citation (RoP.023, R.151) over the rule name —
|
||||
// REGEL is meant for the legal reference, not the rule's display
|
||||
// name (which is the title column's job).
|
||||
if (item.rule_code && item.rule_code.trim()) return esc(item.rule_code);
|
||||
const lang = getLang();
|
||||
const localized = lang === "en" ? item.rule_name_en : item.rule_name;
|
||||
if (localized && localized.trim()) return esc(localized);
|
||||
// t-paliad-258 addendum — canonical display contract: Name primary,
|
||||
// Citation muted secondary ("Notice of Appeal · UPC.RoP.220.1").
|
||||
// Custom rules render the lawyer's free text + a "Custom" badge.
|
||||
// Legacy rule-code-only saves (Fristenrechner, no rule_id) still
|
||||
// show the bare citation as last-resort fallback.
|
||||
const hasName = (item.rule_name && item.rule_name.trim()) ||
|
||||
(item.rule_name_en && item.rule_name_en.trim());
|
||||
if (hasName || (item.rule_code && item.rule_code.trim())) {
|
||||
return formatRuleLabelHTML(
|
||||
{
|
||||
name: item.rule_name || "",
|
||||
name_en: item.rule_name_en,
|
||||
rule_code: item.rule_code,
|
||||
},
|
||||
esc,
|
||||
);
|
||||
}
|
||||
if (item.custom_rule_text && item.custom_rule_text.trim()) {
|
||||
return formatCustomRuleLabelHTML(item.custom_rule_text, esc);
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
@@ -429,12 +449,13 @@ 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") {
|
||||
renderCalendar();
|
||||
renderCalendarView();
|
||||
} else {
|
||||
renderTable();
|
||||
}
|
||||
@@ -557,135 +578,57 @@ function renderRow(item: EventListItem, showReopen: boolean): string {
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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;
|
||||
if (item.type === "deadline") {
|
||||
const src = item.due_date ?? item.event_date;
|
||||
return src.slice(0, 10);
|
||||
bucketDate = item.due_date ?? item.event_date;
|
||||
} else if (item.start_at) {
|
||||
bucketDate = item.start_at;
|
||||
} else {
|
||||
bucketDate = item.event_date;
|
||||
}
|
||||
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")}`;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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")!;
|
||||
function renderCalendarView() {
|
||||
const host = document.getElementById("events-calendar-wrap");
|
||||
if (!host) return;
|
||||
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";
|
||||
wrap.hidden = false;
|
||||
(host as HTMLElement).hidden = false;
|
||||
|
||||
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]);
|
||||
const items = allItems.map(toCalendarItem);
|
||||
if (calendar) {
|
||||
calendar.update(items);
|
||||
return;
|
||||
}
|
||||
|
||||
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!) ?? []));
|
||||
// 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 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 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 teardownCalendar() {
|
||||
if (!calendar) return;
|
||||
calendar.destroy();
|
||||
calendar = null;
|
||||
}
|
||||
|
||||
function applyView() {
|
||||
@@ -706,12 +649,18 @@ 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 = month grid; cards + table both hidden.
|
||||
// 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.
|
||||
summary.style.display = currentView === "cards" ? "" : "none";
|
||||
tableWrap.style.display = currentView === "calendar" ? "none" : "";
|
||||
calWrap.hidden = currentView !== "calendar";
|
||||
|
||||
if (currentView === "calendar" && loadedOK) renderCalendar();
|
||||
if (currentView === "calendar") {
|
||||
if (loadedOK) renderCalendarView();
|
||||
} else {
|
||||
teardownCalendar();
|
||||
}
|
||||
}
|
||||
|
||||
function wireRowHandlers(tbody: HTMLElement) {
|
||||
@@ -1013,12 +962,10 @@ function initFilters() {
|
||||
}
|
||||
|
||||
function initView() {
|
||||
// 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();
|
||||
|
||||
// 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.
|
||||
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const next = btn.dataset.eventView as EventView;
|
||||
@@ -1028,31 +975,6 @@ 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() {
|
||||
|
||||
@@ -429,8 +429,13 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
// Pass the chip-strip perspective through as `side` so the column
|
||||
// bucketer keeps the user's own party on the left (Unsere Seite) —
|
||||
// t-paliad-257: the old Proaktiv/Reaktiv labels lied when the user
|
||||
// was on the defendant side, the new labels demand we route the
|
||||
// user's party into the `ours` column.
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
? renderColumnsBody(data, { editable: true, showNotes, side: currentPerspective })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
|
||||
@@ -27,6 +27,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.glossar": "Glossar",
|
||||
"nav.gebuehrentabellen": "Geb\u00fchrentabellen",
|
||||
"nav.checklisten": "Checklisten",
|
||||
"nav.submissions": "Schriftsätze",
|
||||
"nav.gerichte": "Gerichte",
|
||||
"nav.logout": "Abmelden",
|
||||
"nav.akten": "Akten",
|
||||
@@ -301,9 +302,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Zeitstrahl",
|
||||
"deadlines.view.columns": "Spalten",
|
||||
"deadlines.notes.show": "Hinweise anzeigen",
|
||||
"deadlines.col.proactive": "Proaktiv",
|
||||
"deadlines.col.ours": "Unsere Seite",
|
||||
"deadlines.col.court": "Gericht",
|
||||
"deadlines.col.reactive": "Reaktiv",
|
||||
"deadlines.col.opponent": "Gegnerseite",
|
||||
"deadlines.col.both": "Beide Parteien",
|
||||
// Trigger-event mode (PR-2 \u2014 youpc-parity)
|
||||
"deadlines.mode.procedure": "Verfahrensablauf",
|
||||
@@ -416,6 +417,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.perspective.defendant.title": "Beklagtenseite — versteckt typische Kläger-Schriftsätze",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Berufung eingelegt durch:",
|
||||
"deadlines.perspective.predefined_hint": "vorgegeben durch Akte",
|
||||
"deadlines.side.label": "Seite:",
|
||||
"deadlines.side.claimant": "Klägerseite",
|
||||
"deadlines.side.defendant": "Beklagtenseite",
|
||||
"deadlines.side.both": "Beide",
|
||||
"deadlines.appellant.label": "Berufung durch:",
|
||||
"deadlines.appellant.claimant": "Klägerseite",
|
||||
"deadlines.appellant.defendant": "Beklagtenseite",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.event.composite.label": "Zusammengesetzt:",
|
||||
"deadlines.event.unit.days.one": "Tag",
|
||||
"deadlines.event.unit.days.many": "Tage",
|
||||
@@ -555,7 +564,101 @@ 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",
|
||||
@@ -694,7 +797,6 @@ 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",
|
||||
@@ -780,11 +882,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.title.placeholder": "z.\u202fB. Klageerwiderung einreichen",
|
||||
"deadlines.field.due": "F\u00e4lligkeitsdatum",
|
||||
"deadlines.field.rule": "Regel (optional)",
|
||||
"deadlines.field.rule.none": "Keine Regel",
|
||||
"deadlines.field.rule.autofill": "Typ vorgegeben durch Regel — entfernen, um zu überschreiben.",
|
||||
"deadlines.field.rule.autofill_inline": " (vorgegeben durch Regel)",
|
||||
"deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.",
|
||||
"deadlines.field.rule.override": "Anderen Typ wählen",
|
||||
"deadlines.field.rule.auto_badge": "Auto",
|
||||
"deadlines.field.rule.auto_no_match": "Keine Regel zur gewählten Verfahrenshandlung",
|
||||
"deadlines.field.rule.auto_pick_type": "Wählen Sie zuerst eine Verfahrenshandlung",
|
||||
"deadlines.field.rule.custom_badge": "Eigen",
|
||||
"deadlines.field.rule.custom_placeholder": "z.B. interner Review-Termin, Mandantengespräch",
|
||||
"deadlines.field.rule.mode.toggle_to_auto": "Zurück zu Auto",
|
||||
"deadlines.field.rule.mode.toggle_to_custom": "Eigene Regel eingeben",
|
||||
"deadlines.field.title.default_btn": "Standardtitel",
|
||||
"deadlines.field.title.default_fallback": "Neue Frist",
|
||||
"deadlines.field.notes": "Notizen (optional)",
|
||||
"deadlines.field.notes.placeholder": "Hinweise, Verweise, n\u00e4chste Schritte\u2026",
|
||||
"deadlines.error.required": "Akte, Titel und F\u00e4lligkeitsdatum sind Pflichtfelder.",
|
||||
@@ -817,12 +923,6 @@ 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",
|
||||
@@ -919,6 +1019,50 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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",
|
||||
@@ -1204,7 +1348,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.field.grant_date": "Erteilungstag",
|
||||
"projects.field.court": "Gericht",
|
||||
"projects.field.case_number": "Aktenzeichen (Gericht)",
|
||||
"projects.field.proceeding_type_id": "Verfahrensart",
|
||||
"projects.field.proceeding_type_id": "Verfahrenstyp",
|
||||
"projects.field.proceeding_type": "Verfahrenstyp",
|
||||
"projects.field.proceeding_type.unset": "(nicht gesetzt)",
|
||||
"projects.field.proceeding_type.hint": "Bestimmt, welche Schriftsätze-Vorlagen für dieses Verfahren angezeigt werden.",
|
||||
"projects.field.our_side": "Wir vertreten",
|
||||
"projects.field.our_side.hint": "Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.",
|
||||
"projects.field.our_side.unset": "Unbekannt / nicht gesetzt",
|
||||
@@ -1291,17 +1438,67 @@ 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.tab.settings": "Verwaltung",
|
||||
"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.settings.export.heading": "Daten exportieren",
|
||||
"projects.detail.settings.export.description": "Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.",
|
||||
"projects.detail.settings.archive.heading": "Projekt archivieren",
|
||||
"projects.detail.settings.archive.description": "Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).",
|
||||
"projects.detail.settings.archive.cta": "Bearbeiten öffnen",
|
||||
"projects.detail.submissions.empty": "Es sind aktuell keine Schriftsatzvorlagen hinterlegt.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Für dieses Projekt ist noch kein Verfahrenstyp gesetzt — der Katalog unten zeigt trotzdem alle Vorlagen.",
|
||||
"projects.detail.submissions.empty.no_proceeding.cta": "Projekt bearbeiten",
|
||||
"projects.detail.submissions.col.name": "Schriftsatz",
|
||||
"projects.detail.submissions.col.party": "Partei",
|
||||
"projects.detail.submissions.col.source": "Rechtsgrundlage",
|
||||
"projects.detail.submissions.col.action": "",
|
||||
"projects.detail.submissions.action.generate": "Generieren",
|
||||
"projects.detail.submissions.action.no_template": "Keine Vorlage",
|
||||
"projects.detail.submissions.action.edit": "Bearbeiten",
|
||||
"projects.detail.submissions.hint": "Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.",
|
||||
// t-paliad-238 — dedicated draft editor page.
|
||||
"submissions.draft.title": "Schriftsatz bearbeiten — Paliad",
|
||||
"submissions.draft.back": "← Zurück zum Projekt",
|
||||
"submissions.draft.loading": "Lädt…",
|
||||
"submissions.draft.notfound": "Schriftsatz nicht gefunden oder keine Berechtigung.",
|
||||
"submissions.draft.action.export": "Als .docx exportieren",
|
||||
"submissions.draft.action.new": "+ Neuer Entwurf",
|
||||
"submissions.draft.action.delete": "Löschen",
|
||||
"submissions.draft.switcher.label": "Entwurf",
|
||||
"submissions.draft.name.placeholder": "Name dieses Entwurfs",
|
||||
"submissions.draft.preview.title": "Vorschau",
|
||||
"submissions.draft.preview.hint": "Read-only Vorschau — finale Bearbeitung in Word.",
|
||||
// t-paliad-240 — global Schriftsätze drafts index page.
|
||||
"submissions.index.title": "Schriftsätze — Paliad",
|
||||
"submissions.index.heading": "Schriftsätze",
|
||||
"submissions.index.subtitle": "Ihre Schriftsatz-Entwürfe über alle sichtbaren Projekte.",
|
||||
"submissions.index.loading": "Lädt…",
|
||||
"submissions.index.empty": "Noch keine Entwürfe. Beginnen Sie mit einem neuen Entwurf — mit oder ohne Projekt.",
|
||||
"submissions.index.empty.cta": "+ Neuer Entwurf",
|
||||
"submissions.index.error": "Schriftsätze konnten nicht geladen werden.",
|
||||
"submissions.index.col.project": "Projekt",
|
||||
"submissions.index.col.submission": "Schriftsatz",
|
||||
"submissions.index.col.draft": "Entwurf",
|
||||
"submissions.index.col.updated": "Zuletzt geändert",
|
||||
"submissions.index.action.new": "+ Neuer Entwurf",
|
||||
// t-paliad-243 — global Schriftsatz picker (/submissions/new).
|
||||
"submissions.new.title": "Neuer Schriftsatz — Paliad",
|
||||
"submissions.new.back": "← Zurück zur Übersicht",
|
||||
"submissions.new.heading": "Neuer Schriftsatz",
|
||||
"submissions.new.subtitle": "Wählen Sie eine Vorlage. Optional verknüpfen Sie den Entwurf mit einem Projekt — sonst füllen Sie alle Variablen manuell.",
|
||||
"submissions.new.search.placeholder": "Suche nach Schriftsatz, Code oder Norm…",
|
||||
"submissions.new.loading": "Lädt…",
|
||||
"submissions.new.error": "Katalog konnte nicht geladen werden.",
|
||||
"submissions.new.col.name": "Schriftsatz",
|
||||
"submissions.new.col.party": "Partei",
|
||||
"submissions.new.col.source": "Rechtsgrundlage",
|
||||
"submissions.new.col.actions": "Entwurf starten",
|
||||
"submissions.new.empty.filtered": "Keine passenden Schriftsätze. Filter zurücksetzen.",
|
||||
"submissions.new.picker.title": "Projekt wählen",
|
||||
"submissions.new.picker.placeholder": "Projekt suchen (Titel oder Aktenzeichen)…",
|
||||
"submissions.new.picker.loading": "Lädt Projekte…",
|
||||
"submissions.new.picker.empty": "Keine sichtbaren Projekte.",
|
||||
"projects.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
|
||||
"projects.detail.verlauf.loadMore": "Mehr laden",
|
||||
// SmartTimeline (t-paliad-171, Slice 1).
|
||||
@@ -1415,9 +1612,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.checklisten.col.name": "Name",
|
||||
"projects.detail.checklisten.col.progress": "Fortschritt",
|
||||
"projects.detail.checklisten.col.created": "Angelegt",
|
||||
"projects.detail.checklisten.hint.prefix": "Instanzen werden auf der Vorlagen-Seite unter ",
|
||||
"projects.detail.checklisten.hint.prefix": "Vorlagen werden auf der ",
|
||||
"projects.detail.checklisten.hint.link": "Checklisten",
|
||||
"projects.detail.checklisten.hint.suffix": " angelegt.",
|
||||
"projects.detail.checklisten.hint.suffix": "-Seite angelegt und bearbeitet.",
|
||||
"projects.detail.checklisten.add": "Checkliste hinzuf\u00fcgen",
|
||||
"projects.detail.checklisten.add.search": "Vorlage suchen\u2026",
|
||||
"projects.detail.checklisten.add.empty_pick": "Keine passenden Vorlagen gefunden.",
|
||||
"projects.detail.checklisten.add.created": "Checkliste hinzugef\u00fcgt.",
|
||||
"projects.detail.checklisten.add.error": "Checkliste konnte nicht angelegt werden.",
|
||||
"projects.detail.delete": "Projekt archivieren",
|
||||
"projects.detail.delete.confirm.title": "Projekt wirklich archivieren?",
|
||||
"projects.detail.delete.confirm.body": "Das Projekt wird archiviert. Es kann nicht direkt wiederhergestellt werden.",
|
||||
@@ -1464,6 +1666,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.team.invite.hint": "Benutzer nicht gefunden?",
|
||||
"projects.detail.team.invite.hint_email": "Niemand mit dieser E-Mail.",
|
||||
"projects.detail.team.invite.cta": "Einladen",
|
||||
// t-paliad-231 — pure-client mailto: button on the Team tab. No
|
||||
// server call; opens the local mail client with every selected
|
||||
// member queued in the To: line.
|
||||
"projects.team.mailto.label": "Mail an Auswahl",
|
||||
"projects.team.mailto.empty": "Mindestens ein Mitglied auswählen",
|
||||
"projects.team.mailto.count": "{n} ausgewählt",
|
||||
"projects.team.mailto.select_all": "Alle sichtbaren auswählen",
|
||||
"projects.team.mailto.select_row": "Mitglied auswählen",
|
||||
"projects.view.tree": "Baumansicht",
|
||||
"projects.tree.toggle": "Aufklappen / Zuklappen",
|
||||
"projects.tree.loading": "Baum wird geladen\u2026",
|
||||
@@ -1600,7 +1810,6 @@ 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",
|
||||
@@ -1656,11 +1865,6 @@ 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;
|
||||
@@ -1684,7 +1888,6 @@ 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.",
|
||||
@@ -1802,6 +2005,13 @@ 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",
|
||||
@@ -1905,8 +2115,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"paliadin.error.timeout": "Paliadin antwortet nicht (Timeout 60s). Nochmal versuchen.",
|
||||
"paliadin.error.connection_lost": "Verbindung verloren.",
|
||||
"paliadin.error.upstream": "Fehler beim Senden.",
|
||||
"paliadin.error.upstream_silence": "Paliadin meldet sich nicht mehr — Verbindung wird beendet.",
|
||||
"paliadin.late.waiting": "Antwort wird nachgereicht, sobald sie eintrifft …",
|
||||
"paliadin.late.checking": "Verbindung verloren — Paliadin denkt vielleicht noch. Lade frische Antwort …",
|
||||
"paliadin.late.lost": "Antwort konnte nicht zugestellt werden — bitte Frage erneut stellen.",
|
||||
"paliadin.late.marker": "verspätet",
|
||||
"paliadin.thinking": "Paliadin denkt nach",
|
||||
"paliadin.thinking.seconds": "{seconds}s",
|
||||
"paliadin.widget.title": "Paliadin",
|
||||
"paliadin.widget.trigger": "Paliadin (Cmd+J)",
|
||||
"paliadin.widget.empty": "Was kann ich für dich tun?",
|
||||
@@ -2077,8 +2292,24 @@ 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.",
|
||||
@@ -2218,6 +2449,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event_types.browse.cancel": "Abbrechen",
|
||||
"event_types.browse.selected_count": "{n} ausgewählt",
|
||||
"event_types.browse.jurisdiction.none": "Allgemein",
|
||||
"event_types.browse.jurisdiction.all": "Alle Gerichte",
|
||||
"event_types.browse.jurisdiction.filter_label": "Nach Gerichtsart filtern",
|
||||
"event_types.filter.all": "Alle Typen",
|
||||
"event_types.filter.untyped": "— Ohne Typ —",
|
||||
"event_types.filter.search": "Typ suchen…",
|
||||
@@ -2363,6 +2596,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
|
||||
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
|
||||
"approvals.withdraw.error": "Fehler beim Zurückziehen",
|
||||
"approvals.withdraw.cancel": "Abbrechen",
|
||||
"approvals.withdraw.modal.title": "Genehmigungsanfrage zurückziehen?",
|
||||
"approvals.withdraw.primary.label": "Termin bearbeiten",
|
||||
"approvals.withdraw.destructive.label": "Endgültig zurückziehen und löschen",
|
||||
"approvals.withdraw.lead.create.deadline": "Wenn Sie die Anfrage zurückziehen, wird die Frist gelöscht.",
|
||||
"approvals.withdraw.lead.create.appointment": "Wenn Sie die Anfrage zurückziehen, wird der Termin gelöscht.",
|
||||
"approvals.withdraw.lead.update": "Wenn Sie die Anfrage zurückziehen, werden die vorgeschlagenen Änderungen verworfen — der Eintrag kehrt in den Zustand vor Ihrer Bearbeitung zurück.",
|
||||
"approvals.withdraw.lead.delete": "Wenn Sie die Löschanfrage zurückziehen, bleibt der Eintrag bestehen.",
|
||||
"approvals.withdraw.sub.create": "Alternativ können Sie den Eintrag stattdessen bearbeiten. Die Anfrage bleibt offen und der Genehmiger sieht Ihre neuen Werte.",
|
||||
"approvals.withdraw.sub.update": "Alternativ können Sie Ihre Änderungen bearbeiten und neu absenden. Die Anfrage bleibt offen.",
|
||||
"approvals.withdraw.sub.delete": "Sind Sie sicher, dass Sie die Löschanfrage zurückziehen möchten?",
|
||||
"approvals.pending_create.label": "Erstellung wartet auf Genehmigung",
|
||||
"approvals.pending_update.label": "Änderung wartet auf Genehmigung",
|
||||
"approvals.pending_complete.label": "Erledigung wartet auf Genehmigung",
|
||||
@@ -2757,6 +3001,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.glossar": "Glossary",
|
||||
"nav.gebuehrentabellen": "Fee Schedules",
|
||||
"nav.checklisten": "Checklists",
|
||||
"nav.submissions": "Submissions",
|
||||
"nav.gerichte": "Courts",
|
||||
"nav.logout": "Sign Out",
|
||||
"nav.akten": "Matters",
|
||||
@@ -3028,9 +3273,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Timeline",
|
||||
"deadlines.view.columns": "Columns",
|
||||
"deadlines.notes.show": "Show details",
|
||||
"deadlines.col.proactive": "Proactive",
|
||||
"deadlines.col.ours": "Client Side",
|
||||
"deadlines.col.court": "Court",
|
||||
"deadlines.col.reactive": "Reactive",
|
||||
"deadlines.col.opponent": "Opponent Side",
|
||||
"deadlines.col.both": "Both parties",
|
||||
"deadlines.adjusted": "Adjusted",
|
||||
"deadlines.adjusted.reason": "weekend/holiday",
|
||||
@@ -3150,6 +3395,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.perspective.defendant.title": "Defendant side — hides typical claimant submissions",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Appeal filed by:",
|
||||
"deadlines.perspective.predefined_hint": "predefined from project",
|
||||
"deadlines.side.label": "Side:",
|
||||
"deadlines.side.claimant": "Claimant",
|
||||
"deadlines.side.defendant": "Defendant",
|
||||
"deadlines.side.both": "Both",
|
||||
"deadlines.appellant.label": "Appeal filed by:",
|
||||
"deadlines.appellant.claimant": "Claimant",
|
||||
"deadlines.appellant.defendant": "Defendant",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.event.composite.label": "Composite:",
|
||||
"deadlines.event.unit.days.one": "day",
|
||||
"deadlines.event.unit.days.many": "days",
|
||||
@@ -3282,7 +3535,101 @@ 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",
|
||||
@@ -3421,7 +3768,6 @@ 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",
|
||||
@@ -3507,11 +3853,15 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.title.placeholder": "e.g. File statement of defence",
|
||||
"deadlines.field.due": "Due date",
|
||||
"deadlines.field.rule": "Rule (optional)",
|
||||
"deadlines.field.rule.none": "No rule",
|
||||
"deadlines.field.rule.autofill": "Type set by rule — remove to override.",
|
||||
"deadlines.field.rule.autofill_inline": " (set by rule)",
|
||||
"deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.",
|
||||
"deadlines.field.rule.override": "Choose another type",
|
||||
"deadlines.field.rule.auto_badge": "Auto",
|
||||
"deadlines.field.rule.auto_no_match": "No rule maps to the chosen Type",
|
||||
"deadlines.field.rule.auto_pick_type": "Pick a Type first",
|
||||
"deadlines.field.rule.custom_badge": "Custom",
|
||||
"deadlines.field.rule.custom_placeholder": "e.g. internal review meeting, client call",
|
||||
"deadlines.field.rule.mode.toggle_to_auto": "Back to Auto",
|
||||
"deadlines.field.rule.mode.toggle_to_custom": "Enter custom rule",
|
||||
"deadlines.field.title.default_btn": "Default title",
|
||||
"deadlines.field.title.default_fallback": "New deadline",
|
||||
"deadlines.field.notes": "Notes (optional)",
|
||||
"deadlines.field.notes.placeholder": "References, hints, next steps\u2026",
|
||||
"deadlines.error.required": "Matter, title and due date are required.",
|
||||
@@ -3544,12 +3894,6 @@ 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",
|
||||
@@ -3579,6 +3923,7 @@ 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.",
|
||||
|
||||
@@ -3641,6 +3986,43 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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",
|
||||
@@ -3919,6 +4301,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.field.court": "Court",
|
||||
"projects.field.case_number": "Case number (court)",
|
||||
"projects.field.proceeding_type_id": "Proceeding type",
|
||||
"projects.field.proceeding_type": "Proceeding type",
|
||||
"projects.field.proceeding_type.unset": "(unset)",
|
||||
"projects.field.proceeding_type.hint": "Determines which submission templates show up on this proceeding.",
|
||||
"projects.field.our_side": "We represent",
|
||||
"projects.field.our_side.hint": "Pre-selects the perspective chip in the Fristenrechner Determinator. Always overridable from there.",
|
||||
"projects.field.our_side.unset": "Unknown / not set",
|
||||
@@ -4005,17 +4390,66 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.notizen": "Notes",
|
||||
"projects.detail.tab.checklisten": "Checklists",
|
||||
"projects.detail.tab.submissions": "Submissions",
|
||||
"projects.detail.tab.settings": "Settings",
|
||||
"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.settings.export.heading": "Export data",
|
||||
"projects.detail.settings.export.description": "Download all data for this project (including sub-projects) as an Excel + JSON + CSV archive.",
|
||||
"projects.detail.settings.archive.heading": "Archive project",
|
||||
"projects.detail.settings.archive.description": "Archiving happens in the edit dialog (danger zone).",
|
||||
"projects.detail.settings.archive.cta": "Open edit dialog",
|
||||
"projects.detail.submissions.empty": "No submission templates are configured yet.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "No proceeding type is set for this project yet — the catalog below still lists every template.",
|
||||
"projects.detail.submissions.empty.no_proceeding.cta": "Edit project",
|
||||
"projects.detail.submissions.col.name": "Submission",
|
||||
"projects.detail.submissions.col.party": "Party",
|
||||
"projects.detail.submissions.col.source": "Legal basis",
|
||||
"projects.detail.submissions.col.action": "",
|
||||
"projects.detail.submissions.action.generate": "Generate",
|
||||
"projects.detail.submissions.action.no_template": "No template",
|
||||
"projects.detail.submissions.action.edit": "Edit",
|
||||
"projects.detail.submissions.hint": "Submissions are generated as .docx directly from the project. Edit, print, file.",
|
||||
// t-paliad-238 — dedicated draft editor page.
|
||||
"submissions.draft.title": "Edit submission — Paliad",
|
||||
"submissions.draft.back": "← Back to project",
|
||||
"submissions.draft.loading": "Loading…",
|
||||
"submissions.draft.notfound": "Submission not found or insufficient access.",
|
||||
"submissions.draft.action.export": "Export as .docx",
|
||||
"submissions.draft.action.new": "+ New draft",
|
||||
"submissions.draft.action.delete": "Delete",
|
||||
"submissions.draft.switcher.label": "Draft",
|
||||
"submissions.draft.name.placeholder": "Name of this draft",
|
||||
"submissions.draft.preview.title": "Preview",
|
||||
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
|
||||
// t-paliad-240 — global submissions drafts index page.
|
||||
"submissions.index.title": "Submissions — Paliad",
|
||||
"submissions.index.heading": "Submissions",
|
||||
"submissions.index.subtitle": "Your submission drafts across every visible project.",
|
||||
"submissions.index.loading": "Loading…",
|
||||
"submissions.index.empty": "No drafts yet. Start a new draft — with or without a project.",
|
||||
"submissions.index.empty.cta": "+ New draft",
|
||||
"submissions.index.error": "Could not load submissions.",
|
||||
"submissions.index.col.project": "Project",
|
||||
"submissions.index.col.submission": "Submission",
|
||||
"submissions.index.col.draft": "Draft",
|
||||
"submissions.index.col.updated": "Last updated",
|
||||
"submissions.index.action.new": "+ New draft",
|
||||
"submissions.new.title": "New submission — Paliad",
|
||||
"submissions.new.back": "← Back to drafts",
|
||||
"submissions.new.heading": "New submission",
|
||||
"submissions.new.subtitle": "Pick a template. Optionally bind it to a project — otherwise all variables are filled manually.",
|
||||
"submissions.new.search.placeholder": "Search by name, code or statute…",
|
||||
"submissions.new.loading": "Loading…",
|
||||
"submissions.new.error": "Could not load catalog.",
|
||||
"submissions.new.col.name": "Submission",
|
||||
"submissions.new.col.party": "Party",
|
||||
"submissions.new.col.source": "Legal source",
|
||||
"submissions.new.col.actions": "Start draft",
|
||||
"submissions.new.empty.filtered": "No submissions match the filters. Reset them to see the full catalog.",
|
||||
"submissions.new.picker.title": "Pick a project",
|
||||
"submissions.new.picker.placeholder": "Search project (title or reference)…",
|
||||
"submissions.new.picker.loading": "Loading projects…",
|
||||
"submissions.new.picker.empty": "No visible projects.",
|
||||
"projects.detail.verlauf.empty": "No events recorded yet.",
|
||||
"projects.detail.verlauf.loadMore": "Load more",
|
||||
"projects.detail.smarttimeline.empty": "No events captured yet.",
|
||||
@@ -4128,9 +4562,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.checklisten.col.name": "Name",
|
||||
"projects.detail.checklisten.col.progress": "Progress",
|
||||
"projects.detail.checklisten.col.created": "Created",
|
||||
"projects.detail.checklisten.hint.prefix": "Instances are created on the template page under ",
|
||||
"projects.detail.checklisten.hint.prefix": "Templates are created and edited on the ",
|
||||
"projects.detail.checklisten.hint.link": "Checklists",
|
||||
"projects.detail.checklisten.hint.suffix": ".",
|
||||
"projects.detail.checklisten.hint.suffix": " page.",
|
||||
"projects.detail.checklisten.add": "Add checklist",
|
||||
"projects.detail.checklisten.add.search": "Search template…",
|
||||
"projects.detail.checklisten.add.empty_pick": "No matching templates.",
|
||||
"projects.detail.checklisten.add.created": "Checklist added.",
|
||||
"projects.detail.checklisten.add.error": "Could not create checklist.",
|
||||
"projects.detail.delete": "Archive project",
|
||||
"projects.detail.delete.confirm.title": "Archive project?",
|
||||
"projects.detail.delete.confirm.body": "The project will be archived. It cannot be directly restored.",
|
||||
@@ -4177,6 +4616,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.team.invite.hint": "User not found?",
|
||||
"projects.detail.team.invite.hint_email": "No one with that email.",
|
||||
"projects.detail.team.invite.cta": "Invite",
|
||||
// t-paliad-231 — pure-client mailto: button on the Team tab.
|
||||
"projects.team.mailto.label": "Mail to selection",
|
||||
"projects.team.mailto.empty": "Select at least one member",
|
||||
"projects.team.mailto.count": "{n} selected",
|
||||
"projects.team.mailto.select_all": "Select all visible",
|
||||
"projects.team.mailto.select_row": "Select member",
|
||||
"projects.view.tree": "Tree view",
|
||||
"projects.tree.toggle": "Expand / collapse",
|
||||
"projects.tree.loading": "Loading tree…",
|
||||
@@ -4313,7 +4758,6 @@ 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",
|
||||
@@ -4369,11 +4813,6 @@ 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",
|
||||
@@ -4394,7 +4833,6 @@ 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.",
|
||||
@@ -4512,6 +4950,13 @@ 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",
|
||||
@@ -4613,8 +5058,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"paliadin.error.timeout": "Paliadin didn't respond in time (60s). Try again.",
|
||||
"paliadin.error.connection_lost": "Connection lost.",
|
||||
"paliadin.error.upstream": "Send failed.",
|
||||
"paliadin.error.upstream_silence": "Paliadin went silent — closing the connection.",
|
||||
"paliadin.late.waiting": "Will fill in the response when it arrives …",
|
||||
"paliadin.late.checking": "Connection lost — Paliadin may still be thinking. Fetching fresh answer …",
|
||||
"paliadin.late.lost": "Answer couldn't be delivered — please ask again.",
|
||||
"paliadin.late.marker": "late",
|
||||
"paliadin.thinking": "Paliadin is thinking",
|
||||
"paliadin.thinking.seconds": "{seconds}s",
|
||||
"paliadin.widget.title": "Paliadin",
|
||||
"paliadin.widget.trigger": "Paliadin (Cmd+J)",
|
||||
"paliadin.widget.empty": "What can I help you with?",
|
||||
@@ -4785,8 +5235,24 @@ 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.",
|
||||
@@ -4926,6 +5392,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event_types.browse.cancel": "Cancel",
|
||||
"event_types.browse.selected_count": "{n} selected",
|
||||
"event_types.browse.jurisdiction.none": "Any",
|
||||
"event_types.browse.jurisdiction.all": "All courts",
|
||||
"event_types.browse.jurisdiction.filter_label": "Filter by court type",
|
||||
"event_types.filter.all": "All types",
|
||||
"event_types.filter.untyped": "— Untyped —",
|
||||
"event_types.filter.search": "Search type…",
|
||||
@@ -5071,6 +5539,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.withdraw.cta": "Withdraw approval request",
|
||||
"approvals.withdraw.confirm": "Withdraw the approval request?",
|
||||
"approvals.withdraw.error": "Failed to withdraw",
|
||||
"approvals.withdraw.cancel": "Cancel",
|
||||
"approvals.withdraw.modal.title": "Withdraw approval request?",
|
||||
"approvals.withdraw.primary.label": "Edit event",
|
||||
"approvals.withdraw.destructive.label": "Withdraw permanently and delete",
|
||||
"approvals.withdraw.lead.create.deadline": "Withdrawing this request will delete the deadline.",
|
||||
"approvals.withdraw.lead.create.appointment": "Withdrawing this request will delete the appointment.",
|
||||
"approvals.withdraw.lead.update": "Withdrawing this request will discard your proposed changes — the entry will revert to its state before your edit.",
|
||||
"approvals.withdraw.lead.delete": "Withdrawing the delete request will keep the entry alive.",
|
||||
"approvals.withdraw.sub.create": "Alternatively, you can edit the entry instead. The request stays open and the approver will see your new values.",
|
||||
"approvals.withdraw.sub.update": "Alternatively, you can edit your changes and resubmit. The request stays open.",
|
||||
"approvals.withdraw.sub.delete": "Are you sure you want to withdraw the delete request?",
|
||||
"approvals.pending_create.label": "Awaits approval (creation)",
|
||||
"approvals.pending_update.label": "Awaits approval (change)",
|
||||
"approvals.pending_complete.label": "Awaits approval (completion)",
|
||||
|
||||
@@ -93,12 +93,13 @@ 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";
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
// Late-response polling. The Go backend's pollForResponse window is
|
||||
// 60 s; if Claude writes the response file after that (because the
|
||||
// tmux pane was busy mid-turn when the message arrived), the SSE
|
||||
// stream has already closed with an `error` event. The Janitor
|
||||
// (services.LocalPaliadinService.runJanitor) then patches the
|
||||
// paliadin_turns row when the file lands.
|
||||
// Late-response polling (t-paliad-235 rewrite).
|
||||
//
|
||||
// This module is the FE half of that loop: after the bubble shows an
|
||||
// error, the caller registers the turn here. We poll
|
||||
// `/api/paliadin/turns/{id}` every 3 s for up to 10 minutes; once the
|
||||
// row has a non-empty response, we hand it back so the caller can
|
||||
// swap the bubble content in place.
|
||||
// When the SSE stream closes mid-turn with an error event, the bubble
|
||||
// can't tell from the wire whether (a) the upstream is still finishing
|
||||
// the turn and we just lost transport, or (b) the upstream is truly
|
||||
// dead.
|
||||
//
|
||||
// This module hits the dispatching recovery endpoint
|
||||
// `/api/paliadin/turns/{id}/recover`, which knows the active backend:
|
||||
//
|
||||
// - aichat backend → asks aichat via its conversation API whether
|
||||
// the turn actually completed upstream
|
||||
// - legacy backend → reads the local row (paliad's filesystem
|
||||
// janitor patches it when claude writes the
|
||||
// response file late)
|
||||
//
|
||||
// The endpoint returns:
|
||||
//
|
||||
// recovery_state="recovered" → response is in the payload, render it
|
||||
// recovery_state="pending" → keep polling
|
||||
// recovery_state="lost" → upstream is truly gone, give up
|
||||
|
||||
export interface LateTurn {
|
||||
turn_id: string;
|
||||
@@ -28,6 +37,10 @@ export interface LatePollOptions {
|
||||
intervalMs?: number; // default 3000
|
||||
maxDurationMs?: number; // default 600000 (10 min)
|
||||
onLateResponse: (turn: LateTurn) => void;
|
||||
// onLost — backend confirmed the turn is unrecoverable. Caller should
|
||||
// swap the bubble copy to the "verloren" string. Distinct from
|
||||
// onGiveUp (which fires only on the local timeout).
|
||||
onLost?: () => void;
|
||||
onGiveUp?: () => void;
|
||||
}
|
||||
|
||||
@@ -35,6 +48,20 @@ export interface LatePollHandle {
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
interface RecoverResponse {
|
||||
turn_id: string;
|
||||
started_at: string;
|
||||
response: string | null;
|
||||
error_code: string | null;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
used_tools: string[];
|
||||
rows_seen: number[];
|
||||
chip_count: number;
|
||||
classifier_tag: string | null;
|
||||
recovery_state: "recovered" | "pending" | "lost";
|
||||
}
|
||||
|
||||
export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
const interval = opts.intervalMs ?? 3000;
|
||||
const maxDuration = opts.maxDurationMs ?? 10 * 60 * 1000;
|
||||
@@ -50,18 +77,24 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/paliadin/turns/${opts.turnId}`, {
|
||||
const r = await fetch(`/api/paliadin/turns/${opts.turnId}/recover`, {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (r.ok) {
|
||||
const turn = (await r.json()) as LateTurn;
|
||||
if (turn.response && turn.response.length > 0) {
|
||||
opts.onLateResponse(turn);
|
||||
const body = (await r.json()) as RecoverResponse;
|
||||
if (body.recovery_state === "recovered" && body.response) {
|
||||
opts.onLateResponse(toLateTurn(body));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 404: row gone (very unlikely) — give up.
|
||||
if (r.status === 404) {
|
||||
if (body.recovery_state === "lost") {
|
||||
opts.onLost?.();
|
||||
return;
|
||||
}
|
||||
// pending — keep polling
|
||||
} else if (r.status === 404) {
|
||||
// Row gone — give up. Different signal from `lost`: a missing row
|
||||
// is a paliad-side bookkeeping problem; aichat may still have the
|
||||
// answer but we can't surface it without the row.
|
||||
opts.onGiveUp?.();
|
||||
return;
|
||||
}
|
||||
@@ -72,7 +105,8 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
};
|
||||
|
||||
// First poll deliberately runs after one interval so we don't race
|
||||
// the 60 s timeout on the very first tick.
|
||||
// the dispatch endpoint on the very first tick (gives the upstream a
|
||||
// moment to actually settle the row after the stream drop).
|
||||
timer = window.setTimeout(tick, interval);
|
||||
|
||||
return {
|
||||
@@ -82,3 +116,17 @@ export function pollForLateResponse(opts: LatePollOptions): LatePollHandle {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toLateTurn(body: RecoverResponse): LateTurn {
|
||||
return {
|
||||
turn_id: body.turn_id,
|
||||
response: body.response,
|
||||
error_code: body.error_code,
|
||||
finished_at: body.finished_at,
|
||||
duration_ms: body.duration_ms,
|
||||
used_tools: body.used_tools ?? [],
|
||||
rows_seen: body.rows_seen ?? [],
|
||||
chip_count: body.chip_count ?? 0,
|
||||
classifier_tag: body.classifier_tag,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -381,11 +381,32 @@ async function sendTurn(): Promise<void> {
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
activeStream = es;
|
||||
|
||||
startWidgetThinking(placeholder);
|
||||
|
||||
let fullText = "";
|
||||
es.addEventListener("thinking", (ev) => {
|
||||
let elapsed = 0;
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data || "{}");
|
||||
if (typeof data.elapsed_seconds === "number") elapsed = data.elapsed_seconds;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
updateWidgetThinking(placeholder, elapsed);
|
||||
});
|
||||
es.addEventListener("content", (ev) => {
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
if (typeof data.delta === "string" && data.delta) {
|
||||
// Streamed delta (aichat backend) — append.
|
||||
stopWidgetThinking(placeholder);
|
||||
fullText += data.delta;
|
||||
setBubbleText(placeholder, fullText);
|
||||
return;
|
||||
}
|
||||
// Legacy one-shot full-text payload.
|
||||
fullText = String(data.text || "");
|
||||
stopWidgetThinking(placeholder);
|
||||
setBubbleText(placeholder, fullText);
|
||||
} catch {
|
||||
/* ignore parse error */
|
||||
@@ -393,13 +414,15 @@ async function sendTurn(): Promise<void> {
|
||||
});
|
||||
es.addEventListener("end", () => {
|
||||
placeholder.dataset.streaming = "false";
|
||||
stopWidgetThinking(placeholder);
|
||||
history.push({ role: "assistant", text: fullText || "", ts: new Date().toISOString() });
|
||||
saveHistory();
|
||||
cleanupStream();
|
||||
});
|
||||
es.addEventListener("error", () => {
|
||||
stopWidgetThinking(placeholder);
|
||||
const errText = t("paliadin.error.connection_lost");
|
||||
setBubbleText(placeholder, errText + " " + t("paliadin.late.waiting"));
|
||||
setBubbleText(placeholder, errText + " " + t("paliadin.late.checking"));
|
||||
placeholder.classList.add("paliadin-widget-bubble--error");
|
||||
placeholder.classList.add("paliadin-widget-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
@@ -412,6 +435,39 @@ async function sendTurn(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function startWidgetThinking(bubble: HTMLElement): void {
|
||||
if (bubble.querySelector(".paliadin-widget-thinking")) return;
|
||||
// Clear the static placeholder text — the live pulse + counter is
|
||||
// the canonical "denkt nach" signal.
|
||||
const textNode = bubble.querySelector(".paliadin-widget-bubble-text");
|
||||
if (textNode) textNode.textContent = "";
|
||||
const node = document.createElement("div");
|
||||
node.className = "paliadin-widget-thinking";
|
||||
node.innerHTML = `
|
||||
<span class="paliadin-widget-thinking-dot" aria-hidden="true"></span>
|
||||
<span class="paliadin-widget-thinking-label"></span>
|
||||
<span class="paliadin-widget-thinking-elapsed"></span>
|
||||
`;
|
||||
const label = node.querySelector(".paliadin-widget-thinking-label")!;
|
||||
label.textContent = t("paliadin.thinking");
|
||||
bubble.appendChild(node);
|
||||
updateWidgetThinking(bubble, 0);
|
||||
}
|
||||
|
||||
function updateWidgetThinking(bubble: HTMLElement, elapsedSeconds: number): void {
|
||||
const node = bubble.querySelector(".paliadin-widget-thinking") as HTMLElement | null;
|
||||
if (!node) return;
|
||||
const elapsed = node.querySelector(".paliadin-widget-thinking-elapsed");
|
||||
if (elapsed) {
|
||||
const s = elapsedSeconds < 0 ? 0 : Math.round(elapsedSeconds);
|
||||
elapsed.textContent = t("paliadin.thinking.seconds").replace("{seconds}", String(s));
|
||||
}
|
||||
}
|
||||
|
||||
function stopWidgetThinking(bubble: HTMLElement): void {
|
||||
bubble.querySelector(".paliadin-widget-thinking")?.remove();
|
||||
}
|
||||
|
||||
function cleanupStream(): void {
|
||||
activeStream?.close();
|
||||
activeStream = null;
|
||||
@@ -427,13 +483,24 @@ function startWidgetLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLateResponse(bubble, turn);
|
||||
},
|
||||
onLost: () => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLost(bubble);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
lateWidgetPolls.delete(turnId);
|
||||
applyWidgetLost(bubble);
|
||||
},
|
||||
});
|
||||
lateWidgetPolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function applyWidgetLost(bubble: HTMLElement): void {
|
||||
bubble.classList.remove("paliadin-widget-bubble--late-pending");
|
||||
bubble.classList.add("paliadin-widget-bubble--lost");
|
||||
setBubbleText(bubble, t("paliadin.late.lost"));
|
||||
}
|
||||
|
||||
function applyWidgetLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove(
|
||||
|
||||
@@ -3,16 +3,25 @@ import { initSidebar } from "./sidebar";
|
||||
import { renderResponseHTML } from "./paliadin-render";
|
||||
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
|
||||
|
||||
// Paliadin chat panel client (t-paliad-146 PoC).
|
||||
// Paliadin chat panel client (t-paliad-146 PoC, streaming upgrade
|
||||
// t-paliad-235).
|
||||
//
|
||||
// State machine: empty → typing → sending → streaming → done.
|
||||
// State machine: empty → typing → sending → thinking → streaming → done.
|
||||
// History lives in localStorage under "paliadin:history:<sessionId>"
|
||||
// — design §0.5.4 session-only persistence.
|
||||
//
|
||||
// SSE consumer subscribes to `event: meta`, `event: content`,
|
||||
// `event: end`, `event: error`, `event: ping`. Backend currently
|
||||
// emits one `content` blob per turn (real chunked streaming is
|
||||
// production-v1; PoC simulates with a typewriter effect).
|
||||
// `event: thinking`, `event: end`, `event: error`, `event: ping`.
|
||||
//
|
||||
// `content` events from the aichat backend arrive as incremental
|
||||
// `{delta: "..."}` chunks; the bubble accumulates them in real time —
|
||||
// no typewriter simulation needed. Legacy backends still emit a single
|
||||
// `{text: "..."}` payload and we fall back to the typewriter for that
|
||||
// shape.
|
||||
//
|
||||
// `thinking` events fire while the upstream is alive but hasn't
|
||||
// produced content yet (or stalled mid-stream); the bubble renders a
|
||||
// pulse + counter so the user can SEE the chat is still working.
|
||||
|
||||
interface HistoryEntry {
|
||||
role: "user" | "assistant";
|
||||
@@ -167,25 +176,53 @@ async function sendTurn(text: string): Promise<void> {
|
||||
const es = new EventSource(turnRes.sse_url);
|
||||
currentEventSource = es;
|
||||
|
||||
// Show the thinking pulse immediately — the placeholder text already
|
||||
// says "denkt nach", but the visible pulse + counter is the live
|
||||
// proof-of-life signal m needs to trust that the chat is working.
|
||||
startThinkingIndicator(placeholder);
|
||||
// Reset the streamed accumulator for this turn.
|
||||
placeholder.dataset.fullText = "";
|
||||
|
||||
es.addEventListener("meta", () => {
|
||||
// Could surface a "thinking" indicator; placeholder text already does.
|
||||
});
|
||||
|
||||
es.addEventListener("thinking", (ev) => {
|
||||
let elapsed = 0;
|
||||
try {
|
||||
const data = JSON.parse((ev as MessageEvent).data || "{}");
|
||||
if (typeof data.elapsed_seconds === "number") {
|
||||
elapsed = data.elapsed_seconds;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
updateThinkingIndicator(placeholder, elapsed);
|
||||
});
|
||||
|
||||
es.addEventListener("content", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
const delta = typeof data.delta === "string" ? data.delta : "";
|
||||
if (delta) {
|
||||
// Aichat streaming path — accumulate the delta into the bubble.
|
||||
stopThinkingIndicator(placeholder);
|
||||
const current = placeholder.dataset.fullText ?? "";
|
||||
const next = current + delta;
|
||||
placeholder.dataset.fullText = next;
|
||||
writeStreamedText(placeholder, next);
|
||||
return;
|
||||
}
|
||||
// Legacy one-shot path — full body in `text`.
|
||||
const text = String(data.text || "");
|
||||
// Cache the full text on the bubble so finishBubble can render the
|
||||
// complete response even when the typewriter is mid-flight when end
|
||||
// arrives. textContent reflects only what's been typed so far and
|
||||
// would otherwise truncate the rendered Markdown (m, 2026-05-08 —
|
||||
// saw "## Proje" instead of the full 1408-byte body).
|
||||
placeholder.dataset.fullText = text;
|
||||
stopThinkingIndicator(placeholder);
|
||||
typewriter(placeholder, text);
|
||||
});
|
||||
|
||||
es.addEventListener("end", (ev) => {
|
||||
const data = JSON.parse((ev as MessageEvent).data);
|
||||
placeholder.dataset.streaming = "false";
|
||||
stopThinkingIndicator(placeholder);
|
||||
finishBubble(placeholder, data);
|
||||
history.push({
|
||||
role: "assistant",
|
||||
@@ -210,12 +247,12 @@ async function sendTurn(text: string): Promise<void> {
|
||||
|
||||
es.addEventListener("error", (ev) => {
|
||||
const errText = friendlyErrorMessage((ev as MessageEvent).data);
|
||||
// Annotate the error bubble with a "warten auf späte Antwort" hint
|
||||
// so m knows the turn isn't dead; if Claude finishes after the
|
||||
// 60 s window the Janitor (services.LocalPaliadinService.runJanitor)
|
||||
// patches the row and pollForLateResponse swaps in the real reply.
|
||||
stopThinkingIndicator(placeholder);
|
||||
// Honest copy: we don't claim "nachgereicht" because the recovery
|
||||
// path may report "lost". Frame it as "checking" while we ask the
|
||||
// backend whether the turn actually completed upstream.
|
||||
placeholder.querySelector(".paliadin-bubble-text")!.textContent =
|
||||
errText + " " + t("paliadin.late.waiting");
|
||||
errText + " " + t("paliadin.late.checking");
|
||||
placeholder.classList.add("paliadin-bubble--error");
|
||||
placeholder.classList.add("paliadin-bubble--late-pending");
|
||||
placeholder.dataset.streaming = "false";
|
||||
@@ -232,6 +269,65 @@ async function sendTurn(text: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// thinking indicator — proof-of-life pulse + elapsed counter
|
||||
// =============================================================================
|
||||
|
||||
function startThinkingIndicator(bubble: HTMLElement): void {
|
||||
// Append a thinking node next to the bubble text (sibling, so the
|
||||
// typewriter rewriting text content doesn't clobber it). The node
|
||||
// shows a pulse dot + the elapsed counter.
|
||||
let node = bubble.querySelector(".paliadin-thinking") as HTMLElement | null;
|
||||
if (node) return; // already running
|
||||
// Clear the static placeholder text — the live pulse + counter is
|
||||
// now the canonical "denkt nach" signal. Leaving the text in place
|
||||
// would render the same phrase twice.
|
||||
const textNode = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (textNode) textNode.textContent = "";
|
||||
node = document.createElement("div");
|
||||
node.className = "paliadin-thinking";
|
||||
node.innerHTML = `
|
||||
<span class="paliadin-thinking-dot" aria-hidden="true"></span>
|
||||
<span class="paliadin-thinking-label"></span>
|
||||
<span class="paliadin-thinking-elapsed"></span>
|
||||
`;
|
||||
const label = node.querySelector(".paliadin-thinking-label")!;
|
||||
label.textContent = t("paliadin.thinking");
|
||||
bubble.appendChild(node);
|
||||
// Initial 0s — replaced as soon as a thinking event arrives or our
|
||||
// local ticker fires.
|
||||
updateThinkingIndicator(bubble, 0);
|
||||
}
|
||||
|
||||
function updateThinkingIndicator(bubble: HTMLElement, elapsedSeconds: number): void {
|
||||
const node = bubble.querySelector(".paliadin-thinking") as HTMLElement | null;
|
||||
if (!node) return;
|
||||
const elapsed = node.querySelector(".paliadin-thinking-elapsed");
|
||||
if (elapsed) {
|
||||
elapsed.textContent = formatThinkingSeconds(elapsedSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
function stopThinkingIndicator(bubble: HTMLElement): void {
|
||||
bubble.querySelector(".paliadin-thinking")?.remove();
|
||||
}
|
||||
|
||||
function formatThinkingSeconds(s: number): string {
|
||||
if (s < 0) s = 0;
|
||||
return t("paliadin.thinking.seconds").replace("{seconds}", String(Math.round(s)));
|
||||
}
|
||||
|
||||
// writeStreamedText fills the bubble with raw text as it accumulates.
|
||||
// Cheaper than the typewriter — we already have the real cadence from
|
||||
// the wire, no need to simulate it.
|
||||
function writeStreamedText(bubble: HTMLElement, text: string): void {
|
||||
const node = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (!node) return;
|
||||
node.textContent = text;
|
||||
const stream = document.getElementById("paliadin-stream");
|
||||
if (stream) stream.scrollTop = stream.scrollHeight;
|
||||
}
|
||||
|
||||
// Server emits SSE error events as JSON `{code, message}`. Map known
|
||||
// codes to localised, user-friendly text; fall through to a generic
|
||||
// "connection lost" for anything we don't recognise (including raw
|
||||
@@ -361,11 +457,12 @@ function finishBubble(bubble: HTMLElement, data: any): void {
|
||||
}
|
||||
|
||||
|
||||
// startLatePoll registers the Janitor-patched row poller for one
|
||||
// errored turn. When the row gains a response we swap the bubble's
|
||||
// content + drop the error class + retroactively replace the history
|
||||
// entry (which was never written for the failed turn — append now so
|
||||
// reload renders the late reply).
|
||||
// startLatePoll registers the recovery-endpoint poller for one errored
|
||||
// turn. When the row gains a response we swap the bubble's content +
|
||||
// drop the error class + retroactively replace the history entry
|
||||
// (which was never written for the failed turn — append now so reload
|
||||
// renders the late reply). When the backend confirms the turn is
|
||||
// "lost", we swap the bubble to the honest "verloren" copy.
|
||||
function startLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
// Avoid duplicate pollers for the same turn (e.g. SSE error fires
|
||||
// twice in some browsers when the connection drops).
|
||||
@@ -376,13 +473,25 @@ function startLatePoll(turnId: string, bubble: HTMLElement): void {
|
||||
latePolls.delete(turnId);
|
||||
applyLateResponse(bubble, turn);
|
||||
},
|
||||
onLost: () => {
|
||||
latePolls.delete(turnId);
|
||||
applyLostResponse(bubble);
|
||||
},
|
||||
onGiveUp: () => {
|
||||
latePolls.delete(turnId);
|
||||
applyLostResponse(bubble);
|
||||
},
|
||||
});
|
||||
latePolls.set(turnId, handle);
|
||||
}
|
||||
|
||||
function applyLostResponse(bubble: HTMLElement): void {
|
||||
bubble.classList.remove("paliadin-bubble--late-pending");
|
||||
bubble.classList.add("paliadin-bubble--lost");
|
||||
const node = bubble.querySelector(".paliadin-bubble-text");
|
||||
if (node) node.textContent = t("paliadin.late.lost");
|
||||
}
|
||||
|
||||
function applyLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
||||
if (!turn.response) return;
|
||||
bubble.classList.remove("paliadin-bubble--error", "paliadin-bubble--late-pending");
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
import { t, tDyn } from "./i18n";
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
|
||||
// Shared logic for the Project form rendered by ProjectFormFields.tsx.
|
||||
// Used by /projects/new and the edit modal on /projects/{id}.
|
||||
|
||||
export interface ProceedingTypeRow {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
|
||||
|
||||
// loadProceedingTypes fetches active fristenrechner-category proceeding
|
||||
// types — the only set a project may bind to (mig 087/088 + service
|
||||
// validation guard `validateProceedingTypeCategory`). Cached at module
|
||||
// level so the page only pays for one fetch even when both the new-
|
||||
// project page and the edit modal exercise the picker.
|
||||
export async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
|
||||
if (proceedingTypesCache) return proceedingTypesCache;
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
|
||||
if (!resp.ok) return [];
|
||||
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
|
||||
proceedingTypesCache = rows.filter((r) => r.is_active);
|
||||
return proceedingTypesCache;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProjectMini {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -136,6 +165,34 @@ export function wireTypeChange() {
|
||||
typeSel.addEventListener("change", () => showFieldsForType(typeSel.value));
|
||||
}
|
||||
|
||||
// populateProceedingTypeSelect fills #project-proceeding-type-id with one
|
||||
// option per fristenrechner-category proceeding type, ordered by `code`
|
||||
// (so the user scans `de.*`, `dpma.*`, `epa.*`, `upc.*` in stable
|
||||
// jurisdiction-grouped order). The first option is the empty "unset"
|
||||
// choice already in the markup; this helper only appends rows below it.
|
||||
// Idempotent — clearing rows[1..] on re-call so a re-open of the edit
|
||||
// modal doesn't double-render the list.
|
||||
export async function populateProceedingTypeSelect(): Promise<void> {
|
||||
const sel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const rows = await loadProceedingTypes();
|
||||
rows.sort((a, b) => a.code.localeCompare(b.code));
|
||||
while (sel.options.length > 1) sel.remove(1);
|
||||
const isEN = getLang() === "en";
|
||||
for (const row of rows) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(row.id);
|
||||
const label = isEN && row.name_en ? row.name_en : row.name;
|
||||
opt.textContent = `${label} (${row.code})`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
// Honour a pre-selection value that prefillForm wrote before the
|
||||
// option set existed. dataset.preselect is set to "" or the saved id;
|
||||
// restoring it here keeps the edit modal's saved value visible.
|
||||
const preselect = sel.dataset.preselect;
|
||||
if (preselect !== undefined) sel.value = preselect;
|
||||
}
|
||||
|
||||
// readPayload collects the form's current values into a CreateProjectInput /
|
||||
// UpdateProjectInput compatible JSON payload. Returns null + sets msg when
|
||||
// title is missing.
|
||||
@@ -208,6 +265,22 @@ export function readPayload(
|
||||
stringField("project-court", "court");
|
||||
stringField("project-case-number", "case_number");
|
||||
|
||||
// Proceeding type — optional picker. Per t-paliad-232, an empty
|
||||
// pick simply omits the key from the payload (create: column stays
|
||||
// NULL; edit: server's `omitempty` skips the SET). Clearing a
|
||||
// previously-set value isn't supported in this slice; once bound,
|
||||
// a project's proceeding type can be swapped but not unset from
|
||||
// the form. The server's validateProceedingTypeCategory backs the
|
||||
// selected id with a category check.
|
||||
const ptSel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
|
||||
if (ptSel) {
|
||||
const v = ptSel.value.trim();
|
||||
if (v) {
|
||||
const n = parseInt(v, 10);
|
||||
if (!isNaN(n)) payload.proceeding_type_id = n;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -259,6 +332,16 @@ export function prefillForm(p: Record<string, unknown>) {
|
||||
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 ?? "");
|
||||
// Proceeding-type picker — populated lazily by populateProceedingTypeSelect.
|
||||
// Set the value here even if the options haven't arrived yet; the post-
|
||||
// populate render runs ApplyProceedingTypeValue to re-select the saved id
|
||||
// once the option exists.
|
||||
const ptSel = tryGet("project-proceeding-type-id") as HTMLSelectElement | null;
|
||||
if (ptSel) {
|
||||
const v = p.proceeding_type_id == null ? "" : String(p.proceeding_type_id);
|
||||
ptSel.dataset.preselect = v;
|
||||
ptSel.value = v;
|
||||
}
|
||||
getTA("project-description").value = String(p.description ?? "");
|
||||
getSel("project-status").value = String(p.status ?? "active");
|
||||
}
|
||||
|
||||
@@ -8,11 +8,15 @@ import {
|
||||
wireTypeChange,
|
||||
prefillForm,
|
||||
readPayload,
|
||||
populateProceedingTypeSelect,
|
||||
loadProceedingTypes as loadProceedingTypesShared,
|
||||
} from "./project-form";
|
||||
import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec } from "./views/types";
|
||||
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
|
||||
import { loadAndRenderSubmissions } from "./submissions";
|
||||
import { buildMailtoHref, type BroadcastRecipient } from "./broadcast";
|
||||
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -139,6 +143,11 @@ interface Deadline {
|
||||
status: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
// t-paliad-258 — free-text rule label when the deadline was saved in
|
||||
// Custom mode. Mutually exclusive with rule_id.
|
||||
custom_rule_text?: string;
|
||||
// Populated by the union endpoint (/api/events) which is what the project
|
||||
// detail page calls — used for attribution when the row lives on a
|
||||
// descendant project (t-paliad-139).
|
||||
@@ -172,7 +181,8 @@ type TabId =
|
||||
| "appointments"
|
||||
| "notes"
|
||||
| "checklists"
|
||||
| "submissions";
|
||||
| "submissions"
|
||||
| "settings";
|
||||
|
||||
const VALID_TABS: TabId[] = [
|
||||
"history",
|
||||
@@ -184,6 +194,7 @@ const VALID_TABS: TabId[] = [
|
||||
"notes",
|
||||
"checklists",
|
||||
"submissions",
|
||||
"settings",
|
||||
];
|
||||
|
||||
// Legacy German tab slugs that may appear in bookmarked URLs after the
|
||||
@@ -211,6 +222,9 @@ interface ChecklistTemplateSummary {
|
||||
slug: string;
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
descriptionDE?: string;
|
||||
descriptionEN?: string;
|
||||
regime?: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
@@ -236,6 +250,12 @@ let attachedUnits: AttachedUnit[] = [];
|
||||
let allUnits: { id: string; name: string; office: string }[] = [];
|
||||
let userOptions: { id: string; display_name: string; email: string; profession?: string }[] = [];
|
||||
|
||||
// t-paliad-231 — checkbox selection backing the "Mail an Auswahl"
|
||||
// mailto: button on the Team tab. Pure client state, wiped on page
|
||||
// navigation. Pruned to currently-visible user_ids on every renderTeam
|
||||
// so removed/filtered-out members don't ride along in the next mailto.
|
||||
const selectedMailUserIDs: Set<string> = new Set();
|
||||
|
||||
const EVENTS_PAGE_SIZE = 50;
|
||||
let eventsHasMore = false;
|
||||
let eventsLoadingMore = false;
|
||||
@@ -791,6 +811,9 @@ interface UnionEvent {
|
||||
status?: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
custom_rule_text?: string;
|
||||
start_at?: string;
|
||||
end_at?: string;
|
||||
location?: string;
|
||||
@@ -818,6 +841,9 @@ async function loadDeadlines(id: string) {
|
||||
status: it.status ?? "pending",
|
||||
rule_id: it.rule_id,
|
||||
rule_code: it.rule_code,
|
||||
rule_name: it.rule_name,
|
||||
rule_name_en: it.rule_name_en,
|
||||
custom_rule_text: it.custom_rule_text,
|
||||
project_title: it.project_title,
|
||||
}));
|
||||
} else {
|
||||
@@ -987,6 +1013,27 @@ function fmtDateOnly(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// formatDeadlineRuleCell renders the REGEL column for the project
|
||||
// detail Fristen table using the canonical t-paliad-258 contract:
|
||||
// 1. catalog rule (rule_name / rule_name_en + rule_code) → "Name · Code"
|
||||
// 2. custom_rule_text → text + "Custom" badge
|
||||
// 3. legacy rule_code-only saves → bare citation
|
||||
// 4. otherwise "—"
|
||||
function formatDeadlineRuleCell(f: Deadline): string {
|
||||
const hasName = (f.rule_name && f.rule_name.trim()) ||
|
||||
(f.rule_name_en && f.rule_name_en.trim());
|
||||
if (hasName || (f.rule_code && f.rule_code.trim())) {
|
||||
return formatRuleLabelHTML(
|
||||
{ name: f.rule_name || "", name_en: f.rule_name_en, rule_code: f.rule_code },
|
||||
esc,
|
||||
);
|
||||
}
|
||||
if (f.custom_rule_text && f.custom_rule_text.trim()) {
|
||||
return formatCustomRuleLabelHTML(f.custom_rule_text, esc);
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
function urgencyClass(due: string, status: string): string {
|
||||
if (status === "completed") return "frist-urgency-done";
|
||||
const today = new Date();
|
||||
@@ -1025,7 +1072,7 @@ function renderDeadlines() {
|
||||
</td>
|
||||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(f.title)}${attributionChip(f.project_id, f.project_title)}</td>
|
||||
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
|
||||
<td class="frist-col-rule">${formatDeadlineRuleCell(f)}</td>
|
||||
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
@@ -1173,13 +1220,16 @@ function renderHeader() {
|
||||
netdocs.style.display = "none";
|
||||
}
|
||||
|
||||
// Delete visibility: partner/admin only
|
||||
// Delete visibility: partner/admin only. The Verwaltung tab's archive
|
||||
// sub-section mirrors the same gate (t-paliad-245) — it only points at
|
||||
// the Edit-modal danger zone, so it's pointless to show when the danger
|
||||
// zone itself is hidden.
|
||||
const deleteWrap = document.getElementById("project-delete-wrap")!;
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
}
|
||||
const archiveSection = document.getElementById("project-settings-archive");
|
||||
const canArchive = !!me && me.global_role === "global_admin";
|
||||
deleteWrap.style.display = canArchive ? "" : "none";
|
||||
if (archiveSection) archiveSection.style.display = canArchive ? "" : "none";
|
||||
updateSettingsTabVisibility();
|
||||
}
|
||||
|
||||
// wrapEventTitleLink — kept for the dashboard activity feed which reuses
|
||||
@@ -1443,36 +1493,9 @@ function initSmartTimelineAddModal(id: string) {
|
||||
initCounterclaimRoute(id, modal, choices, form);
|
||||
}
|
||||
|
||||
interface ProceedingTypeRow {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
|
||||
|
||||
// loadProceedingTypes fetches active proceeding types for the project
|
||||
// picker. Phase 3 Slice 5 (t-paliad-186) restricts project-binding to
|
||||
// fristenrechner-category codes (design §3.F + m's Q2 ruling), so the
|
||||
// picker only ever shows those — never the 7 legacy litigation codes
|
||||
// (INF / REV / CCR / APM / APP / AMD / ZPO_CIVIL). The matching
|
||||
// server-side service validation + DB trigger (mig 088) are the
|
||||
// defence-in-depth backstops for any non-UI writer.
|
||||
async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
|
||||
if (proceedingTypesCache) return proceedingTypesCache;
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
|
||||
if (!resp.ok) return [];
|
||||
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
|
||||
proceedingTypesCache = rows.filter((r) => r.is_active);
|
||||
return proceedingTypesCache;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// loadProceedingTypes is shared from ./project-form so the counterclaim
|
||||
// modal here and the project-edit picker hit the same cache.
|
||||
const loadProceedingTypes = loadProceedingTypesShared;
|
||||
|
||||
function initCounterclaimRoute(
|
||||
id: string,
|
||||
@@ -1649,18 +1672,34 @@ function showTab(tab: TabId) {
|
||||
}
|
||||
|
||||
let checklistInstancesInited = false;
|
||||
async function loadAndRenderChecklistInstances(projectID: string) {
|
||||
if (checklistInstancesInited) return;
|
||||
let checklistCatalogLoaded = false;
|
||||
|
||||
// loadChecklistCatalog populates `checklistTemplates` (slug → template) from
|
||||
// `/api/checklists`. Reused by the tab renderer and the add-instance modal so
|
||||
// the second open doesn't refetch the catalog (t-paliad-239).
|
||||
async function loadChecklistCatalog(): Promise<ChecklistTemplateSummary[]> {
|
||||
if (checklistCatalogLoaded) return Object.values(checklistTemplates);
|
||||
try {
|
||||
const resp = await fetch(`/api/checklists`);
|
||||
const list = resp.ok ? (((await resp.json()) as ChecklistTemplateSummary[]) ?? []) : [];
|
||||
checklistTemplates = {};
|
||||
for (const tpl of list) checklistTemplates[tpl.slug] = tpl;
|
||||
checklistCatalogLoaded = true;
|
||||
return list;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRenderChecklistInstances(projectID: string, force = false) {
|
||||
if (checklistInstancesInited && !force) return;
|
||||
checklistInstancesInited = true;
|
||||
try {
|
||||
const [instResp, tplResp] = await Promise.all([
|
||||
const [instResp] = await Promise.all([
|
||||
fetch(`/api/projects/${projectID}/checklists`),
|
||||
fetch(`/api/checklists`),
|
||||
loadChecklistCatalog(),
|
||||
]);
|
||||
checklistInstances = instResp.ok ? ((await instResp.json()) ?? []) : [];
|
||||
const templates = tplResp.ok ? (((await tplResp.json()) as ChecklistTemplateSummary[]) ?? []) : [];
|
||||
checklistTemplates = {};
|
||||
for (const tpl of templates) checklistTemplates[tpl.slug] = tpl;
|
||||
} catch {
|
||||
checklistInstances = [];
|
||||
}
|
||||
@@ -1720,6 +1759,143 @@ function renderChecklistInstances() {
|
||||
});
|
||||
}
|
||||
|
||||
// initAddChecklistModal wires the "Checkliste hinzufügen" button on the
|
||||
// project-detail Checklists tab (t-paliad-239). Opens a template picker
|
||||
// modal; on pick, POSTs to /api/checklists/{slug}/instances with the
|
||||
// current project_id and the template title as the instance name.
|
||||
function initAddChecklistModal(projectID: string) {
|
||||
const addBtn = document.getElementById("checklist-add-btn") as HTMLButtonElement | null;
|
||||
const modal = document.getElementById("add-checklist-modal") as HTMLDivElement | null;
|
||||
const closeBtn = document.getElementById("add-checklist-close") as HTMLButtonElement | null;
|
||||
const search = document.getElementById("add-checklist-search") as HTMLInputElement | null;
|
||||
const list = document.getElementById("add-checklist-list") as HTMLDivElement | null;
|
||||
const empty = document.getElementById("add-checklist-empty") as HTMLParagraphElement | null;
|
||||
const modalMsg = document.getElementById("add-checklist-msg") as HTMLParagraphElement | null;
|
||||
const tabMsg = document.getElementById("project-checklists-msg") as HTMLParagraphElement | null;
|
||||
if (!addBtn || !modal || !closeBtn || !search || !list || !empty || !modalMsg || !tabMsg) return;
|
||||
|
||||
const close = () => {
|
||||
modal.style.display = "none";
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
};
|
||||
|
||||
const renderPicker = () => {
|
||||
const isEN = getLang() === "en";
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const all = Object.values(checklistTemplates);
|
||||
all.sort((a, b) => {
|
||||
const at = (isEN ? a.titleEN : a.titleDE) || a.slug;
|
||||
const bt = (isEN ? b.titleEN : b.titleDE) || b.slug;
|
||||
return at.localeCompare(bt, isEN ? "en" : "de");
|
||||
});
|
||||
const filtered = q
|
||||
? all.filter((tpl) => {
|
||||
const title = (isEN ? tpl.titleEN : tpl.titleDE) || "";
|
||||
const desc = (isEN ? tpl.descriptionEN : tpl.descriptionDE) || "";
|
||||
return title.toLowerCase().includes(q)
|
||||
|| desc.toLowerCase().includes(q)
|
||||
|| (tpl.regime || "").toLowerCase().includes(q);
|
||||
})
|
||||
: all;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = filtered.map((tpl) => {
|
||||
const title = (isEN ? tpl.titleEN : tpl.titleDE) || tpl.slug;
|
||||
const desc = (isEN ? tpl.descriptionEN : tpl.descriptionDE) || "";
|
||||
const regime = tpl.regime || "";
|
||||
const regimeChip = regime
|
||||
? `<span class="checklist-regime checklist-regime-${escapeHtml(regime)}">${escapeHtml(regime)}</span>`
|
||||
: "";
|
||||
const descLine = desc ? `<p class="add-checklist-row-desc">${escapeHtml(desc)}</p>` : "";
|
||||
return `<button type="button" class="add-checklist-row" data-slug="${escapeHtml(tpl.slug)}">
|
||||
<div class="add-checklist-row-head">
|
||||
<span class="add-checklist-row-title">${escapeHtml(title)}</span>
|
||||
${regimeChip}
|
||||
</div>
|
||||
${descLine}
|
||||
</button>`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const slug = btn.dataset.slug!;
|
||||
void pickTemplate(slug, btn);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const pickTemplate = async (slug: string, btn: HTMLButtonElement) => {
|
||||
const tpl = checklistTemplates[slug];
|
||||
if (!tpl) return;
|
||||
const isEN = getLang() === "en";
|
||||
const name = (isEN ? tpl.titleEN : tpl.titleDE) || tpl.slug;
|
||||
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = true;
|
||||
});
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, project_id: projectID }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
modalMsg.textContent = t("projects.detail.checklisten.add.error");
|
||||
modalMsg.className = "form-msg form-msg-error";
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
close();
|
||||
flashTabMsg(t("projects.detail.checklisten.add.created"));
|
||||
await loadAndRenderChecklistInstances(projectID, true);
|
||||
} catch {
|
||||
modalMsg.textContent = t("projects.detail.checklisten.add.error");
|
||||
modalMsg.className = "form-msg form-msg-error";
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let flashTimer = 0;
|
||||
const flashTabMsg = (text: string) => {
|
||||
tabMsg.textContent = text;
|
||||
tabMsg.className = "form-msg form-msg-success";
|
||||
if (flashTimer) window.clearTimeout(flashTimer);
|
||||
flashTimer = window.setTimeout(() => {
|
||||
tabMsg.textContent = "";
|
||||
tabMsg.className = "form-msg";
|
||||
}, 3500);
|
||||
};
|
||||
|
||||
addBtn.addEventListener("click", async () => {
|
||||
await loadChecklistCatalog();
|
||||
search.value = "";
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
renderPicker();
|
||||
modal.style.display = "flex";
|
||||
search.focus();
|
||||
});
|
||||
closeBtn.addEventListener("click", close);
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
|
||||
search.addEventListener("input", renderPicker);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.style.display !== "none") close();
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
@@ -1753,9 +1929,14 @@ async function prepareEditForm() {
|
||||
// as the new parent (server would reject anyway).
|
||||
await loadParentCandidates(project?.id);
|
||||
initParentPicker();
|
||||
await populateProceedingTypeSelect();
|
||||
}
|
||||
|
||||
function openEditModal() {
|
||||
// openEditModal opens the project-edit modal, optionally scrolling +
|
||||
// focusing a specific field after the form is prefilled. Callers like
|
||||
// the Schriftsätze empty-state CTA pass focusFieldID="project-proceeding-
|
||||
// type-id" to land the user directly on the picker they came to set.
|
||||
function openEditModal(focusFieldID?: string) {
|
||||
if (!project) return;
|
||||
const modal = document.getElementById("project-edit-modal");
|
||||
const msg = document.getElementById("project-edit-msg");
|
||||
@@ -1791,6 +1972,19 @@ function openEditModal() {
|
||||
};
|
||||
}
|
||||
renderTypeChangeWarning();
|
||||
if (focusFieldID) {
|
||||
// Wait a tick so the modal has laid out before scrolling — the
|
||||
// wrapping flex container is display:flex so the field's offset
|
||||
// height is only reliable after the next animation frame.
|
||||
requestAnimationFrame(() => {
|
||||
const target = document.getElementById(focusFieldID);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
if (target instanceof HTMLSelectElement || target instanceof HTMLInputElement) {
|
||||
target.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
@@ -1869,13 +2063,37 @@ function initEditModal() {
|
||||
const msg = document.getElementById("project-edit-msg") as HTMLParagraphElement | null;
|
||||
if (!editBtn || !modal || !closeBtn || !cancelBtn || !form || !msg) return;
|
||||
|
||||
editBtn.addEventListener("click", openEditModal);
|
||||
editBtn.addEventListener("click", () => openEditModal());
|
||||
closeBtn.addEventListener("click", closeEditModal);
|
||||
cancelBtn.addEventListener("click", closeEditModal);
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeEditModal();
|
||||
});
|
||||
|
||||
// Schriftsätze empty-state CTA — when the panel reports "no proceeding
|
||||
// set", clicking the button opens the edit modal directly on the
|
||||
// Verfahrenstyp picker so the lawyer can resolve the gap in one step
|
||||
// (t-paliad-232).
|
||||
const submissionsCTA = document.getElementById(
|
||||
"project-submissions-edit-cta",
|
||||
) as HTMLButtonElement | null;
|
||||
if (submissionsCTA) {
|
||||
submissionsCTA.addEventListener("click", () => {
|
||||
openEditModal("project-proceeding-type-id");
|
||||
});
|
||||
}
|
||||
|
||||
// Verwaltung → Projekt archivieren — opens the edit modal scrolled to
|
||||
// the danger-zone archive button (t-paliad-245).
|
||||
const archiveLink = document.getElementById(
|
||||
"project-settings-archive-link",
|
||||
) as HTMLButtonElement | null;
|
||||
if (archiveLink) {
|
||||
archiveLink.addEventListener("click", () => {
|
||||
openEditModal("project-delete-btn");
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!project) return;
|
||||
@@ -2092,6 +2310,7 @@ async function main() {
|
||||
initSmartTimelineClientToggle(id);
|
||||
initSmartTimelineAddModal(id);
|
||||
initAttachUnitForm(id);
|
||||
initAddChecklistModal(id);
|
||||
initNotesContainer(id);
|
||||
mountVerlaufFilterBar(id);
|
||||
wireExportButton(id);
|
||||
@@ -2507,6 +2726,7 @@ async function loadUserList() {
|
||||
function renderTeam() {
|
||||
const body = document.getElementById("team-body")!;
|
||||
const empty = document.getElementById("team-empty")!;
|
||||
const mailtoControls = document.getElementById("team-mailto-controls") as HTMLDivElement | null;
|
||||
|
||||
// Existing team-body shows the direct + ancestor-inherited members
|
||||
// returned by /api/projects/{id}/team. The derived + descendant
|
||||
@@ -2517,12 +2737,21 @@ function renderTeam() {
|
||||
if (totalRows === 0) {
|
||||
body.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
if (mailtoControls) mailtoControls.style.display = "none";
|
||||
selectedMailUserIDs.clear();
|
||||
syncMailtoButton();
|
||||
syncMasterCheckbox();
|
||||
renderDescendantStaffed();
|
||||
renderDerivedMembers();
|
||||
renderAttachedUnits();
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
if (mailtoControls) mailtoControls.style.display = teamMembers.length > 0 ? "" : "none";
|
||||
// Prune the selection to whoever is actually rendered in team-body
|
||||
// right now (e.g. a member just got removed). Invariant: selection ⊆
|
||||
// currently-visible team-body rows.
|
||||
pruneMailSelectionToVisible();
|
||||
|
||||
// t-paliad-223: callers with effective_project_admin authority see an
|
||||
// inline <select> on the Rolle cell. Everyone else sees the read-only
|
||||
@@ -2563,7 +2792,17 @@ function renderTeam() {
|
||||
? renderResponsibilitySelect(m.user_id, responsibility)
|
||||
: `<span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span>`;
|
||||
|
||||
// t-paliad-231: per-row checkbox feeding selectedMailUserIDs. Only
|
||||
// rows with a real email participate in the mailto: build; rows
|
||||
// without are still rendered with a disabled checkbox so the
|
||||
// column geometry stays uniform.
|
||||
const hasEmail = !!(m.user_email && m.user_email.trim());
|
||||
const checked = hasEmail && selectedMailUserIDs.has(m.user_id) ? " checked" : "";
|
||||
const disabled = hasEmail ? "" : " disabled";
|
||||
const checkboxCell = `<td class="team-col-select"><input type="checkbox" class="team-mail-select" data-user-id="${esc(m.user_id)}" data-email="${escAttr(m.user_email || "")}" aria-label="${escAttr(t("projects.team.mailto.select_row") || "Mitglied auswählen")}"${checked}${disabled} /></td>`;
|
||||
|
||||
return `<tr>
|
||||
${checkboxCell}
|
||||
<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>
|
||||
@@ -2574,6 +2813,21 @@ function renderTeam() {
|
||||
})
|
||||
.join("");
|
||||
|
||||
// t-paliad-231 — wire row checkboxes + master + mailto button.
|
||||
body.querySelectorAll<HTMLInputElement>(".team-mail-select").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const userID = cb.dataset.userId!;
|
||||
if (cb.checked) selectedMailUserIDs.add(userID);
|
||||
else selectedMailUserIDs.delete(userID);
|
||||
syncMailtoButton();
|
||||
syncMasterCheckbox();
|
||||
});
|
||||
});
|
||||
wireMailtoMaster();
|
||||
wireMailtoButton();
|
||||
syncMailtoButton();
|
||||
syncMasterCheckbox();
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".team-remove-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!project) return;
|
||||
@@ -2786,17 +3040,21 @@ function canExportProject(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// 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.
|
||||
// wireExportButton reveals the Export sub-section of the Verwaltung tab
|
||||
// (t-paliad-245) and hooks up the project-export button. Triggers a
|
||||
// download via a transient <a download> — same pattern as the personal
|
||||
// export in client/settings.ts.
|
||||
function wireExportButton(projectID: string): void {
|
||||
const section = document.getElementById("project-settings-export") as HTMLElement | null;
|
||||
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
if (!section || !btn) return;
|
||||
if (!canExportProject()) {
|
||||
btn.style.display = "none";
|
||||
section.style.display = "none";
|
||||
updateSettingsTabVisibility();
|
||||
return;
|
||||
}
|
||||
btn.style.display = "";
|
||||
section.style.display = "";
|
||||
updateSettingsTabVisibility();
|
||||
btn.addEventListener("click", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
|
||||
@@ -2807,6 +3065,17 @@ function wireExportButton(projectID: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
// updateSettingsTabVisibility hides the Verwaltung tab when none of its
|
||||
// sub-sections are visible to the current user — an empty tab is worse
|
||||
// UX than no tab. Called whenever a sub-section's visibility flips.
|
||||
function updateSettingsTabVisibility(): void {
|
||||
const tab = document.querySelector<HTMLElement>('.entity-tab[data-tab="settings"]');
|
||||
if (!tab) return;
|
||||
const exportShown = document.getElementById("project-settings-export")?.style.display !== "none";
|
||||
const archiveShown = document.getElementById("project-settings-archive")?.style.display !== "none";
|
||||
tab.style.display = exportShown || archiveShown ? "" : "none";
|
||||
}
|
||||
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
if (!me) return false;
|
||||
if (m.user_id === me.id) return true;
|
||||
@@ -2860,6 +3129,113 @@ async function showTeamErrorToast(resp: Response): Promise<void> {
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// t-paliad-231 — mailto: selection helpers for the Team tab. The
|
||||
// admin-only server SMTP broadcast (POST /api/team/broadcast) lives
|
||||
// elsewhere; this is the non-admin / quick-CC variant that opens the
|
||||
// user's local mail client. Pure client; no server call.
|
||||
function pruneMailSelectionToVisible(): void {
|
||||
const visible = new Set<string>();
|
||||
for (const m of teamMembers) {
|
||||
if (m.user_email && m.user_email.trim()) visible.add(m.user_id);
|
||||
}
|
||||
for (const id of Array.from(selectedMailUserIDs)) {
|
||||
if (!visible.has(id)) selectedMailUserIDs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function selectedMailRecipients(): BroadcastRecipient[] {
|
||||
const out: BroadcastRecipient[] = [];
|
||||
for (const m of teamMembers) {
|
||||
if (!selectedMailUserIDs.has(m.user_id)) continue;
|
||||
if (!m.user_email || !m.user_email.trim()) continue;
|
||||
out.push({
|
||||
user_id: m.user_id,
|
||||
email: m.user_email,
|
||||
display_name: m.user_display_name || m.user_email,
|
||||
first_name: (m.user_display_name || m.user_email).trim().split(/\s+/)[0] ?? "",
|
||||
role_on_project: m.responsibility || "member",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function syncMailtoButton(): void {
|
||||
const btn = document.getElementById("team-mailto-btn") as HTMLButtonElement | null;
|
||||
const label = document.getElementById("team-mailto-label") as HTMLSpanElement | null;
|
||||
if (!btn || !label) return;
|
||||
const n = selectedMailRecipients().length;
|
||||
const baseLabel = t("projects.team.mailto.label") || "Mail an Auswahl";
|
||||
if (n === 0) {
|
||||
btn.disabled = true;
|
||||
label.textContent = baseLabel;
|
||||
btn.title = t("projects.team.mailto.empty") || "Mindestens ein Mitglied auswählen";
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
label.textContent = `${baseLabel} (${n})`;
|
||||
const tooltip = (t("projects.team.mailto.count") || "{n} ausgewählt").replace("{n}", String(n));
|
||||
btn.title = tooltip;
|
||||
}
|
||||
}
|
||||
|
||||
function syncMasterCheckbox(): void {
|
||||
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
|
||||
if (!master) return;
|
||||
// Only count rows that actually rendered with an enabled checkbox —
|
||||
// members without an email don't participate.
|
||||
const checkboxes = document.querySelectorAll<HTMLInputElement>(
|
||||
"#team-body .team-mail-select:not(:disabled)",
|
||||
);
|
||||
const total = checkboxes.length;
|
||||
let selected = 0;
|
||||
checkboxes.forEach((cb) => {
|
||||
if (selectedMailUserIDs.has(cb.dataset.userId!)) selected++;
|
||||
});
|
||||
master.disabled = total === 0;
|
||||
if (total === 0 || selected === 0) {
|
||||
master.checked = false;
|
||||
master.indeterminate = false;
|
||||
} else if (selected === total) {
|
||||
master.checked = true;
|
||||
master.indeterminate = false;
|
||||
} else {
|
||||
master.checked = false;
|
||||
master.indeterminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// wireMailtoMaster is idempotent — registers once via a sentinel data
|
||||
// attr so re-renders don't stack click handlers.
|
||||
function wireMailtoMaster(): void {
|
||||
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
|
||||
if (!master || master.dataset.wired === "1") return;
|
||||
master.dataset.wired = "1";
|
||||
master.addEventListener("change", () => {
|
||||
const turnOn = master.checked;
|
||||
document
|
||||
.querySelectorAll<HTMLInputElement>("#team-body .team-mail-select:not(:disabled)")
|
||||
.forEach((cb) => {
|
||||
const id = cb.dataset.userId!;
|
||||
if (turnOn) selectedMailUserIDs.add(id);
|
||||
else selectedMailUserIDs.delete(id);
|
||||
cb.checked = turnOn;
|
||||
});
|
||||
syncMailtoButton();
|
||||
syncMasterCheckbox();
|
||||
});
|
||||
}
|
||||
|
||||
function wireMailtoButton(): void {
|
||||
const btn = document.getElementById("team-mailto-btn") as HTMLButtonElement | null;
|
||||
if (!btn || btn.dataset.wired === "1") return;
|
||||
btn.dataset.wired = "1";
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const recipients = selectedMailRecipients();
|
||||
if (recipients.length === 0) return;
|
||||
window.location.href = buildMailtoHref(recipients);
|
||||
});
|
||||
}
|
||||
|
||||
function initTeamForm(id: string) {
|
||||
const addBtn = document.getElementById("team-add-btn") as HTMLButtonElement | null;
|
||||
const form = document.getElementById("team-form") as HTMLFormElement | null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
wireTypeChange,
|
||||
showFieldsForType,
|
||||
readPayload,
|
||||
populateProceedingTypeSelect,
|
||||
} from "./project-form";
|
||||
|
||||
// /projects/new client. Posts v2 CreateProjectInput shape using the shared
|
||||
@@ -106,5 +107,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
await loadParentCandidates();
|
||||
initParentPicker();
|
||||
await applyParentFromQueryString();
|
||||
// Fire-and-forget — the picker is hidden until type=case, so no need
|
||||
// to block initial render on the fetch.
|
||||
void populateProceedingTypeSelect();
|
||||
submitForm();
|
||||
});
|
||||
|
||||
87
frontend/src/client/rule-label.ts
Normal file
87
frontend/src/client/rule-label.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// rule-label — canonical display contract for deadline rules.
|
||||
//
|
||||
// t-paliad-258 / m/paliad#89 addendum. Previously each surface (deadline
|
||||
// form, list rows, detail header, Schriftsätze tab, browse-a-proceeding)
|
||||
// invented its own pattern: sometimes citation-only, sometimes name-only,
|
||||
// sometimes "code — name". m flagged this on the first submissions in a
|
||||
// proceeding sequence where the inconsistency was most visible.
|
||||
//
|
||||
// Canonical pattern: **Name primary, Citation muted secondary**.
|
||||
// Text: "Notice of Appeal · UPC.RoP.220.1"
|
||||
// HTML: <span class="rule-label-name">Notice of Appeal</span>
|
||||
// <span class="rule-label-sep"> · </span>
|
||||
// <span class="rule-label-cite">UPC.RoP.220.1</span>
|
||||
//
|
||||
// Custom rules (t-paliad-258 — free-text label entered by the lawyer):
|
||||
// formatCustomRuleLabel produces "<text>" with a "Custom" badge slot
|
||||
// so list/detail surfaces can render both shapes uniformly.
|
||||
|
||||
import { getLang, t } from "./i18n";
|
||||
|
||||
export interface RuleLike {
|
||||
name: string;
|
||||
name_en?: string | null;
|
||||
// The catalog carries multiple citation fields depending on which
|
||||
// surface populated it. Order of preference: legal_source > rule_code
|
||||
// > code. All three are accepted so callers don't have to normalise.
|
||||
rule_code?: string | null;
|
||||
code?: string | null;
|
||||
legal_source?: string | null;
|
||||
}
|
||||
|
||||
// formatRuleLabel returns the canonical plain-text label.
|
||||
// Falls back gracefully when either side is missing.
|
||||
export function formatRuleLabel(r: RuleLike): string {
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
|
||||
const cite = ruleCitation(r);
|
||||
if (name && cite) return `${name} · ${cite}`;
|
||||
return name || cite || "";
|
||||
}
|
||||
|
||||
// formatRuleLabelHTML returns the canonical HTML form with muted-citation
|
||||
// styling. The caller passes the HTML-escape helper so we don't pull a
|
||||
// dependency on a specific esc() module — every surface already has one.
|
||||
export function formatRuleLabelHTML(r: RuleLike, esc: (s: string) => string): string {
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
|
||||
const cite = ruleCitation(r);
|
||||
if (name && cite) {
|
||||
return (
|
||||
`<span class="rule-label-name">${esc(name)}</span>` +
|
||||
`<span class="rule-label-sep"> · </span>` +
|
||||
`<span class="rule-label-cite">${esc(cite)}</span>`
|
||||
);
|
||||
}
|
||||
return esc(name || cite || "");
|
||||
}
|
||||
|
||||
// ruleCitation returns the best-available citation string for a rule.
|
||||
// Exported so callers that need the bare code (e.g. CalDAV exports,
|
||||
// inline data attributes) can pull it without going through the label
|
||||
// formatter.
|
||||
export function ruleCitation(r: RuleLike): string {
|
||||
return r.legal_source || r.rule_code || r.code || "";
|
||||
}
|
||||
|
||||
// formatCustomRuleLabelHTML — render a free-text custom rule label with
|
||||
// a "Custom" badge slot. Used by surfaces that may display either a
|
||||
// catalog rule (formatRuleLabelHTML) or a custom one. Returns "" when
|
||||
// the text is empty so callers can fall through to "—".
|
||||
export function formatCustomRuleLabelHTML(text: string | null | undefined, esc: (s: string) => string): string {
|
||||
const trimmed = (text ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
|
||||
return (
|
||||
`<span class="rule-label-name">${esc(trimmed)}</span>` +
|
||||
`<span class="rule-label-badge rule-label-badge--custom">${esc(badge)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
// formatCustomRuleLabel — plain-text equivalent of the above.
|
||||
export function formatCustomRuleLabel(text: string | null | undefined): string {
|
||||
const trimmed = (text ?? "").trim();
|
||||
if (!trimmed) return "";
|
||||
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
|
||||
return `${trimmed} · ${badge}`;
|
||||
}
|
||||
@@ -11,6 +11,13 @@ const WIDTH_KEY = "paliad-sidebar-width";
|
||||
const SIDEBAR_WIDTH_MIN = 180;
|
||||
const SIDEBAR_WIDTH_MAX = 480;
|
||||
const SIDEBAR_WIDTH_DEFAULT = 240;
|
||||
// Per-tab scroll position of the .sidebar-nav scroll container. Persisted
|
||||
// on every scroll event, restored on initSidebar() so a full-page nav
|
||||
// click doesn't bounce the user back to the top of a long sidebar
|
||||
// (Werkzeuge + projects + user views can easily overflow). sessionStorage
|
||||
// scopes it to the tab — opening a sidebar link in a new tab (Cmd-click)
|
||||
// starts that tab fresh at the top, which matches user expectation.
|
||||
const SCROLL_KEY = "paliad.sidebar.scroll";
|
||||
|
||||
// toggleMobileSidebar opens or closes the slide-out drawer. Exposed so the
|
||||
// BottomNav menu slot can call it without duplicating the open/close
|
||||
@@ -49,6 +56,23 @@ function applySidebarWidth(px: number): void {
|
||||
document.documentElement.style.setProperty("--sidebar-width", `${px}px`);
|
||||
}
|
||||
|
||||
// readStoredScroll returns the persisted scrollTop or 0 when missing /
|
||||
// malformed. Bounds are checked at apply time against the actual
|
||||
// scrollHeight, so a stale value pointing past the current scroll range
|
||||
// is harmless (the browser clamps assignments to [0, max]).
|
||||
function readStoredScroll(): number {
|
||||
const raw = sessionStorage.getItem(SCROLL_KEY);
|
||||
if (raw === null) return 0;
|
||||
const n = parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
function applySidebarScroll(nav: HTMLElement, px: number): void {
|
||||
if (px <= 0) return;
|
||||
nav.scrollTop = px;
|
||||
}
|
||||
|
||||
// migrateLegacyPinKey copies the pre-rebrand pin state into the new key on
|
||||
// first load and removes the stale entry. Drop this fallback once the rename
|
||||
// grace period is over.
|
||||
@@ -79,6 +103,7 @@ export function initSidebar() {
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
if (!sidebar) return;
|
||||
initSidebarResize(sidebar);
|
||||
initSidebarScrollRestore(sidebar);
|
||||
|
||||
const pinBtn = sidebar.querySelector<HTMLButtonElement>(".sidebar-pin");
|
||||
const hamburger = document.querySelector<HTMLButtonElement>(".sidebar-hamburger");
|
||||
@@ -293,6 +318,29 @@ function initSidebarResize(sidebar: HTMLElement): void {
|
||||
});
|
||||
}
|
||||
|
||||
// initSidebarScrollRestore wires the .sidebar-nav scroll container to
|
||||
// sessionStorage so the user's scroll position survives a full-page
|
||||
// navigation (every sidebar link click is a real reload — see m/paliad#85).
|
||||
// Restore is synchronous on init so the first paint is already at the
|
||||
// right offset; the passive scroll listener persists subsequent moves.
|
||||
// reapplySidebarScroll() exists so callers that mutate sidebar content
|
||||
// async (initUserViewsGroup appending /api/user-views into the Ansichten
|
||||
// group) can nudge the scroll back to where it was after the layout shift.
|
||||
function initSidebarScrollRestore(sidebar: HTMLElement): void {
|
||||
const nav = sidebar.querySelector<HTMLElement>(".sidebar-nav");
|
||||
if (!nav) return;
|
||||
applySidebarScroll(nav, readStoredScroll());
|
||||
nav.addEventListener("scroll", () => {
|
||||
sessionStorage.setItem(SCROLL_KEY, String(nav.scrollTop));
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function reapplySidebarScroll(): void {
|
||||
const nav = document.querySelector<HTMLElement>(".sidebar .sidebar-nav");
|
||||
if (!nav) return;
|
||||
applySidebarScroll(nav, readStoredScroll());
|
||||
}
|
||||
|
||||
// Changelog badge — fetches the count of entries newer than the locally
|
||||
// stored "last seen" stamp and renders a dot + number on the Neuigkeiten
|
||||
// link. Skipped on the changelog page itself because changelog.ts stamps
|
||||
@@ -432,6 +480,11 @@ function initUserViewsGroup(): void {
|
||||
for (const view of views) {
|
||||
items.appendChild(renderUserViewItem(view, currentPath));
|
||||
}
|
||||
// The synchronous restore in initSidebarScrollRestore() happened
|
||||
// before these views were appended, so a saved scrollTop that
|
||||
// pointed below the Ansichten group would now sit on the wrong
|
||||
// row. Re-apply once the layout has stabilised.
|
||||
reapplySidebarScroll();
|
||||
// After rendering, kick off count refresh for views that opted in.
|
||||
for (const view of views) {
|
||||
if (view.show_count) {
|
||||
|
||||
1104
frontend/src/client/submission-draft.ts
Normal file
1104
frontend/src/client/submission-draft.ts
Normal file
File diff suppressed because it is too large
Load Diff
130
frontend/src/client/submissions-index.ts
Normal file
130
frontend/src/client/submissions-index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-240 — global Schriftsätze drafts index. Loads
|
||||
// /api/user/submission-drafts and renders one entity-table row per
|
||||
// draft. Row click → editor at /projects/{project_id}/submissions/
|
||||
// {submission_code}/draft/{draft_id}. Per project CLAUDE.md row-click
|
||||
// contract: a table whose rows look clickable must navigate on click;
|
||||
// inner links / buttons keep their own affordance.
|
||||
|
||||
interface DraftRow {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
project_title: string | null;
|
||||
project_reference?: string | null;
|
||||
submission_code: string;
|
||||
name: string;
|
||||
last_exported_at?: string | null;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
let drafts: DraftRow[] = [];
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const isEN = getLang() === "en";
|
||||
return d.toLocaleDateString(isEN ? "en-GB" : "de-DE", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const loading = document.getElementById("submissions-index-loading")!;
|
||||
const empty = document.getElementById("submissions-index-empty")!;
|
||||
const error = document.getElementById("submissions-index-error")!;
|
||||
const wrap = document.getElementById("submissions-index-tablewrap")!;
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/user/submission-drafts");
|
||||
if (!resp.ok) {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
drafts = (data.drafts ?? []) as DraftRow[];
|
||||
} catch {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.style.display = "none";
|
||||
|
||||
if (drafts.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
render();
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const body = document.getElementById("submissions-index-body")!;
|
||||
|
||||
const isEN = getLang() === "en";
|
||||
const noProjectLabel = isEN ? "(no project)" : "(kein Projekt)";
|
||||
|
||||
body.innerHTML = drafts.map((d) => {
|
||||
const projectCell = (() => {
|
||||
if (!d.project_id) {
|
||||
return `<span class="submissions-index-no-project">${esc(noProjectLabel)}</span>`;
|
||||
}
|
||||
const title = esc(d.project_title ?? "");
|
||||
if (d.project_reference) {
|
||||
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project"><span class="entity-ref">${esc(d.project_reference)}</span> ${title}</a>`;
|
||||
}
|
||||
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project">${title}</a>`;
|
||||
})();
|
||||
|
||||
const href = d.project_id
|
||||
? `/projects/${esc(d.project_id)}/submissions/${esc(d.submission_code)}/draft/${esc(d.id)}`
|
||||
: `/submissions/draft/${esc(d.id)}`;
|
||||
|
||||
return `<tr class="submissions-index-row" data-href="${esc(href)}">
|
||||
<td>${projectCell}</td>
|
||||
<td>${esc(d.submission_code)}</td>
|
||||
<td><a href="${esc(href)}" class="submissions-index-draft-name">${esc(d.name)}</a></td>
|
||||
<td>${esc(fmtDate(d.updated_at))}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
body.querySelectorAll<HTMLTableRowElement>(".submissions-index-row").forEach((row) => {
|
||||
const href = row.dataset.href!;
|
||||
row.addEventListener("click", (e) => {
|
||||
// Inner <a> elements (project link, draft name) handle their own
|
||||
// navigation — let the browser dispatch them.
|
||||
if ((e.target as HTMLElement).closest("a, button")) return;
|
||||
window.location.href = href;
|
||||
});
|
||||
});
|
||||
|
||||
// Keep tsc happy for the imported `t` (used only via data-i18n on
|
||||
// static markup — keep the import so future dynamic strings can hook
|
||||
// in without re-importing).
|
||||
void t;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(() => {
|
||||
if (drafts.length > 0) render();
|
||||
});
|
||||
void load();
|
||||
});
|
||||
368
frontend/src/client/submissions-new.ts
Normal file
368
frontend/src/client/submissions-new.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { initI18n, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-243 — client for /submissions/new. Fetches the
|
||||
// cross-proceeding submission catalog, groups it by proceeding, filters
|
||||
// by text + chip, and offers two start paths per row: with project
|
||||
// (modal picker) or without (project-less draft → /submissions/draft/{id}).
|
||||
|
||||
interface CatalogEntry {
|
||||
submission_code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
event_type?: string;
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
proceeding_code: string;
|
||||
proceeding_name: string;
|
||||
proceeding_name_en: string;
|
||||
}
|
||||
|
||||
interface CatalogResponse {
|
||||
entries: CatalogEntry[];
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
}
|
||||
|
||||
interface State {
|
||||
entries: CatalogEntry[];
|
||||
activeProceeding: string | null; // null = all
|
||||
searchTerm: string;
|
||||
pickerForCode: string | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
entries: [],
|
||||
activeProceeding: null,
|
||||
searchTerm: "",
|
||||
pickerForCode: null,
|
||||
};
|
||||
|
||||
function isEN(): boolean {
|
||||
return getLang() === "en";
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function partyLabel(role: string | undefined): string {
|
||||
switch ((role ?? "").toLowerCase()) {
|
||||
case "claimant": return isEN() ? "Claimant" : "Klägerin";
|
||||
case "defendant": return isEN() ? "Defendant" : "Beklagte";
|
||||
case "both": return isEN() ? "Both" : "Beide";
|
||||
case "court": return isEN() ? "Court" : "Gericht";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalog(): Promise<void> {
|
||||
const loading = document.getElementById("submissions-new-loading")!;
|
||||
const error = document.getElementById("submissions-new-error")!;
|
||||
const wrap = document.getElementById("submissions-new-tablewrap")!;
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/submissions/catalog");
|
||||
if (!resp.ok) {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as CatalogResponse;
|
||||
state.entries = data.entries ?? [];
|
||||
} catch {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
renderChips();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderChips(): void {
|
||||
const host = document.getElementById("submissions-new-proceeding-chips");
|
||||
if (!host) return;
|
||||
const seen = new Map<string, string>();
|
||||
for (const e of state.entries) {
|
||||
if (!seen.has(e.proceeding_code)) {
|
||||
seen.set(e.proceeding_code, isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name);
|
||||
}
|
||||
}
|
||||
const chips: string[] = [];
|
||||
const allLabel = isEN() ? "All" : "Alle";
|
||||
const allActive = state.activeProceeding === null;
|
||||
chips.push(`<button type="button" class="submissions-new-chip${allActive ? " submissions-new-chip--active" : ""}" data-code="">${esc(allLabel)}</button>`);
|
||||
for (const [code, name] of seen) {
|
||||
const active = state.activeProceeding === code;
|
||||
chips.push(`<button type="button" class="submissions-new-chip${active ? " submissions-new-chip--active" : ""}" data-code="${esc(code)}">${esc(name)} <span class="submissions-new-chip-code">${esc(code)}</span></button>`);
|
||||
}
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".submissions-new-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code ?? "";
|
||||
state.activeProceeding = code === "" ? null : code;
|
||||
renderChips();
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function filtered(): CatalogEntry[] {
|
||||
const term = state.searchTerm.trim().toLowerCase();
|
||||
return state.entries.filter((e) => {
|
||||
if (state.activeProceeding !== null && e.proceeding_code !== state.activeProceeding) {
|
||||
return false;
|
||||
}
|
||||
if (term === "") return true;
|
||||
const name = isEN() && e.name_en ? e.name_en : e.name;
|
||||
const hay = [
|
||||
name,
|
||||
e.submission_code,
|
||||
e.legal_source ?? "",
|
||||
e.proceeding_code,
|
||||
e.proceeding_name,
|
||||
e.proceeding_name_en,
|
||||
].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(): void {
|
||||
const body = document.getElementById("submissions-new-body");
|
||||
const empty = document.getElementById("submissions-new-empty");
|
||||
const wrap = document.getElementById("submissions-new-tablewrap");
|
||||
if (!body || !empty || !wrap) return;
|
||||
|
||||
const rows = filtered();
|
||||
if (rows.length === 0) {
|
||||
wrap.style.display = "none";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
|
||||
// Group by proceeding.
|
||||
const groups = new Map<string, { name: string; entries: CatalogEntry[] }>();
|
||||
for (const e of rows) {
|
||||
const gname = isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name;
|
||||
const bucket = groups.get(e.proceeding_code);
|
||||
if (bucket) {
|
||||
bucket.entries.push(e);
|
||||
} else {
|
||||
groups.set(e.proceeding_code, { name: gname, entries: [e] });
|
||||
}
|
||||
}
|
||||
|
||||
const colspan = 4;
|
||||
const html: string[] = [];
|
||||
for (const [code, group] of groups) {
|
||||
html.push(`<tr class="entity-table-group-header"><th colspan="${colspan}" scope="colgroup"><span class="entity-table-group-header__name">${esc(group.name)}</span> <span class="entity-table-group-header__code">${esc(code)}</span></th></tr>`);
|
||||
for (const entry of group.entries) {
|
||||
html.push(renderRow(entry));
|
||||
}
|
||||
}
|
||||
body.innerHTML = html.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-no-project").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code;
|
||||
if (code) void startDraft(code, null);
|
||||
});
|
||||
});
|
||||
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-with-project").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code;
|
||||
if (code) openProjectPicker(code);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderRow(entry: CatalogEntry): string {
|
||||
const name = isEN() && entry.name_en ? entry.name_en : entry.name;
|
||||
const source = entry.legal_source ?? "";
|
||||
const templateBadge = entry.has_template
|
||||
? ""
|
||||
: ` <span class="submission-template-badge" title="${esc(isEN() ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage")}">${esc(isEN() ? "universal" : "universell")}</span>`;
|
||||
const withProject = isEN() ? "Mit Projekt…" : "Mit Projekt…";
|
||||
const noProject = isEN() ? "Ohne Projekt" : "Ohne Projekt";
|
||||
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${esc(name)}</span>
|
||||
<span class="submission-code">${esc(entry.submission_code)}</span>${templateBadge}
|
||||
</td>
|
||||
<td>${esc(partyLabel(entry.primary_party))}</td>
|
||||
<td>${esc(source)}</td>
|
||||
<td class="submission-action-cell">
|
||||
<button type="button" class="btn-secondary btn-small submissions-new-start-with-project" data-code="${esc(entry.submission_code)}">${esc(withProject)}</button>
|
||||
<button type="button" class="btn-primary btn-cta-lime btn-small submissions-new-start-no-project" data-code="${esc(entry.submission_code)}">${esc(noProject)}</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
async function startDraft(submissionCode: string, projectID: string | null): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch("/api/submission-drafts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ submission_code: submissionCode, project_id: projectID }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = (await resp.json()) as { error?: string };
|
||||
detail = data.error ?? "";
|
||||
} catch { /* ignore */ }
|
||||
alert((isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.") + (detail ? `\n\n${detail}` : ""));
|
||||
return;
|
||||
}
|
||||
const view = await resp.json() as { draft: { id: string; project_id: string | null; submission_code: string } };
|
||||
const id = view.draft.id;
|
||||
const pid = view.draft.project_id;
|
||||
const code = view.draft.submission_code;
|
||||
if (pid) {
|
||||
window.location.href = `/projects/${pid}/submissions/${encodeURIComponent(code)}/draft/${id}`;
|
||||
} else {
|
||||
window.location.href = `/submissions/draft/${id}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("submissions-new createDraft:", err);
|
||||
alert(isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Project picker modal
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let pickerProjects: ProjectRow[] = [];
|
||||
let pickerLoaded = false;
|
||||
|
||||
function openProjectPicker(submissionCode: string): void {
|
||||
state.pickerForCode = submissionCode;
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) modal.style.display = "";
|
||||
if (!pickerLoaded) {
|
||||
void loadPickerProjects();
|
||||
} else {
|
||||
renderPickerList();
|
||||
}
|
||||
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
if (searchInput) {
|
||||
searchInput.value = "";
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
function closeProjectPicker(): void {
|
||||
state.pickerForCode = null;
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
async function loadPickerProjects(): Promise<void> {
|
||||
const loadingEl = document.getElementById("submissions-new-project-loading");
|
||||
if (loadingEl) loadingEl.style.display = "";
|
||||
try {
|
||||
const resp = await fetch("/api/projects?status=active");
|
||||
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
|
||||
const rows = (await resp.json()) as ProjectRow[];
|
||||
pickerProjects = rows ?? [];
|
||||
pickerLoaded = true;
|
||||
} catch (err) {
|
||||
console.error("submissions-new loadPickerProjects:", err);
|
||||
pickerProjects = [];
|
||||
} finally {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
}
|
||||
renderPickerList();
|
||||
}
|
||||
|
||||
function renderPickerList(): void {
|
||||
const list = document.getElementById("submissions-new-project-list");
|
||||
const empty = document.getElementById("submissions-new-project-empty");
|
||||
if (!list || !empty) return;
|
||||
|
||||
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
const term = (searchInput?.value ?? "").trim().toLowerCase();
|
||||
|
||||
const matches = pickerProjects.filter((p) => {
|
||||
if (term === "") return true;
|
||||
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
}).slice(0, 50);
|
||||
|
||||
if (matches.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
list.innerHTML = matches.map((p) => {
|
||||
const ref = p.reference ? `<span class="entity-ref">${esc(p.reference)}</span> ` : "";
|
||||
return `<li class="submissions-new-project-item" data-id="${esc(p.id)}">${ref}<span class="submissions-new-project-title">${esc(p.title)}</span></li>`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const pid = li.dataset.id;
|
||||
const code = state.pickerForCode;
|
||||
if (pid && code) {
|
||||
closeProjectPicker();
|
||||
void startDraft(code, pid);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Boot
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function wireToolbar(): void {
|
||||
const search = document.getElementById("submissions-new-search") as HTMLInputElement | null;
|
||||
if (search) {
|
||||
search.addEventListener("input", () => {
|
||||
state.searchTerm = search.value;
|
||||
renderTable();
|
||||
});
|
||||
}
|
||||
|
||||
const closeBtn = document.getElementById("submissions-new-project-modal-close");
|
||||
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectPicker());
|
||||
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) {
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeProjectPicker();
|
||||
});
|
||||
}
|
||||
|
||||
const pickerSearch = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
if (pickerSearch) {
|
||||
pickerSearch.addEventListener("input", () => renderPickerList());
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && state.pickerForCode) closeProjectPicker();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
wireToolbar();
|
||||
void loadCatalog();
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
// Submissions panel — fetches the project's submission catalog and
|
||||
// renders one row per filing-type rule, with a [Generieren] action
|
||||
// when a .docx template resolves server-side.
|
||||
// Submissions panel — fetches the full submission catalog across every
|
||||
// proceeding and renders it grouped by proceeding, with the project's
|
||||
// own proceeding pinned at the top.
|
||||
//
|
||||
// t-paliad-215 Slice 1. Loaded lazily by the projects-detail tab
|
||||
// switcher so projects without the Schriftsätze tab open don't pay
|
||||
// for the per-row template-availability probes.
|
||||
// t-paliad-215 Slice 1 introduced the per-project list. t-paliad-242
|
||||
// broadened it to the catalog: from any project a lawyer can pick a
|
||||
// Statement of Defence under UPC.INF.CFI, a Klageerwiderung under
|
||||
// DE.INF.LG, an Opposition under EPO, etc. — the editor (t-paliad-238)
|
||||
// handles missing variables gracefully via the [KEIN WERT: …] marker,
|
||||
// so cross-proceeding picks still render cleanly.
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
@@ -23,11 +26,15 @@ interface SubmissionEntry {
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
proceeding_code: string;
|
||||
proceeding_name: string;
|
||||
proceeding_name_en: string;
|
||||
}
|
||||
|
||||
interface SubmissionListResponse {
|
||||
project_id: string;
|
||||
proceeding_type_id?: number;
|
||||
project_proceeding_code?: string;
|
||||
entries: SubmissionEntry[];
|
||||
}
|
||||
|
||||
@@ -74,13 +81,13 @@ function render(data: SubmissionListResponse): void {
|
||||
const body = document.getElementById("project-submissions-body");
|
||||
if (!empty || !noProc || !wrap || !body) return;
|
||||
|
||||
if (data.proceeding_type_id == null || data.proceeding_type_id === 0) {
|
||||
noProc.style.display = "";
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
noProc.style.display = "none";
|
||||
// t-paliad-242: the catalog is shown to every project regardless of
|
||||
// whether a proceeding is bound — the no-proceeding hint stays as a
|
||||
// soft nudge above the table, but no longer hides the catalog.
|
||||
noProc.style.display = data.proceeding_type_id == null || data.proceeding_type_id === 0
|
||||
? ""
|
||||
: "none";
|
||||
|
||||
if (data.entries.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
@@ -90,29 +97,56 @@ function render(data: SubmissionListResponse): void {
|
||||
wrap.style.display = "";
|
||||
|
||||
const isEN = document.documentElement.lang === "en";
|
||||
body.innerHTML = data.entries.map((entry) => {
|
||||
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
||||
const party = formatParty(entry.primary_party, isEN);
|
||||
const source = entry.legal_source ?? "";
|
||||
const action = entry.has_template
|
||||
? `<button type="button" class="btn-primary btn-cta-lime btn-small submission-generate-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(data.project_id)}"
|
||||
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`
|
||||
: `<span class="submission-no-template" data-i18n="projects.detail.submissions.action.no_template">${isEN ? "No template" : "Keine Vorlage"}</span>`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>
|
||||
</td>
|
||||
<td>${escapeHtml(party)}</td>
|
||||
<td>${escapeHtml(source)}</td>
|
||||
<td class="submission-action-cell">${action}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
// Wire button clicks. One click handler per render to avoid stale
|
||||
// closures from the previous render's data.
|
||||
// Group entries by proceeding_code. Build a stable group order:
|
||||
// project's own proceeding first (when present), then alphabetical
|
||||
// by proceeding_code for the rest.
|
||||
const groups = new Map<string, { name: string; entries: SubmissionEntry[] }>();
|
||||
for (const entry of data.entries) {
|
||||
const key = entry.proceeding_code || "";
|
||||
const groupName = isEN && entry.proceeding_name_en
|
||||
? entry.proceeding_name_en
|
||||
: entry.proceeding_name;
|
||||
const bucket = groups.get(key);
|
||||
if (bucket) {
|
||||
bucket.entries.push(entry);
|
||||
} else {
|
||||
groups.set(key, { name: groupName, entries: [entry] });
|
||||
}
|
||||
}
|
||||
|
||||
const ownCode = data.project_proceeding_code ?? "";
|
||||
const orderedCodes: string[] = [];
|
||||
if (ownCode && groups.has(ownCode)) orderedCodes.push(ownCode);
|
||||
for (const code of Array.from(groups.keys()).sort()) {
|
||||
if (code !== ownCode) orderedCodes.push(code);
|
||||
}
|
||||
|
||||
const ownSuffix = isEN ? " (this project)" : " (dieses Projekt)";
|
||||
const colspan = 4;
|
||||
|
||||
const html: string[] = [];
|
||||
for (const code of orderedCodes) {
|
||||
const group = groups.get(code);
|
||||
if (!group) continue;
|
||||
const isOwn = code === ownCode;
|
||||
const label = group.name + (isOwn ? ownSuffix : "");
|
||||
const headerClass = isOwn
|
||||
? "entity-table-group-header entity-table-group-header--own"
|
||||
: "entity-table-group-header";
|
||||
html.push(`<tr class="${headerClass}">`
|
||||
+ `<th colspan="${colspan}" scope="colgroup">`
|
||||
+ `<span class="entity-table-group-header__name">${escapeHtml(label)}</span>`
|
||||
+ ` <span class="entity-table-group-header__code">${escapeHtml(code)}</span>`
|
||||
+ `</th></tr>`);
|
||||
for (const entry of group.entries) {
|
||||
html.push(renderRow(entry, data.project_id, isEN));
|
||||
}
|
||||
}
|
||||
body.innerHTML = html.join("");
|
||||
|
||||
// Wire button clicks. One handler per render to avoid stale closures
|
||||
// from the previous render's data.
|
||||
body.querySelectorAll<HTMLButtonElement>(".submission-generate-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
@@ -122,6 +156,33 @@ function render(data: SubmissionListResponse): void {
|
||||
});
|
||||
}
|
||||
|
||||
function renderRow(entry: SubmissionEntry, projectID: string, isEN: boolean): string {
|
||||
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
||||
const party = formatParty(entry.primary_party, isEN);
|
||||
const source = entry.legal_source ?? "";
|
||||
const draftHref = `/projects/${encodeURIComponent(projectID)}/submissions/${encodeURIComponent(entry.submission_code)}/draft`;
|
||||
const templateBadge = entry.has_template
|
||||
? ""
|
||||
: ` <span class="submission-template-badge" title="${isEN ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage"}">${isEN ? "universal" : "universell"}</span>`;
|
||||
const editBtn = `<a href="${escapeHtml(draftHref)}" class="btn-primary btn-cta-lime btn-small submission-edit-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-i18n="projects.detail.submissions.action.edit">${isEN ? "Edit" : "Bearbeiten"}</a>`;
|
||||
const generateBtn = `<button type="button" class="btn-secondary btn-small submission-generate-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(projectID)}"
|
||||
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`;
|
||||
const action = `${editBtn} ${generateBtn}`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>${templateBadge}
|
||||
</td>
|
||||
<td>${escapeHtml(party)}</td>
|
||||
<td>${escapeHtml(source)}</td>
|
||||
<td class="submission-action-cell">${action}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderError(): void {
|
||||
const empty = document.getElementById("project-submissions-empty");
|
||||
const noProc = document.getElementById("project-submissions-no-proceeding");
|
||||
@@ -159,7 +220,7 @@ async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
|
||||
|
||||
try {
|
||||
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/generate`;
|
||||
const resp = await fetch(url, { method: "GET" });
|
||||
const resp = await fetch(url, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast";
|
||||
import { openBroadcastModal, firstName, buildMailtoHref, type BroadcastRecipient } from "./broadcast";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -341,28 +341,64 @@ function buildProjectFilter() {
|
||||
function buildBroadcastButton() {
|
||||
const wrap = document.getElementById("team-broadcast-wrap");
|
||||
if (!wrap) return;
|
||||
if (!canBroadcast()) {
|
||||
// Wait for /api/me so the affordance never flickers between admin (form)
|
||||
// and non-admin (mailto) on initial paint. canBroadcast() already returns
|
||||
// false when me is null but we'd briefly render the mailto anchor before
|
||||
// the admin form, which is visually jarring.
|
||||
if (!me) {
|
||||
wrap.innerHTML = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
|
||||
${esc(t("team.broadcast.button") || "E-Mail an Auswahl")} <span class="team-broadcast-count" id="team-broadcast-count">0</span>
|
||||
</button>
|
||||
`;
|
||||
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
|
||||
const label = esc(t("team.broadcast.button") || "E-Mail an Auswahl");
|
||||
const counter = `<span class="team-broadcast-count" id="team-broadcast-count">0</span>`;
|
||||
if (canBroadcast()) {
|
||||
// Admin path (global_admin or project-lead-of-selected): opens the
|
||||
// in-app compose modal that POSTs to /api/team/broadcast.
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
|
||||
${label} ${counter}
|
||||
</button>
|
||||
`;
|
||||
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
|
||||
} else {
|
||||
// Non-admin path (t-paliad-244): native mailto: anchor pre-filled with
|
||||
// the current filter set. href is refreshed in updateBroadcastButton()
|
||||
// whenever filters change so the link always reflects what's visible.
|
||||
wrap.innerHTML = `
|
||||
<a class="btn btn-primary" id="team-broadcast-btn" href="mailto:">
|
||||
${label} ${counter}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateBroadcastButton() {
|
||||
buildBroadcastButton();
|
||||
const recipients = displayedRecipients();
|
||||
const countEl = document.getElementById("team-broadcast-count");
|
||||
if (countEl) {
|
||||
const n = displayedRecipients().length;
|
||||
countEl.textContent = String(n);
|
||||
const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = n === 0;
|
||||
if (countEl) countEl.textContent = String(recipients.length);
|
||||
const btn = document.getElementById("team-broadcast-btn");
|
||||
if (!btn) return;
|
||||
if (btn.tagName === "BUTTON") {
|
||||
(btn as HTMLButtonElement).disabled = recipients.length === 0;
|
||||
} else {
|
||||
// Anchor (non-admin): regenerate the mailto: href against the current
|
||||
// visible recipients, and disable the affordance when empty so a click
|
||||
// doesn't open an empty mail composer.
|
||||
const a = btn as HTMLAnchorElement;
|
||||
if (recipients.length === 0) {
|
||||
a.setAttribute("href", "mailto:");
|
||||
a.setAttribute("aria-disabled", "true");
|
||||
a.style.pointerEvents = "none";
|
||||
a.style.opacity = "0.5";
|
||||
} else {
|
||||
a.setAttribute("href", buildMailtoHref(recipients));
|
||||
a.removeAttribute("aria-disabled");
|
||||
a.style.pointerEvents = "";
|
||||
a.style.opacity = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,14 +709,21 @@ function renderSelectionFooter(): void {
|
||||
"{n}",
|
||||
String(n),
|
||||
);
|
||||
const sendLabel = esc(t("team.selection.send") || "E-Mail an Auswahl");
|
||||
// t-paliad-244: mirror buildBroadcastButton() so the bottom send button
|
||||
// behaves the same as the filter-bar one. Admin (canBroadcast) opens the
|
||||
// compose modal; non-admin gets a native mailto: anchor pre-filled with
|
||||
// the explicit selection.
|
||||
const adminPath = canBroadcast();
|
||||
const sendAction = adminPath
|
||||
? `<button type="button" class="btn-primary" id="team-selection-send">${sendLabel}</button>`
|
||||
: `<a class="btn-primary" id="team-selection-send" href="${buildMailtoHref(selectedRecipients())}">${sendLabel}</a>`;
|
||||
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>
|
||||
${sendAction}
|
||||
`;
|
||||
footer.style.display = "";
|
||||
document.body.classList.add("team-has-selection");
|
||||
@@ -691,9 +734,12 @@ function renderSelectionFooter(): void {
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
});
|
||||
document.getElementById("team-selection-send")?.addEventListener("click", () => {
|
||||
onBroadcastFromSelection();
|
||||
});
|
||||
if (adminPath) {
|
||||
document.getElementById("team-selection-send")?.addEventListener("click", () => {
|
||||
onBroadcastFromSelection();
|
||||
});
|
||||
}
|
||||
// Anchor path has no click handler — native href open is the action.
|
||||
}
|
||||
|
||||
// selectedRecipients maps the explicit selection Set into the
|
||||
|
||||
@@ -12,6 +12,7 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
type Side,
|
||||
calculateDeadlines,
|
||||
escHtml,
|
||||
formatDate,
|
||||
@@ -24,6 +25,70 @@ import {
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
// Perspective state (t-paliad-250 / m/paliad#81). URL-driven so the
|
||||
// view is shareable and survives reload:
|
||||
// ?side=claimant|defendant → swaps which column owns the user's
|
||||
// side (proactive vs reactive label).
|
||||
// Default null = claimant-on-the-left.
|
||||
// ?appellant=claimant|defendant → collapses party=both rows into the
|
||||
// appellant's column (no mirror).
|
||||
// Only meaningful for role-swap
|
||||
// proceedings (Appeal etc.). Default
|
||||
// null = legacy mirror behaviour.
|
||||
let currentSide: Side = null;
|
||||
let currentAppellant: Side = null;
|
||||
|
||||
// Proceedings where one party initiates and "both" rows are role-swap
|
||||
// (i.e. either party files depending on who acted at the lower
|
||||
// instance). For these proceedings the appellant selector is meaningful
|
||||
// — when set, "both" rows collapse to a single row in the appellant's
|
||||
// column. For first-instance proceedings (Inf, Rev, …) the selector is
|
||||
// hidden because there's no appellant axis.
|
||||
//
|
||||
// Today: every upc.apl.* family member plus dpma.appeal.* and
|
||||
// de.inf.olg / de.inf.bgh / de.null.bgh (DE Berufung / Revision).
|
||||
// Conservative — false negatives just hide a control; false positives
|
||||
// would show an irrelevant control.
|
||||
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"upc.apl.merits",
|
||||
"upc.apl.cost",
|
||||
"upc.apl.order",
|
||||
"de.inf.olg",
|
||||
"de.inf.bgh",
|
||||
"de.null.bgh",
|
||||
"dpma.appeal.bpatg",
|
||||
"dpma.appeal.bgh",
|
||||
"epa.opp.boa",
|
||||
]);
|
||||
|
||||
function hasAppellantAxis(proceedingType: string): boolean {
|
||||
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
|
||||
function readSideFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("side");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function readAppellantFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("appellant");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function writeSideToURL(s: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (s === null) url.searchParams.delete("side");
|
||||
else url.searchParams.set("side", s);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
function writeAppellantToURL(a: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (a === null) url.searchParams.delete("appellant");
|
||||
else url.searchParams.set("appellant", a);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -154,20 +219,31 @@ async function doCalc() {
|
||||
}
|
||||
|
||||
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
|
||||
// label from the calc response. The root rule (isRootEvent=true) is
|
||||
// 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).
|
||||
// label from the calc response. Precedence:
|
||||
//
|
||||
// 1. Server-supplied triggerEventLabel from proceeding_types
|
||||
// (mig 121, m/paliad#81). UPC Appeal sets this to
|
||||
// "Anfechtbare Entscheidung" / "Appealable Decision" — its rules
|
||||
// all carry a non-zero duration off the trigger date so none is
|
||||
// the root, and the proceedingName fallback ("Berufungsverfahren")
|
||||
// misnamed the input as the proceeding itself.
|
||||
// 2. Root rule (isRootEvent=true) — the first event in the
|
||||
// proceeding, e.g. Klageerhebung for upc.inf.cfi,
|
||||
// Nichtigkeitsklage for upc.rev.cfi.
|
||||
// 3. Active proceeding name — last-resort fallback. Language-aware
|
||||
// (m/paliad#58: prior code rendered DE on EN for sub-track
|
||||
// proceedings like upc.ccr.cfi which had no rules → no root).
|
||||
function triggerEventLabelFor(data: DeadlineResponse): string {
|
||||
const lang = getLang();
|
||||
const curated = lang === "en"
|
||||
? (data.triggerEventLabelEN || data.triggerEventLabel)
|
||||
: (data.triggerEventLabel || data.triggerEventLabelEN);
|
||||
if (curated) return curated;
|
||||
const root = data.deadlines.find((d) => d.isRootEvent);
|
||||
if (root) {
|
||||
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
||||
return lang === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
||||
}
|
||||
if (getLang() === "en") {
|
||||
if (lang === "en") {
|
||||
return data.proceedingNameEN || data.proceedingName || "";
|
||||
}
|
||||
return data.proceedingName || data.proceedingNameEN || "";
|
||||
@@ -213,7 +289,12 @@ function renderResults(data: DeadlineResponse) {
|
||||
: "";
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
? renderColumnsBody(data, {
|
||||
editable: true,
|
||||
showNotes,
|
||||
side: currentSide,
|
||||
appellant: hasAppellantAxis(selectedType) ? currentAppellant : null,
|
||||
})
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + noteHtml + bodyHtml;
|
||||
@@ -276,6 +357,7 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
syncFlagRows();
|
||||
syncAppellantRowVisibility();
|
||||
|
||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||
|
||||
@@ -283,18 +365,53 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
scheduleCalc(0);
|
||||
}
|
||||
|
||||
// syncAppellantRowVisibility hides the appellant selector for
|
||||
// proceedings that have no appellant axis (first-instance Inf, Rev,
|
||||
// …). Clears the in-memory state and the URL param when hidden so a
|
||||
// shared link with ?appellant= doesn't leak into an unrelated
|
||||
// proceeding's render.
|
||||
function syncAppellantRowVisibility() {
|
||||
const row = document.getElementById("appellant-row");
|
||||
if (!row) return;
|
||||
const visible = hasAppellantAxis(selectedType);
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppellant !== null) {
|
||||
currentAppellant = null;
|
||||
writeAppellantToURL(null);
|
||||
syncRadioGroup("appellant", "");
|
||||
}
|
||||
}
|
||||
|
||||
function syncRadioGroup(name: string, value: string) {
|
||||
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
|
||||
input.checked = input.value === value;
|
||||
});
|
||||
}
|
||||
|
||||
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
|
||||
// Mirrors the events.ts pattern (body.events-view-*). The print
|
||||
// stylesheet keys `body.verfahrensablauf-view-timeline` to
|
||||
// `@page paliad-landscape`, so flipping this class is what lets a
|
||||
// user print the horizontal timeline in landscape without affecting
|
||||
// the columns view (which stays portrait).
|
||||
document.body.classList.toggle("verfahrensablauf-view-timeline", view === "timeline");
|
||||
document.body.classList.toggle("verfahrensablauf-view-columns", view === "columns");
|
||||
}
|
||||
|
||||
function initViewToggle() {
|
||||
const toggle = document.getElementById("fristen-view-toggle");
|
||||
if (!toggle) return;
|
||||
|
||||
const initial = new URLSearchParams(window.location.search).get("view");
|
||||
if (initial === "timeline") procedureView = "timeline";
|
||||
applyVerfahrensablaufViewBodyClass(procedureView);
|
||||
|
||||
toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
|
||||
input.checked = input.value === procedureView;
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
procedureView = input.value === "columns" ? "columns" : "timeline";
|
||||
applyVerfahrensablaufViewBodyClass(procedureView);
|
||||
const url = new URL(window.location.href);
|
||||
if (procedureView === "columns") {
|
||||
url.searchParams.delete("view");
|
||||
@@ -309,6 +426,38 @@ function initViewToggle() {
|
||||
toggle.style.display = "none";
|
||||
}
|
||||
|
||||
// initPerspectiveControls hydrates side+appellant from the URL,
|
||||
// reflects state into the radio inputs, and wires onchange handlers
|
||||
// that update state + URL + re-render. Re-render path skips the
|
||||
// /api/tools/fristenrechner round-trip — perspective is a pure
|
||||
// projection of the last response, no backend involved.
|
||||
function initPerspectiveControls() {
|
||||
currentSide = readSideFromURL();
|
||||
currentAppellant = readAppellantFromURL();
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeSideToURL(currentSide);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeAppellantToURL(currentAppellant);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
@@ -378,6 +527,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
initViewToggle();
|
||||
initPerspectiveControls();
|
||||
|
||||
onLangChange(() => {
|
||||
// Active-button name updates with language change (the data-i18n
|
||||
|
||||
@@ -204,6 +204,12 @@ function setActiveShape(shape: RenderShape | null): void {
|
||||
document.querySelectorAll<HTMLButtonElement>("#views-shape-chips [data-shape]").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.shape === shape);
|
||||
});
|
||||
// Mirror the active shape on <body> so the print stylesheet can opt
|
||||
// calendar / timeline into landscape (`@page paliad-landscape`) while
|
||||
// list / cards stay portrait — t-paliad-233.
|
||||
for (const s of ["list", "cards", "calendar", "timeline"]) {
|
||||
document.body.classList.toggle(`views-shape-active-${s}`, shape === s);
|
||||
}
|
||||
}
|
||||
|
||||
let timelineHandle: ChartHandle | null = null;
|
||||
|
||||
@@ -1,525 +1,28 @@
|
||||
import { t, tDyn, type I18nKey, getLang } from "../i18n";
|
||||
import { mountCalendar, type CalendarItem } from "../calendar/mount-calendar";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
|
||||
// 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;
|
||||
// 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.
|
||||
|
||||
export function renderCalendarShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
||||
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;
|
||||
const items: CalendarItem[] = rows.map(toCalendarItem);
|
||||
mountCalendar(host, items, {
|
||||
defaultView: render.calendar?.default_view ?? "month",
|
||||
urlState: true,
|
||||
});
|
||||
}
|
||||
|
||||
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());
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -147,8 +147,22 @@ function formatColumn(row: ViewRow, col: string): string {
|
||||
const s = (row.detail.status as string | undefined) ?? "";
|
||||
return s ? t(("deadlines.status." + s) as I18nKey) : "—";
|
||||
}
|
||||
case "rule":
|
||||
return (row.detail.rule_code as string | undefined) ?? "—";
|
||||
case "rule": {
|
||||
// t-paliad-258 — canonical "Name · Citation" pattern; fall back
|
||||
// to custom_rule_text + " · Custom" for Custom-mode deadlines.
|
||||
const lang = getLang();
|
||||
const nameKey = lang === "en" ? "rule_name_en" : "rule_name";
|
||||
const name = (row.detail[nameKey] as string | undefined)
|
||||
|| (row.detail.rule_name as string | undefined)
|
||||
|| "";
|
||||
const cite = (row.detail.rule_code as string | undefined) ?? "";
|
||||
if (name && cite) return `${name} · ${cite}`;
|
||||
if (name) return name;
|
||||
if (cite) return cite;
|
||||
const custom = (row.detail.custom_rule_text as string | undefined) ?? "";
|
||||
if (custom.trim()) return `${custom} · Custom`;
|
||||
return "—";
|
||||
}
|
||||
case "event_type":
|
||||
return (row.detail.event_type as string | undefined) ?? "—";
|
||||
case "location":
|
||||
|
||||
@@ -99,6 +99,21 @@ export interface PredecessorMissingPayload {
|
||||
message_en: string;
|
||||
}
|
||||
|
||||
// t-paliad-237 — server tells us the anchored rule belongs to the
|
||||
// parent infringement project, not this CCR. Frontend renders the
|
||||
// message with a clickable link to the parent project.
|
||||
export interface CrossProceedingAnchorPayload {
|
||||
error: "cross_proceeding_anchor";
|
||||
requested_rule_code: string;
|
||||
requested_rule_name_de: string;
|
||||
requested_rule_name_en: string;
|
||||
parent_project_id: string;
|
||||
parent_project_title: string;
|
||||
parent_project_url: string;
|
||||
message_de: string;
|
||||
message_en: string;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
// Today's date as ISO YYYY-MM-DD; defaults to "now in browser TZ".
|
||||
today?: string;
|
||||
@@ -822,7 +837,13 @@ function buildAnchorEditor(
|
||||
return;
|
||||
}
|
||||
if (resp.status === 409) {
|
||||
const payload = (await resp.json()) as PredecessorMissingPayload;
|
||||
const payload = (await resp.json()) as
|
||||
| PredecessorMissingPayload
|
||||
| CrossProceedingAnchorPayload;
|
||||
if (payload.error === "cross_proceeding_anchor") {
|
||||
renderCrossProceedingError(msg, payload, opts);
|
||||
return;
|
||||
}
|
||||
renderPredecessorError(msg, payload, ev, opts, dateInput, submit, cancel);
|
||||
return;
|
||||
}
|
||||
@@ -886,6 +907,34 @@ function renderPredecessorError(
|
||||
msg.appendChild(link);
|
||||
}
|
||||
|
||||
// t-paliad-237 — rule belongs to the parent inf project, not this CCR.
|
||||
// Render the bilingual message + a link to the parent project so the
|
||||
// user can navigate over and anchor the rule there. We deliberately do
|
||||
// NOT auto-route the write across projects (out of scope per brief).
|
||||
function renderCrossProceedingError(
|
||||
msg: HTMLElement,
|
||||
payload: CrossProceedingAnchorPayload,
|
||||
opts: RenderOptions,
|
||||
): void {
|
||||
msg.innerHTML = "";
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
msg.classList.add("smart-timeline-anchor-msg--cross-proceeding");
|
||||
|
||||
const lang = (opts.lang ?? getLang()) === "en" ? "en" : "de";
|
||||
const main = document.createElement("p");
|
||||
main.textContent = lang === "en" ? payload.message_en : payload.message_de;
|
||||
msg.appendChild(main);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.className = "smart-timeline-anchor-parent-link";
|
||||
link.href = payload.parent_project_url;
|
||||
link.textContent =
|
||||
lang === "en"
|
||||
? `Open „${payload.parent_project_title}“`
|
||||
: `„${payload.parent_project_title}“ öffnen`;
|
||||
msg.appendChild(link);
|
||||
}
|
||||
|
||||
function findRowForRuleCode(ruleCode: string): HTMLElement | null {
|
||||
const rows = document.querySelectorAll<HTMLElement>(".smart-timeline-row");
|
||||
for (const r of Array.from(rows)) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
bucketDeadlinesIntoColumns,
|
||||
deadlineCardHtml,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
@@ -65,3 +66,141 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
});
|
||||
|
||||
// Pure column-routing behaviour. Originally pinned by m/paliad#81
|
||||
// (side + appellant axes), re-framed by m/paliad#88: the column
|
||||
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
|
||||
// left") instead of the misleading Proaktiv/Reaktiv pair.
|
||||
// Hits bucketDeadlinesIntoColumns directly so the assertions stay
|
||||
// in pure-Node territory (renderColumnsBody goes through escHtml ->
|
||||
// document.createElement which isn't available in plain bun test).
|
||||
//
|
||||
// Scenario fixture mirrors the UPC Appeal "both parties" case m
|
||||
// pasted into #81: every filing rule carries party='both' so the
|
||||
// legacy mirror path duplicates every row across both columns.
|
||||
// With ?appellant= set, the duplicate must collapse to a single
|
||||
// row in the appellant's column.
|
||||
describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81, #88)", () => {
|
||||
const both = (name: string, due: string): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
nameEN: name,
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: due,
|
||||
originalDate: due,
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
});
|
||||
const partySpecific = (party: string, name: string, due: string): CalculatedDeadline => ({
|
||||
...both(name, due),
|
||||
party,
|
||||
});
|
||||
|
||||
test("default (no opts) mirrors 'both' rules into ours AND opponent — legacy behaviour preserved", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].court).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("default (no side) places claimant on the left (ours) — 'we are claimant' fallback", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("claimant", "Klageschrift", "2026-01-01"),
|
||||
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
]);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].opponent.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
});
|
||||
|
||||
test("appellant=claimant collapses 'both' rules into ours when side=claimant (or default)", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")],
|
||||
{ appellant: "claimant" },
|
||||
);
|
||||
expect(rows.map((r) => r.ours.map((d) => d.name))).toEqual([
|
||||
["Notice of Appeal"],
|
||||
["Statement of Grounds"],
|
||||
]);
|
||||
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
|
||||
});
|
||||
|
||||
test("appellant=defendant collapses 'both' rules into opponent when side=null/claimant", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ appellant: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
});
|
||||
|
||||
test("side=defendant flips which party owns 'ours' vs 'opponent' — WE always on the left", () => {
|
||||
// User is on the defendant side: defendant filings land in 'ours'
|
||||
// (left), claimant filings land in 'opponent' (right). Court rules
|
||||
// stay in court regardless of side.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[
|
||||
partySpecific("claimant", "Klageschrift", "2026-01-01"),
|
||||
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
partySpecific("court", "Urteil", "2026-10-01"),
|
||||
],
|
||||
{ side: "defendant" },
|
||||
);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].ours.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]);
|
||||
});
|
||||
|
||||
test("side=defendant + appellant=defendant routes 'both' into 'ours' (user's own column)", () => {
|
||||
// The user is the defendant AND the appellant, so the appellant's
|
||||
// column == the user's own column == ours after the swap.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ side: "defendant", appellant: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=defendant + appellant=claimant routes 'both' into opponent (claimant ≠ us)", () => {
|
||||
// Side flip + appellant axis combined: the claimant is the appellant
|
||||
// but NOT us, so the collapsed 'both' row lands in the opponent
|
||||
// column (right). This is the UPC Appeal "they appealed, we
|
||||
// respond" scenario.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ side: "defendant", appellant: "claimant" },
|
||||
);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
|
||||
const sameDate = "2026-07-23";
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("claimant", "A", sameDate),
|
||||
partySpecific("defendant", "B", sameDate),
|
||||
partySpecific("court", "C", sameDate),
|
||||
]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["A"]);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["B"]);
|
||||
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
|
||||
});
|
||||
|
||||
test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("court", "Oral Hearing", ""),
|
||||
partySpecific("claimant", "Statement of Claim", "2026-01-01"),
|
||||
partySpecific("court", "Decision", ""),
|
||||
]);
|
||||
expect(rows.map((r) => [r.ours, r.court, r.opponent].flat().map((d) => d.name))).toEqual([
|
||||
["Statement of Claim"],
|
||||
["Oral Hearing"],
|
||||
["Decision"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,16 @@ export interface DeadlineResponse {
|
||||
// explains the framing. (m/paliad#58)
|
||||
contextualNote?: string;
|
||||
contextualNoteEN?: string;
|
||||
// triggerEventLabel / triggerEventLabelEN: optional caption for the
|
||||
// "Auslösendes Ereignis" / "Triggering event" field on
|
||||
// /tools/verfahrensablauf. Populated from paliad.proceeding_types
|
||||
// when set (mig 121). The page prefers this over the proceedingName
|
||||
// fallback that fires when no rule has isRootEvent=true. UPC Appeal
|
||||
// uses this so the field reads "Anfechtbare Entscheidung" /
|
||||
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
|
||||
// (m/paliad#81)
|
||||
triggerEventLabel?: string;
|
||||
triggerEventLabelEN?: string;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -412,42 +422,124 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
|
||||
return html;
|
||||
}
|
||||
|
||||
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
|
||||
// (defendant). Each grid row shares a dueDate so same-day events line up
|
||||
// across columns; party=both renders in BOTH the Proactive and Reactive
|
||||
// cells of the row. Undated rows (Urteil etc.) trail the dated tail, each
|
||||
// keyed by sequence-order so e.g. Urteil precedes Berufungseinlegung.
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "showParty"> = {}): string {
|
||||
type Cell = CalculatedDeadline[];
|
||||
type Row = { proactive: Cell; court: Cell; reactive: Cell };
|
||||
// Three-column timeline layout: Unsere Seite | Gericht | Gegnerseite.
|
||||
//
|
||||
// The columns are user-perspective ("WE are always on the left", per
|
||||
// t-paliad-257 / m/paliad#88). The old Proaktiv/Reaktiv axis lied:
|
||||
// Klägerseite is sometimes proactive (filing the claim) and sometimes
|
||||
// reactive (responding to a counterclaim), so the static "Proaktiv =
|
||||
// Klägerseite" label-pair was wrong half the time. The new axis is
|
||||
// "ours vs opponent" — the side toggle picks who WE are in this
|
||||
// proceeding (Klägerseite vs Beklagtenseite, i.e. patentee vs alleged
|
||||
// infringer / Einsprechender vs Patentinhaber, etc.), and rule
|
||||
// placement re-resolves around that pick.
|
||||
//
|
||||
// Column assignment per deadline (default opts.side === null keeps
|
||||
// the legacy claimant-on-the-left layout — i.e. "we are claimant"):
|
||||
//
|
||||
// - party=claimant → ours when side ∈ {null,"claimant"}, else opponent
|
||||
// - party=defendant → opponent when side ∈ {null,"claimant"}, else ours
|
||||
// - party=court → court (independent of side)
|
||||
// - party=both → BOTH ours AND opponent (mirror)
|
||||
//
|
||||
// When `opts.appellant` is set (claimant|defendant), "both" rows
|
||||
// collapse to a single row in the appellant's column — the intent is
|
||||
// role-swap proceedings (UPC Appeal, Counterclaim, …) where "both"
|
||||
// really means "either party files, depending on who initiated".
|
||||
// Appellant axis is independent of `side`: in an Appeal CoA, the
|
||||
// appellant selector pins which party appealed; the side toggle
|
||||
// still picks which of those is us.
|
||||
export type Side = "claimant" | "defendant" | null;
|
||||
|
||||
// Internal column-position alias. "ours" is always rendered in the
|
||||
// left grid column ("Unsere Seite"); "opponent" is always the right
|
||||
// column ("Gegnerseite"). Field names mirror the labels so the
|
||||
// bucketing primitive reads as a direct mapping.
|
||||
type ColumnPosition = "ours" | "opponent";
|
||||
|
||||
export interface ColumnsBodyOpts {
|
||||
editable?: boolean;
|
||||
showNotes?: boolean;
|
||||
// side: which side the user is on. Drives column placement;
|
||||
// does NOT filter rows. Default null = claimant-on-the-left
|
||||
// (i.e. "ours = claimant", legacy default).
|
||||
side?: Side;
|
||||
// appellant: which side initiated the appeal / counterclaim.
|
||||
// When set, party=both rows go to the appellant's column ONLY
|
||||
// (no mirror). Default null = mirror "both" into both cells
|
||||
// (legacy behaviour). Independent of `side`.
|
||||
appellant?: Side;
|
||||
}
|
||||
|
||||
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
|
||||
// so unit tests can hit the pure routing logic without going through
|
||||
// document.createElement (no jsdom in this repo).
|
||||
export interface ColumnsRow {
|
||||
key: string;
|
||||
ours: CalculatedDeadline[];
|
||||
court: CalculatedDeadline[];
|
||||
opponent: CalculatedDeadline[];
|
||||
}
|
||||
|
||||
export interface BucketingOpts {
|
||||
side?: Side;
|
||||
appellant?: Side;
|
||||
}
|
||||
|
||||
// bucketDeadlinesIntoColumns is the pure routing primitive that
|
||||
// renderColumnsBody uses. Extracted as its own export so the per-row
|
||||
// column placement (including the side-swap + appellant-collapse
|
||||
// logic from m/paliad#81 and the user-perspective re-frame from
|
||||
// m/paliad#88) is unit-testable without a DOM. The returned rows are
|
||||
// sorted: dated rows ascending by dueDate, then unscheduled rows in
|
||||
// declaration order (each keyed by sequence).
|
||||
export function bucketDeadlinesIntoColumns(
|
||||
deadlines: CalculatedDeadline[],
|
||||
opts: BucketingOpts = {},
|
||||
): ColumnsRow[] {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
// Default (side=null) treats the user as claimant — keeps the
|
||||
// legacy claimant-on-the-left layout when no perspective is picked.
|
||||
const claimantColumn: ColumnPosition = userSide === "defendant" ? "opponent" : "ours";
|
||||
const defendantColumn: ColumnPosition = claimantColumn === "ours" ? "opponent" : "ours";
|
||||
const appellantColumn: ColumnPosition | null =
|
||||
opts.appellant === "claimant" ? claimantColumn
|
||||
: opts.appellant === "defendant" ? defendantColumn
|
||||
: null;
|
||||
|
||||
const UNSCHEDULED_PREFIX = "__unscheduled__";
|
||||
const rowsMap = new Map<string, Row>();
|
||||
const ensureRow = (key: string): Row => {
|
||||
const rowsMap = new Map<string, ColumnsRow>();
|
||||
const ensureRow = (key: string): ColumnsRow => {
|
||||
let r = rowsMap.get(key);
|
||||
if (!r) {
|
||||
r = { proactive: [], court: [], reactive: [] };
|
||||
r = { key, ours: [], court: [], opponent: [] };
|
||||
rowsMap.set(key, r);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
data.deadlines.forEach((dl, idx) => {
|
||||
deadlines.forEach((dl, idx) => {
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
switch (dl.party) {
|
||||
case "claimant":
|
||||
row.proactive.push(dl);
|
||||
row[claimantColumn].push(dl);
|
||||
break;
|
||||
case "defendant":
|
||||
row.reactive.push(dl);
|
||||
row[defendantColumn].push(dl);
|
||||
break;
|
||||
case "court":
|
||||
row.court.push(dl);
|
||||
break;
|
||||
case "both":
|
||||
row.proactive.push(dl);
|
||||
row.reactive.push(dl);
|
||||
if (appellantColumn !== null) {
|
||||
// Role-swap collapse: appellant initiated → both → one row
|
||||
// in appellant's column. Mirror suppressed.
|
||||
row[appellantColumn].push(dl);
|
||||
} else {
|
||||
row.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
row.court.push(dl);
|
||||
@@ -462,17 +554,28 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
}
|
||||
datedKeys.sort();
|
||||
unscheduledKeys.sort();
|
||||
const keys = [...datedKeys, ...unscheduledKeys];
|
||||
return [...datedKeys, ...unscheduledKeys].map((k) => rowsMap.get(k)!);
|
||||
}
|
||||
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
|
||||
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
|
||||
|
||||
// Collapsed "both" rows lose their mirror tag — there's no longer
|
||||
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
|
||||
// be misleading. Keep it for the legacy mirror path.
|
||||
const showMirrorTag = !appellantPinned;
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
|
||||
}
|
||||
const cards = items
|
||||
.map((dl) => {
|
||||
const mirrorTag = dl.party === "both"
|
||||
const mirrorTag = showMirrorTag && dl.party === "both"
|
||||
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
@@ -487,16 +590,19 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
// Static labels — "Unsere Seite" is always the left column, regardless
|
||||
// of which physical party (claimant vs defendant) occupies it. The
|
||||
// bucketing primitive already routes the user's side into the `ours`
|
||||
// bucket, so the header truth-fully describes the column contents.
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
|
||||
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
|
||||
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
|
||||
|
||||
for (const key of keys) {
|
||||
const row = rowsMap.get(key)!;
|
||||
html += renderCell(row.proactive);
|
||||
for (const row of rows) {
|
||||
html += renderCell(row.ours);
|
||||
html += renderCell(row.court);
|
||||
html += renderCell(row.reactive);
|
||||
html += renderCell(row.opponent);
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
|
||||
@@ -171,6 +171,16 @@ export function ProjectFormFields(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-proceeding-type-id" data-i18n="projects.field.proceeding_type">Verfahrenstyp</label>
|
||||
<select id="project-proceeding-type-id">
|
||||
<option value="" data-i18n="projects.field.proceeding_type.unset">(nicht gesetzt)</option>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="projects.field.proceeding_type.hint">
|
||||
Bestimmt, welche Schriftsätze-Vorlagen für dieses Verfahren angezeigt werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-our-side" data-i18n="projects.field.client_role">Mandantenrolle</label>
|
||||
<select id="project-our-side">
|
||||
|
||||
@@ -13,6 +13,10 @@ const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
||||
// at a glance.
|
||||
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
|
||||
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
||||
// Document-with-lines icon for /submissions (t-paliad-240) — distinct
|
||||
// from ICON_BOOK / ICON_BOOK_OPEN / ICON_NEWSPAPER so the Schriftsätze
|
||||
// affordance reads as "a draft document" at a glance.
|
||||
const ICON_FILE_TEXT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg>';
|
||||
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||||
const ICON_GLOBE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
|
||||
const ICON_BUILDING = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/><path d="M9 7h2"/><path d="M9 11h2"/><path d="M9 15h2"/></svg>';
|
||||
@@ -175,6 +179,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/submissions", ICON_FILE_TEXT, "nav.submissions", "Schriftsätze", currentPath) +
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
||||
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
|
||||
|
||||
@@ -76,6 +76,17 @@ 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">
|
||||
@@ -90,66 +101,66 @@ export function renderDashboard(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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 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" data-widget-key="matter-summary">
|
||||
<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>
|
||||
{/* 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="/events?type=deadline&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="/events?type=deadline&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="/events?type=deadline&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="/events?type=deadline&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="/events?type=deadline&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>
|
||||
<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>
|
||||
</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>
|
||||
</a>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 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" widgetKey="upcoming-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>
|
||||
@@ -157,55 +168,117 @@ export function renderDashboard(): string {
|
||||
|
||||
<CollapsibleSection id="appointments" widgetKey="upcoming-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>
|
||||
|
||||
{/* 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.
|
||||
</p>
|
||||
<p className="dashboard-agenda-link">
|
||||
<a href="/projects" data-i18n="dashboard.pinned.full_link">Alle Akten öffnen →</a>
|
||||
</p>
|
||||
</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>
|
||||
|
||||
{/* 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" />
|
||||
<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>
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
{/* Save toast slot — managed by dashboard.ts. */}
|
||||
<div
|
||||
id="dashboard-save-toast"
|
||||
className="dashboard-save-toast"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,84 +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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,19 @@ export function renderDeadlinesDetail(): string {
|
||||
<div className="entity-detail-title-col">
|
||||
<h1 id="deadline-title-display" />
|
||||
<input type="text" id="deadline-title-edit" className="entity-title-input" style="display:none" />
|
||||
{/* t-paliad-251 Part 4 — Standardtitel button only
|
||||
visible in edit mode; clicking replaces the
|
||||
title with a default derived from the project
|
||||
and the deadline's event types / rule. */}
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-title-default-btn"
|
||||
className="btn-link-action"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.title.default_btn"
|
||||
>
|
||||
Standardtitel
|
||||
</button>
|
||||
<div className="entity-detail-meta">
|
||||
<span id="deadline-due-chip" className="frist-due-chip" />
|
||||
<span id="deadline-status-chip" className="entity-status-chip" />
|
||||
@@ -95,7 +108,36 @@ export function renderDeadlinesDetail(): string {
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.rule">Regel</dt>
|
||||
<dd id="deadline-rule-display">—</dd>
|
||||
<dd>
|
||||
<span id="deadline-rule-display">—</span>
|
||||
{/* t-paliad-258 — Auto / Custom rule editor.
|
||||
Mirrors /deadlines/new: read-only Auto display
|
||||
(resolved from Type) or free-text Custom input,
|
||||
with a toggle link. Hidden outside edit mode. */}
|
||||
<div className="rule-edit-block" id="deadline-rule-edit" style="display:none">
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-rule-mode-toggle"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
|
||||
>
|
||||
Eigene Regel eingeben
|
||||
</button>
|
||||
<div className="rule-mode-auto" id="deadline-rule-auto-display">
|
||||
<span className="form-hint-badge" data-i18n="deadlines.field.rule.auto_badge">Auto</span>
|
||||
<span id="deadline-rule-auto-text" className="rule-auto-text">—</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-rule-custom-input"
|
||||
className="rule-mode-custom"
|
||||
style="display:none"
|
||||
placeholder="z.B. interner Review-Termin"
|
||||
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.source">Quelle</dt>
|
||||
<dd id="deadline-source-display" />
|
||||
|
||||
@@ -45,7 +45,22 @@ export function renderDeadlinesNew(): string {
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
|
||||
<div className="form-field-label-row">
|
||||
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
|
||||
{/* t-paliad-251 Part 4 — derive a Standardtitel from the
|
||||
currently-known context (event type → rule → proceeding
|
||||
type → fallback) with the project reference as suffix.
|
||||
Always replaces the title; no destructive confirmation
|
||||
because the user invoked it explicitly. */}
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-title-default-btn"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.title.default_btn"
|
||||
>
|
||||
Standardtitel
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-title"
|
||||
@@ -57,58 +72,42 @@ export function renderDeadlinesNew(): string {
|
||||
|
||||
<div className="form-field" id="deadline-event-type-field">
|
||||
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
|
||||
{/* t-paliad-165 follow-up — collapsed view: when a Regel
|
||||
is selected and a default event_type is known, the
|
||||
Typ chip is hidden and the type is rendered inline
|
||||
as a single read-only summary with an "Anderen Typ
|
||||
wählen" link that re-expands the picker. */}
|
||||
<div
|
||||
className="event-type-collapsed"
|
||||
id="deadline-event-type-collapsed"
|
||||
style="display:none"
|
||||
>
|
||||
<span
|
||||
className="event-type-collapsed-label"
|
||||
id="deadline-event-type-collapsed-label"
|
||||
/>
|
||||
<span
|
||||
className="event-type-collapsed-source"
|
||||
data-i18n="deadlines.field.rule.autofill_inline"
|
||||
>
|
||||
(vorgegeben durch Regel)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="event-type-collapsed-override"
|
||||
id="deadline-event-type-override-btn"
|
||||
data-i18n="deadlines.field.rule.override"
|
||||
>
|
||||
Anderen Typ wählen
|
||||
</button>
|
||||
</div>
|
||||
<div id="deadline-event-types" className="event-type-picker-host" />
|
||||
{/* Soft warning when the user is in expanded mode AND
|
||||
has picked an event_type that doesn't include the
|
||||
rule's canonical default. Reuses the existing
|
||||
yellow form-hint--warning style; never blocking. */}
|
||||
<p
|
||||
className="form-hint form-hint--warning"
|
||||
id="deadline-event-type-rule-mismatch"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.rule.mismatch"
|
||||
>
|
||||
Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.
|
||||
</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. */}
|
||||
{/* t-paliad-258 / m/paliad#89 — binary Rule field.
|
||||
Auto (default): rule_id derived from the chosen
|
||||
Type, displayed read-only with a canonical
|
||||
"Name · Citation" label. Custom: free-text input,
|
||||
no catalog FK. Toggle switches modes. */}
|
||||
<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 className="form-field-label-row">
|
||||
<label data-i18n="deadlines.field.rule">Regel</label>
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-rule-mode-toggle"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
|
||||
>
|
||||
Eigene Regel eingeben
|
||||
</button>
|
||||
</div>
|
||||
<div className="rule-mode-auto" id="deadline-rule-auto-display">
|
||||
<span
|
||||
className="form-hint-badge"
|
||||
data-i18n="deadlines.field.rule.auto_badge"
|
||||
>Auto</span>
|
||||
<span id="deadline-rule-auto-text" className="rule-auto-text">—</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-rule-custom-input"
|
||||
className="rule-mode-custom"
|
||||
style="display:none"
|
||||
placeholder="z.B. interner Review-Termin"
|
||||
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
|
||||
@@ -236,37 +236,10 @@ export function renderEvents(): string {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{/* 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 className="entity-empty" id="events-empty" style="display:none">
|
||||
<h2 data-i18n="events.empty.title">Keine Einträge vorhanden</h2>
|
||||
|
||||
@@ -440,7 +440,23 @@ 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"
|
||||
@@ -486,6 +502,13 @@ 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"
|
||||
@@ -555,12 +578,6 @@ 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"
|
||||
@@ -665,9 +682,20 @@ export type I18nKey =
|
||||
| "approvals.tab.mine"
|
||||
| "approvals.tab.pending_mine"
|
||||
| "approvals.title"
|
||||
| "approvals.withdraw.cancel"
|
||||
| "approvals.withdraw.confirm"
|
||||
| "approvals.withdraw.cta"
|
||||
| "approvals.withdraw.destructive.label"
|
||||
| "approvals.withdraw.error"
|
||||
| "approvals.withdraw.lead.create.appointment"
|
||||
| "approvals.withdraw.lead.create.deadline"
|
||||
| "approvals.withdraw.lead.delete"
|
||||
| "approvals.withdraw.lead.update"
|
||||
| "approvals.withdraw.modal.title"
|
||||
| "approvals.withdraw.primary.label"
|
||||
| "approvals.withdraw.sub.create"
|
||||
| "approvals.withdraw.sub.delete"
|
||||
| "approvals.withdraw.sub.update"
|
||||
| "bottomnav.add"
|
||||
| "bottomnav.add.appointment"
|
||||
| "bottomnav.add.appointment.sub"
|
||||
@@ -705,6 +733,7 @@ export type I18nKey =
|
||||
| "cal.month.9"
|
||||
| "cal.month.next"
|
||||
| "cal.month.prev"
|
||||
| "cal.today"
|
||||
| "cal.view.day"
|
||||
| "cal.view.month"
|
||||
| "cal.view.week"
|
||||
@@ -789,7 +818,54 @@ 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"
|
||||
@@ -807,11 +883,23 @@ 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"
|
||||
@@ -834,6 +922,18 @@ 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"
|
||||
@@ -850,8 +950,31 @@ 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"
|
||||
@@ -927,6 +1050,30 @@ 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"
|
||||
@@ -938,6 +1085,19 @@ export type I18nKey =
|
||||
| "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"
|
||||
@@ -963,6 +1123,10 @@ export type I18nKey =
|
||||
| "deadlines.adjusted.weekend"
|
||||
| "deadlines.adjusted.weekend.saturday"
|
||||
| "deadlines.adjusted.weekend.sunday"
|
||||
| "deadlines.appellant.claimant"
|
||||
| "deadlines.appellant.defendant"
|
||||
| "deadlines.appellant.label"
|
||||
| "deadlines.appellant.none"
|
||||
| "deadlines.calculate"
|
||||
| "deadlines.card.calc.add_to_project"
|
||||
| "deadlines.card.calc.add_to_project.disabled"
|
||||
@@ -989,8 +1153,8 @@ export type I18nKey =
|
||||
| "deadlines.col.court"
|
||||
| "deadlines.col.due"
|
||||
| "deadlines.col.event_type"
|
||||
| "deadlines.col.proactive"
|
||||
| "deadlines.col.reactive"
|
||||
| "deadlines.col.opponent"
|
||||
| "deadlines.col.ours"
|
||||
| "deadlines.col.rule"
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
@@ -1078,12 +1242,16 @@ export type I18nKey =
|
||||
| "deadlines.field.notes"
|
||||
| "deadlines.field.notes.placeholder"
|
||||
| "deadlines.field.rule"
|
||||
| "deadlines.field.rule.autofill"
|
||||
| "deadlines.field.rule.autofill_inline"
|
||||
| "deadlines.field.rule.mismatch"
|
||||
| "deadlines.field.rule.none"
|
||||
| "deadlines.field.rule.override"
|
||||
| "deadlines.field.rule.auto_badge"
|
||||
| "deadlines.field.rule.auto_no_match"
|
||||
| "deadlines.field.rule.auto_pick_type"
|
||||
| "deadlines.field.rule.custom_badge"
|
||||
| "deadlines.field.rule.custom_placeholder"
|
||||
| "deadlines.field.rule.mode.toggle_to_auto"
|
||||
| "deadlines.field.rule.mode.toggle_to_custom"
|
||||
| "deadlines.field.title"
|
||||
| "deadlines.field.title.default_btn"
|
||||
| "deadlines.field.title.default_fallback"
|
||||
| "deadlines.field.title.placeholder"
|
||||
| "deadlines.filter.akte"
|
||||
| "deadlines.filter.akte.all"
|
||||
@@ -1120,13 +1288,6 @@ 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"
|
||||
@@ -1224,6 +1385,10 @@ export type I18nKey =
|
||||
| "deadlines.search.placeholder"
|
||||
| "deadlines.search.results.count"
|
||||
| "deadlines.search.results.count_one"
|
||||
| "deadlines.side.both"
|
||||
| "deadlines.side.claimant"
|
||||
| "deadlines.side.defendant"
|
||||
| "deadlines.side.label"
|
||||
| "deadlines.source.caldav"
|
||||
| "deadlines.source.fristenrechner"
|
||||
| "deadlines.source.imported"
|
||||
@@ -1432,6 +1597,8 @@ export type I18nKey =
|
||||
| "event_types.browse.apply"
|
||||
| "event_types.browse.cancel"
|
||||
| "event_types.browse.empty"
|
||||
| "event_types.browse.jurisdiction.all"
|
||||
| "event_types.browse.jurisdiction.filter_label"
|
||||
| "event_types.browse.jurisdiction.none"
|
||||
| "event_types.browse.search"
|
||||
| "event_types.browse.selected_count"
|
||||
@@ -1454,7 +1621,6 @@ 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"
|
||||
@@ -1763,6 +1929,7 @@ export type I18nKey =
|
||||
| "nav.paliadin"
|
||||
| "nav.projekte"
|
||||
| "nav.soon.tooltip"
|
||||
| "nav.submissions"
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
@@ -1844,8 +2011,11 @@ export type I18nKey =
|
||||
| "paliadin.error.shim_error"
|
||||
| "paliadin.error.timeout"
|
||||
| "paliadin.error.upstream"
|
||||
| "paliadin.error.upstream_silence"
|
||||
| "paliadin.heading"
|
||||
| "paliadin.input.placeholder"
|
||||
| "paliadin.late.checking"
|
||||
| "paliadin.late.lost"
|
||||
| "paliadin.late.marker"
|
||||
| "paliadin.late.waiting"
|
||||
| "paliadin.reset"
|
||||
@@ -1855,6 +2025,8 @@ export type I18nKey =
|
||||
| "paliadin.starter.week"
|
||||
| "paliadin.stop"
|
||||
| "paliadin.tagline"
|
||||
| "paliadin.thinking"
|
||||
| "paliadin.thinking.seconds"
|
||||
| "paliadin.title"
|
||||
| "paliadin.widget.close"
|
||||
| "paliadin.widget.context.on_page"
|
||||
@@ -1991,6 +2163,11 @@ export type I18nKey =
|
||||
| "projects.detail.appointments.form.cancel"
|
||||
| "projects.detail.appointments.form.submit"
|
||||
| "projects.detail.back"
|
||||
| "projects.detail.checklisten.add"
|
||||
| "projects.detail.checklisten.add.created"
|
||||
| "projects.detail.checklisten.add.empty_pick"
|
||||
| "projects.detail.checklisten.add.error"
|
||||
| "projects.detail.checklisten.add.search"
|
||||
| "projects.detail.checklisten.col.created"
|
||||
| "projects.detail.checklisten.col.name"
|
||||
| "projects.detail.checklisten.col.progress"
|
||||
@@ -2036,6 +2213,11 @@ export type I18nKey =
|
||||
| "projects.detail.parteien.role.defendant"
|
||||
| "projects.detail.parteien.role.thirdparty"
|
||||
| "projects.detail.save"
|
||||
| "projects.detail.settings.archive.cta"
|
||||
| "projects.detail.settings.archive.description"
|
||||
| "projects.detail.settings.archive.heading"
|
||||
| "projects.detail.settings.export.description"
|
||||
| "projects.detail.settings.export.heading"
|
||||
| "projects.detail.smarttimeline.add.cancel"
|
||||
| "projects.detail.smarttimeline.add.choice.amend"
|
||||
| "projects.detail.smarttimeline.add.choice.appointment"
|
||||
@@ -2109,6 +2291,7 @@ export type I18nKey =
|
||||
| "projects.detail.smarttimeline.track.only.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.only.parent"
|
||||
| "projects.detail.smarttimeline.track.only.parent_context"
|
||||
| "projects.detail.submissions.action.edit"
|
||||
| "projects.detail.submissions.action.generate"
|
||||
| "projects.detail.submissions.action.no_template"
|
||||
| "projects.detail.submissions.col.action"
|
||||
@@ -2117,12 +2300,14 @@ export type I18nKey =
|
||||
| "projects.detail.submissions.col.source"
|
||||
| "projects.detail.submissions.empty"
|
||||
| "projects.detail.submissions.empty.no_proceeding"
|
||||
| "projects.detail.submissions.empty.no_proceeding.cta"
|
||||
| "projects.detail.submissions.hint"
|
||||
| "projects.detail.tab.checklisten"
|
||||
| "projects.detail.tab.fristen"
|
||||
| "projects.detail.tab.kinder"
|
||||
| "projects.detail.tab.notizen"
|
||||
| "projects.detail.tab.parteien"
|
||||
| "projects.detail.tab.settings"
|
||||
| "projects.detail.tab.submissions"
|
||||
| "projects.detail.tab.team"
|
||||
| "projects.detail.tab.termine"
|
||||
@@ -2213,6 +2398,9 @@ export type I18nKey =
|
||||
| "projects.field.parent.hint"
|
||||
| "projects.field.parent.placeholder"
|
||||
| "projects.field.patent_number"
|
||||
| "projects.field.proceeding_type"
|
||||
| "projects.field.proceeding_type.hint"
|
||||
| "projects.field.proceeding_type.unset"
|
||||
| "projects.field.proceeding_type_id"
|
||||
| "projects.field.ref"
|
||||
| "projects.field.ref.placeholder"
|
||||
@@ -2258,6 +2446,11 @@ export type I18nKey =
|
||||
| "projects.team.error.generic"
|
||||
| "projects.team.error.last_admin"
|
||||
| "projects.team.inherited.hint"
|
||||
| "projects.team.mailto.count"
|
||||
| "projects.team.mailto.empty"
|
||||
| "projects.team.mailto.label"
|
||||
| "projects.team.mailto.select_all"
|
||||
| "projects.team.mailto.select_row"
|
||||
| "projects.team.profession.associate"
|
||||
| "projects.team.profession.hint"
|
||||
| "projects.team.profession.none"
|
||||
@@ -2335,6 +2528,45 @@ export type I18nKey =
|
||||
| "search.no_results"
|
||||
| "search.placeholder"
|
||||
| "sidebar.resize.title"
|
||||
| "submissions.draft.action.delete"
|
||||
| "submissions.draft.action.export"
|
||||
| "submissions.draft.action.new"
|
||||
| "submissions.draft.back"
|
||||
| "submissions.draft.loading"
|
||||
| "submissions.draft.name.placeholder"
|
||||
| "submissions.draft.notfound"
|
||||
| "submissions.draft.preview.hint"
|
||||
| "submissions.draft.preview.title"
|
||||
| "submissions.draft.switcher.label"
|
||||
| "submissions.draft.title"
|
||||
| "submissions.index.action.new"
|
||||
| "submissions.index.col.draft"
|
||||
| "submissions.index.col.project"
|
||||
| "submissions.index.col.submission"
|
||||
| "submissions.index.col.updated"
|
||||
| "submissions.index.empty"
|
||||
| "submissions.index.empty.cta"
|
||||
| "submissions.index.error"
|
||||
| "submissions.index.heading"
|
||||
| "submissions.index.loading"
|
||||
| "submissions.index.subtitle"
|
||||
| "submissions.index.title"
|
||||
| "submissions.new.back"
|
||||
| "submissions.new.col.actions"
|
||||
| "submissions.new.col.name"
|
||||
| "submissions.new.col.party"
|
||||
| "submissions.new.col.source"
|
||||
| "submissions.new.empty.filtered"
|
||||
| "submissions.new.error"
|
||||
| "submissions.new.heading"
|
||||
| "submissions.new.loading"
|
||||
| "submissions.new.picker.empty"
|
||||
| "submissions.new.picker.loading"
|
||||
| "submissions.new.picker.placeholder"
|
||||
| "submissions.new.picker.title"
|
||||
| "submissions.new.search.placeholder"
|
||||
| "submissions.new.subtitle"
|
||||
| "submissions.new.title"
|
||||
| "team.broadcast.body"
|
||||
| "team.broadcast.body_placeholder"
|
||||
| "team.broadcast.button"
|
||||
|
||||
@@ -104,7 +104,7 @@ export function renderKostenrechner(): string {
|
||||
<title data-i18n="kosten.title">Prozesskostenrechner — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<body className="has-sidebar page-kostenrechner">
|
||||
<Sidebar currentPath="/tools/kostenrechner" />
|
||||
<BottomNav currentPath="/tools/kostenrechner" />
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export function renderProjectsChart(): string {
|
||||
<title data-i18n="projects.chart.title">Projekt-Chart — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<body className="has-sidebar page-projects-chart">
|
||||
<Sidebar currentPath="/projects" />
|
||||
<BottomNav currentPath="/projects" />
|
||||
|
||||
|
||||
@@ -89,20 +89,9 @@ 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>
|
||||
{/* Verwaltung — rare admin actions (export, archive). Sits
|
||||
last in the tab list per t-paliad-245. */}
|
||||
<a className="entity-tab" data-tab="settings" href="#" data-i18n="projects.detail.tab.settings">Verwaltung</a>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
@@ -286,9 +275,33 @@ export function renderProjectsDetail(): string {
|
||||
<p className="form-msg" id="team-msg" />
|
||||
</form>
|
||||
|
||||
{/* t-paliad-231 — pure-client mailto: for non-admins.
|
||||
Button stays disabled until at least one row is
|
||||
selected; click opens a mailto: with every selected
|
||||
member in the To: line. No server call. */}
|
||||
<div className="party-controls team-mailto-controls" id="team-mailto-controls" style="display:none">
|
||||
<button
|
||||
type="button"
|
||||
id="team-mailto-btn"
|
||||
className="btn-secondary btn-small"
|
||||
disabled
|
||||
data-i18n-title="projects.team.mailto.empty"
|
||||
title="Mindestens ein Mitglied auswählen"
|
||||
>
|
||||
<span id="team-mailto-label" data-i18n="projects.team.mailto.label">Mail an Auswahl</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<table className="party-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="team-col-select">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="team-select-master"
|
||||
aria-label="Alle sichtbaren auswählen"
|
||||
/>
|
||||
</th>
|
||||
<th data-i18n="projects.detail.team.col.name">Name</th>
|
||||
<th data-i18n="projects.detail.team.col.profession">Profession</th>
|
||||
<th data-i18n="projects.detail.team.col.responsibility">Rolle</th>
|
||||
@@ -572,6 +585,12 @@ export function renderProjectsDetail(): string {
|
||||
|
||||
{/* Checklists (Checklisten) */}
|
||||
<section className="entity-tab-panel" id="tab-checklists" style="display:none">
|
||||
<div className="party-controls">
|
||||
<button id="checklist-add-btn" className="btn-primary btn-cta-lime btn-small" type="button" data-i18n="projects.detail.checklisten.add">
|
||||
Checkliste hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-msg" id="project-checklists-msg" />
|
||||
<p id="project-checklists-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.checklisten.empty">
|
||||
Für dieses Projekt sind noch keine Checklisten-Instanzen erfasst.
|
||||
</p>
|
||||
@@ -589,25 +608,35 @@ export function renderProjectsDetail(): string {
|
||||
</table>
|
||||
</div>
|
||||
<p className="tool-subtitle checklists-hint">
|
||||
<span data-i18n="projects.detail.checklisten.hint.prefix">Instanzen werden auf der Vorlagen-Seite unter </span>
|
||||
<span data-i18n="projects.detail.checklisten.hint.prefix">Vorlagen werden auf der </span>
|
||||
<a href="/checklists" data-i18n="projects.detail.checklisten.hint.link">Checklisten</a>
|
||||
<span data-i18n="projects.detail.checklisten.hint.suffix"> angelegt.</span>
|
||||
<span data-i18n="projects.detail.checklisten.hint.suffix">-Seite angelegt und bearbeitet.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Submissions (Schriftsätze) — t-paliad-215 Slice 1.
|
||||
Lists the project's filing-type rules with a per-row
|
||||
[Generieren] button when a .docx template resolves
|
||||
in the registry's fallback chain (firm → base/code →
|
||||
base/family → skeleton). Empty for projects with no
|
||||
proceeding bound; otherwise enumerates every active
|
||||
filing rule for the proceeding. */}
|
||||
{/* Submissions (Schriftsätze) — t-paliad-242 broadened
|
||||
the original t-paliad-215 list to the full
|
||||
cross-proceeding catalog. The table shows every
|
||||
active filing rule across every proceeding, grouped
|
||||
by proceeding; the project's own proceeding is
|
||||
pinned to the top. The no-proceeding hint stays as
|
||||
a soft nudge above the catalog (the table renders
|
||||
regardless). */}
|
||||
<section className="entity-tab-panel" id="tab-submissions" style="display:none">
|
||||
<p id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty.no_proceeding">
|
||||
Bitte zuerst einen Verfahrenstyp setzen.
|
||||
</p>
|
||||
<div id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none">
|
||||
<p data-i18n="projects.detail.submissions.empty.no_proceeding">
|
||||
Für dieses Projekt ist noch kein Verfahrenstyp gesetzt — der Katalog unten zeigt trotzdem alle Vorlagen.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-submissions-edit-cta"
|
||||
className="btn-primary btn-small"
|
||||
data-i18n="projects.detail.submissions.empty.no_proceeding.cta">
|
||||
Projekt bearbeiten
|
||||
</button>
|
||||
</div>
|
||||
<p id="project-submissions-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty">
|
||||
Für dieses Verfahren sind keine Schriftsätze hinterlegt.
|
||||
Es sind aktuell keine Schriftsatzvorlagen hinterlegt.
|
||||
</p>
|
||||
<div className="entity-table-wrap" id="project-submissions-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
@@ -627,11 +656,38 @@ export function renderProjectsDetail(): string {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="entity-detail-footer" id="project-delete-wrap" style="display:none">
|
||||
<button id="project-delete-btn" className="btn-secondary" type="button" data-i18n="projects.detail.delete">
|
||||
Projekt archivieren
|
||||
</button>
|
||||
</div>
|
||||
{/* Verwaltung — rare admin actions (export, archive). Each
|
||||
sub-section hides itself if the caller is not entitled
|
||||
(export: §4 gate; archive: global_admin). */}
|
||||
<section className="entity-tab-panel" id="tab-settings" style="display:none">
|
||||
<div className="settings-section" id="project-settings-export" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.export.heading">Daten exportieren</h3>
|
||||
<p className="tool-subtitle" data-i18n="projects.detail.settings.export.description">
|
||||
Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-export-btn"
|
||||
className="btn-secondary"
|
||||
data-i18n="projects.detail.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-section" id="project-settings-archive" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.archive.heading">Projekt archivieren</h3>
|
||||
<p className="tool-subtitle" data-i18n="projects.detail.settings.archive.description">
|
||||
Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-settings-archive-link"
|
||||
className="btn-secondary"
|
||||
data-i18n="projects.detail.settings.archive.cta">
|
||||
Bearbeiten öffnen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Full edit modal — same form as /projects/new, pre-filled. */}
|
||||
@@ -663,6 +719,33 @@ export function renderProjectsDetail(): string {
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.save">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{/* Danger zone — destructive action, visually separated from Save/Cancel. */}
|
||||
<div className="modal-danger-zone" id="project-delete-wrap" style="display:none">
|
||||
<button id="project-delete-btn" className="btn-link-danger" type="button" data-i18n="projects.detail.delete">
|
||||
Projekt archivieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add-checklist-instance modal (t-paliad-239) — picks a
|
||||
template and POSTs to /api/checklists/{slug}/instances
|
||||
with the current project_id; the row appears in the
|
||||
Checklists tab list on success. */}
|
||||
<div className="modal-overlay" id="add-checklist-modal" style="display:none">
|
||||
<div className="modal-card modal-card-wide">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="projects.detail.checklisten.add">Checkliste hinzufügen</h2>
|
||||
<button className="modal-close" id="add-checklist-close" type="button">×</button>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<input type="text" id="add-checklist-search" autocomplete="off" data-i18n-placeholder="projects.detail.checklisten.add.search" placeholder="Vorlage suchen…" />
|
||||
</div>
|
||||
<div className="add-checklist-list" id="add-checklist-list" />
|
||||
<p className="entity-events-empty" id="add-checklist-empty" style="display:none" data-i18n="projects.detail.checklisten.add.empty_pick">
|
||||
Keine passenden Vorlagen gefunden.
|
||||
</p>
|
||||
<p className="form-msg" id="add-checklist-msg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
143
frontend/src/submission-draft.tsx
Normal file
143
frontend/src/submission-draft.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
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";
|
||||
|
||||
// t-paliad-238 Slice A — dedicated Submissions/Schriftsätze editor page.
|
||||
//
|
||||
// Lawyer picks (or creates) a draft for one (project, submission_code),
|
||||
// edits placeholder variables in a sticky sidebar, sees a read-only
|
||||
// HTML preview of the merged document, and exports the result as
|
||||
// .docx. Drafts persist server-side per paliad.submission_drafts.
|
||||
//
|
||||
// Pure shell: client/submission-draft.ts hydrates draft list + variable
|
||||
// form + preview pane after page load. The same dist/submission-draft.html
|
||||
// serves every (project_id, submission_code, [draft_id]) URL.
|
||||
//
|
||||
// Design ref: docs/design-submission-page-2026-05-22.md §6.
|
||||
|
||||
export function renderSubmissionDraft(): 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="submissions.draft.title">Schriftsatz bearbeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-submission-draft">
|
||||
<Sidebar currentPath="/projects" />
|
||||
<BottomNav currentPath="/projects" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page submission-draft-page">
|
||||
<div className="container">
|
||||
<a
|
||||
id="submission-draft-back-link"
|
||||
href="/projects"
|
||||
className="back-link"
|
||||
data-i18n="submissions.draft.back">
|
||||
← Zurück zum Projekt
|
||||
</a>
|
||||
|
||||
<div id="submission-draft-loading" className="entity-loading">
|
||||
<p data-i18n="submissions.draft.loading">Lädt…</p>
|
||||
</div>
|
||||
|
||||
<div id="submission-draft-notfound" className="entity-empty" style="display:none">
|
||||
<p data-i18n="submissions.draft.notfound">
|
||||
Schriftsatz nicht gefunden oder keine Berechtigung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="submission-draft-error" className="entity-empty" style="display:none" />
|
||||
|
||||
<div id="submission-draft-body" style="display:none">
|
||||
<header className="submission-draft-header">
|
||||
<div className="submission-draft-header-text">
|
||||
<h1 id="submission-draft-title" />
|
||||
<p id="submission-draft-subtitle" className="tool-subtitle" />
|
||||
</div>
|
||||
<div className="submission-draft-header-actions">
|
||||
<button
|
||||
id="submission-draft-export-btn"
|
||||
type="button"
|
||||
className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.draft.action.export">
|
||||
Als .docx exportieren
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="submission-draft-grid">
|
||||
{/* Sidebar — draft switcher + variable groups. */}
|
||||
<aside className="submission-draft-sidebar" id="submission-draft-sidebar">
|
||||
<div className="submission-draft-switcher">
|
||||
<label htmlFor="submission-draft-pick" data-i18n="submissions.draft.switcher.label">
|
||||
Entwurf
|
||||
</label>
|
||||
<select id="submission-draft-pick" />
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-new-btn"
|
||||
className="btn-small btn-secondary"
|
||||
data-i18n="submissions.draft.action.new">
|
||||
+ Neuer Entwurf
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="submission-draft-name-row">
|
||||
<input
|
||||
type="text"
|
||||
id="submission-draft-name"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.draft.name.placeholder"
|
||||
placeholder="Name dieses Entwurfs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-delete-btn"
|
||||
className="btn-small btn-link-danger"
|
||||
data-i18n="submissions.draft.action.delete">
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
|
||||
|
||||
<div className="submission-draft-variables" id="submission-draft-variables" />
|
||||
</aside>
|
||||
|
||||
{/* Preview pane — read-only HTML render of the merged
|
||||
document body. Re-renders on autosave round-trip. */}
|
||||
<section className="submission-draft-preview-wrap">
|
||||
<header className="submission-draft-preview-header">
|
||||
<h2 data-i18n="submissions.draft.preview.title">Vorschau</h2>
|
||||
<span
|
||||
className="submission-draft-preview-hint"
|
||||
data-i18n="submissions.draft.preview.hint">
|
||||
Read-only Vorschau — finale Bearbeitung in Word.
|
||||
</span>
|
||||
</header>
|
||||
<div className="submission-draft-preview" id="submission-draft-preview" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
<script src="/assets/submission-draft.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
84
frontend/src/submissions-index.tsx
Normal file
84
frontend/src/submissions-index.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";
|
||||
|
||||
// t-paliad-240 — global Schriftsätze drafts index. Top-level sidebar
|
||||
// entry that lists every draft the caller owns across visible projects.
|
||||
// Per-project editor stays at /projects/{id}/submissions/{code}/draft —
|
||||
// this page only adds a discovery surface and click-through to it.
|
||||
|
||||
export function renderSubmissionsIndex(): 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="submissions.index.title">Schriftsätze — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/submissions" />
|
||||
<BottomNav currentPath="/submissions" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div className="submissions-index-headline">
|
||||
<div>
|
||||
<h1 data-i18n="submissions.index.heading">Schriftsätze</h1>
|
||||
<p className="tool-subtitle" data-i18n="submissions.index.subtitle">
|
||||
Ihre Schriftsatz-Entwürfe über alle sichtbaren Projekte.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/submissions/new" className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.index.action.new">+ Neuer Entwurf</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="submissions-index-loading"
|
||||
data-i18n="submissions.index.loading">Lädt…</p>
|
||||
|
||||
<div className="entity-empty" id="submissions-index-empty" style="display:none">
|
||||
<p data-i18n="submissions.index.empty">
|
||||
Noch keine Entwürfe. Beginnen Sie mit einem neuen Entwurf — mit oder ohne Projekt.
|
||||
</p>
|
||||
<a href="/submissions/new" className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.index.empty.cta">+ Neuer Entwurf</a>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="submissions-index-error" style="display:none">
|
||||
<p data-i18n="submissions.index.error">Schriftsätze konnten nicht geladen werden.</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="submissions-index-tablewrap" style="display:none">
|
||||
<table className="entity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="submissions.index.col.project">Projekt</th>
|
||||
<th data-i18n="submissions.index.col.submission">Schriftsatz</th>
|
||||
<th data-i18n="submissions.index.col.draft">Entwurf</th>
|
||||
<th data-i18n="submissions.index.col.updated">Zuletzt geändert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissions-index-body" />
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/submissions-index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
118
frontend/src/submissions-new.tsx
Normal file
118
frontend/src/submissions-new.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
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";
|
||||
|
||||
// t-paliad-243 — global Schriftsatz picker. Lists the full
|
||||
// cross-proceeding submission catalog (grouped by proceeding,
|
||||
// filterable) and lets the lawyer start a draft with or without
|
||||
// binding a project. Picking "Ohne Projekt" jumps straight to
|
||||
// /submissions/draft/{id}; picking "Mit Projekt verknüpfen" opens an
|
||||
// autocomplete project picker, then redirects to the project-scoped
|
||||
// editor.
|
||||
|
||||
export function renderSubmissionsNew(): 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="submissions.new.title">Neuer Schriftsatz — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/submissions" />
|
||||
<BottomNav currentPath="/submissions" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page submissions-new-page">
|
||||
<div className="container">
|
||||
<a href="/submissions" className="back-link"
|
||||
data-i18n="submissions.new.back">← Zurück zur Übersicht</a>
|
||||
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="submissions.new.heading">Neuer Schriftsatz</h1>
|
||||
<p className="tool-subtitle" data-i18n="submissions.new.subtitle">
|
||||
Wählen Sie eine Vorlage. Optional verknüpfen Sie den
|
||||
Entwurf mit einem Projekt — sonst füllen Sie alle
|
||||
Variablen manuell.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="submissions-new-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
id="submissions-new-search"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.new.search.placeholder"
|
||||
placeholder="Suche nach Schriftsatz, Code oder Norm…" />
|
||||
<div id="submissions-new-proceeding-chips" className="submissions-new-chips" />
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="submissions-new-loading"
|
||||
data-i18n="submissions.new.loading">Lädt…</p>
|
||||
|
||||
<div className="entity-empty" id="submissions-new-error" style="display:none">
|
||||
<p data-i18n="submissions.new.error">Katalog konnte nicht geladen werden.</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="submissions-new-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="submissions.new.col.name">Schriftsatz</th>
|
||||
<th data-i18n="submissions.new.col.party">Partei</th>
|
||||
<th data-i18n="submissions.new.col.source">Rechtsgrundlage</th>
|
||||
<th data-i18n="submissions.new.col.actions">Entwurf starten</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissions-new-body" />
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="entity-empty" id="submissions-new-empty" style="display:none">
|
||||
<span data-i18n="submissions.new.empty.filtered">
|
||||
Keine passenden Schriftsätze. Filter zurücksetzen.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Project picker modal — opened by "Mit Projekt verknüpfen". */}
|
||||
<div id="submissions-new-project-modal" className="modal-overlay" style="display:none" role="dialog" aria-modal="true">
|
||||
<div className="modal-card">
|
||||
<header className="modal-header">
|
||||
<h2 data-i18n="submissions.new.picker.title">Projekt wählen</h2>
|
||||
<button type="button" id="submissions-new-project-modal-close"
|
||||
className="modal-close" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
<input
|
||||
type="search"
|
||||
id="submissions-new-project-search"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.new.picker.placeholder"
|
||||
placeholder="Projekt suchen (Titel oder Aktenzeichen)…" />
|
||||
<ul id="submissions-new-project-list" className="submissions-new-project-list" />
|
||||
<p id="submissions-new-project-loading" className="entity-events-empty" style="display:none"
|
||||
data-i18n="submissions.new.picker.loading">Lädt Projekte…</p>
|
||||
<p id="submissions-new-project-empty" className="entity-empty" style="display:none"
|
||||
data-i18n="submissions.new.picker.empty">Keine sichtbaren Projekte.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/submissions-new.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export function renderVerfahrensablauf(): string {
|
||||
<title data-i18n="tools.verfahrensablauf.title">Verfahrensablauf — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<body className="has-sidebar page-verfahrensablauf">
|
||||
<Sidebar currentPath="/tools/verfahrensablauf" />
|
||||
<BottomNav currentPath="/tools/verfahrensablauf" />
|
||||
|
||||
@@ -210,6 +210,53 @@ export function renderVerfahrensablauf(): string {
|
||||
Fristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Perspective strip (t-paliad-250 / m/paliad#81). Side
|
||||
swaps the column LABELS so the user's own side is
|
||||
proactive (= "your filings"). Appellant collapses
|
||||
party=both rows to a single column when set — only
|
||||
relevant for role-swap proceedings (Appeal etc.);
|
||||
the row hides itself when the picked proceeding has
|
||||
no appellant axis (see hasAppellantAxis() in the
|
||||
client). Both selectors are URL-driven (?side= +
|
||||
?appellant=) so the perspective survives reload
|
||||
and is shareable. */}
|
||||
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
|
||||
<div className="verfahrensablauf-perspective-row" id="side-row">
|
||||
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="claimant" />
|
||||
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="defendant" />
|
||||
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.both">Beide</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="claimant" />
|
||||
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="defendant" />
|
||||
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="" checked />
|
||||
<span data-i18n="deadlines.appellant.none">—</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
|
||||
@@ -30,6 +30,78 @@ type Entry struct {
|
||||
// Entries lists everything shipped so far, newest first. Append new rows
|
||||
// at the top.
|
||||
var Entries = []Entry{
|
||||
{
|
||||
Date: "2026-05-21",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Konfigurierbares Dashboard",
|
||||
TitleEN: "Configurable dashboard",
|
||||
BodyDE: "Das Dashboard lässt sich jetzt frei zusammenstellen: Widgets per Drag-and-drop verschieben, in der Größe ändern und einzeln konfigurieren. Der Katalog umfasst Fristen-Ampel, Termine, Agenda, Inbox-Übersicht, angepinnte Projekte und Schnellaktionen. Admins können eine kanzleiweite Standardanordnung festlegen, von der jeder Nutzer startet und sie nach Wunsch anpasst.",
|
||||
BodyEN: "The dashboard can now be assembled freely: drag-and-drop widgets, resize them and configure each one individually. The catalog covers the deadline traffic-light, appointments, agenda, inbox summary, pinned projects and quick actions. Admins can set a firm-wide default layout that every user starts from and then tweaks to taste.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-20",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Eigene Einreichungs-Checklisten",
|
||||
TitleEN: "User-authored checklists",
|
||||
BodyDE: "Eigene Checklisten lassen sich per Wizard anlegen und gezielt mit einzelnen Kolleg:innen, einem Büro, einer Partnereinheit oder einem Projekt teilen. Admins können besonders gute Vorlagen kanzleiweit unter „Geteilte Vorlagen\" freigeben. Wird eine Vorlage später überarbeitet, erscheint an laufenden Instanzen ein Hinweis-Badge auf die neuere Version.",
|
||||
BodyEN: "Build your own filing checklists through a wizard and share them explicitly with individual colleagues, an office, a partner unit or a project. Admins can promote the best templates firm-wide under „Shared templates\". When a template is later revised, running instances surface a notice badge pointing at the newer version.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-20",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Genehmigungen: Änderungen vorschlagen",
|
||||
TitleEN: "Approvals: suggest changes",
|
||||
BodyDE: "Im Inbox gibt es eine dritte Aktion neben „Genehmigen\" und „Ablehnen\": „Änderungen vorschlagen\". Ein Modal zeigt den ursprünglichen Wert, der Gegenvorschlag wandert mit einem Kommentar zurück an die Antragsteller:in. Der gesamte Austausch erscheint im Verlauf des Eintrags.",
|
||||
BodyEN: "Inbox now offers a third action alongside „Approve\" and „Reject\": „Suggest changes\". A modal shows the original value, the counter-proposal travels back to the requester together with a comment. The full exchange shows up in the entry's Verlauf.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-20",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Mandant:innen-Rolle und automatische Projekt-Codes",
|
||||
TitleEN: "Client role and auto-derived project codes",
|
||||
BodyDE: "Mandant:innen lassen sich jetzt als eigene Rolle in das Team eines Projekts aufnehmen — separat von HLC-Mitgliedern und mit eigenem Sichtbarkeitsumfang. Außerdem leitet Paliad pro Projekt einen kompakten Code aus dem Baum ab (etwa /9999-1-EP123-CFI) und zeigt ihn als zweites Badge im Header und in jedem Projekt-Picker.",
|
||||
BodyEN: "Clients can now be added to a project's team as their own role — separate from HLC members and with their own visibility scope. In addition, Paliad derives a compact code per project from the ancestor tree (e.g. /9999-1-EP123-CFI) and shows it as a second badge in the header and in every project picker.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-19",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Datenexport — Excel, CSV, JSON",
|
||||
TitleEN: "Data export — Excel, CSV, JSON",
|
||||
BodyDE: "Unter Einstellungen → Datenexport lassen sich alle sichtbaren Projekte, Fristen, Termine, Notizen und Checklisten als Excel-, CSV- oder JSON-Datei herunterladen. Auf jeder Projekt-Seite gibt es zusätzlich einen „Daten exportieren\"-Button, der nur den jeweiligen Teilbaum mitnimmt.",
|
||||
BodyEN: "Settings → Data export lets you download every project, deadline, appointment, note and checklist you can see as an Excel, CSV or JSON file. Each project page additionally offers a „Daten exportieren\" button that exports just that subtree.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-15",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Eigene Sichten — Liste, Karten, Kalender, Timeline",
|
||||
TitleEN: "Custom views — list, cards, calendar, timeline",
|
||||
BodyDE: "Eigene Filter über Fristen, Termine und Projekte lassen sich speichern und als Liste, Karten, Kalender oder Timeline rendern. Jede Sicht erhält einen permanenten Link, lässt sich als SVG, PNG, CSV, JSON oder iCal exportieren und erscheint in der Seitenleiste unter „Meine Sichten\".",
|
||||
BodyEN: "Custom filters over deadlines, appointments and projects can be saved and rendered as list, cards, calendar or timeline. Each view gets a permalink, can be exported as SVG, PNG, CSV, JSON or iCal and shows up in the sidebar under „Meine Sichten\".",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-07",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Projekte-Seite mit Baum, Pinnungen und Karten-Ansicht",
|
||||
TitleEN: "Projects page with tree, pins and cards view",
|
||||
BodyDE: "Die Projekte-Seite öffnet jetzt mit einem zusammenklappbaren Baum, Volltextsuche und Chips für Mandant, Ort und Status. Häufig genutzte Projekte lassen sich oben anpinnen; die alternative Karten-Ansicht erlaubt frei per Drag-and-drop sortierbare Layouts pro Nutzer.",
|
||||
BodyEN: "The Projects page now opens with a collapsible tree, full-text search and chips for client, location and status. Frequently used projects can be pinned to the top; the alternative cards view supports per-user drag-and-drop layouts.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-06",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Vier-Augen-Prinzip für Fristen und Termine",
|
||||
TitleEN: "Four-eyes principle for deadlines and appointments",
|
||||
BodyDE: "Pro Projekt lässt sich festlegen, dass Anlegen, Ändern, Abhaken und Löschen von Fristen oder Terminen durch eine zweite Person freigegeben werden müssen. Anfragen erscheinen im Inbox, am Eintrag selbst und mit „PENDING\"-Vermerk im CalDAV-Kalender. Admins pflegen die Regeln zentral unter /admin/approval-policies.",
|
||||
BodyEN: "Per project you can require that creating, editing, completing or deleting a deadline or appointment must be cleared by a second person. Requests show up in the inbox, on the entry itself and as a „PENDING\" marker in the CalDAV calendar. Admins maintain the rules centrally under /admin/approval-policies.",
|
||||
},
|
||||
{
|
||||
Date: "2026-05-05",
|
||||
Tag: TagFeature,
|
||||
TitleDE: "Fristenrechner v3 — Entscheidungsbaum, Begriffe, DE/EPA/DPMA",
|
||||
TitleEN: "Deadline calculator v3 — decision tree, concepts, DE/EPA/DPMA",
|
||||
BodyDE: "Der Fristenrechner wurde grundlegend überarbeitet: ein Entscheidungsbaum führt durch Verfahren und Fristart, eine neue Begriffsebene fasst Wiedereinsetzung, Säumnis, Schriftsatznachreichung und Weiterbehandlung als wiederverwendbare Konzepte zusammen. Der Regelbestand wurde um deutsche Verfahren (PatG, BPatG, BGH), EPA- und DPMA-Strecken erweitert, mit aktuellen Werten und Querverweisen.",
|
||||
BodyEN: "The deadline calculator has been overhauled from the ground up: a decision tree walks you through proceeding and deadline type, and a new concept layer treats Wiedereinsetzung, default, post-filing and further processing as reusable cross-cutting building blocks. The rule corpus has been extended with German proceedings (PatG, BPatG, BGH), EPO and DPMA tracks, with current values and cross-references.",
|
||||
},
|
||||
{
|
||||
Date: "2026-04-30",
|
||||
Tag: TagFeature,
|
||||
|
||||
13
internal/db/migrations/114_user_checklists.down.sql
Normal file
13
internal/db/migrations/114_user_checklists.down.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- 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;
|
||||
178
internal/db/migrations/114_user_checklists.up.sql
Normal file
178
internal/db/migrations/114_user_checklists.up.sql
Normal file
@@ -0,0 +1,178 @@
|
||||
-- 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).';
|
||||
26
internal/db/migrations/115_checklist_shares.down.sql
Normal file
26
internal/db/migrations/115_checklist_shares.down.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- 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;
|
||||
211
internal/db/migrations/115_checklist_shares.up.sql
Normal file
211
internal/db/migrations/115_checklist_shares.up.sql
Normal file
@@ -0,0 +1,211 @@
|
||||
-- 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).';
|
||||
7
internal/db/migrations/116_checklist_versioning.down.sql
Normal file
7
internal/db/migrations/116_checklist_versioning.down.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- 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;
|
||||
39
internal/db/migrations/116_checklist_versioning.up.sql
Normal file
39
internal/db/migrations/116_checklist_versioning.up.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- 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.';
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS paliad.firm_dashboard_default;
|
||||
33
internal/db/migrations/117_firm_dashboard_default.up.sql
Normal file
33
internal/db/migrations/117_firm_dashboard_default.up.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- 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);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE paliad.paliadin_turns
|
||||
DROP COLUMN IF EXISTS aichat_conversation_id;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- t-paliad-235: track aichat conversation id on each paliadin turn so the
|
||||
-- recovery endpoint can ask aichat for the late-arriving response when
|
||||
-- paliad's stream connection drops mid-turn.
|
||||
--
|
||||
-- The PALIADIN_BACKEND=aichat path persists this from the upstream
|
||||
-- /chat/turn/stream `done` frame's conversation_id. PALIADIN_BACKEND=legacy
|
||||
-- turns leave it NULL — the filesystem janitor is still the recovery path
|
||||
-- there.
|
||||
|
||||
ALTER TABLE paliad.paliadin_turns
|
||||
ADD COLUMN IF NOT EXISTS aichat_conversation_id uuid;
|
||||
|
||||
COMMENT ON COLUMN paliad.paliadin_turns.aichat_conversation_id IS
|
||||
'Aichat backend conversation id (t-paliad-235). Set when the streaming /chat/turn/stream done frame arrives, or when the recovery endpoint asks aichat to disambiguate which conversation this turn lives in. NULL for legacy backend turns and for aichat turns that errored before the conversation id was resolved.';
|
||||
7
internal/db/migrations/119_submission_drafts.down.sql
Normal file
7
internal/db/migrations/119_submission_drafts.down.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- t-paliad-238: revert submission_drafts table.
|
||||
--
|
||||
-- The shared paliad.tg_set_updated_at trigger function is intentionally
|
||||
-- left in place — it may be in use by other tables. DROP TABLE cascades
|
||||
-- to the trigger that references it on this table only.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.submission_drafts;
|
||||
88
internal/db/migrations/119_submission_drafts.up.sql
Normal file
88
internal/db/migrations/119_submission_drafts.up.sql
Normal file
@@ -0,0 +1,88 @@
|
||||
-- t-paliad-238: dedicated Submissions/Schriftsätze page.
|
||||
--
|
||||
-- paliad.submission_drafts holds the lawyer's per-(project, submission_code)
|
||||
-- draft state for the new editor at /projects/{id}/submissions/{code}/draft.
|
||||
-- Each row is one named draft owned by one user; multiple drafts per
|
||||
-- (project, submission_code, user_id) are supported via the `name` field
|
||||
-- (auto-generated "Entwurf 1", "Entwurf 2", … and lawyer-renameable).
|
||||
--
|
||||
-- `variables` carries the lawyer's overrides for the placeholder map
|
||||
-- assembled at export time by SubmissionVarsService — empty string forces
|
||||
-- the [KEIN WERT: …] marker; absent key falls back to the resolved bag.
|
||||
--
|
||||
-- `last_exported_at` / `last_exported_sha` record provenance of the most
|
||||
-- recent .docx export (template SHA pinned for audit). Audit rows live
|
||||
-- in paliad.system_audit_log + paliad.project_events; this table is the
|
||||
-- lawyer's working state, not the audit log.
|
||||
--
|
||||
-- Visibility: per project CLAUDE.md, every project-scoped resource gates
|
||||
-- on paliad.can_see_project. UPDATE / DELETE additionally require the
|
||||
-- draft's user_id to match auth.uid() so two co-team-members don't stomp
|
||||
-- on each other's drafts (head-confirmed Q-E4 owner-scoped pick).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
submission_code text NOT NULL,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
|
||||
variables jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
last_exported_at timestamptz,
|
||||
last_exported_sha text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT submission_drafts_unique_per_user
|
||||
UNIQUE (project_id, submission_code, user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_drafts_project_user_idx
|
||||
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
|
||||
|
||||
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND paliad.can_see_project(project_id)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id))
|
||||
WITH CHECK (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
-- updated_at maintenance: trigger function lives once per schema; reuse if
|
||||
-- it already exists, otherwise create the standard shape.
|
||||
CREATE OR REPLACE FUNCTION paliad.tg_set_updated_at()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS submission_drafts_set_updated_at ON paliad.submission_drafts;
|
||||
CREATE TRIGGER submission_drafts_set_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_drafts
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.submission_drafts IS
|
||||
't-paliad-238: per-(project, submission_code, user) named drafts for the dedicated Submissions/Schriftsätze page. Each row holds the lawyer-edited variable overrides for the .docx export. Audit rows live in paliad.system_audit_log + paliad.project_events.';
|
||||
@@ -0,0 +1,46 @@
|
||||
-- t-paliad-243 revert: restore NOT NULL on project_id.
|
||||
--
|
||||
-- The revert refuses to run if any project-less draft exists — those
|
||||
-- rows would silently fail the NOT NULL re-imposition and corrupt the
|
||||
-- migration runner's state. The safe revert path is to surface the
|
||||
-- conflict to the operator who can decide whether to attach the rows
|
||||
-- to a project or delete them before retrying the down.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts WHERE project_id IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'cannot re-impose NOT NULL on paliad.submission_drafts.project_id: '
|
||||
'project-less drafts exist. Attach them to a project or delete '
|
||||
'them, then re-run the down migration.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ALTER COLUMN project_id SET NOT NULL;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND paliad.can_see_project(project_id)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id))
|
||||
WITH CHECK (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
@@ -0,0 +1,70 @@
|
||||
-- t-paliad-243: drafts may exist without a project attached.
|
||||
--
|
||||
-- The global /submissions/new picker lets a lawyer start a Schriftsatz
|
||||
-- draft straight from the top-level Schriftsätze sidebar, with or
|
||||
-- without binding it to a project. project_id therefore becomes
|
||||
-- optional. Existing rows are unaffected; new rows may insert NULL.
|
||||
--
|
||||
-- RLS rewrite: every policy splits on (project_id IS NULL):
|
||||
--
|
||||
-- project_id IS NOT NULL → gate on paliad.can_see_project (existing
|
||||
-- inheritance-aware visibility).
|
||||
-- project_id IS NULL → owner-only (user_id = auth.uid()). A
|
||||
-- project-less draft is a personal scratch
|
||||
-- space — never shared, never visible to
|
||||
-- other team members.
|
||||
--
|
||||
-- INSERT enforces the same shape via WITH CHECK: a project-less insert
|
||||
-- only writes user_id = auth.uid(); a project-scoped insert additionally
|
||||
-- requires can_see_project.
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ALTER COLUMN project_id DROP NOT NULL;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND user_id = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Drop the optional trigger-event label columns added in
|
||||
-- 121_proceeding_trigger_event_label.up.sql. Any populated rows lose
|
||||
-- their override; the frontend falls back to proceedingName.
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS trigger_event_label_en,
|
||||
DROP COLUMN IF EXISTS trigger_event_label_de;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- t-paliad-250 / m/paliad#81 — Concern B: UPC Appeal trigger-event label.
|
||||
--
|
||||
-- The /tools/verfahrensablauf "Auslösendes Ereignis" caption falls back
|
||||
-- to `paliad.proceeding_types.name` whenever the calculator finds no
|
||||
-- root rule (duration_value=0 + parent_id=NULL + !is_court_set). For
|
||||
-- UPC Appeal (upc.apl.merits) all rules carry a non-zero duration off
|
||||
-- the trigger date, so the caption reads "Berufungsverfahren" /
|
||||
-- "Appeal" — the proceeding itself — instead of the appealable
|
||||
-- decision that actually starts the clock.
|
||||
--
|
||||
-- Fix: add an optional `trigger_event_label_de` / `trigger_event_label_en`
|
||||
-- pair on proceeding_types. When set, the calculator surfaces it on the
|
||||
-- response (TriggerEventLabel{,EN}) and the frontend prefers it over
|
||||
-- proceedingName. No deadline-rule additions, no slug changes; existing
|
||||
-- proceeding_type.code stays stable (hard rule from the issue).
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN IF NOT EXISTS trigger_event_label_de text,
|
||||
ADD COLUMN IF NOT EXISTS trigger_event_label_en text;
|
||||
|
||||
-- UPC Appeal: the trigger date is the date of the appealable first-instance
|
||||
-- decision (per UPC RoP R.224(1)(a) the 2-month appeal clock runs from
|
||||
-- service of the decision per R.220.1(a)/(b)).
|
||||
UPDATE paliad.proceeding_types
|
||||
SET trigger_event_label_de = 'Anfechtbare Entscheidung',
|
||||
trigger_event_label_en = 'Appealable Decision'
|
||||
WHERE code = 'upc.apl.merits';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- t-paliad-258: revert the additive custom_rule_text column.
|
||||
-- Drop the column; rows that used the Custom path lose their free-text
|
||||
-- label and read as "no rule".
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
DROP COLUMN IF EXISTS custom_rule_text;
|
||||
26
internal/db/migrations/122_deadlines_custom_rule_text.up.sql
Normal file
26
internal/db/migrations/122_deadlines_custom_rule_text.up.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- t-paliad-258 / m/paliad#89 — binary Auto/Custom Rule model on the
|
||||
-- deadline form.
|
||||
--
|
||||
-- t-paliad-251 shipped the form with a full deadline_rules catalog
|
||||
-- dropdown. m's verdict: too noisy (4 "Oral hearings" across UPC CFI,
|
||||
-- UPC CoA, DPMA, EPO etc.). Replace with a binary model:
|
||||
--
|
||||
-- 1. Auto — rule_id derived from the chosen event_type, displayed
|
||||
-- read-only.
|
||||
-- 2. Custom — rule_id is NULL and the lawyer's free-text label is
|
||||
-- stored here.
|
||||
--
|
||||
-- The column is additive + nullable: existing rows keep their
|
||||
-- deadline_rule_id and read as Auto-equivalent. A future row with both
|
||||
-- columns NULL renders as "keine Regel" (matches today's no-rule state).
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN IF NOT EXISTS custom_rule_text text;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadlines.custom_rule_text IS
|
||||
'Free-text rule label entered when the lawyer chose Custom on the '
|
||||
'deadline form (t-paliad-258). Mutually exclusive with rule_id at '
|
||||
'the application layer: Auto path sets rule_id and leaves this '
|
||||
'NULL; Custom path sets this and leaves rule_id NULL. Display '
|
||||
'surfaces prefer the rule_id-joined deadline_rules.name when '
|
||||
'present, else fall back to custom_rule_text + a "Custom" badge.';
|
||||
@@ -44,6 +44,78 @@ 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),
|
||||
|
||||
@@ -24,8 +24,13 @@ 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.ServeFile(w, r, "dist/appointments-calendar.html")
|
||||
http.Redirect(w, r, "/events?type=appointment&view=calendar", http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
// handleSettingsPage serves the unified settings page with tabs for
|
||||
|
||||
@@ -326,6 +326,56 @@ func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
handleApprovalDecision(w, r, "revoke")
|
||||
}
|
||||
|
||||
// POST /api/approval-requests/{id}/edit-entity — t-paliad-252 / m/paliad#83.
|
||||
//
|
||||
// Lets the requester revise the in-flight entity (e.g. tweak the title on a
|
||||
// pending create) without withdrawing the request. The non-destructive
|
||||
// sibling of /revoke that m asked for after noticing that withdraw silently
|
||||
// deletes the underlying event.
|
||||
//
|
||||
// Body: {"fields": {<entity-shape>}}
|
||||
// 200: {"status": "ok"}
|
||||
//
|
||||
// Status mapping (mapApprovalError):
|
||||
//
|
||||
// 400 suggestion_requires_change — payload has no allowlisted fields
|
||||
// 403 not_authorized — caller isn't the requested_by
|
||||
// 404 — request not found / not visible
|
||||
// 409 request_not_pending — request already decided / revoked
|
||||
type editPendingEntityBody struct {
|
||||
Fields map[string]any `json:"fields"`
|
||||
}
|
||||
|
||||
func handleEditPendingEntity(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requestID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
|
||||
return
|
||||
}
|
||||
var body editPendingEntityBody
|
||||
if r.Body != nil && r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"code": "invalid_body",
|
||||
"message": "Ungültiger Body.",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := dbSvc.approval.EditPendingEntity(r.Context(), requestID, uid, body.Fields); err != nil {
|
||||
writeApprovalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// suggestChangesBody is the JSON body for POST /api/approval-requests/{id}/suggest-changes.
|
||||
// counter_payload is an entity-shaped jsonb of the approver's edited
|
||||
// values (allowlist enforced server-side); note is the optional free-text
|
||||
|
||||
131
internal/handlers/checklist_shares.go
Normal file
131
internal/handlers/checklist_shares.go
Normal file
@@ -0,0 +1,131 @@
|
||||
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)
|
||||
}
|
||||
133
internal/handlers/checklist_templates.go
Normal file
133
internal/handlers/checklist_templates.go
Normal file
@@ -0,0 +1,133 @@
|
||||
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,31 +24,143 @@ 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 {
|
||||
http.NotFound(w, r)
|
||||
// Static catalog match → serve unconditionally; the static templates
|
||||
// are always visible.
|
||||
if _, ok := checklists.Find(slug); ok {
|
||||
http.ServeFile(w, r, "dist/checklists-detail.html")
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, "dist/checklists-detail.html")
|
||||
// Otherwise fall back to the DB-backed catalog (authored templates,
|
||||
// slug shape "u-a-..." from t-paliad-225). The catalog enforces
|
||||
// visibility per-user; a slug the caller can't see returns
|
||||
// ErrNotVisible and the user gets the same 404 they'd see for an
|
||||
// unknown slug. Without this branch authored checklists 404'd at the
|
||||
// page level even though they showed up in the overview, which is
|
||||
// exactly m's 2026-05-22 report.
|
||||
if dbSvc != nil && dbSvc.checklistCatalog != nil {
|
||||
uid, ok := auth.UserIDFromContext(r.Context())
|
||||
if ok {
|
||||
if _, err := dbSvc.checklistCatalog.Find(r.Context(), uid, slug); err == nil {
|
||||
http.ServeFile(w, r, "dist/checklists-detail.html")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
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) {
|
||||
writeJSON(w, http.StatusOK, checklists.Summaries())
|
||||
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)
|
||||
}
|
||||
|
||||
// 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")
|
||||
c, ok := checklists.Find(slug)
|
||||
if !ok {
|
||||
// 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
|
||||
}
|
||||
writeJSON(w, http.StatusOK, c)
|
||||
uid, ok := requireUser(w, r)
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
func handleChecklistsFeedback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -11,8 +11,15 @@ import "net/http"
|
||||
// to the canonical /events?type=deadline (t-paliad-115). Detail page
|
||||
// /deadlines/{id} stays type-specific. Drop this redirect once we're
|
||||
// confident no caches / bookmarks / external links still hit the old URL.
|
||||
//
|
||||
// Preserves the incoming query string so filter params (e.g. status=this_week
|
||||
// from the dashboard summary cards) survive the redirect.
|
||||
func handleDeadlinesListRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/events?type=deadline", http.StatusMovedPermanently)
|
||||
target := "/events?type=deadline"
|
||||
if r.URL.RawQuery != "" {
|
||||
target += "&" + r.URL.RawQuery
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
func handleDeadlinesNewPage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -23,6 +30,13 @@ 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.ServeFile(w, r, "dist/deadlines-calendar.html")
|
||||
http.Redirect(w, r, "/events?type=deadline&view=calendar", http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -36,6 +37,11 @@ type fileEntry struct {
|
||||
//
|
||||
// The URL slug ("hl-patents-style.dotm") is preserved as a stable public
|
||||
// identifier so existing bookmarks keep working post-rebrand.
|
||||
//
|
||||
// Per-submission templates (slug `submission/<code>.docx`) are server-only:
|
||||
// only the submission-draft editor reaches them via fetchSubmissionTemplateBytes.
|
||||
// handleFileDownload serves any slug that lands here, but the public URL
|
||||
// surface for submission templates is the export endpoint, not /files.
|
||||
var fileRegistry = map[string]fileEntry{
|
||||
"hl-patents-style.dotm": {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/HL%20Patents%20Style.dotm",
|
||||
@@ -45,6 +51,92 @@ var fileRegistry = map[string]fileEntry{
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/HL Patents Style.dotm",
|
||||
},
|
||||
// Per-submission demo template (t-paliad-241). Exercises every
|
||||
// placeholder SubmissionVarsService resolves so the
|
||||
// /projects/{id}/submissions/{code}/draft editor has variables to
|
||||
// substitute. One file per submission_code; future codes register
|
||||
// the same way — slug shape "submission/<code>.docx" so the
|
||||
// namespace stays separate from the universal style template.
|
||||
"submission/de.inf.lg.erwidg.docx": {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
|
||||
DownloadName: "Klageerwiderung — " + branding.Name + ".docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
|
||||
},
|
||||
// Universal skeleton (t-paliad-259). Code-agnostic Schriftsatz starter
|
||||
// that carries every placeholder SubmissionVarsService resolves but no
|
||||
// submission_code-specific body structure. Slot between the per-firm
|
||||
// per-code template and the bare HL Patents Style .dotm fallback: every
|
||||
// submission_code without a dedicated template still renders with
|
||||
// variables substituted instead of the macro-only letterhead.
|
||||
skeletonSubmissionSlug: {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
|
||||
DownloadName: branding.Name + " — Schriftsatz-Skelett.docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
|
||||
},
|
||||
}
|
||||
|
||||
// skeletonSubmissionSlug names the universal skeleton template inside
|
||||
// the shared fileRegistry cache. Exported via a const so handler code
|
||||
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
|
||||
// the same string the registry uses.
|
||||
const skeletonSubmissionSlug = "submission/_skeleton.docx"
|
||||
|
||||
// submissionTemplateRegistry maps a deadline-rule submission_code to a
|
||||
// fileRegistry slug. Lookup order matches the cronus design fallback
|
||||
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
|
||||
// universal HL Patents Style as the global fallback.
|
||||
//
|
||||
// Add new entries here as the firm authors per-submission templates;
|
||||
// the file itself lives in mWorkRepo and is served through the shared
|
||||
// Gitea proxy cache so refreshes are visible to all consumers in one
|
||||
// place.
|
||||
var submissionTemplateRegistry = map[string]string{
|
||||
"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
|
||||
}
|
||||
|
||||
// fetchSubmissionTemplateBytes returns the per-submission_code template
|
||||
// bytes (and provenance SHA) when one is registered. The bool result
|
||||
// distinguishes "no per-code template registered" (callers fall back to
|
||||
// HL Patents Style) from an upstream fetch error.
|
||||
func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) {
|
||||
slug, ok := submissionTemplateRegistry[submissionCode]
|
||||
if !ok {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
entry, ok := fileRegistry[slug]
|
||||
if !ok {
|
||||
return nil, "", false, fmt.Errorf("file proxy: submission template slug %q not registered", slug)
|
||||
}
|
||||
ce := getCacheEntry(slug)
|
||||
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
needsCheck := time.Since(ce.lastChecked) >= checkInterval
|
||||
ce.mu.RUnlock()
|
||||
|
||||
if !hasData {
|
||||
if err := fileFetch(ce, entry); err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
} else if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, "", false, fmt.Errorf("file proxy: %s cache empty after fetch", slug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
_ = ctx
|
||||
return out, ce.sha, true, nil
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
@@ -117,6 +209,85 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
|
||||
}
|
||||
|
||||
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
|
||||
// template bytes plus its provenance SHA. Sits between the per-firm
|
||||
// per-submission_code template (fetchSubmissionTemplateBytes) and the
|
||||
// bare universal HL Patents Style .dotm (fetchHLPatentsStyleBytes) in
|
||||
// resolveSubmissionTemplate's fallback chain — used for every
|
||||
// submission_code that has no dedicated template registered. Same
|
||||
// stale-while-revalidate semantics as the rest of the file proxy: first
|
||||
// call warms the cache synchronously from mWorkRepo via Gitea; later
|
||||
// calls return immediately while a background refresh runs.
|
||||
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
entry, ok := fileRegistry[skeletonSubmissionSlug]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
|
||||
}
|
||||
ce := getCacheEntry(skeletonSubmissionSlug)
|
||||
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
needsCheck := time.Since(ce.lastChecked) >= checkInterval
|
||||
ce.mu.RUnlock()
|
||||
|
||||
if !hasData {
|
||||
if err := fileFetch(ce, entry); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
} else if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
_ = ctx
|
||||
return out, ce.sha, nil
|
||||
}
|
||||
|
||||
// fetchHLPatentsStyleBytes returns the cached HL Patents Style .dotm
|
||||
// bytes. Shared accessor used by both the /files/{slug} download path
|
||||
// (Word auto-update channel) and the submission generator
|
||||
// (handlers/submissions.go) so a refresh through one path is visible to
|
||||
// the other. First call warms the cache from Gitea synchronously;
|
||||
// subsequent calls are sub-millisecond. A stale-but-present cache is
|
||||
// returned immediately while a background refresh runs.
|
||||
func fetchHLPatentsStyleBytes(ctx context.Context) ([]byte, error) {
|
||||
entry, ok := fileRegistry[hlPatentsStyleSlug]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file proxy: %s not registered", hlPatentsStyleSlug)
|
||||
}
|
||||
ce := getCacheEntry(hlPatentsStyleSlug)
|
||||
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
needsCheck := time.Since(ce.lastChecked) >= checkInterval
|
||||
ce.mu.RUnlock()
|
||||
|
||||
if !hasData {
|
||||
if err := fileFetch(ce, entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, fmt.Errorf("file proxy: %s cache empty after fetch", hlPatentsStyleSlug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
_ = ctx // ctx reserved for future timeout pass-through; fileFetch
|
||||
// uses the package httpClient timeout today.
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fileFetch downloads the file synchronously (first request).
|
||||
func fileFetch(ce *cacheEntry, entry fileEntry) error {
|
||||
sha, _ := giteaLatestSHA(entry)
|
||||
|
||||
139
internal/handlers/firm_dashboard_default.go
Normal file
139
internal/handlers/firm_dashboard_default.go
Normal file
@@ -0,0 +1,139 @@
|
||||
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)
|
||||
}
|
||||
@@ -70,7 +70,11 @@ type Services struct {
|
||||
EventType *services.EventTypeService
|
||||
Dashboard *services.DashboardService
|
||||
Note *services.NoteService
|
||||
ChecklistInst *services.ChecklistInstanceService
|
||||
ChecklistInst *services.ChecklistInstanceService
|
||||
ChecklistCatalog *services.ChecklistCatalogService
|
||||
ChecklistTemplate *services.ChecklistTemplateService
|
||||
ChecklistShare *services.ChecklistShareService
|
||||
ChecklistPromotion *services.ChecklistPromotionService
|
||||
Mail *services.MailService
|
||||
Invite *services.InviteService
|
||||
Agenda *services.AgendaService
|
||||
@@ -86,17 +90,16 @@ type Services struct {
|
||||
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
|
||||
|
||||
// Submission generator (t-paliad-215) — Klageerwiderung &
|
||||
// friends. Three coordinated services: registry fetches templates
|
||||
// from Gitea; vars builds the placeholder map from project +
|
||||
// parties + rule; renderer merges the .docx. Wired together in
|
||||
// cmd/server/main.go; nil here when DATABASE_URL is unset.
|
||||
SubmissionRegistry *services.TemplateRegistry
|
||||
SubmissionVars *services.SubmissionVarsService
|
||||
SubmissionRenderer *services.SubmissionRenderer
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
|
||||
SubmissionDraft *services.SubmissionDraftService
|
||||
|
||||
// Paliadin is wired when DATABASE_URL is set. The concrete backend
|
||||
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
|
||||
@@ -114,14 +117,6 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
paliadinSvc = svc.Paliadin
|
||||
}
|
||||
|
||||
// Submission generator singletons (t-paliad-215). All three or
|
||||
// none — the handler short-circuits with 503 when any is nil.
|
||||
if svc != nil {
|
||||
submissionRegistry = svc.SubmissionRegistry
|
||||
submissionVars = svc.SubmissionVars
|
||||
submissionRenderer = svc.SubmissionRenderer
|
||||
}
|
||||
|
||||
if svc != nil {
|
||||
dbSvc = &dbServices{
|
||||
projects: svc.Project,
|
||||
@@ -144,7 +139,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
eventType: svc.EventType,
|
||||
dashboard: svc.Dashboard,
|
||||
note: svc.Note,
|
||||
checklistInst: svc.ChecklistInst,
|
||||
checklistInst: svc.ChecklistInst,
|
||||
checklistCatalog: svc.ChecklistCatalog,
|
||||
checklistTemplate: svc.ChecklistTemplate,
|
||||
checklistShare: svc.ChecklistShare,
|
||||
checklistPromotion: svc.ChecklistPromotion,
|
||||
mail: svc.Mail,
|
||||
invite: svc.Invite,
|
||||
agenda: svc.Agenda,
|
||||
@@ -160,8 +159,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
pin: svc.Pin,
|
||||
cardLayout: svc.CardLayout,
|
||||
dashboardLayout: svc.DashboardLayout,
|
||||
firmDashboardDefault: svc.FirmDashboardDefault,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,11 +249,25 @@ 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)
|
||||
@@ -261,6 +276,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/checklist-instances/{id}/reset", handleResetChecklistInstance)
|
||||
protected.HandleFunc("DELETE /api/checklist-instances/{id}", handleDeleteChecklistInstance)
|
||||
protected.HandleFunc("GET /api/projects/{id}/checklists", handleListChecklistInstancesForProject)
|
||||
// t-paliad-240 — global Schriftsätze drafts index (top-level sidebar
|
||||
// entry). Lists every draft the caller owns across visible projects.
|
||||
// The per-project Schriftsätze tab keeps the editor itself project-
|
||||
// scoped; this index is the cross-project landing.
|
||||
protected.HandleFunc("GET /submissions", gateOnboarded(handleSubmissionsIndexPage))
|
||||
protected.HandleFunc("GET /courts", handleCourtsPage)
|
||||
protected.HandleFunc("GET /api/courts", handleCourtsAPI)
|
||||
protected.HandleFunc("POST /api/courts/feedback", handleCourtsFeedback)
|
||||
@@ -295,11 +315,40 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
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)
|
||||
// t-paliad-215 Slice 1 — submission generator. /submissions lists
|
||||
// the project's filing-type rules with template-availability flags;
|
||||
// /submissions/{code}/generate streams the rendered .docx.
|
||||
// t-paliad-230 — submission generator (format-only). /submissions
|
||||
// lists the project's published filing rules; /generate fetches the
|
||||
// universal HL Patents Style .dotm, strips the macro project, and
|
||||
// streams a clean .docx attachment. POST because each generation
|
||||
// writes an audit row.
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions", handleListProjectSubmissions)
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/generate", handleGenerateProjectSubmission)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/generate", handleGenerateProjectSubmission)
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze draft editor.
|
||||
// Per (project, submission_code, user) named drafts with autosaved
|
||||
// variable overrides; export merges the bag with the universal HL
|
||||
// Patents Style template (Slice A) or the per-code template
|
||||
// (Slice B, future).
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts", handleListSubmissionDrafts)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/drafts", handleCreateSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handleGetSubmissionDraft)
|
||||
protected.HandleFunc("PATCH /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handlePatchSubmissionDraft)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handleDeleteSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}/preview", handlePreviewSubmissionDraft)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/drafts/{draft_id}/export", handleExportSubmissionDraft)
|
||||
// t-paliad-240 — global drafts index (across visible projects).
|
||||
protected.HandleFunc("GET /api/user/submission-drafts", handleListUserSubmissionDrafts)
|
||||
// t-paliad-243 — global Schriftsätze drafts with optional project
|
||||
// binding. The picker page at /submissions/new lists the full
|
||||
// cross-proceeding catalog (without a project context) and posts to
|
||||
// POST /api/submission-drafts to spawn a draft. The
|
||||
// /api/submission-drafts/{draft_id}* endpoints back the project-less
|
||||
// editor and ALSO accept project-scoped drafts (the draft row
|
||||
// carries its own project_id so the project segment is redundant).
|
||||
protected.HandleFunc("GET /api/submissions/catalog", handleListSubmissionCatalog)
|
||||
protected.HandleFunc("POST /api/submission-drafts", handleCreateGlobalSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/submission-drafts/{draft_id}", handleGetGlobalSubmissionDraft)
|
||||
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}", handleGlobalPatchSubmissionDraft)
|
||||
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}", handleGlobalDeleteSubmissionDraft)
|
||||
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/export", handleGlobalExportSubmissionDraft)
|
||||
// /counterclaim creates a CCR sub-project linked via the new
|
||||
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
|
||||
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)
|
||||
@@ -453,6 +502,20 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /projects/{id}/notes", gateOnboarded(handleProjectsDetailPage))
|
||||
protected.HandleFunc("GET /projects/{id}/checklists", gateOnboarded(handleProjectsDetailPage))
|
||||
protected.HandleFunc("GET /projects/{id}/team", gateOnboarded(handleProjectsDetailPage))
|
||||
// t-paliad-230 Schriftsätze tab — same shape as every other tab above.
|
||||
// Without this route the deep-link 404s; the tab still works via
|
||||
// in-page click since it just toggles a panel.
|
||||
protected.HandleFunc("GET /projects/{id}/submissions", gateOnboarded(handleProjectsDetailPage))
|
||||
// t-paliad-238 — dedicated submission draft editor. Both routes
|
||||
// render the project detail page; the actual editor mounts
|
||||
// client-side based on the URL path.
|
||||
protected.HandleFunc("GET /projects/{id}/submissions/{code}/draft", gateOnboarded(handleSubmissionDraftPage))
|
||||
protected.HandleFunc("GET /projects/{id}/submissions/{code}/draft/{draft_id}", gateOnboarded(handleSubmissionDraftPage))
|
||||
// t-paliad-243 — global Schriftsätze pages: picker + project-less
|
||||
// editor. Both render dist/* files; client bundles parse the URL
|
||||
// and branch on whether a project segment is present.
|
||||
protected.HandleFunc("GET /submissions/new", gateOnboarded(handleSubmissionsNewPage))
|
||||
protected.HandleFunc("GET /submissions/draft/{draft_id}", gateOnboarded(handleSubmissionDraftGlobalPage))
|
||||
// t-paliad-177 — standalone Project Timeline / Chart page (Slice 1).
|
||||
// Horizontal SVG renderer mounted client-side; reuses the existing
|
||||
// /api/projects/{id}/timeline JSON endpoint for data.
|
||||
@@ -509,10 +572,17 @@ 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))
|
||||
@@ -588,6 +658,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
|
||||
// t-paliad-252 — non-destructive sibling of /revoke: lets the
|
||||
// requester revise the in-flight entity without withdrawing.
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/edit-entity", handleEditPendingEntity)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
|
||||
|
||||
// t-paliad-154 — form-time effective policy lookup. Reachable by
|
||||
@@ -629,6 +702,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/paliadin/turn", handlePaliadinTurn)
|
||||
protected.HandleFunc("GET /api/paliadin/stream/{id}", handlePaliadinStream)
|
||||
protected.HandleFunc("GET /api/paliadin/turns/{id}", handlePaliadinTurnGet)
|
||||
// Recovery endpoint (t-paliad-235): when the SSE stream drops mid-turn,
|
||||
// the frontend hits this to ask whether aichat actually finished the
|
||||
// turn upstream. Dispatches per backend — aichat hits the conversation
|
||||
// API; legacy backends fall through to the local row read + janitor.
|
||||
protected.HandleFunc("GET /api/paliadin/turns/{id}/recover", handlePaliadinTurnRecover)
|
||||
// Crash-resistant history hydrate (t-paliad-161 follow-up): both
|
||||
// Paliadin surfaces use this to seed their UI from the DB before
|
||||
// consulting localStorage.
|
||||
|
||||
@@ -185,16 +185,21 @@ func handlePaliadinTurn(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// runPaliadinTurnAsync executes the turn and writes events into ch.
|
||||
// Uses a 150 s hard timeout independently of the originating request,
|
||||
// which leaves headroom over the shim's 120 s run-turn cap + SSH
|
||||
// overhead (t-paliad-155: cold-start safety for skill + MCP discovery).
|
||||
//
|
||||
// Backend dispatch:
|
||||
// - StreamingPaliadin (aichat) → drives runStreamingTurn which relays
|
||||
// incremental chunks + upstream heartbeats. No hard ceiling on
|
||||
// stream duration; falls back to silence_timeout (silenceTimeout)
|
||||
// if the upstream goes dark.
|
||||
// - Plain Paliadin (legacy local/remote) → one-shot RunTurn with the
|
||||
// original 150 s ceiling (matches the shim's 120 s run-turn cap +
|
||||
// SSH overhead per t-paliad-155).
|
||||
func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent) {
|
||||
defer func() {
|
||||
// Drain + close. The SSE handler reads until the channel closes.
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
// Send a meta event so the client can show "Paliadin denkt nach …"
|
||||
send(ch, turnEvent{
|
||||
Kind: "meta",
|
||||
Data: map[string]any{
|
||||
@@ -203,6 +208,16 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
|
||||
},
|
||||
})
|
||||
|
||||
if streamer, ok := paliadinSvc.(services.StreamingPaliadin); ok {
|
||||
runStreamingTurn(turnID, req, ch, streamer)
|
||||
return
|
||||
}
|
||||
runOneShotTurn(turnID, req, ch)
|
||||
}
|
||||
|
||||
// runOneShotTurn drives the legacy synchronous backends (local-tmux PoC,
|
||||
// remote ssh+paliadin-shim). Preserves the original 150 s ceiling.
|
||||
func runOneShotTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent) {
|
||||
ctx, cancel := newDetachedContext(150 * time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -220,9 +235,7 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
|
||||
}
|
||||
|
||||
// One-shot content event with the full body. The frontend simulates
|
||||
// streaming with a typewriter effect (cf. design §0.5.5: real
|
||||
// chunked streaming would require Claude to write the response file
|
||||
// progressively — out of PoC scope).
|
||||
// streaming with a typewriter effect.
|
||||
send(ch, turnEvent{
|
||||
Kind: "content",
|
||||
Data: map[string]any{"text": result.Response},
|
||||
@@ -241,6 +254,224 @@ func runPaliadinTurnAsync(turnID uuid.UUID, req services.TurnRequest, ch chan<-
|
||||
})
|
||||
}
|
||||
|
||||
// silenceTimeout is the longest the aichat upstream may stay silent
|
||||
// (no chunk, no heartbeat) before runStreamingTurn gives up and fires
|
||||
// an error frame. 90 s comfortably exceeds aichat's 5 s heartbeat
|
||||
// cadence so a transient stall (model wedge, GC pause) doesn't kill
|
||||
// the turn, while still catching a hard upstream drop.
|
||||
const silenceTimeout = 90 * time.Second
|
||||
|
||||
// streamingThinkingInterval is the cadence at which we emit a synthetic
|
||||
// `thinking` event when the upstream has gone quiet but the connection
|
||||
// is still alive. 5 s matches aichat's own heartbeat tick so the UI
|
||||
// pulse never falls more than 5 s out of date.
|
||||
const streamingThinkingInterval = 5 * time.Second
|
||||
|
||||
// streamingTurnDeadline is the upper bound for a single streaming turn.
|
||||
// Far above any realistic Claude turn but finite so a runaway upstream
|
||||
// (or a paliad bug that never closes the channel) can't leak forever.
|
||||
const streamingTurnDeadline = 30 * time.Minute
|
||||
|
||||
// runStreamingTurn drives an incremental turn against the StreamingPaliadin
|
||||
// backend. Relays chunks → content events, upstream heartbeats →
|
||||
// thinking events, errors → error events. Adds its own silence-watch:
|
||||
// if the upstream emits no event for silenceTimeout, fire an error
|
||||
// frame so the client doesn't sit on a dead stream forever.
|
||||
func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turnEvent, streamer services.StreamingPaliadin) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), streamingTurnDeadline)
|
||||
defer cancel()
|
||||
|
||||
events := make(chan services.StreamEvent, 32)
|
||||
startedAt := time.Now()
|
||||
|
||||
// streamerDone closes when the backend's RunTurnStream returns. We
|
||||
// race the silence watcher and the event pump against it so the
|
||||
// goroutine exit is clean either way.
|
||||
type runResult struct {
|
||||
result *services.TurnResult
|
||||
err error
|
||||
}
|
||||
runCh := make(chan runResult, 1)
|
||||
go func() {
|
||||
res, err := streamer.RunTurnStream(ctx, req, events)
|
||||
runCh <- runResult{res, err}
|
||||
}()
|
||||
|
||||
var (
|
||||
lastEventAt = time.Now()
|
||||
usedTools []string
|
||||
rowsSeen []int
|
||||
classifierTag string
|
||||
convID string
|
||||
gotChunk bool
|
||||
errorEmitted bool
|
||||
)
|
||||
|
||||
silenceTicker := time.NewTicker(streamingThinkingInterval)
|
||||
defer silenceTicker.Stop()
|
||||
|
||||
emitThinking := func(elapsedSeconds int) {
|
||||
// Don't emit `thinking` after the first real chunk arrives —
|
||||
// the frontend hides the pulse once content starts flowing
|
||||
// anyway, but we save bandwidth by stopping emission.
|
||||
send(ch, turnEvent{
|
||||
Kind: "thinking",
|
||||
Data: map[string]any{
|
||||
"elapsed_seconds": elapsedSeconds,
|
||||
"since_first": gotChunk,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case ev, more := <-events:
|
||||
if !more {
|
||||
events = nil // disable case
|
||||
continue
|
||||
}
|
||||
lastEventAt = time.Now()
|
||||
switch ev.Kind {
|
||||
case services.StreamChunk:
|
||||
gotChunk = true
|
||||
send(ch, turnEvent{
|
||||
Kind: "content",
|
||||
Data: map[string]any{
|
||||
"delta": ev.Content,
|
||||
"streamed": true,
|
||||
},
|
||||
})
|
||||
case services.StreamHeartbeat:
|
||||
// Upstream is alive but no chunks yet (or a mid-stream
|
||||
// stall). Pass through with our own thinking shape.
|
||||
send(ch, turnEvent{
|
||||
Kind: "thinking",
|
||||
Data: map[string]any{
|
||||
"elapsed_seconds": ev.ElapsedSeconds,
|
||||
"since_first": gotChunk,
|
||||
"upstream": true,
|
||||
},
|
||||
})
|
||||
case services.StreamMeta:
|
||||
usedTools = ev.UsedTools
|
||||
rowsSeen = ev.RowsSeen
|
||||
classifierTag = ev.ClassifierTag
|
||||
case services.StreamConversation:
|
||||
convID = ev.ConversationID
|
||||
case services.StreamError:
|
||||
errorEmitted = true
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": ev.Code,
|
||||
"message": ev.Message,
|
||||
"retryable": ev.Retryable,
|
||||
},
|
||||
})
|
||||
}
|
||||
case <-silenceTicker.C:
|
||||
elapsed := time.Since(lastEventAt)
|
||||
if elapsed >= silenceTimeout {
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": "upstream_silence",
|
||||
"message": "aichat upstream went silent for over " + silenceTimeout.String(),
|
||||
},
|
||||
})
|
||||
// Cancel the backend so it doesn't keep running.
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
emitThinking(int(time.Since(startedAt).Seconds()))
|
||||
case res := <-runCh:
|
||||
// Drain any remaining events the backend pushed before
|
||||
// closing the channel.
|
||||
if events != nil {
|
||||
for ev := range events {
|
||||
switch ev.Kind {
|
||||
case services.StreamChunk:
|
||||
gotChunk = true
|
||||
send(ch, turnEvent{
|
||||
Kind: "content",
|
||||
Data: map[string]any{
|
||||
"delta": ev.Content,
|
||||
"streamed": true,
|
||||
},
|
||||
})
|
||||
case services.StreamMeta:
|
||||
usedTools = ev.UsedTools
|
||||
rowsSeen = ev.RowsSeen
|
||||
classifierTag = ev.ClassifierTag
|
||||
case services.StreamConversation:
|
||||
convID = ev.ConversationID
|
||||
case services.StreamError:
|
||||
errorEmitted = true
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": ev.Code,
|
||||
"message": ev.Message,
|
||||
"retryable": ev.Retryable,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if res.err != nil {
|
||||
if !errorEmitted {
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": "upstream_error",
|
||||
"message": res.err.Error(),
|
||||
},
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
result := res.result
|
||||
if result == nil {
|
||||
// Shouldn't happen — backend contract returns either err
|
||||
// or a result. Defensive bail.
|
||||
if !errorEmitted {
|
||||
send(ch, turnEvent{
|
||||
Kind: "error",
|
||||
Data: map[string]any{
|
||||
"code": "upstream_error",
|
||||
"message": "stream closed without result",
|
||||
},
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if result.UsedTools != nil {
|
||||
usedTools = result.UsedTools
|
||||
}
|
||||
if result.RowsSeen != nil {
|
||||
rowsSeen = result.RowsSeen
|
||||
}
|
||||
if classifierTag == "" && result.ClassifierTag != "" {
|
||||
classifierTag = result.ClassifierTag
|
||||
}
|
||||
endData := map[string]any{
|
||||
"turn_id": turnID.String(),
|
||||
"used_tools": usedTools,
|
||||
"rows_seen": rowsSeen,
|
||||
"chip_count": result.ChipCount,
|
||||
"classifier_tag": classifierTag,
|
||||
"duration_ms": result.DurationMS,
|
||||
"streamed": true,
|
||||
}
|
||||
if convID != "" {
|
||||
endData["aichat_conversation_id"] = convID
|
||||
}
|
||||
send(ch, turnEvent{Kind: "end", Data: endData})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handlePaliadinStream is the SSE endpoint the EventSource subscribes
|
||||
// to. Reads from the per-turn channel + writes SSE-framed events.
|
||||
func handlePaliadinStream(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -354,6 +585,114 @@ func handlePaliadinTurnGet(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handlePaliadinTurnRecover is the dispatching late-recovery endpoint
|
||||
// (t-paliad-235). Replaces the legacy direct-row-read for the aichat
|
||||
// backend. When the backend implements services.AichatRecoverer (the
|
||||
// PALIADIN_BACKEND=aichat path), we ask aichat directly via its
|
||||
// conversation API whether the turn actually completed upstream after
|
||||
// our stream connection dropped. When it doesn't implement it (legacy
|
||||
// local/remote backends), we fall back to reading the local row —
|
||||
// services.LocalPaliadinService.runJanitor is still the recovery path
|
||||
// there.
|
||||
//
|
||||
// Response shape mirrors handlePaliadinTurnGet so the frontend
|
||||
// late-poll module doesn't need a backend-specific code path.
|
||||
// Additional field `recovery_state` distinguishes:
|
||||
//
|
||||
// "recovered" — the response is in the row (already there, or freshly
|
||||
// written from the upstream check)
|
||||
// "pending" — still no response; caller should keep polling
|
||||
// "lost" — backend confirms the turn is gone (aichat doesn't
|
||||
// have it either). UI should degrade to "verloren".
|
||||
func handlePaliadinTurnRecover(w http.ResponseWriter, r *http.Request) {
|
||||
if !requirePaliadinOwner(w, r) {
|
||||
return
|
||||
}
|
||||
uid, _ := requireUser(w, r)
|
||||
turnID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "invalid turn_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Quick read first — gives us the row regardless of backend.
|
||||
row, err := paliadinSvc.GetTurn(r.Context(), uid, turnID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, "lookup failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
state := recoveryStateFor(row)
|
||||
|
||||
// Aichat backend: when the row still has no response, ask aichat
|
||||
// whether the turn actually finished upstream.
|
||||
if state == "pending" {
|
||||
if rec, ok := paliadinSvc.(services.AichatRecoverer); ok {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
defer cancel()
|
||||
recovered, recErr := rec.RecoverTurn(ctx, uid, turnID)
|
||||
if recErr != nil {
|
||||
// Log + fall through to a plain pending response — a
|
||||
// transient aichat hiccup shouldn't flip the UI to
|
||||
// "lost".
|
||||
_ = recErr
|
||||
} else if recovered != nil {
|
||||
row = recovered
|
||||
state = recoveryStateFor(row)
|
||||
} else {
|
||||
// Aichat returned a clean "no, I don't have it either".
|
||||
// Only mark as lost when the turn is older than the
|
||||
// upstream's plausible turn budget — otherwise the
|
||||
// recovery just hit the window between paliad's stream
|
||||
// dropping and aichat finishing the run.
|
||||
if recoveryShouldGiveUp(row) {
|
||||
state = "lost"
|
||||
}
|
||||
}
|
||||
} else if recoveryShouldGiveUp(row) {
|
||||
// Legacy backends: rely on the janitor. If we're past the
|
||||
// give-up threshold and still no response, surface "lost".
|
||||
state = "lost"
|
||||
}
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"turn_id": row.TurnID.String(),
|
||||
"started_at": row.StartedAt.Format(time.RFC3339),
|
||||
"response": row.Response,
|
||||
"error_code": row.ErrorCode,
|
||||
"finished_at": row.FinishedAt,
|
||||
"duration_ms": row.DurationMS,
|
||||
"used_tools": []string(row.UsedTools),
|
||||
"rows_seen": []int64(row.RowsSeen),
|
||||
"chip_count": row.ChipCount,
|
||||
"classifier_tag": row.ClassifierTag,
|
||||
"recovery_state": state,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// recoveryStateFor returns the lifecycle state of a paliadin turn from
|
||||
// the recovery endpoint's perspective.
|
||||
func recoveryStateFor(row *services.PaliadinTurn) string {
|
||||
if row.Response != nil && *row.Response != "" {
|
||||
return "recovered"
|
||||
}
|
||||
return "pending"
|
||||
}
|
||||
|
||||
// recoveryShouldGiveUp returns true when a turn has been pending long
|
||||
// enough that we should surface "lost" rather than asking the user to
|
||||
// keep waiting. 12 minutes is comfortably beyond the longest realistic
|
||||
// Claude turn (cold-start + reasoning + tool calls all bundled).
|
||||
func recoveryShouldGiveUp(row *services.PaliadinTurn) bool {
|
||||
return time.Since(row.StartedAt) > 12*time.Minute
|
||||
}
|
||||
|
||||
// handlePaliadinHistory returns the caller's prior turns for a given
|
||||
// browser session id, oldest → newest. Both Paliadin surfaces (the
|
||||
// inline drawer and the standalone /paliadin page) hit this on mount
|
||||
|
||||
@@ -251,6 +251,23 @@ func handleProjectTimelineAnchor(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if cpe, ok := services.IsCrossProceedingAnchor(err); ok {
|
||||
parentURL := "/projects/" + cpe.ParentProjectID.String()
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": "cross_proceeding_anchor",
|
||||
"requested_rule_code": cpe.RequestedRuleCode,
|
||||
"requested_rule_name_de": cpe.RequestedRuleNameDE,
|
||||
"requested_rule_name_en": cpe.RequestedRuleNameEN,
|
||||
"parent_project_id": cpe.ParentProjectID.String(),
|
||||
"parent_project_title": cpe.ParentProjectTitle,
|
||||
"parent_project_url": parentURL,
|
||||
"message_de": "Diese Frist gehört zum Verletzungsverfahren „" +
|
||||
cpe.ParentProjectTitle + "“. Bitte den Anker dort setzen, nicht auf der Widerklage.",
|
||||
"message_en": "This deadline belongs to the infringement proceeding „" +
|
||||
cpe.ParentProjectTitle + "“. Anchor it on the parent project, not the counterclaim.",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user