Compare commits
416 Commits
mai/ritchi
...
mai/noethe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a61c1490e3 | ||
|
|
544bb63684 | ||
|
|
f8d8ea591d | ||
|
|
77d664c5cc | ||
|
|
8cf95761d0 | ||
|
|
d41fc49809 | ||
|
|
1eebf2fc44 | ||
|
|
fb1a709bb8 | ||
|
|
e2e1381395 | ||
|
|
0d54da1d5b | ||
|
|
deef5aaff5 | ||
|
|
bc47d78d97 | ||
|
|
07a1c17861 | ||
|
|
2247c0707d | ||
|
|
93c4453ce5 | ||
|
|
a42322de3f | ||
|
|
457af2f6c4 | ||
|
|
abc395fcfa | ||
|
|
747d85fe49 | ||
|
|
6c41550945 | ||
|
|
fb6a07f4b7 | ||
|
|
10b3426086 | ||
|
|
4ebbf2c1af | ||
|
|
b3401ec8ac | ||
|
|
7d1ddb9b84 | ||
|
|
c1ceab7f4b | ||
|
|
733917aae2 | ||
|
|
d72990ad1b | ||
|
|
a9d3695719 | ||
|
|
bf06499d9c | ||
|
|
98cb65f2cc | ||
|
|
b54e938bdf | ||
|
|
6c3a6efc34 | ||
|
|
d22ace1019 | ||
|
|
7f292e5fa5 | ||
|
|
b7470d7d77 | ||
|
|
30ac337a78 | ||
|
|
25b4491681 | ||
|
|
3d905a0694 | ||
|
|
19a1b8c942 | ||
|
|
acaab22ad7 | ||
|
|
931673337a | ||
|
|
63eb5bde6f | ||
|
|
cc0059d050 | ||
|
|
b32cfed37d | ||
|
|
ff36528148 | ||
|
|
f40b652d01 | ||
|
|
5bd17de732 | ||
|
|
f7d72ff1d3 | ||
|
|
568bc99a36 | ||
|
|
c399caff75 | ||
|
|
7141f4a954 | ||
|
|
1182771fed | ||
|
|
2c770ef02f | ||
|
|
4d820892e8 | ||
|
|
7e363ac01d | ||
|
|
2ed476dc64 | ||
|
|
1ea983f0c7 | ||
|
|
1e5df8201b | ||
|
|
7bd223ecd9 | ||
|
|
b45278b060 | ||
|
|
16c991288f | ||
|
|
53d5e5306c | ||
|
|
8c64344126 | ||
|
|
706afb617f | ||
|
|
a9531afbf4 | ||
|
|
25076142f4 | ||
|
|
d747046bf0 | ||
|
|
e3b093d9a2 | ||
|
|
9a4f45fe48 | ||
|
|
24e22511ec | ||
|
|
3b595390c7 | ||
|
|
cc68ab2873 | ||
|
|
258ebb8508 | ||
|
|
78966ec098 | ||
|
|
20eaa9bba4 | ||
|
|
94ebc1d043 | ||
|
|
79f09006fc | ||
|
|
355e718516 | ||
|
|
6eece2d0ff | ||
|
|
0e1d4869fb | ||
|
|
7fdd74ed5d | ||
|
|
cca433cb10 | ||
|
|
049136d424 | ||
|
|
9919e04657 | ||
|
|
c1ff631257 | ||
|
|
bf80c167ba | ||
|
|
63e5fb0b86 | ||
|
|
04d034af81 | ||
|
|
371a38a194 | ||
|
|
4d7c74994a | ||
|
|
062630ca38 | ||
|
|
8123d71d08 | ||
|
|
a69fff73e9 | ||
|
|
1bba9cb3ce | ||
|
|
d286da34d5 | ||
|
|
7461c4af49 | ||
|
|
0587fc2296 | ||
|
|
d688ebde90 | ||
|
|
6940c1e030 | ||
|
|
f79dbdba4a | ||
|
|
ecfd62e330 | ||
|
|
7581444cd4 | ||
|
|
e9e445fddf | ||
|
|
5875a62aba | ||
|
|
0ead001811 | ||
|
|
c09150e744 | ||
|
|
4e1213fbd1 | ||
|
|
df2b2114df | ||
|
|
2f44461275 | ||
|
|
fe8ba15477 | ||
|
|
53f7eae665 | ||
|
|
c554e865eb | ||
|
|
0be2dfb5a0 | ||
|
|
eb6e194684 | ||
|
|
56522adffe | ||
|
|
b88afe8ddd | ||
|
|
341fa6c26f | ||
|
|
45979caf81 | ||
|
|
1dad1c7371 | ||
|
|
9dbc55638a | ||
|
|
2c346672bb | ||
|
|
7463831932 | ||
|
|
165f0c1717 | ||
|
|
82421b3c86 | ||
|
|
b2dfc87f57 | ||
|
|
57237a55a3 | ||
|
|
88af8d3487 | ||
|
|
50ac065c7d | ||
|
|
285e97203a | ||
|
|
fe9c1b7de2 | ||
|
|
49eb3c97e1 | ||
|
|
2102dfd07d | ||
|
|
25efce0c76 | ||
|
|
1def9e86b9 | ||
|
|
db09f6e7d5 | ||
|
|
99af714d65 | ||
|
|
de03f3ddcb | ||
|
|
d19e35bfaf | ||
|
|
37a925d3b2 | ||
|
|
16eb73bf44 | ||
|
|
2bbbe562d7 | ||
|
|
102d0168e9 | ||
|
|
e6369bc4c2 | ||
|
|
1a815979f8 | ||
|
|
8a9b9c6611 | ||
|
|
73d108d878 | ||
|
|
0081749f69 | ||
|
|
95a6df5b49 | ||
|
|
f51bce3342 | ||
|
|
268695d83f | ||
|
|
e436abd631 | ||
|
|
a020c1e4c8 | ||
|
|
2d46a86c50 | ||
|
|
95cedebea7 | ||
|
|
19e4bfacfb | ||
|
|
8f7f53b4c8 | ||
|
|
215d4a465b | ||
|
|
a5a05b1a66 | ||
|
|
df321acb63 | ||
|
|
a05002299d | ||
|
|
fd6517d53a | ||
|
|
d14c8111eb | ||
|
|
c6872f94b0 | ||
|
|
47af52d7ea | ||
|
|
460736ad1e | ||
|
|
bbd46f658b | ||
|
|
4ddab75493 | ||
|
|
90b2f935c2 | ||
|
|
fca7143244 | ||
|
|
2ee4189d74 | ||
|
|
909167b036 | ||
|
|
60653a51be | ||
|
|
401186f05b | ||
|
|
8ddc8277d0 | ||
|
|
ad9a53a04d | ||
|
|
04ce6a8bfa | ||
|
|
c74f6b494c | ||
|
|
75867b2a3e | ||
|
|
43abb41f28 | ||
|
|
f721d7eccd | ||
|
|
413a40c808 | ||
|
|
8668c7d5ad | ||
|
|
721560074b | ||
|
|
97ea393fe9 | ||
|
|
d00974424f | ||
|
|
29143e15fd | ||
|
|
d78f20be8a | ||
|
|
30e2beed87 | ||
|
|
b3b85261e1 | ||
|
|
50685e6e13 | ||
|
|
2c4e1e5782 | ||
|
|
b1bdf8ceb3 | ||
|
|
aab82d4aca | ||
|
|
31afab031f | ||
|
|
22156f0cd5 | ||
|
|
8ddfb94f9e | ||
|
|
fee6afdb14 | ||
|
|
34e5ffe94b | ||
|
|
ce3227c1c0 | ||
|
|
4b4c61903d | ||
|
|
5c11fe5e6d | ||
|
|
74d4d913c2 | ||
|
|
b25da860c8 | ||
|
|
d6a91ee43c | ||
|
|
800668a483 | ||
|
|
2b476e4f25 | ||
|
|
31db66e3b7 | ||
|
|
b178c47a44 | ||
|
|
3da11bd798 | ||
|
|
17aa840977 | ||
|
|
e468930342 | ||
|
|
8cd67433df | ||
|
|
25ca1fa763 | ||
|
|
db20bf5442 | ||
|
|
8bcfb6b960 | ||
|
|
270f7d7ddc | ||
|
|
61766161b7 | ||
| 2c67299740 | |||
|
|
aef40bb425 | ||
|
|
d6ff36dce4 | ||
|
|
ee83748089 | ||
|
|
194c61b498 | ||
|
|
832104af9e | ||
|
|
d50ba363a8 | ||
|
|
8dc1beb4e1 | ||
|
|
0e3411c40b | ||
|
|
76785da3f6 | ||
|
|
f963b4b2bc | ||
|
|
633ce5a9fe | ||
|
|
c4122bc265 | ||
|
|
9e216a4c44 | ||
|
|
933a16b6eb | ||
|
|
2422603abf | ||
|
|
1a89b0c490 | ||
|
|
a719eb26a6 | ||
|
|
25a44dcaee | ||
|
|
1652436f1b | ||
|
|
93a90b0ffa | ||
|
|
4a25f2ee0f | ||
|
|
3dc56552fa | ||
|
|
d00eb5f598 | ||
|
|
8fe05fe696 | ||
|
|
7d45626d57 | ||
|
|
f583c650a2 | ||
|
|
2ffdcb9c25 | ||
|
|
bff2ec5107 | ||
|
|
80fdab0963 | ||
|
|
1efa0abc10 | ||
|
|
ee1af9d9cf | ||
|
|
f0d01a84a4 | ||
|
|
be40425623 | ||
|
|
495e519475 | ||
|
|
4a84814b1d | ||
|
|
765bfe0648 | ||
|
|
80518e4dd8 | ||
|
|
525b409fd0 | ||
|
|
f988666ba0 | ||
|
|
93fdf10537 | ||
|
|
12f535abd3 | ||
|
|
b21dacf15c | ||
|
|
5423a4e1f1 | ||
|
|
c9ca08fcbb | ||
|
|
6620ac6379 | ||
|
|
3a695eca72 | ||
|
|
c9054ed753 | ||
|
|
f7d01b9996 | ||
|
|
84145f6599 | ||
|
|
de2788c2d7 | ||
|
|
f8982a6628 | ||
|
|
a36e9dffff | ||
|
|
71ab1e9916 | ||
|
|
7644c2e2d8 | ||
|
|
abd99980fc | ||
|
|
7e76b0e414 | ||
|
|
b0ecd24d00 | ||
|
|
f33ac9469c | ||
|
|
d5d1cffd3a | ||
|
|
5f11b6a1c8 | ||
|
|
4e796c5627 | ||
|
|
bad65c3ffe | ||
|
|
c2eb23aa5b | ||
|
|
d2777be931 | ||
|
|
b34500ad31 | ||
|
|
aec150f1cd | ||
|
|
1588da371f | ||
|
|
d55e98806f | ||
|
|
c697fe3418 | ||
|
|
c68e464d67 | ||
|
|
59cf47b5ed | ||
|
|
94222f790b | ||
|
|
e68ff5b434 | ||
|
|
fa1525b620 | ||
|
|
132992ba2a | ||
|
|
fde4cbe2a9 | ||
|
|
75b52d49ba | ||
|
|
c226a8b14d | ||
|
|
79d332d5b2 | ||
|
|
044166ffed | ||
|
|
2a178695ac | ||
|
|
3aa8bae8e9 | ||
|
|
a3f778c86a | ||
|
|
58692a4411 | ||
|
|
1b0de2f89c | ||
|
|
ccbb7e9e33 | ||
|
|
71d49d8b81 | ||
|
|
0800ba97f3 | ||
|
|
4c74b960e9 | ||
|
|
44ad50d5e4 | ||
|
|
134c807da3 | ||
|
|
dc70114d92 | ||
|
|
4e06a5db39 | ||
|
|
8921830f43 | ||
|
|
69efafeb33 | ||
|
|
ad77eb98a3 | ||
|
|
3f0c26fd3a | ||
|
|
2b6218ae2d | ||
|
|
2cf20448b3 | ||
|
|
3a1eb07781 | ||
|
|
d219ca7cdf | ||
|
|
263a4605e3 | ||
|
|
b4a409a013 | ||
|
|
70c3f08668 | ||
|
|
3ff982cc51 | ||
|
|
6698210e9b | ||
|
|
f782ef7975 | ||
|
|
5611e0154c | ||
|
|
d81da4b3a8 | ||
|
|
cf94f0ca25 | ||
|
|
05623b673a | ||
|
|
3111c7440a | ||
|
|
fc8275288a | ||
|
|
4bc23958ee | ||
|
|
144a08d409 | ||
|
|
1f9c4d0296 | ||
|
|
5c887df5fa | ||
|
|
0c382b6f69 | ||
|
|
50a1dae357 | ||
|
|
0d0ba6ee1d | ||
|
|
14d5706a5e | ||
|
|
83d5973dd6 | ||
|
|
0ad2e86945 | ||
|
|
761e350261 | ||
|
|
21415ce941 | ||
|
|
d4abfb7299 | ||
|
|
c4e6d0eeef | ||
|
|
9c96446bbe | ||
|
|
28d747e656 | ||
|
|
aafbfbbf12 | ||
|
|
c893027457 | ||
|
|
881bc98eb1 | ||
|
|
34194aedd5 | ||
|
|
2131fdbf55 | ||
|
|
01de3f736b | ||
|
|
edad61478d | ||
|
|
544149114c | ||
|
|
a2d90be72d | ||
|
|
9705290f3d | ||
|
|
f25113abe0 | ||
|
|
b13065b61a | ||
|
|
0d6c58a337 | ||
|
|
9bb9f0c3df | ||
|
|
94e2fc0024 | ||
|
|
b06a040e2b | ||
|
|
d20cf8deef | ||
|
|
caf319e7ee | ||
|
|
49c6bc75ca | ||
|
|
3faec6c526 | ||
|
|
fb401c63c0 | ||
|
|
eb6de16e88 | ||
|
|
51b16a6a41 | ||
|
|
79889a2b83 | ||
|
|
bde4b57099 | ||
|
|
ff1c5ceb0e | ||
|
|
59e1cb1445 | ||
|
|
449075deaf | ||
|
|
adb0ce2c9d | ||
|
|
746bced30b | ||
|
|
7e0c06342b | ||
|
|
a2388e9a6b | ||
|
|
41cc295500 | ||
|
|
640d5c1a23 | ||
|
|
4ac9dacaa0 | ||
|
|
cb2841fba9 | ||
|
|
9aa8037193 | ||
|
|
5fcaa7471b | ||
|
|
da509755cf | ||
|
|
fabe32aa56 | ||
|
|
b370d59eee | ||
|
|
5fb55164b3 | ||
|
|
e76056cfd1 | ||
|
|
11217f7bfa | ||
|
|
45c7cf34ef | ||
|
|
7c44bbec7e | ||
|
|
ebb206307d | ||
|
|
b8f95f5d7a | ||
|
|
41b28bdfdb | ||
|
|
0cdc644b50 | ||
|
|
f80d1a5753 | ||
|
|
67cd66e054 | ||
|
|
a7df6eb977 | ||
|
|
6406aba2a5 | ||
|
|
3e20806aee | ||
|
|
3e14171808 | ||
|
|
bcdd3d7a59 | ||
|
|
117ccefe07 | ||
|
|
4c0babb2f3 | ||
|
|
e96b9dfb77 | ||
|
|
ee341742b6 | ||
|
|
42e5a8471c | ||
|
|
59ba1d5778 | ||
|
|
416234b25a | ||
|
|
5a9f8e5874 | ||
|
|
d0d4f624a1 | ||
|
|
04bf36666f | ||
|
|
b56ef660df |
@@ -1,6 +1,6 @@
|
||||
# paliad
|
||||
|
||||
Paliad — the patent paladin. Patent practice platform for HLC (formerly Hogan Lovells) colleagues.
|
||||
Paliad — the patent paladin. All-in-one patent practice platform for HLC (formerly Hogan Lovells) colleagues. Knowledge platform + Aktenverwaltung in one sidebar, one URL, one login.
|
||||
|
||||
**Brand:** Paliad (firm-agnostic — survives firm renames)
|
||||
**Primary domain:** paliad.de
|
||||
@@ -10,11 +10,12 @@ Paliad — the patent paladin. Patent practice platform for HLC (formerly Hogan
|
||||
|
||||
## Purpose
|
||||
|
||||
- Share guides, templates, and documents with the patent team
|
||||
- Centralized knowledge base and toolkit for patent workflows
|
||||
- Interactive tools: Prozesskostenrechner, Fristenrechner, Gebührentabellen, Checklisten, Gerichtsverzeichnis, Patentglossar, Link Hub, Downloads
|
||||
- Document best practices and style guides (HL Patents Style)
|
||||
- Long-term: collaboration features, document management
|
||||
- **Project management** — hierarchical projects (Client → Litigation → Patent → Case), deadlines, appointments, parties, notes, audit trail. Team-based visibility with inheritance down the project tree. Personal CalDAV sync.
|
||||
- **Interactive knowledge tools** — Prozesskostenrechner, Fristenrechner, Gebührentabellen, Checklisten, Gerichtsverzeichnis, Patentglossar, Link Hub, Downloads.
|
||||
- **Dashboard** — logged-in landing with deadline traffic lights, upcoming appointments, recent activity, all scoped to visible projects.
|
||||
- Share guides, templates, and documents with the patent team.
|
||||
- Document best practices and style guides (HL Patents Style).
|
||||
- Long-term: document upload, collaboration annotations, Outlook/Exchange sync.
|
||||
|
||||
## Audience
|
||||
|
||||
@@ -24,18 +25,41 @@ Paliad — the patent paladin. Patent practice platform for HLC (formerly Hogan
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend:** Bun + TSX (custom JSX renderer, no React)
|
||||
- **Backend:** Go API, net/http
|
||||
- **Auth:** Supabase (youpc instance) — password-based, `@hoganlovells.com` gate (TBD: update to `@hlc.*` post-merger)
|
||||
- **Frontend:** Bun + TSX (custom JSX renderer, no React), per-page client TS bundles, HTML-first forms
|
||||
- **Backend:** Go API, `net/http`, `sqlx` for DB access
|
||||
- **Migrations:** `golang-migrate/migrate/v4` with SQL files embedded via `embed.FS`; applied at server startup before the HTTP listener binds. Migration tracker is `paliad.paliad_schema_migrations` (avoids collision with other apps on the shared `public.schema_migrations`).
|
||||
- **Database:** youpc Supabase Postgres (port 11833), `paliad` schema. Team-based RLS via `paliad.can_see_project(project_id)` — visibility determined by team membership (direct + inherited up the project tree). See `docs/design-data-model-v2.md`.
|
||||
- **Auth:** Supabase (youpc instance) — password-based, email-domain gate via `ALLOWED_EMAIL_DOMAINS` (default `hoganlovells.com,hlc.com,hlc.de`). The whitelist references real DNS domains and rotates independently from `FIRM_NAME` (display name).
|
||||
- **Hosting:** Dokploy compose on mlake (72.62.52.189), compose ID `Zx147ycurfYagKRl_Zzyo`
|
||||
- **Domains on Dokploy:** paliad.de (primary, Let's Encrypt), patholo.de (legacy), patholo.msbls.de (internal)
|
||||
- **Deploy:** push to main → Gitea webhook → Dokploy auto-deploy
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Required | Purpose |
|
||||
|---|---|---|
|
||||
| `PORT` | no (default 8080) | HTTP listen port |
|
||||
| `SUPABASE_URL` | yes | Supabase project URL (auth) |
|
||||
| `SUPABASE_ANON_KEY` | yes | Supabase anon key (auth) |
|
||||
| `DATABASE_URL` | for Aktenverwaltung | Direct Postgres conn for migrations + Akten/Fristen/Termine services. Knowledge-platform features (Kostenrechner, Glossar, Links, Gebührentabellen, Checklisten, Gerichte, Downloads) work without it — those endpoints return data from static JSON and never touch the pool. Aktenverwaltung endpoints return 503 if unset. |
|
||||
| `CALDAV_ENCRYPTION_KEY` | for CalDAV sync | 32-byte AES-256 key, base64-encoded. Encrypts CalDAV passwords at rest (AES-GCM). Server fails fast on malformed key; CalDAV is silently disabled if unset (Termine still work locally; `/api/caldav-config` returns 501). |
|
||||
| `GITEA_TOKEN` | optional | Gitea API token for the private file proxy (Downloads) |
|
||||
| `PALIAD_BASE_URL` | optional | Public origin used in email links. Defaults to `https://paliad.de`; override for staging/preview. |
|
||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_PASSWORD` / `SMTP_FROM` / `SMTP_FROM_NAME` / `SMTP_USE_TLS` | for email | SMTP credentials for Paliad's transactional mail (reminders, invitations). Port 465 uses implicit TLS. `MailService` silently no-ops when any required var is missing — the server still boots for knowledge-platform-only deployments. |
|
||||
| `ANTHROPIC_API_KEY` | not used today | Reserved for Phase H (AI Frist-Extraktion) which is deferred per m's 2026-04-16 decision. Do not set. |
|
||||
| `FIRM_NAME` | optional (default `HLC`) | Display name of the firm Paliad is being branded for in this deployment. Read once at process start by `internal/branding.Name` (Go) and inlined into client bundles by `frontend/build.ts` (TypeScript). Powers every user-facing surface — landing hero, page titles, login hint, Downloads page, footer, invitation/reminder email bodies. The `ALLOWED_EMAIL_DOMAINS` whitelist is a separate concern (real DNS domains, not display name) and rotates independently. |
|
||||
|
||||
> *Note on `DATABASE_URL`:* "Work without DB" ≠ "ungated". All knowledge-platform routes (Kostenrechner, Glossar, Links, Gebührentabellen, Checklisten, Gerichte, Downloads) are still behind the auth gate (302 to `/login` for anon visitors); only `/`, `/login`, `/logout`, and `/assets/*` are public. The `gateOnboarded` middleware additionally blocks unonboarded users from app pages but does NOT gate the knowledge-platform pages.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- **Gitea:** `mAi/paliad` on mgit.msbls.de (renamed from mAi/patholo — auto-redirects)
|
||||
- **Gitea:** `m/paliad` on mgit.msbls.de (renamed from `mAi/paliad` 2026-04-30; previously `mAi/patholo` — auto-redirects)
|
||||
- **DNS:** paliad.de → 72.62.52.189 (via Hostinger)
|
||||
- **Branding:** lime green accent (`#c6f41c`), Sidebar layout, DE/EN i18n
|
||||
- **Branding:** lime green accent (`#c6f41c`), sidebar layout, DE/EN i18n. Firm-agnostic: every user-facing firm reference is rendered from `internal/branding.Name` (Go) / `frontend/src/branding.ts` (TypeScript). Default "HLC", overridable via `FIRM_NAME`. See t-paliad-065.
|
||||
|
||||
## Project status & history
|
||||
|
||||
Phase status, shipped milestones, open follow-ups, and the patHoLo→Paliad rebrand history live in `docs/project-status.md`. Read that before assuming a feature is or isn't built.
|
||||
|
||||
## Worker Preferences
|
||||
|
||||
@@ -43,6 +67,21 @@ Paliad — the patent paladin. Patent practice platform for HLC (formerly Hogan
|
||||
- Use **Sonnet** for implementation
|
||||
- Prefer **gitster** role for issues
|
||||
|
||||
## Historical naming
|
||||
## Language convention
|
||||
|
||||
Previously called *patHoLo* (Patent + Hogan Lovells). Rebranded to Paliad on 2026-04-16 when HL announced the merger into HLC, making "HoLo" obsolete. Paliad — "Patent Litigation Administration" but in UI used as a standalone word evoking *paladin*, the champion. Firm-agnostic so the brand survives any future firm renames. Lime branding kept throughout.
|
||||
**System language is English.** All code, table names, Go types, service names, URL paths, API endpoints, file names — English. Examples: `projects` not `projekte`, `deadlines` not `fristen`, `appointments` not `termine`, `ProjectService` not `ProjektService`, `/projects` not `/projekte`.
|
||||
|
||||
**Frontend default language is German.** User-facing i18n strings are bilingual (DE primary, EN secondary). UI labels, error messages, page titles — all translated via `i18n.ts`. The product speaks German to its users but the codebase speaks English to developers.
|
||||
|
||||
**Product tool names stay German** as brand names: Fristenrechner, Kostenrechner, Gebührentabellen (these are proper nouns in the product context, kept in URLs as `/tools/fristenrechner` etc.).
|
||||
|
||||
## Frontend conventions
|
||||
|
||||
**`.entity-table` row-click contract.** The default `.entity-table tbody tr` rule sets `cursor: pointer` + a hover highlight on every row. If you add an `.entity-table` to a page, the row affordance must match reality:
|
||||
|
||||
- **Rows that navigate** — wire a row-level click handler that does `window.location.href = "..."` and skips clicks on inner `<a>` / `<button>` (so nested links and action buttons still work). Pattern lives in `frontend/src/client/checklists.ts`, `client/projects-detail.ts`, `client/deadlines.ts`.
|
||||
- **Rows that don't navigate** (read-only summary tables, admin tables where all actions are inline buttons) — add `entity-table--readonly` to the `<table>` className. That modifier neutralises the cursor and hover.
|
||||
|
||||
A row that looks clickable but isn't is a UX lie and confuses users (cf. t-paliad-098/099). The CSS rule and modifier are anchored in `frontend/src/styles/global.css` near `.entity-table tbody tr`.
|
||||
|
||||
**Whole-card / whole-row click → use a JS row handler, not a `::before` overlay.** Don't make a card fully clickable by spanning a `::before { inset: 0 }` (or any pointer-event overlay) over it — the overlay swallows pointer events on the text and breaks selection / copy (cf. t-paliad-102 → t-paliad-103). Instead, attach a row-level click handler that calls `window.location.href = ...` and skips clicks on inner `<a>` / `<button>` (the same pattern as the `.entity-table` rule above). Examples on `.entity-event` (Verlauf) and `.dashboard-activity-item` in `frontend/src/client/projects-detail.ts` + `client/dashboard.ts`. Text stays selectable, click still navigates, keyboard / Cmd-click semantics intact.
|
||||
|
||||
7
.gitignore
vendored
@@ -14,3 +14,10 @@ frontend/dist/
|
||||
# OS
|
||||
.DS_Store
|
||||
.worktrees/
|
||||
|
||||
# mai worker state
|
||||
.m/
|
||||
|
||||
# Playwright MCP scratch (screenshots + console logs from local verification)
|
||||
/.playwright-mcp/
|
||||
/paliad-*.png
|
||||
|
||||
@@ -50,7 +50,7 @@ worker:
|
||||
max_workers: 5
|
||||
persistent: true
|
||||
head:
|
||||
name: ""
|
||||
name: "maria"
|
||||
max_loops: 50
|
||||
infinity_mode: false
|
||||
max_idle_duration: 2h0m0s
|
||||
|
||||
13
.mcp.json
@@ -2,10 +2,19 @@
|
||||
"mcpServers": {
|
||||
"supabase": {
|
||||
"type": "http",
|
||||
"url": "http://100.99.98.201:8000/mcp",
|
||||
"url": "https://ystudio.msbls.de/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Basic ${SUPABASE_AUTH}"
|
||||
"Authorization": "Basic ${YOUPC_SUPABASE_AUTH}"
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"command": "bunx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--headless",
|
||||
"--user-data-dir",
|
||||
"/tmp/mai-playwright-profile"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
69
README.md
@@ -1,27 +1,53 @@
|
||||
# paliad
|
||||
|
||||
Paliad — patent practice platform for HLC colleagues. Knowledge tools (Kostenrechner, Glossar, Gebührentabellen, Checklisten, Gerichtsverzeichnis, Links, Downloads) plus Aktenverwaltung (matters, deadlines, appointments, documents — Phase 0 in progress).
|
||||
Paliad — all-in-one patent practice platform for HLC (formerly Hogan Lovells). Knowledge tools and Aktenverwaltung behind one sidebar.
|
||||
|
||||
- **Aktenverwaltung**: Akten (matters), Fristen (deadlines), Termine (appointments) with CalDAV sync, Parteien, Dashboard. Office-scoped visibility with explicit collaborators.
|
||||
- **Knowledge tools**: Prozesskostenrechner (DE / UPC / EPA), Fristenrechner, Gebührentabellen, Patentglossar, Gerichtsverzeichnis, Checklisten, Link Hub, Downloads.
|
||||
|
||||
Domain: `paliad.de` (legacy: `patholo.de`, `patholo.msbls.de`).
|
||||
Repo: `mAi/paliad` on `mgit.msbls.de`.
|
||||
Repo: `m/paliad` on `mgit.msbls.de`.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Frontend**: Bun + custom JSX/TSX renderer (no React), per-page client TS bundles
|
||||
- **Backend**: Go (net/http), embedded migrations via `golang-migrate/migrate/v4`
|
||||
- **Frontend**: Bun + custom JSX/TSX renderer (no React), per-page client TS bundles, HTML-first forms
|
||||
- **Backend**: Go (`net/http`), `sqlx` for DB access
|
||||
- **Migrations**: `golang-migrate/migrate/v4` with SQL files embedded via `embed.FS`; applied at server startup before the HTTP listener binds
|
||||
- **Database**: youpc Supabase Postgres, `paliad` schema. Office-scoped RLS (`paliad.can_see_akte(akte_id)`) — see `docs/design-kanzlai-integration.md` §2
|
||||
- **Auth**: Supabase password (cookie session, `@hoganlovells.com` / `@hlc.*` email gate)
|
||||
- **DB**: youpc Supabase Postgres, `paliad` schema (office-scoped RLS — see `docs/design-kanzlai-integration.md` §2)
|
||||
- **CalDAV**: hand-rolled iCal + minimal WebDAV client in `internal/services/caldav_*.go`; AES-GCM at rest for stored passwords
|
||||
- **Hosting**: Dokploy compose `Zx147ycurfYagKRl_Zzyo` on mlake
|
||||
|
||||
## Database migrations
|
||||
|
||||
Migrations live in `internal/db/migrations/` as `NNN_description.up.sql` + `.down.sql` pairs. They are embedded into the Go binary via `embed.FS` and applied automatically at server startup (before the HTTP listener binds) when `DATABASE_URL` is set.
|
||||
|
||||
The migration tracker is `paliad.paliad_schema_migrations` (not the default `public.schema_migrations`). This avoids a collision with other apps on the shared youpc Supabase instance — see the memory episode "paliad migration bootstrap collision with shared Postgres" for the incident that drove the change.
|
||||
|
||||
Current migrations (as of April 2026):
|
||||
|
||||
```
|
||||
001_paliad_schema schema + extensions
|
||||
002_users paliad.users (office, role, practice_group)
|
||||
003_reference_tables proceeding_types, deadline_rules, holidays
|
||||
004_akten paliad.akten with visibility columns
|
||||
005_akten_children parteien, fristen, termine, dokumente, akten_events, notizen
|
||||
006_visibility paliad.can_see_akte() function
|
||||
007_rls_policies RLS on every paliad table
|
||||
008_seed_proceeding_types
|
||||
009_seed_deadline_rules 32 UPC + 4 ZPO rules
|
||||
010_seed_holidays DE federal + UPC judicial vacations
|
||||
011_feedback_tables link_suggestions, checklisten_feedback, gerichte_feedback
|
||||
012_fristenrechner_rules DB-backed rule set for /tools/fristenrechner
|
||||
013_user_caldav_config per-user CalDAV (encrypted) + sync log
|
||||
014_checklist_instances persisted checklist instances linkable to Akten
|
||||
```
|
||||
|
||||
Add a new migration:
|
||||
|
||||
```
|
||||
internal/db/migrations/012_<description>.up.sql
|
||||
internal/db/migrations/012_<description>.down.sql
|
||||
internal/db/migrations/015_<description>.up.sql
|
||||
internal/db/migrations/015_<description>.down.sql
|
||||
```
|
||||
|
||||
The down file is required and must reverse the up cleanly (verified by adding a one-off down test before merge).
|
||||
@@ -32,7 +58,7 @@ To run migrations against a local Postgres:
|
||||
docker run -d --name paliad-pg -e POSTGRES_PASSWORD=test -p 5432:5432 postgres:16-alpine
|
||||
# bootstrap a mock auth schema (auth.users + auth.uid()) — required because
|
||||
# the migrations reference Supabase-provided objects:
|
||||
psql postgres://postgres:test@localhost:5432/postgres -f internal/db/migrations/_dev/mock_supabase_auth.sql
|
||||
psql postgres://postgres:test@localhost:5432/postgres -f internal/db/devtools/mock_supabase_auth.sql
|
||||
DATABASE_URL='postgres://postgres:test@localhost:5432/postgres?sslmode=disable' \
|
||||
SUPABASE_URL=stub SUPABASE_ANON_KEY=stub \
|
||||
go run ./cmd/server
|
||||
@@ -45,8 +71,10 @@ go run ./cmd/server
|
||||
| `PORT` | no (default 8080) | HTTP listen port |
|
||||
| `SUPABASE_URL` | yes | Supabase project URL (auth) |
|
||||
| `SUPABASE_ANON_KEY` | yes | Supabase anon key (auth) |
|
||||
| `DATABASE_URL` | optional today, required after Phase B | Direct Postgres conn for migrations + services |
|
||||
| `GITEA_TOKEN` | optional | Gitea API token for private file proxy |
|
||||
| `DATABASE_URL` | for Aktenverwaltung | Direct Postgres conn for migrations + Akten/Fristen/Termine services. Knowledge-platform endpoints (Kostenrechner, Glossar, Links, Gebührentabellen, Checklisten, Gerichte, Downloads) don't use the pool and work without it. Aktenverwaltung endpoints return `503` if unset. |
|
||||
| `CALDAV_ENCRYPTION_KEY` | for CalDAV sync | 32-byte AES-256 key, base64-encoded. Encrypts CalDAV passwords at rest (AES-GCM). Server fails fast on malformed key; if unset, CalDAV is silently disabled (`/api/caldav-config` returns `501`). Generate with `openssl rand -base64 32`. |
|
||||
| `GITEA_TOKEN` | optional | Gitea API token for the private file proxy (Downloads) |
|
||||
| `ANTHROPIC_API_KEY` | not used today | Reserved for Phase H (AI Frist-Extraktion). Currently deferred — do not set. |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -54,8 +82,29 @@ go run ./cmd/server
|
||||
make build # compile backend + frontend
|
||||
make test # run Go tests + frontend tests
|
||||
go build ./... # backend only
|
||||
go vet ./... # static checks
|
||||
go test ./... # Go tests
|
||||
bun run build # frontend only (produces frontend/dist/)
|
||||
```
|
||||
|
||||
Project layout:
|
||||
|
||||
```
|
||||
cmd/server/ # main entry point
|
||||
internal/db/ # sqlx pool + embedded migrations
|
||||
internal/services/ # AkteService, FristService, TerminService, CalDAV, ...
|
||||
internal/handlers/ # HTTP handlers (pages + API)
|
||||
internal/calc/ # Kostenrechner / Fristenrechner logic
|
||||
frontend/ # Bun + TSX source; static HTML output to frontend/dist/
|
||||
docs/ # design docs + this roadmap
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
Push to `main` → Gitea webhook → Dokploy auto-deploy on mlake.
|
||||
|
||||
## Project status (April 2026)
|
||||
|
||||
Phases A–G, I and J of the KanzlAI integration are shipped: schema, services, Akten, Fristen, Termine + CalDAV, Dashboard, Notizen service + UI (commit `5a9f8e5`, 2026-04-17), and instanceable Checklisten (migration 014). Phase H (AI Frist extraction) is **deferred** pending a reversal of the "no Anthropic API" decision; the Dokumente tab on Akten detail is hidden until that lands. KanzlAI infra retirement (Dokploy shutdown, `kanzlai` schema drop, Gitea archive) is still pending.
|
||||
|
||||
See `docs/feature-roadmap.md` for the full backlog and `docs/design-kanzlai-integration.md` for the integration design.
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/auth"
|
||||
"mgit.msbls.de/m/patholo/internal/db"
|
||||
"mgit.msbls.de/m/patholo/internal/handlers"
|
||||
"mgit.msbls.de/m/patholo/internal/services"
|
||||
// Embed Go's IANA tz database into the binary so time.LoadLocation works
|
||||
// without OS tzdata. The runtime image (alpine) doesn't ship /usr/share/
|
||||
// zoneinfo — without this import, every reminder timezone lookup fails
|
||||
// silently and the hourly reminder slot fires in UTC instead of the
|
||||
// user's chosen tz (t-paliad-064 root cause). Adds ~450KB to the binary.
|
||||
_ "time/tzdata"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/auth"
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/internal/handlers"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -17,38 +28,47 @@ func main() {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
// Surface the firm name in the boot log so a deployer can confirm
|
||||
// FIRM_NAME took effect without curl-ing a rendered page.
|
||||
log.Printf("branding: firm=%q (override with FIRM_NAME)", branding.Name)
|
||||
|
||||
supabaseURL := os.Getenv("SUPABASE_URL")
|
||||
supabaseAnonKey := os.Getenv("SUPABASE_ANON_KEY")
|
||||
if supabaseURL == "" || supabaseAnonKey == "" {
|
||||
log.Fatal("SUPABASE_URL and SUPABASE_ANON_KEY must be set")
|
||||
}
|
||||
|
||||
client := auth.NewClient(supabaseURL, supabaseAnonKey)
|
||||
jwtSecret := os.Getenv("SUPABASE_JWT_SECRET")
|
||||
if jwtSecret == "" {
|
||||
log.Fatal("SUPABASE_JWT_SECRET must be set — session cookies cannot be trusted without signature verification")
|
||||
}
|
||||
|
||||
client := auth.NewClient(supabaseURL, supabaseAnonKey, []byte(jwtSecret))
|
||||
|
||||
giteaToken := os.Getenv("GITEA_TOKEN")
|
||||
if giteaToken == "" {
|
||||
log.Println("GITEA_TOKEN not set — file proxy will not be able to access private repos")
|
||||
}
|
||||
|
||||
// Phase H: optional dependencies for document upload + AI extraction.
|
||||
// Both services degrade to a 501 response when their env vars are unset;
|
||||
// the UI hides their buttons based on GET /api/config/features.
|
||||
supabaseServiceKey := os.Getenv("SUPABASE_SERVICE_KEY")
|
||||
anthropicAPIKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||
storageClient := services.NewStorageClient(supabaseURL, supabaseServiceKey)
|
||||
aiService := services.NewAIService(anthropicAPIKey)
|
||||
if storageClient == nil {
|
||||
log.Println("SUPABASE_SERVICE_KEY not set — document upload/download disabled")
|
||||
}
|
||||
if aiService == nil {
|
||||
log.Println("ANTHROPIC_API_KEY not set — AI deadline extraction disabled")
|
||||
// MailService is wired regardless of DB availability — it no-ops when
|
||||
// SMTP env vars are unset, so the server stays runnable for knowledge-
|
||||
// platform-only deployments. Template-parse errors at boot are fatal.
|
||||
mailSvc, err := services.NewMailService()
|
||||
if err != nil {
|
||||
log.Fatalf("mail service init: %v", err)
|
||||
}
|
||||
|
||||
// Shared context for background goroutines (CalDAV sync + reminder job).
|
||||
bgCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// DATABASE_URL is optional during the Phase A → Phase D transition. The
|
||||
// existing knowledge-platform features (Kostenrechner, Glossar, etc.) work
|
||||
// without a DB. Akten/Frist endpoints return 503 until DATABASE_URL is set.
|
||||
// without a DB. matter-management endpoints return 503 until DATABASE_URL is set.
|
||||
dbURL := os.Getenv("DATABASE_URL")
|
||||
var svcBundle *handlers.Services
|
||||
var caldavSvc *services.CalDAVService
|
||||
|
||||
if dbURL != "" {
|
||||
log.Println("applying database migrations…")
|
||||
if err := db.ApplyMigrations(dbURL); err != nil {
|
||||
@@ -60,26 +80,114 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("open db pool: %v", err)
|
||||
}
|
||||
|
||||
// Refresh paliad.deadline_search whenever migrations run, so
|
||||
// search reflects any newly-seeded rule / concept / trigger.
|
||||
// Migration 047 created the matview already-populated; this
|
||||
// is only a no-op for the boot that introduced it. CONCURRENTLY
|
||||
// keeps reads online and stays well under 100 ms at < 1k rows.
|
||||
if err := services.RefreshSearchView(bgCtx, pool); err != nil {
|
||||
log.Printf("refresh deadline_search: %v", err)
|
||||
}
|
||||
holidays := services.NewHolidayService(pool)
|
||||
courts := services.NewCourtService(pool)
|
||||
users := services.NewUserService(pool)
|
||||
akteSvc := services.NewAkteService(pool, users)
|
||||
projectSvc := services.NewProjectService(pool, users)
|
||||
teamSvc := services.NewTeamService(pool, projectSvc)
|
||||
partnerUnitSvc := services.NewPartnerUnitService(pool, users)
|
||||
rules := services.NewDeadlineRuleService(pool)
|
||||
fristSvc := services.NewFristService(pool, akteSvc)
|
||||
dokumentSvc := services.NewDokumentService(pool, storageClient, aiService, akteSvc, fristSvc)
|
||||
|
||||
// Phase F: optional CalDAV cipher. If CALDAV_ENCRYPTION_KEY is unset
|
||||
// the service exists but Enabled() reports false; handlers return 501.
|
||||
// If the env var is malformed, fail fast — silently skipping would
|
||||
// leave plaintext-credential bugs hidden.
|
||||
cipher, err := services.LoadCalDAVCipher()
|
||||
if err != nil {
|
||||
log.Fatalf("CALDAV_ENCRYPTION_KEY: %v", err)
|
||||
}
|
||||
if cipher == nil {
|
||||
log.Println("CALDAV_ENCRYPTION_KEY not set — CalDAV endpoints will return 501")
|
||||
} else {
|
||||
log.Println("CalDAV encryption configured (AES-256-GCM)")
|
||||
}
|
||||
|
||||
appointmentSvc := services.NewAppointmentService(pool, projectSvc)
|
||||
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc)
|
||||
// Wire the push hook so user-driven mutations sync to the external
|
||||
// calendar without waiting for the next 60-second tick.
|
||||
appointmentSvc.SetCalDAVPusher(caldavSvc)
|
||||
|
||||
baseURL := os.Getenv("PALIAD_BASE_URL")
|
||||
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
|
||||
reminderSvc := services.NewReminderService(pool, mailSvc, users, 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
|
||||
// at the point we build mailSvc above.
|
||||
emailTemplateSvc := services.NewEmailTemplateService(pool)
|
||||
mailSvc.SetTemplateService(emailTemplateSvc)
|
||||
|
||||
eventTypeSvc := services.NewEventTypeService(pool, users)
|
||||
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
|
||||
svcBundle = &handlers.Services{
|
||||
Akte: akteSvc,
|
||||
Parteien: services.NewParteienService(pool, akteSvc),
|
||||
Frist: fristSvc,
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: services.NewPartyService(pool, projectSvc),
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
Rules: rules,
|
||||
Calculator: services.NewDeadlineCalculator(holidays),
|
||||
Users: users,
|
||||
Fristenrechner: services.NewFristenrechnerService(rules, holidays),
|
||||
Fristenrechner: services.NewFristenrechnerService(rules, holidays, courts),
|
||||
EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays, courts),
|
||||
Courts: courts,
|
||||
DeadlineSearch: services.NewDeadlineSearchService(pool),
|
||||
EventCategory: nil, // wired below; cross-link order matters
|
||||
EventType: eventTypeSvc,
|
||||
Dashboard: services.NewDashboardService(pool, users),
|
||||
Dokument: dokumentSvc,
|
||||
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
|
||||
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
|
||||
Mail: mailSvc,
|
||||
Invite: inviteSvc,
|
||||
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
|
||||
Audit: services.NewAuditService(pool),
|
||||
EmailTemplate: emailTemplateSvc,
|
||||
Link: services.NewLinkService(pool),
|
||||
Event: services.NewEventService(pool, deadlineSvc, appointmentSvc),
|
||||
Approval: services.NewApprovalService(pool, users),
|
||||
Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
|
||||
}
|
||||
// Wire ApprovalService into the entity services so Create / Update /
|
||||
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
|
||||
// Without this wiring, the policies and request tables exist but no
|
||||
// mutation path consults them — paliad behaves as before.
|
||||
deadlineSvc.SetApprovalService(svcBundle.Approval)
|
||||
appointmentSvc.SetApprovalService(svcBundle.Approval)
|
||||
// v3 (t-paliad-133): wire EventCategoryService and cross-link
|
||||
// it into DeadlineSearchService so ?event_category_slug= can
|
||||
// resolve to a concept-id allow-list during search.
|
||||
eventCategorySvc := services.NewEventCategoryService(pool)
|
||||
svcBundle.EventCategory = eventCategorySvc
|
||||
svcBundle.DeadlineSearch.SetEventCategoryService(eventCategorySvc)
|
||||
log.Println("Phase B services initialised")
|
||||
|
||||
// Spawn background goroutines: CalDAV sync (one per enabled user)
|
||||
// and the hourly reminder scanner. Both live for the process
|
||||
// lifetime; the signal-scoped context cleans them up on SIGTERM.
|
||||
if err := caldavSvc.Start(bgCtx); err != nil {
|
||||
log.Printf("CalDAV start: %v", err)
|
||||
}
|
||||
reminderSvc.Start(bgCtx)
|
||||
go func() {
|
||||
<-bgCtx.Done()
|
||||
log.Println("background services: shutdown signal received")
|
||||
caldavSvc.Stop()
|
||||
}()
|
||||
} else {
|
||||
log.Println("DATABASE_URL not set — Akten/Frist endpoints will return 503")
|
||||
log.Println("DATABASE_URL not set — matter-management endpoints will return 503")
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
@@ -7,6 +7,18 @@ services:
|
||||
- PORT=8080
|
||||
- SUPABASE_URL=${SUPABASE_URL}
|
||||
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
- SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY}
|
||||
- ALLOWED_EMAIL_DOMAINS=${ALLOWED_EMAIL_DOMAINS}
|
||||
- PALIAD_BASE_URL=${PALIAD_BASE_URL}
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_USERNAME=${SMTP_USERNAME}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- SMTP_FROM=${SMTP_FROM}
|
||||
- SMTP_FROM_NAME=${SMTP_FROM_NAME}
|
||||
- SMTP_USE_TLS=${SMTP_USE_TLS}
|
||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
|
||||
restart: unless-stopped
|
||||
|
||||
314
docs/audit-fristenrechner-completeness-2026-04-30.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# Audit — Fristenrechner Completeness (paliad vs youpc deadline calc)
|
||||
|
||||
**Author:** curie (researcher)
|
||||
**Date:** 2026-04-30
|
||||
**Task:** t-paliad-084
|
||||
**Mode:** read-only research, no code changes
|
||||
**Question (m):** *"Does paliad's Fristenrechner have all the data from our youpc deadline calc?"*
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
**No.** Paliad's Fristenrechner is **timeline-shaped**, youpc's deadline calc is **trigger-event-shaped**, and the two have only ~10 % overlap of the rule corpus. They were built for different jobs:
|
||||
|
||||
| | paliad Fristenrechner | youpc deadline calc |
|
||||
|---|---|---|
|
||||
| Data shape | 9 proceeding-type trees (UPC + DE + EPA) | 102 trigger events → 70 deadlines (UPC only) |
|
||||
| Rule count | 52 rules across 9 trees (the 9 public types) | 70 standalone deadlines + 36 timeline events |
|
||||
| Tool UX | "pick proceeding type → see whole timeline from trigger date" | "pick trigger event → see all deadlines that flow from it" |
|
||||
| RoP rule-code coverage | 5 distinct UPC RoP refs (`023`, `029b`, `029c`, `050`, `220.1`) | **64 distinct RoP refs** (`016.3.a` … `353`) |
|
||||
| Working-day arithmetic | ❌ no | ❌ no, but data references it (RoP.198 / RoP.213 notes "or 20 working days, whichever is longer") |
|
||||
| Holiday handling | ✅ DB-driven, German federal + UPC summer/winter vacation 2026/27 | ❌ empty default config, hardcoded TODO |
|
||||
| Adaptive rules (with/without CCR) | ✅ via `condition_rule_id` + `alt_*` columns (KanzlAI types only — INF/REV/CCR; **not** in the public Fristenrechner trees) | ✅ via separate trigger events (29.a/b/c/d/e all distinct) |
|
||||
| Law citation links | ❌ free-text rule_ref | ✅ `deadline_rule_codes` → `rule_codes` → `laws.unique_id` |
|
||||
| Linkage to project / matter | ✅ (via `paliad.deadlines` rows, separate concern) | ✅ (via `data.proceeding_events` graph) |
|
||||
|
||||
The short answer for m: **paliad covers the 9 high-level UPC/DE/EPA procedure timelines well, but is missing ~56 of the 64 granular UPC RoP deadlines that the youpc calc exposes.** The ones in paliad are the SoC→SoD→Reply→Rejoinder backbone; the gap is everything around damages determination, protective letters, evidence preservation, lay-open-books, translation orders, leave-to-appeal, rectification, rehearing, cross-appeals, and the "correction of deficiencies" family. None of these have ever been ported.
|
||||
|
||||
---
|
||||
|
||||
## 2. Rule inventory
|
||||
|
||||
### 2.1 youpc — `data.deadlines` (primary "deadline calc" content)
|
||||
|
||||
70 active deadlines, grouped by RoP family. Each row: `title`, `duration_value`, `duration_unit` (`days|weeks|months`), `timing` (`before|after`), trigger event(s), rule code(s), notes. Source: `internal/services/deadline_service.go` + production DB.
|
||||
|
||||
| RoP family | # deadlines | Examples |
|
||||
|---|---|---|
|
||||
| Pleadings (R.019–R.032, R.039) | 14 | Preliminary Objection (1mo, R.019.1), SoD (3mo, R.023), CCR (3mo, R.025), Reply 029.a/b/c/d/e (5 distinct rules) |
|
||||
| Revocation (R.049–R.052) | 5 | Defence to revocation (2mo, R.049.1), Counterclaim for infringement (2mo, R.049.2.b), Application to amend (2mo, R.049.2.a) |
|
||||
| Counterclaim infringement (R.056) | 3 | Defence (2mo, R.056.1), Reply (1mo, R.056.3), Rejoinder (1mo, R.056.4) |
|
||||
| DNI (R.067–R.069) | 3 | Defence (2mo, R.067), Reply (1mo, R.069.1), Rejoinder (1mo, R.069.2) |
|
||||
| Office decisions (R.088, R.097.1) | 2 | Annul EPO decision (1mo, R.088), Annul unitary-effect refusal (3w, R.097.1) |
|
||||
| Oral hearing (R.109) | 3 | Simultaneous translation (1mo **before**), Interpreter cost (2w **before**), Translation org (2w after summons) |
|
||||
| Cost orders (R.118.4, R.151, R.221.1) | 3 | App. for orders consequential on validity (2mo, R.118.4), Cost decision app (1mo, R.151), Leave-to-appeal cost decision (15d, R.221.1) |
|
||||
| Damages (R.137–R.139) | 3 | Defence (2mo, R.137.2), Reply (1mo, R.139), Rejoinder (1mo, R.139) |
|
||||
| Lay-open books (R.142) | 3 | Defence (2mo, R.142.2), Reply (14d, R.142.3), Rejoinder (14d, R.142.3) |
|
||||
| Evidence preservation (R.197.3, R.198) | 2 | Review request (30d, R.197.3), Start of merits (31d, R.198 — "or 20 working days, whichever is longer") |
|
||||
| Provisional measures (R.207.6/9, R.213) | 3 | Correction (14d, R.207.6.a), Renewal (6mo, R.207.9), Start of merits (31d, R.213) |
|
||||
| Appeals (R.220–R.245) | 16 | Statement of Appeal (15d / 2mo, R.224.1.a/b), Grounds (15d / 4mo, R.224.2.a/b), Response (15d / 3mo, R.235.1/2), Cross-appeal (15d / 3mo, R.237), Reply to cross-appeal (15d / 2mo, R.238.1/2), Discretionary review (15d, R.220.3), Reject inadmissible (1mo, R.234.1), Rehearing (2mo, R.245.2.a/b) |
|
||||
| Other (R.262.2, R.321.3, R.333.2, R.353) | 4 | Confidentiality (14d, R.262.2), Refer to central division (10d, R.321.3), Review CMO (15d, R.333.2), Rectification (1mo, R.353) |
|
||||
| Registry corrections (multiple) | 6 | "Correction of deficiencies / payment" (14d) under R.016.3.a, R.027.2, R.089.2, R.229.2, R.253.2, R.207.6.a |
|
||||
|
||||
**Duration unit distribution:** 44 months · 23 days · 3 weeks. Min 10d, max 6mo.
|
||||
**`timing='before'`:** 2 rows (R.109 family — simultaneous translation, interpreter cost).
|
||||
|
||||
### 2.2 youpc — `data.proceeding_events` (timeline tree, 36 rules)
|
||||
|
||||
Self-referential tree; parent_id = sequence, duration = edge weight. 6 proceeding types (INF=8, REV=7, CCR=7, APM=4, APP=8, AMD=2). **This is the table that paliad ported into `paliad.deadline_rules` (via KanzlAI).**
|
||||
|
||||
### 2.3 paliad — `paliad.deadline_rules` (96 rules across 16 proceeding types)
|
||||
|
||||
| Category | Proceeding types | Rules | Source |
|
||||
|---|---|---|---|
|
||||
| Public Fristenrechner (`category='fristenrechner'`) | 9: UPC_INF, UPC_REV, UPC_PI, UPC_APP, DE_INF, DE_NULL, EPA_OPP, EPA_APP, EP_GRANT | **52** | migration `012_fristenrechner_rules.up.sql`, ported from pre-Phase-C in-memory `internal/calc/deadline_rules.go` |
|
||||
| Internal/matter-attached (KanzlAI port) | 7: INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL | 44 | migration `009_seed_deadline_rules.up.sql`, ported from KanzlAI seed which itself came from `data.proceeding_events` |
|
||||
|
||||
The **public Fristenrechner UI** (`frontend/src/fristenrechner.tsx`, lines 21–37) only exposes the 9 public types. The KanzlAI 7-type set is for matter-attached fristen (internal Aktenverwaltung), reachable only when a deadline is linked to a project — never as a "calculate deadlines from event" tool.
|
||||
|
||||
**UPC_CCR was planned in the design doc** (`design-prozesskostenrechner-fristenrechner.md` §4.1, line 386) but never seeded in `012_fristenrechner_rules.up.sql`. The CCR variant only exists under the internal `CCR` proceeding type.
|
||||
|
||||
---
|
||||
|
||||
## 3. Gap analysis — youpc rules NOT in paliad
|
||||
|
||||
The bulk of the youpc 70-deadline corpus is **invisible to a paliad user** today. Grouped by missing functional area:
|
||||
|
||||
### 3.1 Procedural-defect "Correction of deficiencies" family (6 rules, all 14d)
|
||||
- R.016.3.a (initial), R.027.2 (CCR), R.089.2 (Office annul), R.207.6.a (PM), R.229.2 (appeal), R.253.2 (other) — six distinct triggers, six distinct rule codes, but identical duration. Paliad has no equivalent — there's no procedural-defect proceeding.
|
||||
|
||||
### 3.2 Damages determination (R.131.2, R.137–R.139)
|
||||
- Application for damages → 2mo Defence → 1mo Reply → 1mo Rejoinder. **Entirely missing from paliad.**
|
||||
|
||||
### 3.3 Lay-open books / discovery (R.142)
|
||||
- Request → 2mo Defence → 14d Reply → 14d Rejoinder. **Entirely missing.**
|
||||
|
||||
### 3.4 Evidence preservation (R.197.3, R.198)
|
||||
- Review request (30d), Start of merits ("31d **or** 20 working days, whichever is longer", R.198). **Missing.** Note: R.198 is the only deadline in either system that requires the **`max(calendar-days, working-days)` operator** — paliad has no support for it.
|
||||
|
||||
### 3.5 Provisional measures (R.207, R.213)
|
||||
- Paliad's UPC_PI has 4 rules (Antrag → Erwiderung [court-set] → Oral → Beschluss). The **PI Renewal of Protective Letter (6mo, R.207.9)** and **Start of merits (31d / 20wd, R.213)** are missing.
|
||||
|
||||
### 3.6 Oral-hearing prep "before"-mode rules (R.109)
|
||||
- Simultaneous translation (1 month **before** oral hearing, R.109)
|
||||
- Interpreter cost notice (2 weeks **before**, R.109.4)
|
||||
- The paliad data model has no `timing` column → all rules implicitly fire `after`. The `DeadlineCalculator.CalculateEndDate` reads `rule.Timing` (a `*string`), but the rule struct has no DB-mapped `timing` column; the `addDuration` helper in `fristenrechner.go:217–228` always adds, never subtracts. **Adding "before" support is a tiny change to the schema + service, but no rule today populates it.**
|
||||
|
||||
### 3.7 Rule 220 leave/discretionary review (R.220.2, R.220.3)
|
||||
- Appeal (orders & with leave) → 15d when leave granted; Discretionary review → 15d when leave refused. Both **missing.**
|
||||
|
||||
### 3.8 Rehearing (R.245.2.a/b)
|
||||
- 2mo from final decision OR discovery of fundamental defect (whichever is later). Has its own "max of two trigger dates" semantics. **Missing**, and like R.198 needs a "max of multiple anchors" concept.
|
||||
|
||||
### 3.9 Cross-appeal (R.237, R.238)
|
||||
- Cross-appeal 15d / 3mo (R.237), Reply to cross-appeal 15d / 2mo (R.238.1/2). **Missing.**
|
||||
|
||||
### 3.10 Other one-offs
|
||||
- Rectification (1mo, R.353), Refer to central division (10d, R.321.3), Review of CMO (15d, R.333.2), Confidentiality (14d, R.262.2), Application for the review of leave-to-appeal-refused-on-cost-decision (R.221.1). **Missing.**
|
||||
|
||||
### 3.11 Aggregated count
|
||||
|
||||
Out of youpc's 64 distinct UPC RoP rule codes referenced by `data.deadline_rule_codes`, paliad's `paliad.deadline_rules.rule_code` references at most 5 (`RoP.023`, `RoP.029b`, `RoP.029c`, `RoP.050`, `RoP.220.1` — and even these have format drift, see §4.3). **That's a 92 % miss on UPC RoP coverage.**
|
||||
|
||||
### 3.12 Why so much was unported (likely)
|
||||
|
||||
Paliad's Fristenrechner was scoped as a **timeline visualisation** for the 4 most common UPC procedure types (Verletzung / Nichtigkeit / Einstweilige Maßnahme / Berufung) plus DE + EPA, **not** as a search-by-trigger-event tool. The youpc deadline calc is the latter — a reference for "a court just sent me X, what deadlines does this start?" That use case has never been part of paliad's product scope (the design doc `design-prozesskostenrechner-fristenrechner.md` doesn't mention it).
|
||||
|
||||
This is a product question, not a porting oversight. m needs to decide whether paliad should grow that second mode or keep the timeline-only shape and accept the corpus gap.
|
||||
|
||||
---
|
||||
|
||||
## 4. Divergences — rules in both systems with different logic/labels
|
||||
|
||||
### 4.1 RoP.029.b/c/d/e — Adaptive Reply/Rejoinder
|
||||
|
||||
- **youpc** models the with-CCR vs without-CCR variants as **separate trigger events**: "Statement of defence which includes a Counterclaim for Revocation" → Reply 029.a (2mo); "Statement of defence without a Counterclaim for Revocation" → Reply 029.b (2mo). The user picks the right trigger event; the data drives the rule code.
|
||||
- **paliad** (KanzlAI port, internal `INF` type) uses `condition_rule_id` + `alt_rule_code`/`alt_duration_value` columns on a single rule row. The default is no-CCR (029.c, 1mo Rejoinder), `condition_rule_id=ccr_root` flips it to with-CCR (029.d, 2mo). Migration `009_seed_deadline_rules.up.sql:331–341`.
|
||||
- **public Fristenrechner type `UPC_INF`** uses **only the no-CCR path** — `RoP.029b/c` hard-coded, no `condition_rule_id`. So paliad's public Fristenrechner is incorrect when a defendant filed a counterclaim for revocation: the rejoinder duration should be 2mo not 1mo, and the rule code should be RoP.029d not RoP.029c.
|
||||
|
||||
### 4.2 SoD duration anchor
|
||||
|
||||
- **youpc:** SoD is 3 months from the trigger event "Statement of Claim" (which is anchor day = filing day).
|
||||
- **paliad:** SoD (`inf.sod`) is 3 months from `inf.soc`, with `inf.soc` itself being the trigger event (`duration=0`, `parent_id=NULL` → `IsRootEvent`). Same outcome, different shape.
|
||||
|
||||
### 4.3 Rule-code format inconsistency in paliad
|
||||
|
||||
```
|
||||
RoP 23 ← UPC_INF (Fristenrechner, public)
|
||||
RoP.023 ← INF (KanzlAI, internal)
|
||||
RoP 29b ← UPC_INF
|
||||
RoP.029b ← INF
|
||||
RoP 29c ← UPC_INF
|
||||
RoP.029c ← INF
|
||||
RoP 220.1 ← UPC_APP
|
||||
RoP.220.1 ← APP
|
||||
```
|
||||
Both halves of the codebase use different formatting for the same rule. youpc is uniform: `RoP.029.b` (period before letter). Paliad's two seeds disagree: `RoP 29b`, `RoP.029b`, never `RoP.029.b`.
|
||||
|
||||
If/when these surfaces ever merge (e.g. a deeplink from Fristenrechner result to a law-citation page), this drift will bite. Pick one canonical format (recommend youpc's `RoP.029.b`) and normalise.
|
||||
|
||||
### 4.4 EPA Beschwerdebegründung
|
||||
|
||||
- **paliad** UPC_APP has both `app.notice` (2mo, RoP 220.1) and `app.grounds` (2mo, RoP 220.1) — same rule code on both, both anchored 2mo from prior step.
|
||||
- **youpc** has `Statement of Appeal` (R.224.1.a, 2mo from decision) and `Statement of grounds` (R.224.2.a, **4 months from decision**, not from notice). Paliad's chain "decision → 2mo notice → 2mo grounds" gives a final grounds date 4mo after decision by accident, but it models the dependency wrong: the official Rule is "grounds = 4 months **from decision**" — i.e., independent of when the notice was filed.
|
||||
|
||||
This is a **subtle but meaningful divergence**. If the appellant files the notice early (e.g. 1 month after decision), paliad would compute grounds at "1mo + 2mo = 3 months after decision" — incorrect; the real deadline is still 4 months after decision regardless.
|
||||
|
||||
The same pattern applies to the EPA Beschwerdeverfahren (`epa_app.beschwerde` + `epa_app.begr`): paliad chains them, youpc anchors both to the decision date. The note `'Ab Zustellung, nicht ab Beschwerdeeinlegung'` (migration `012_fristenrechner_rules.up.sql:211`) acknowledges this: **the data is right, the parent_id is wrong**. `epa_app.begr.parent_id = epa_app_entsch` would be correct, not `epa_app_entsch` indirectly via `epa_app.beschwerde`.
|
||||
|
||||
Wait — re-reading migration 012:206–214 — `epa_app.begr` already has `parent_id = r_epa_app_entsch` (the decision row), not `r_epa_app.beschwerde`. So the EPA case is **right**. Verify: `epa_app.entsch → 2mo → epa_app.beschwerde` and **`epa_app.entsch → 4mo → epa_app.begr` (independent siblings).** OK. EPA is fine.
|
||||
|
||||
The UPC_APP case (`app.notice` → `app.grounds`) is **still wrong** — `app.grounds.parent_id = r_app_notice` (line 153), so grounds compounds onto notice. Should be `app.grounds.parent_id = NULL` with anchor on the trigger date (the appealed decision), with duration = 4 months. Alternatively store both as siblings of an `app.decision_appealed` root. Today the displayed dates work out fine when the user enters the decision date as trigger and the notice is filed on day 60, but **break** if the user enters the notice-filing date or if the notice is filed on a non-canonical day.
|
||||
|
||||
### 4.5 EP_GRANT publish date
|
||||
|
||||
- paliad: 18 months from filing (`ep_grant.publish.parent_id = ep_grant.filing`, `ab Prioritätstag` in notes).
|
||||
- youpc: not modelled in `data.deadlines`.
|
||||
- Note inconsistency: paliad's `parent_id = ep_grant_filing` but the note says "Ab Prioritätstag". Filing date and priority date can differ. If the patent has a foreign priority claim, paliad will compute the publish date from the filing of the **EP** application, not the priority date — typically off by up to 12 months.
|
||||
- This is the same parent-vs-anchor confusion as §4.4 UPC_APP.
|
||||
|
||||
---
|
||||
|
||||
## 5. Edge cases — youpc handles, paliad doesn't
|
||||
|
||||
### 5.1 Working days vs calendar days
|
||||
youpc has notes on R.198 + R.213: *"Or 20 Working days, whichever is longer."* No code today implements `max(calendarDays, workingDays)`. Paliad's `DeadlineCalculator.CalculateEndDate` only takes `(value, unit)` where unit ∈ {days, weeks, months}. **Neither system actually computes the correct R.198/R.213 deadline.** If paliad ports these rules, it needs:
|
||||
- A new unit `working_days`
|
||||
- A `max_of_units` semantics, or two duration columns + a `combine` operator (`max` / `min`)
|
||||
|
||||
### 5.2 "Whichever is later" trigger events
|
||||
youpc R.245.2.a/b: trigger event is *"Final decision (Service) **/** Discovery of the fundamental defect (whichever is later)"* — a single trigger event row that wraps two real-world dates. The user picks the later. youpc handles this by encoding it in the **event name** (the user reads the name, picks the later date themselves). Paliad doesn't have any "meta" trigger events like this, so the same rule would either need:
|
||||
- A "compound trigger" event family, or
|
||||
- Multiple separate triggers + a UI-level guidance note
|
||||
|
||||
### 5.3 Holiday handling — paliad WINS
|
||||
youpc's `internal/services/holidays.go:46–69` has an empty `defaultHolidayConfig()` with TODOs to populate. **No production holiday data is loaded** — `IsUPCNonWorkingDay()` only catches Saturday/Sunday. So a deadline falling on Christmas Day in youpc is silently treated as a working day.
|
||||
|
||||
paliad's `internal/services/holidays.go` is materially better:
|
||||
- DB-driven via `paliad.holidays` (55 rows in production, covering 2026 + 2027)
|
||||
- Race-safe per-year cache via `sync.Map` of `*sync.Once`
|
||||
- German federal holidays as embedded fallback (Easter via Anonymous Gregorian — same algorithm in both repos)
|
||||
- Seeded UPC summer (27 Jul – 28 Aug 2026) and winter vacation (24 Dec 2026 – 6 Jan 2027) per the official UPC Annual Report
|
||||
|
||||
If paliad ports the missing UPC rules, **the holiday system carries them**. youpc would need its holiday system filled in first.
|
||||
|
||||
### 5.4 Forward-only adjustment
|
||||
Both systems push non-working deadlines **forward** to the next working day. Neither supports "previous working day" (which some legal systems use for "before" deadlines — e.g., translations 1 month *before* hearing should land on a working day at or before the target). For paliad's R.109 family port, this matters: if oral hearing is Mon, translation deadline is "1 month before" = Sun, → forward-adjustment would push to Mon (the hearing itself), which is wrong. Should push backward to Fri.
|
||||
|
||||
### 5.5 Soft "non-month" deadlines
|
||||
youpc has `15d / 3mo` and `4mo / 15d` — wildly different durations on the same rule depending on which sub-rule (R.224.2.a vs .b) applies. paliad's tree shape can model this via two separate rules in different proceeding types, but if the same proceeding has both branches it'll need either conditional rules (`condition_rule_id`) or duplicate trees per branch.
|
||||
|
||||
### 5.6 Court-set deadlines (`pi.response`, `de_inf.replik`, etc.)
|
||||
paliad already has `IsCourtSet` semantics: `duration=0` + non-NULL `parent_id` → UI shows "vom Gericht gesetzt" placeholder. youpc's data has the same gap (R.131.2 indication, etc.) and just stores them as separate trigger events with no calculation. **This is one area where paliad is slightly cleaner.**
|
||||
|
||||
### 5.7 Date arithmetic correctness
|
||||
Both use Go `time.AddDate(0, n, 0)` for months — correct calendar math. Note however that youpc's `time_relationship_calculator.go:282` approximates months as 30 days (`time.Duration(value) * 30 * 24 * time.Hour`) for the **graph-based timeline calculator** path — **wrong** for legal months. This is a youpc bug, not a gap to port.
|
||||
|
||||
---
|
||||
|
||||
## 6. Amendment recommendations
|
||||
|
||||
Ranked by user value × implementation effort. **None of these should be implemented under this task — m needs to review §3 and §4 first.**
|
||||
|
||||
### Tier 1 — fix existing paliad bugs (no scope question)
|
||||
|
||||
1. **UPC_INF — wire the CCR-conditional adaptive rule** (§4.1).
|
||||
- Public Fristenrechner today silently always uses 029.b/c (no-CCR variant). When defendant counterclaims for revocation, rejoinder is 2mo not 1mo and rule code is 029.d. Add `condition_rule_id` + `alt_*` to `inf.reply` and `inf.rejoin` rows in the public `UPC_INF` tree, mirroring the KanzlAI `INF` rows.
|
||||
- Surface the toggle in the Fristenrechner UI: a checkbox "Mit Widerklage auf Nichtigkeit" between step 1 and step 2.
|
||||
|
||||
2. **UPC_APP grounds anchoring** (§4.4).
|
||||
- Today: `app.grounds.parent_id = app.notice`, so grounds = (decision + 2mo notice + 2mo) instead of (decision + 4mo).
|
||||
- Fix: change `app.grounds.parent_id` to NULL (sibling of notice) and `duration=4mo` from trigger date. Or add an explicit `app.decision_appealed` root and re-parent both.
|
||||
- Same review needed for the matter-attached `APP` tree (`009_seed_deadline_rules.up.sql:269–296`) — `app.grounds.parent_id = v_app_notice` there too.
|
||||
|
||||
3. **EP_GRANT publish — anchor on priority date, not filing date** (§4.5).
|
||||
- Today: chained off `ep_grant.filing`. Note acknowledges "Ab Prioritätstag" but parent says otherwise.
|
||||
- Fix: model priority date as a separate (court-event-style) input on the proceeding; or document that this rule assumes filing == priority.
|
||||
|
||||
4. **Normalise rule_code format** (§4.3).
|
||||
- Migrate `RoP 23` / `RoP.029b` / `RoP 220.1` → uniform `RoP.023` / `RoP.029.b` / `RoP.220.1` (youpc style).
|
||||
- One-time UPDATE; no schema change.
|
||||
|
||||
### Tier 2 — port a high-value subset of youpc deadlines
|
||||
|
||||
5. **Damages determination family** (R.137.2 / R.139, 3 rules) — common follow-on, no special arithmetic needed.
|
||||
|
||||
6. **Cost-decision appeals** (R.151, R.221.1) — frequently relevant after main proceedings end.
|
||||
|
||||
7. **Statement-of-Appeal "with leave" / discretionary review** (R.220.2, R.220.3) — closes a legitimate gap in the UPC_APP timeline.
|
||||
|
||||
8. **Cross-appeal family** (R.237, R.238.1/2, 4 rules) — straightforward calendar math, fills out the Berufung tree.
|
||||
|
||||
9. **Lay-open books / discovery** (R.142, 3 rules) — common in infringement cases where damages claim raised.
|
||||
|
||||
### Tier 3 — needs new arithmetic primitives
|
||||
|
||||
10. **R.198 / R.213 "Start of merits" — 31d OR 20 working days, whichever is longer.**
|
||||
- Requires:
|
||||
- New `duration_unit = 'working_days'` value (DeadlineService skips weekends + holidays via existing `HolidayService.IsNonWorkingDay`)
|
||||
- Either a second `(alt_duration_value, alt_duration_unit, combine='max')` triple on the rule, or a Go-side composite rule type
|
||||
- Decide whether to support this only for R.198/R.213 or generalise.
|
||||
|
||||
11. **R.245.2.a/b — Rehearing "whichever is later" trigger** (§5.2).
|
||||
- Could ship as a "compound trigger" date input in the UI: two date pickers, take max.
|
||||
- Alternatively, document the rule and accept manual user judgement.
|
||||
|
||||
### Tier 4 — separate product mode
|
||||
|
||||
12. **"Search by trigger event" mode for the public Fristenrechner.**
|
||||
- This is the youpc deadline-calc UX. Inputs: trigger event (autocomplete from a list) + date. Output: all deadlines that flow from it.
|
||||
- Requires either porting `data.events` + `data.deadlines` + `data.deadline_events` into the paliad schema, or an alternative data shape (e.g. flat list of rules tagged with trigger codes).
|
||||
- This is the most fundamental gap and the most expensive — it's a second product, not a deeper rule set. m should explicitly decide whether paliad wants both modes.
|
||||
|
||||
13. **Procedural-defect "Correction of deficiencies" (6 rules, all 14d).**
|
||||
- Hard to fit into the timeline model since the trigger ("Notification by the Registry to correct deficiencies") can fire from many different proceeding states with different rule codes. Naturally fits the trigger-event model (Tier 4), not the proceeding-tree model.
|
||||
|
||||
### Tier 5 — purely cosmetic
|
||||
|
||||
14. **Add law-citation links on rule codes** (paliad has no `deadline_rule_codes` / `deadline_laws` join). Low-value until paliad has a law-text database to link to. **Defer.**
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions for m
|
||||
|
||||
1. **Is the "search by trigger event" mode (Tier 4) in scope for paliad?**
|
||||
- This is the single biggest gap. The youpc deadline calc is fundamentally event-driven; paliad's Fristenrechner is fundamentally proceeding-driven. They serve different jobs. If you want both, that's a sizeable second feature. If you only want timelines, the gap reduces to Tiers 1–3 (~10 fixable rules).
|
||||
|
||||
2. **The CCR adaptive bug (§4.1, recommendation 1) — do we want a UI toggle, or default-CCR-on?** A defendant facing a UPC infringement claim will almost always counterclaim for revocation. Defaulting `UPC_INF` to "with CCR" would silently fix 90 % of users without adding UI complexity. But it's a behaviour change and the result (rejoinder 2mo not 1mo) is subtle.
|
||||
|
||||
3. **Rule code format (§4.3) — accept normalisation to `RoP.029.b` style?** Cosmetic but disruptive if any downstream system parses the current strings.
|
||||
|
||||
4. **EP_GRANT priority date (recommendation 3)** — paliad doesn't model priority date as a project field today. Should we add a "priority date" input to the EP_GRANT Fristenrechner, or accept that it's an edge case for users with foreign priority claims and document the limitation?
|
||||
|
||||
5. **Working-days arithmetic (R.198/R.213, recommendation 10)** — only relevant for evidence-preservation cases. Real users? If never, skip Tier 3 entirely.
|
||||
|
||||
6. **The internal KanzlAI proceeding types (INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL) — are they still wired into the matter-attached Fristen UX?** Recent task `t-paliad-080` did a service-layer naming sweep. If those types are still surfaced anywhere, fix recommendation 2 (UPC_APP grounds) needs to be applied to both the public `UPC_APP` rules and the internal `APP` rules.
|
||||
|
||||
7. **youpc's deadline calc itself has known gaps** (empty holiday config — §5.3) — should paliad's amendment work also feed back to youpc? Or is youpc intentionally decoupled? (My read: keep them decoupled; paliad serves HLC, youpc serves the public.)
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — File references
|
||||
|
||||
**paliad — source of truth:**
|
||||
- `internal/db/migrations/012_fristenrechner_rules.up.sql` (52 rules, 9 public types)
|
||||
- `internal/db/migrations/009_seed_deadline_rules.up.sql` (44 rules, 7 internal types — ported from KanzlAI which itself came from youpc `data.proceeding_events`)
|
||||
- `internal/db/migrations/010_seed_holidays.up.sql` (55 holiday rows)
|
||||
- `internal/services/deadline_calculator.go` (date math)
|
||||
- `internal/services/fristenrechner.go` (UI response shape)
|
||||
- `internal/services/holidays.go` (DB-driven holidays + German federal fallback)
|
||||
- `frontend/src/fristenrechner.tsx` (the 9-type wizard)
|
||||
- `docs/design-prozesskostenrechner-fristenrechner.md` (intent — note: UPC_CCR was planned in §4.1 line 386 but never seeded)
|
||||
|
||||
**youpc — source of comparison:**
|
||||
- `youpc-go/internal/services/deadline_service.go` (70-deadline calc service)
|
||||
- `youpc-go/internal/services/holidays.go` (empty default config — known gap)
|
||||
- `youpc-go/internal/services/time_relationship_calculator.go` (graph timeline calc — uses 30-day month approximation, **bug in youpc**)
|
||||
- `youpc-go/internal/migrations/sql-migrations/039_create_proceeding_events.sql` (timeline tree schema)
|
||||
- `youpc-go/internal/migrations/sql-migrations/040_adaptive_reply_deadline.sql` (CCR-conditional duration columns — origin of paliad's `condition_rule_id` pattern)
|
||||
- `frontend/templates/mgmt/deadline-calculations.html` (UI shell — the actual logic is in handler/service, not template)
|
||||
|
||||
**Production data verified via Supabase:**
|
||||
- youpc: 70 deadlines · 102 events · 140 deadline-event relations · 36 proceeding events · 6 proc types · 72 deadline-rule-code links · 0 deadline-law links
|
||||
- paliad: 96 deadline_rules · 16 proc types (9 fristenrechner) · 55 holidays
|
||||
259
docs/audit-polish-2-2026-04-29.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# Paliad Polish Audit — Triage 2 — 2026-04-29
|
||||
|
||||
**Source audit:** `docs/audit-polish-2026-04-27.md` (50 findings, 41 screenshots).
|
||||
**Method:** for every BATCH-tagged finding, re-verified against the current
|
||||
codebase (post-PRs B/D/E + the brand palette sweep + firm-name sweep).
|
||||
**Goal:** mark each finding KEEP / OBSOLETE / RESCOPED / DEFER, group the keeps
|
||||
into 2–3 ship-ready PRs, and rank what m would notice first on Monday morning.
|
||||
|
||||
**What already shipped since the original audit:**
|
||||
- t-paliad-060 (PR-B): F-05, F-06 — `lang` attr on date/time inputs.
|
||||
- t-paliad-061 (PR-D): F-11, F-17, F-18, F-19, F-26, F-44, F-45.
|
||||
- t-paliad-062 (PR-E): F-02, F-03, F-08, F-09.
|
||||
- t-paliad-063 (palette): F-14, F-30, F-31 (explicitly superseded per commit).
|
||||
- t-paliad-064 (reminder redesign): the new settings UI uses inline-flex
|
||||
`caldav-toggle-label` blocks → incidentally fixes F-22.
|
||||
- t-paliad-065 (firm-name): F-01.
|
||||
- Pre-audit (t-paliad-049 modal/breadcrumb polish): /projects/new already has
|
||||
a Cancel button (`btn-cancel`) → F-34 OBSOLETE.
|
||||
|
||||
---
|
||||
|
||||
## Verification table
|
||||
|
||||
| ID | Status | Notes |
|
||||
|---|---|---|
|
||||
| F-01 | OBSOLETE | Stripped by t-paliad-065 firm sweep. Curl of `/` and `/login` shows only "Paliad" + "HLC". |
|
||||
| F-02 | OBSOLETE | t-paliad-062 (search input padding). |
|
||||
| F-03 | OBSOLETE | t-paliad-062 (migration 024 column rename). |
|
||||
| F-04 | KEEP | `frontend/src/client/deadlines-new.ts:45` still references `fristen.field.project.choose`; `i18n.ts` only has `fristen.field.akte.choose` → renders raw key. Either rename in deadlines-new.ts to `fristen.field.akte.choose`, or add the new key. |
|
||||
| F-05 | OBSOLETE | t-paliad-060. |
|
||||
| F-06 | OBSOLETE | t-paliad-060. |
|
||||
| F-07 | KEEP | `internal/services/project_service.go:625` emits `project_type_changed` with English title "Project type changed"; description embeds raw English values (`case → litigation`). Dashboard activity widget renders these verbatim. Plus mixed-language nouns ("Note zu deadline hinzugefügt"). Same class as the t-paliad-037 sweep but for newer events. |
|
||||
| F-08 | OBSOLETE | t-paliad-062. |
|
||||
| F-09 | OBSOLETE | t-paliad-062. |
|
||||
| F-10 | KEEP | The `inf.rejoin` raw slug renders because client-side rule lookup falls through when no matching rule label exists. Need a missing-rule label fallback (display "—" or a humanized variant) and/or i18n key for known catalog slugs. |
|
||||
| F-11 | OBSOLETE | t-paliad-061. |
|
||||
| F-12 | KEEP | `frontend/src/deadlines.tsx:101` and `frontend/src/appointments.tsx:101` still ship `<th data-i18n="fristen.col.akte">Akte</th>` / `termine.col.akte`; i18n.ts L541/1043 still maps to "Akte". PR-D fixed the *filter* dropdown; the *column header* was deliberately left for PR-A which never ran. |
|
||||
| F-13 | KEEP | `client/appointments.ts:153` and `client/deadlines.ts:197` render `<span class="frist-project-title">` but `global.css:4716` only styles `.frist-akte-title { display: block; }` — class-name mismatch from the rename → ref + title render inline → "L-2026-001Siemens AG ./." collision. Trivial: rename the CSS or the markup. |
|
||||
| F-14 | OBSOLETE | t-paliad-063 (palette sweep). |
|
||||
| F-15 | KEEP | `projects-detail.tsx:334` still uses `className="btn-danger"` (red `#dc2626`) for the "Projekt archivieren" button. The destructive-modal confirm action also uses `btn-danger`, which is fine — it's the entry-point button on the working surface that screams. |
|
||||
| F-16 | KEEP | `global.css:4089-4093` still ships saturated random colors per type chip (`akten-type-client` lavender, `akten-type-litigation` pink-red, `akten-type-patent` cyan, `akten-type-case` salmon, `akten-type-project` neutral-green). Same classes drive `/projects` and `/admin/team`. |
|
||||
| F-17 | OBSOLETE | t-paliad-061. |
|
||||
| F-18 | OBSOLETE | t-paliad-061. |
|
||||
| F-19 | OBSOLETE | t-paliad-061. |
|
||||
| F-20 | RESCOPED | Palette sweep harmonized the *colour* (every active tab now points at `--hlc-lime`), but the structural inconsistency remains: `.akten-tab.active` uses `font-weight: 600` + midnight text, while `.login-tab.active` and `.gebuehren-tab.active` use accent-coloured text. Drop to one rule. |
|
||||
| F-21 | KEEP | `internal/services/deadline_service.go:306` still inserts events with title `"Deadline updated"` (English, in DE narrative on /projects/{id}/history). Same fix lane as F-07. |
|
||||
| F-22 | OBSOLETE | t-paliad-064 PR-3/4 introduced `caldav-toggle-label` (`display: inline-flex`, `gap: 0.5rem`) for every checkbox row — labels and checkboxes are now adjacent. |
|
||||
| F-23 | DEFER | Hiding STATUS when single-valued is a design call (some users like the redundancy as a trust signal). Punt to a later "table density" pass. |
|
||||
| F-24 | KEEP | Mobile filter row still stacks awkwardly. Single-page CSS fix on `/projects` (and adjacent `/deadlines`/`/appointments`). |
|
||||
| F-25 | DEFER | Card-layout-on-mobile is a design refactor, not a polish edit. Spans `/projects`, `/deadlines`, `/appointments`, `/admin/team`. Out of scope for polish-2; flag as a standalone t-task. |
|
||||
| F-26 | OBSOLETE | t-paliad-061. |
|
||||
| F-27 | KEEP | `client/projects-detail.ts:1143` always renders the breadcrumb; no path-depth check. One conditional in `renderBreadcrumb()`. |
|
||||
| F-28 | KEEP | Cell empty-placeholder is split: `/admin/team` and `/projects/{id}/deadlines` use "—"; `/projects` (REFERENZ, CLIENTMATTER) and `/appointments` (ORT) render blank. Pick one and grep for empty-cell renders. |
|
||||
| F-29 | KEEP | TSX has a real `<a href="/checklists">` but `i18n.ts` strings (L949/2190) ship plain text "…unter Checklisten angelegt." On runtime translation, the anchor disappears. Fix: store the link in i18n with a placeholder (`{link}`) and substitute at render, or render two strings and inject the anchor. |
|
||||
| F-30 | OBSOLETE | t-paliad-063 sidebar reskin. |
|
||||
| F-31 | OBSOLETE | t-paliad-063 (button restyle). |
|
||||
| F-32 | DEFER | The agenda was redesigned (day-bucket section headings now exist). The per-card urgency pill is still rendered, but it now carries information *only* when the urgency disagrees with the bucket (e.g. an "Überfällig" item appearing under HEUTE). Keeping it is defensible. Mark as design-call, not polish. |
|
||||
| F-33 | KEEP | One `title=` attribute per project-ref render in `dashboard.ts` and `agenda.ts`. Trivial. |
|
||||
| F-34 | OBSOLETE | `projects-new.tsx:46` already ships `<a className="btn-cancel" data-i18n="projekte.cancel">Abbrechen</a>`. |
|
||||
| F-35 | KEEP | `projects.tsx:34` + `i18n.ts:844` still read "Mandanten, Streitsachen, Patente und Fälle …". Actual taxonomy is Mandant / Streitsache / Patent / Verfahren / Projekt. Replace "Fälle" → "Verfahren" (and possibly mention "Projekte"). |
|
||||
| F-36 | KEEP | `ProjectFormFields.tsx:19` still ships `<option value="client">Mandant</option>` first → implicit default. Switch to a "Bitte wählen…" placeholder option, or default to `case`. |
|
||||
| F-37 | KEEP | `client/notes.ts` textarea has no Strg+Enter / Cmd+Enter hint, no character counter, no markdown hint. Add a small footer line. |
|
||||
| F-38 | DEFER | Bottom-nav badge semantics is a design decision — needs to match the agenda urgency definition. Tackle alongside any future agenda-redesign task. |
|
||||
| F-39 | KEEP | Tree view shows "11" while flat shows "11 / 11"; pick one format. One client edit. |
|
||||
| F-40 | DEFER | Glossary chip language ("Litigation" / "Prosecution" vs "Allgemein") is a product decision, not a polish fix. m to call. |
|
||||
| F-41 | OBSOLETE | Was tagged OK in the audit. |
|
||||
| F-42 | KEEP | Same fix as F-13 (`frist-project-title` CSS class) plus a monospace ref pill style + ellipsis on title. Bundle with F-13. |
|
||||
| F-43 | KEEP | Empty state on `/projects/{id}/parties` is a single line — add an empty-state CTA card (matches the pattern used elsewhere). |
|
||||
| F-44 | OBSOLETE | t-paliad-061. |
|
||||
| F-45 | OBSOLETE | t-paliad-061. |
|
||||
| F-46 | KEEP | `i18n.ts:1906` still maps `dashboard.greeting.prefix` to "Good day". Change to "Hello" (or "Hi"). One-line. |
|
||||
| F-47 | KEEP | `/settings` profile placeholder "z.B. Associate, Partner, PA" still mixed EN/DE in i18n. One-line. |
|
||||
| F-48 | DEFER | `/projects/{id}/sub-projects` would 404, but the canonical `/children` URL works and tabs auto-resolve to it. Aliasing is low-value; flag the canonical path in docs instead. |
|
||||
| F-49 | DEFER | Tagged DEFER in original audit. |
|
||||
| F-50 | KEEP | One CSS rule on `<main>` (or `body`) — bottom-padding equal to bottom-nav height on `<768px`. |
|
||||
|
||||
**Summary:** 18 OBSOLETE (already shipped), 26 KEEP, 1 RESCOPED (F-20), 7 DEFER (F-23, F-25, F-32, F-38, F-40, F-48, F-49). The KEEP set is the polish-2 backlog.
|
||||
|
||||
---
|
||||
|
||||
## PR plan — 3 bundles
|
||||
|
||||
### PR-1 — i18n leak sweep + activity log narrative 🟡
|
||||
|
||||
Single concern: text rendered to a German narrative that's still English or
|
||||
raw-keyed. Ship as one PR — they're touched in adjacent files and the
|
||||
reviewer can verify them together by walking the dashboard and the activity
|
||||
tab.
|
||||
|
||||
**Includes:** F-04, F-07, F-10, F-12, F-21, F-29, F-35, F-46.
|
||||
|
||||
- F-04 (deadlines-new picker key) — i18n key add or rename. *Pure i18n.ts.*
|
||||
- F-07 (dashboard activity event types + narrative nouns) — needs three
|
||||
edits: (a) Go service-side, switch event titles from "Project type changed"
|
||||
/ "Note added to deadline" to neutral identifiers; (b) i18n.ts add
|
||||
`dashboard.action.project_type_changed`, `…note_added`, etc.; (c) frontend
|
||||
dashboard renderer translates the event_type and the dynamic values
|
||||
(`case` → t("projekte.type.case")) before joining into the narrative.
|
||||
*This is the only Go-side change in the bundle.*
|
||||
- F-10 (raw `inf.rejoin`) — frontend rule-label lookup falls back to "—"
|
||||
when no label exists; can be done in `client/deadlines.ts` only. Optional
|
||||
follow-up: backfill `fristen.rule.<slug>` keys.
|
||||
- F-12 (AKTE column header) — flip `fristen.col.akte` and `termine.col.akte`
|
||||
to "Projekt" / "Matter" (DE/EN), plus the `data-i18n` attribute label.
|
||||
- F-21 ("Deadline updated" in Verlauf) — same shape as F-07; one Go edit
|
||||
(`deadline_service.go:306` title → neutral identifier) plus i18n key add.
|
||||
- F-29 (checklists empty-state link) — store the empty-state copy as two
|
||||
i18n strings or a `{link}` placeholder; render with a real `<a>`.
|
||||
- F-35 (subtitle taxonomy) — flip "Fälle" → "Verfahren" in
|
||||
`projekte.subtitle` (DE+EN) and the SSR fallback in `projects.tsx:34`.
|
||||
- F-46 (Good day) — one-line i18n change.
|
||||
|
||||
**Risk:** medium. Go-side event-emission edits need a smoke test of the
|
||||
activity feed (dashboard widget + project Verlauf tab) post-deploy. Existing
|
||||
events in the DB carry the *old* English titles — the renderer should
|
||||
translate the event_type, not the stored title (so historical rows benefit
|
||||
too). Worth calling out explicitly in the PR description.
|
||||
|
||||
### PR-2 — visual residue + small per-page polish 🟢
|
||||
|
||||
Single concern: small CSS/markup edits, mostly self-contained per page.
|
||||
|
||||
**Includes:** F-13, F-15, F-24, F-27, F-28, F-33, F-36, F-39, F-42, F-43,
|
||||
F-47, F-50.
|
||||
|
||||
- F-13 (appointments AKTE collision) — rename CSS rule
|
||||
`.frist-akte-title` → `.frist-project-title` (or add the new selector).
|
||||
Same rule fixes F-42 partially.
|
||||
- F-15 (red archive button) — change `className="btn-danger"` →
|
||||
`btn-secondary` (or introduce `btn-archive` with neutral/outline styling).
|
||||
Modal confirm button stays red.
|
||||
- F-24 (mobile filter row wrapping) — `/projects` filter container CSS:
|
||||
`flex-direction: column` + `align-items: stretch` at `<480px`; each
|
||||
filter as its own labelled block.
|
||||
- F-27 (single-child breadcrumb) — `renderBreadcrumb` early-return when
|
||||
the chain has length ≤ 1.
|
||||
- F-28 (placeholder consistency) — grep cell renderers; render "—" for
|
||||
empty REFERENZ, CLIENTMATTER, ORT, REGEL, WEITERE STANDORTE.
|
||||
- F-33 (truncated ref tooltip) — `title=` attr on
|
||||
`.dashboard-upcoming-project-ref` and `.agenda-item-project`.
|
||||
- F-36 (Mandant default) — add a `<option value="" disabled selected>` /
|
||||
`Bitte wählen…` first row in `ProjectFormFields.tsx:18`.
|
||||
- F-39 (search counter format) — match flat ("11 / 11") to tree view, or
|
||||
the other way, by editing the tree-view counter.
|
||||
- F-42 (deadlines AKTE wrapping) — same CSS rename as F-13 + `text-overflow:
|
||||
ellipsis` on `.frist-project-title` with a `title=` for the full text.
|
||||
- F-43 (parties empty state) — add an empty-state CTA card with a "Partei
|
||||
hinzufügen" call.
|
||||
- F-47 (settings placeholder mixed) — pick all-DE or all-EN; one i18n edit.
|
||||
- F-50 (mobile bottom-nav overlap) — `<main>` `padding-bottom: var(--bottom-nav-h)`
|
||||
at `<768px`.
|
||||
|
||||
**Risk:** low. Each change is local; the only cross-cutting bit is the F-13
|
||||
+ F-42 CSS rename, but the class is referenced in exactly two TS files.
|
||||
|
||||
### PR-3 — visual consistency: tabs + chips + Notiz hint 🟡
|
||||
|
||||
Single concern: harmonization that touches several pages with one change
|
||||
each.
|
||||
|
||||
**Includes:** F-16, F-20, F-37.
|
||||
|
||||
- F-16 (type pill saturated colors) — neutralize to a single chip background
|
||||
with the type icon for differentiation; reserve color-as-signal for the
|
||||
*Mandant* root (or for `/admin/team` STANDORT). Touches `global.css:4089-4093`
|
||||
+ `admin-team.tsx` chip render.
|
||||
- F-20 (tab styling) — collapse the three active-tab rules
|
||||
(`.akten-tab.active`, `.login-tab.active`, `.gebuehren-tab.active`) to one
|
||||
shared style. Could simply make the two minority rules `@extend` the
|
||||
canonical lime-underline + midnight-text + 600-weight pattern.
|
||||
- F-37 (Notiz textarea hint) — small footer line under the textarea with
|
||||
"Strg+Enter zum Speichern" (DE) / "Cmd+Enter to save" (EN).
|
||||
|
||||
**Risk:** medium. Tab rule consolidation is the riskiest edit in the
|
||||
backlog — it touches every `.login-tab` and `.gebuehren-tab` consumer
|
||||
(login page + Gebührentabellen page). Verify both visually post-edit.
|
||||
The chip change is visually larger but lower risk because the saturated
|
||||
colors carry no behaviour.
|
||||
|
||||
---
|
||||
|
||||
## Top 5 — what m notices first on Monday morning
|
||||
|
||||
1. **F-07 — Dashboard activity log English event types.** The activity
|
||||
widget is on the landing page, every login. Reading "Test Tester
|
||||
project_type_changed" or "Note zu deadline hinzugefügt" makes the app
|
||||
feel half-baked in 30 seconds. Sibling F-21 lives on the project Verlauf
|
||||
tab (next-most-read). High visibility, medium effort.
|
||||
2. **F-15 — "Projekt archivieren" red button.** Wrong affordance changes
|
||||
user behaviour: real lawyers will hesitate to archive routine matters
|
||||
because "red = scary". Trivial fix, biggest behaviour delta.
|
||||
3. **F-04 — Raw `fristen.field.project.choose` key on /deadlines/new.**
|
||||
Visible, plainly broken text in a primary form. One-line fix.
|
||||
4. **F-12 — AKTE column header on /deadlines and /appointments.** The
|
||||
filter dropdown was renamed to "Projekt" in PR-D; the *column header* still
|
||||
says "Akte" in the same row. Side-by-side inconsistency screams "rename
|
||||
half-done". Trivial.
|
||||
5. **F-13 — `L-2026-001Siemens AG ./.` collision on /appointments AKTE
|
||||
cell.** Looks like a data-corruption bug at first glance. Caused by a
|
||||
single CSS class rename that was missed; one-line fix.
|
||||
|
||||
(All five fit in PR-1 + PR-2 above. Recommend shipping those two first.)
|
||||
|
||||
### Honourable mentions (#6–10)
|
||||
|
||||
6. F-46 (Good day → Hello) — one-line warmth fix on EN dashboard.
|
||||
7. F-29 (empty-state link not real) — feels broken when you click the word
|
||||
and nothing happens.
|
||||
8. F-16 (type pill saturated colors) — calmer chip palette makes /projects
|
||||
feel less alarming.
|
||||
9. F-35 (subtitle taxonomy "Fälle") — small but visible on /projects intro.
|
||||
10. F-50 (mobile bottom-nav overlap on dashboard) — only mobile, but
|
||||
immediately visible to anyone on a phone.
|
||||
|
||||
---
|
||||
|
||||
## Defer list
|
||||
|
||||
Findings where polish-2 isn't the right scope — either they need a design
|
||||
call, span a redesign-class change, or carry low value-per-effort.
|
||||
|
||||
- **F-23** — STATUS column noise. Hiding when single-valued is a usability
|
||||
call (some users like the redundancy). Defer to a "table density" pass.
|
||||
- **F-25** — Mobile tables → card layout. Genuine redesign across four
|
||||
pages. Should be its own t-task with screenshots and an alignment
|
||||
pass on the mobile pattern. Out of scope for "polish".
|
||||
- **F-32** — Agenda redundant urgency pill. After the day-bucket redesign,
|
||||
the pill *can* still differ from the bucket (e.g. an overdue item under
|
||||
HEUTE). Keeping it is defensible; design call before changing.
|
||||
- **F-38** — Bottom-nav agenda badge semantics. Needs to match the agenda
|
||||
redesign decision; tackle there.
|
||||
- **F-40** — Glossary chip language (EN/DE mix). Product decision (m).
|
||||
- **F-48** — `/sub-projects` URL alias. The canonical `/children` works;
|
||||
guessable-URL-alias is low value. Document the canonical path instead.
|
||||
- **F-49** — Already DEFER in original audit (meta-circular changelog
|
||||
entry).
|
||||
|
||||
---
|
||||
|
||||
## Recommendation summary
|
||||
|
||||
- Ship **PR-1** (i18n leak sweep + activity log narrative) first — biggest
|
||||
user-visible delta, contains 3 of the top-5.
|
||||
- Ship **PR-2** (small visual residue) right after — low-risk, high
|
||||
per-edit value, contains the other 2 top-5 items + F-46 / F-50.
|
||||
- **PR-3** (tab + chip consistency) is worthwhile but riskier; OK to land
|
||||
after PR-1/PR-2 stabilize. Leave for later in the week or punt to a
|
||||
separate t-task if velocity is constrained.
|
||||
- **DEFER list** to a later "design-pass" or "mobile-pass" task; do not
|
||||
bundle them with these PRs.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [x] Every BATCH finding (F-01..F-50) classified KEEP / OBSOLETE /
|
||||
RESCOPED / DEFER against current code state.
|
||||
- [x] Keeps grouped into 3 PR bundles with effort + risk + finding IDs.
|
||||
- [x] Top 5 ranked with rationale.
|
||||
- [x] Defer list with reason per item.
|
||||
- [ ] Head greenlights individual PRs before any coder shift.
|
||||
440
docs/audit-polish-2026-04-27.md
Normal file
@@ -0,0 +1,440 @@
|
||||
# Paliad Polish Audit — 2026-04-27
|
||||
|
||||
**Scope:** survey-only. Find high-value, low-risk UX improvements across the
|
||||
authenticated paliad surface. **No fixes in this doc** — head dispatches
|
||||
implementation tasks separately.
|
||||
|
||||
**Method:** Playwright headless against `https://paliad.de`, logged in as
|
||||
`tester@hlc.de` (admin, Munich, DE), captures at 1280×900 (desktop primary),
|
||||
spot-checks at 375×900 (mobile) and DE/EN toggle. 41 screenshots in
|
||||
`tests/screenshots-polish-2026-04-27/`.
|
||||
|
||||
**Bias:** what would a HLC patent lawyer notice on Monday morning? Spacing,
|
||||
copy, brand, i18n leaks, stale firm names, English in DE narrative, and
|
||||
broken-feeling defaults. Architectural changes, perf, new features — out of
|
||||
scope.
|
||||
|
||||
**Severity legend:** 🔴 broken / 🟠 friction / 🟡 polish.
|
||||
**Effort legend:** 🟢 ≤30min / 🟡 1–2h / 🔴 half-day+.
|
||||
**Scope:** `BATCH` = bundle as one PR with siblings; `STANDALONE` = own task;
|
||||
`DEFER` = mention but don't ship now.
|
||||
|
||||
---
|
||||
|
||||
## Findings
|
||||
|
||||
### 🔴 Broken — visible bugs / data-incorrect / leaking implementation
|
||||
|
||||
**F-01 — Marketing landing still says "Hogan Lovells", page title same** 🟢 STANDALONE
|
||||
The hero on `/` reads *"Patent Knowledge for Hogan Lovells"* and the subtitle
|
||||
*"Guides, templates, and documents for the HL patent team."* The browser tab
|
||||
title is *"Paliad — Patent Knowledge for Hogan Lovells"*. Per CLAUDE.md the
|
||||
firm rebranded to **HLC** on 2026-04-16; this is the first thing colleagues
|
||||
see. Same stale firm reference on `/downloads` ("HL Patents Style", "für das
|
||||
HL Patent-Team"). _Screens: `34-landing.png`, `25-downloads.png`._
|
||||
|
||||
**F-02 — `/admin/team` search input has overlapping placeholder text** 🟢 STANDALONE
|
||||
The search box visibly renders *two* pieces of placeholder copy on top of
|
||||
each other: *"Nach Name oder E-Mail"* + *"suche"*. Reproducible at desktop
|
||||
and mobile. Looks like a `data-i18n-placeholder` doubled with another
|
||||
attribute. _Screens: `30-admin-team.png`, `36-admin-team-mobile.png`._
|
||||
|
||||
**F-03 — `/api/departments?include=members` returns 500 on `/team`** 🟡 STANDALONE
|
||||
Console error every time `/team` loads as the test admin. Page falls back
|
||||
to `/api/users` so it still renders, but the dept-grouped toggle uses the
|
||||
500'd endpoint. Smoke 2026-04-25 reported the same error class; t-paliad-037
|
||||
claimed to fix it via INNER JOIN. Either a regression or a different code
|
||||
path. Worth a fresh look. _Screens: `03-team-1280.png` + console log._
|
||||
|
||||
**F-04 — `/deadlines/new` shows raw i18n key in the project picker** 🟢 STANDALONE
|
||||
The default option text in the *Akte* `<select>` is literally
|
||||
`fristen.field.project.choose`. t-paliad-037 fixed `fristen.filter.project.all`
|
||||
but missed `.choose`. Add the key DE+EN, done. _Screen: `16-deadline-new.png`._
|
||||
|
||||
**F-05 — Date pickers show US `mm/dd/yyyy` in German UI** 🟢 STANDALONE
|
||||
`/deadlines/new` Fälligkeitsdatum field, `/appointments` Von/Bis filters,
|
||||
and inline date inputs across the app render `mm/dd/yyyy` placeholder text
|
||||
even though the user's `lang` is `de`. Native `<input type="date">` follows
|
||||
the *browser* locale, not the page's `lang` attribute. Fix: set `lang="de"`
|
||||
on the input element (or render the date in a labelled wrapper). The
|
||||
*output* dates everywhere are correctly `27.04.2026` — only inputs are wrong.
|
||||
_Screens: `16-deadline-new.png`, `17-appointments-list.png`._
|
||||
|
||||
**F-06 — Time pickers show 12-hour `09:00 AM` / `04:00 PM` in DE Settings** 🟢 STANDALONE
|
||||
`/settings?tab=benachrichtigungen` reminder time fields render in 12h format
|
||||
with English AM/PM. Same root cause as F-05: native `<input type="time">`.
|
||||
Set `lang="de"` on the inputs to force 24h. _Screen: `28-settings-notifications.png`._
|
||||
|
||||
**F-07 — Activity log leaks raw English event types** 🟡 STANDALONE
|
||||
Dashboard "Letzte Aktivität" includes:
|
||||
- *"Test Tester project_type_changed"* — raw English event slug instead of a
|
||||
translated verb.
|
||||
- *"Type case → litigation"* — raw English values inside German narrative.
|
||||
- *"Note zu deadline hinzugefügt"* and *"Deadline „Foo" geändert"* — English
|
||||
nouns ("Note", "Deadline") inside German prose; should be "Notiz" / "Frist".
|
||||
Same class as Bug 4 from the 2026-04-25 smoke audit but for events shipped
|
||||
in t-paliad-056 and the polymorphic notes. Add the missing
|
||||
`dashboard.action.project_type_changed`, render dynamic value in the
|
||||
description (translated), and switch the noun. _Screens: `01-dashboard-1280-viewport.png`, `06-project-detail-1280.png`._
|
||||
|
||||
**F-08 — Project tabs use `href="#"` (or `…/history#`)** 🟡 BATCH
|
||||
Every tab on `/projects/{id}` (Verlauf/Team/Untergeordnet/Parteien/Fristen/
|
||||
Termine/Notizen/Checklisten) is a JS-only navigation: middle-click and
|
||||
"open in new tab" don't work. The URL *does* update via history.replaceState
|
||||
when you click them, so the routes are real (`/projects/{id}/team`,
|
||||
`/projects/{id}/parties`, etc.) — the anchors just aren't pointing at them.
|
||||
Wire the real path into `href` and let the click handler `preventDefault` for
|
||||
the SPA flow. _Screens: `06-project-detail-1280.png`, `07-project-team-1280.png`._
|
||||
|
||||
**F-09 — `?view=tree` URL parameter is silently ignored on `/projects`** 🟢 STANDALONE
|
||||
Visiting `https://paliad.de/projects?view=tree` shows the flat list with the
|
||||
"Ansicht" dropdown set to "Flache Liste". The `?view=` query param has no SSR
|
||||
effect. Bookmarks, dashboard links, and shareable filtered views don't work.
|
||||
_Screens: `04-projects-list-1280.png`, `05b-projects-tree-actual.png`._
|
||||
|
||||
**F-10 — REGEL column shows raw rule slug `inf.rejoin` on `/deadlines`** 🟢 BATCH
|
||||
Two rows show "inf.rejoin" instead of a human label like "Replik (Patent)" or
|
||||
similar. Either a missing i18n key or a missing display lookup against the
|
||||
Fristenrechner regel-catalog. _Screen: `15-deadlines-list.png`._
|
||||
|
||||
**F-11 — Office values render lowercase, no umlauts** 🟢 BATCH
|
||||
`/projects/{id}/team` lists members with `· duesseldorf`, `· munich` —
|
||||
slugs from the DB, not the localized labels. The offices module already has
|
||||
`LabelDE` / `LabelEN`; just look up by key. _Screen: `07-project-team-1280.png`._
|
||||
|
||||
**F-12 — AKTE column header + filter still says "Akte" on `/deadlines`** 🟢 BATCH
|
||||
The column header on the deadlines table is "AKTE", and the filter dropdown
|
||||
shows "Alle Akten". The rest of the app uses Projekt/Projekte after the
|
||||
rename. Same on `/appointments`. _Screens: `15-deadlines-list.png`,
|
||||
`17-appointments-list.png`._
|
||||
|
||||
**F-13 — `L-2026-001Siemens AG ./.` collision on `/appointments` AKTE cell** 🟢 BATCH
|
||||
The reference code and the project title are concatenated with no separator
|
||||
in the AKTE column ("`L-2026-001Siemens AG ./. Huawei Technologies`"). Need
|
||||
either a space, a delimiter, or two visual lines. _Screen: `17-appointments-list.png`._
|
||||
|
||||
### 🟠 Friction — visibly inconsistent / awkward
|
||||
|
||||
**F-14 — Two greens fight everywhere (forest dark green vs lime brand)** 🟡 STANDALONE
|
||||
Brand inconsistency is the most pervasive issue in the app. Lime
|
||||
`--accent (#c6f41c)` is the brand; but a darker forest green is used for many
|
||||
primary CTAs and active filter chips. Same page often has both:
|
||||
- Lime: "Neue Frist", "Neuer Termin", "Neues Projekt", "Mitglied hinzufügen",
|
||||
"Frist hinzufügen", "Hinzufügen" (Notizen), "Zurück zum Dashboard" CTA on
|
||||
404, "Vergleichen" preset chips, year tab on Gebührentabellen ("2025
|
||||
(Aktuell)").
|
||||
- Forest dark green: "Vergleichen" submit, "Begriff vorschlagen", "Korrektur
|
||||
vorschlagen", "Link vorschlagen", "Sign In", "Bestehendes Konto onboarden",
|
||||
"Neue:n Kolleg:in einladen", "Nachschlagen", filter "Alle" chips on
|
||||
Checklisten/Gerichte/Links/Glossar, year-bucket tabs, GKG/RVG/UPC/EPA tabs
|
||||
on Gebührentabellen, the active sidebar tab indicator on Settings.
|
||||
Pick one (lime is the brand) and convert. Touches lots of files but each edit
|
||||
is trivial. _Screens: most of them — see `19-kostenrechner.png`,
|
||||
`20-gebuehrentabellen.png`, `22-glossary.png`, `23-courts.png`, `24-links.png`,
|
||||
`30-admin-team.png`, `33-login.png`._
|
||||
|
||||
**F-15 — "Projekt archivieren" button is bright red** 🟢 STANDALONE
|
||||
Bottom of every project-detail tab. Archiving is reversible — red signals
|
||||
*destructive* and will make real lawyers hesitate to archive routine matters,
|
||||
defeating the affordance. Make it neutral/outline (or amber if you want
|
||||
*caution* without *danger*). Reserve red for Löschen. _Screens: `06-project-detail-1280.png`,
|
||||
`07-project-team-1280.png`, `09-project-parties-1280.png`, `10-project-deadlines-tab.png`._
|
||||
|
||||
**F-16 — Type pills on `/projects` use saturated random colors** 🟡 STANDALONE
|
||||
Mandant=lavender, Streitsache=pink-red, Patent=cyan, Verfahren=salmon-orange,
|
||||
Projekt=neutral. The colors aren't carrying meaning (they're not
|
||||
ordered/ranked) and red-pink looks alarming for a routine type label.
|
||||
Recommend: single neutral chip with the type icon (project-tree.ts has
|
||||
icons), use color only when the type *is* the salient signal (e.g. Mandant
|
||||
to mark a client root). Same critique for STANDORT pills on `/admin/team`
|
||||
which random-color per office. _Screens: `04-projects-list-1280.png`,
|
||||
`05b-projects-tree-actual.png`, `30-admin-team.png`._
|
||||
|
||||
**F-17 — "Lead" role label is English in German UI** 🟢 BATCH
|
||||
`/projects/{id}/team` ROLLE column shows literal "Lead". Subtitle on
|
||||
`/projects/new`: *„Sie werden als „Lead" automatisch hinzugefügt"*. Should
|
||||
be "Leitung" or "Verantwortlich" in DE; keep "Lead" in EN. _Screens:
|
||||
`07-project-team-1280.png`, `14-projects-new.png`._
|
||||
|
||||
**F-18 — "Berechtigung" column on `/admin/team` shows "Global Admin", "Standard"** 🟢 BATCH
|
||||
Half-translated. Either translate both ("Globaler Admin" / "Standard" — both
|
||||
fine in DE) or display a localized label keyed off the role enum. _Screen:
|
||||
`30-admin-team.png`._
|
||||
|
||||
**F-19 — German DOM IDs lingering on `/projects`** 🟢 BATCH
|
||||
Dropdowns have `id="projekt-type"`, `id="projekt-view"`, `id="akten-status"`
|
||||
even though everywhere else this app is now English-coded. Stale rename
|
||||
sweep — flag for the next pass. _Screen: `04-projects-list-1280.png` (DOM)._
|
||||
|
||||
**F-20 — Tab styling is inconsistent across the app** 🟡 BATCH
|
||||
Three different tab styles in current use:
|
||||
- Lime underline on active tab: `/settings`, `/projects/{id}` — the canonical
|
||||
pattern.
|
||||
- Color-only no underline: `/tools/gebuehrentabellen` (GKG/RVG/UPC/EPA tabs).
|
||||
- Card grid with badges: `/admin`.
|
||||
Pull all top-level tab navs into the lime-underline pattern; the card grid
|
||||
on /admin is fine because it's a launcher, not nav. _Screens:
|
||||
`20-gebuehrentabellen.png`, `27-settings.png`, `06-project-detail-1280.png`._
|
||||
|
||||
**F-21 — "Deadline updated" English event title in Verlauf** 🟢 BATCH
|
||||
On `/projects/{id}/history`, an event row reads *"Deadline updated"* + DE
|
||||
description. Same i18n class as F-07 — different code path. _Screen:
|
||||
`06-project-detail-1280.png`._
|
||||
|
||||
**F-22 — Settings notification checkboxes are far from their labels** 🟢 BATCH
|
||||
`/settings?tab=benachrichtigungen` lays out checkboxes flush-right and
|
||||
labels flush-left with a wide gap between. Hard to scan which checkbox
|
||||
belongs to which option. Pull them together (label + checkbox in the same
|
||||
row, justify-start). _Screen: `28-settings-notifications.png`._
|
||||
|
||||
**F-23 — Status column noise on `/deadlines` and `/projects`** 🟡 BATCH
|
||||
`/deadlines` shows STATUS=Offen on every row (filter default is "Alle
|
||||
offenen"). `/projects` shows STATUS=Aktiv on every row (filter default is
|
||||
visible status). When the filter constrains the value, the column adds
|
||||
nothing. Either hide the column when single-valued or move it to a small
|
||||
tag in the title cell. _Screens: `04-projects-list-1280.png`, `15-deadlines-list.png`._
|
||||
|
||||
**F-24 — Dropdowns in `/projects` filter row wrap awkwardly on mobile** 🟢 BATCH
|
||||
At 375 the Typ/Status/Ansicht filter row stacks oddly: each label floats on
|
||||
its own line, selects on the next, no clean grouping. Should stack each as
|
||||
a labelled block (label above select, full-width). _Screen: `35-projects-mobile.png`._
|
||||
|
||||
**F-25 — Mobile project + admin tables overflow horizontally** 🟡 BATCH
|
||||
At 375 the `/projects` table shows TITEL + TYP only (4 other columns clipped
|
||||
right). `/admin/team` shows NAME + E-MAIL only. No horizontal scroll
|
||||
indicator, and important columns (Status, last-modified, Standort, Rolle)
|
||||
just disappear. Card layout on mobile is the standard fix. _Screens:
|
||||
`35-projects-mobile.png`, `36-admin-team-mobile.png`._
|
||||
|
||||
**F-26 — "Akte" filter dropdown label on `/deadlines`/`/appointments` is "Akte"** 🟢 BATCH
|
||||
Already covered by F-12 but worth flagging: filter label literally says
|
||||
"Akte" while the rest of the app says "Projekt".
|
||||
|
||||
**F-27 — Single-child breadcrumb is redundant** 🟢 BATCH
|
||||
On `/projects/{root-id}/{tab}` the breadcrumb shows just the project title
|
||||
in a pill, then below it the H1 shows the same title. When path-depth=1,
|
||||
hide the breadcrumb. _Screens: `10-project-deadlines-tab.png`,
|
||||
`12-project-notizen-tab.png`._
|
||||
|
||||
**F-28 — Empty placeholder inconsistency: "—" vs blank cell** 🟢 BATCH
|
||||
- `/projects` REFERENZ + CLIENTMATTER cells render blank when empty.
|
||||
- `/admin/team` WEITERE STANDORTE renders "—".
|
||||
- `/projects/{id}/deadlines` REGEL renders "—".
|
||||
- `/appointments` ORT renders blank when empty.
|
||||
Pick one (recommend "—") and apply consistently. _Screens: `04-projects-list-1280.png`,
|
||||
`17-appointments-list.png`, `30-admin-team.png`._
|
||||
|
||||
**F-29 — `/projects/{id}/checklists` empty state references "Vorlagen-Seite" as plain text** 🟢 BATCH
|
||||
Empty-state copy says *"Instanzen werden auf der Vorlagen-Seite unter
|
||||
Checklisten angelegt."* — but "Checklisten" is just text, not a link. Make
|
||||
it a real `<a href="/checklists">` so the user can jump there. _Screen:
|
||||
`13-project-checklists-tab.png`._
|
||||
|
||||
**F-30 — Email cells on `/admin/team` are default-blue underlined links** 🟢 BATCH
|
||||
Inconsistent with the lime accent system used everywhere else. Either
|
||||
restyle as a normal text + small icon, or keep `<a href="mailto:">` but use
|
||||
the Paliad link styling. _Screen: `30-admin-team.png`._
|
||||
|
||||
### 🟡 Polish — small wins
|
||||
|
||||
**F-31 — `/deadlines` "Kalenderansicht" link is underlined plain text next to a styled button** 🟢 BATCH
|
||||
Inconsistent click affordance with the adjacent "Neue Frist" filled button.
|
||||
Make it a secondary outline button (or vice-versa). _Screen: `15-deadlines-list.png`._
|
||||
|
||||
**F-32 — `/agenda` redundant status pill below each card** 🟡 BATCH
|
||||
Cards already carry an urgency stripe on the left edge (red/orange/green).
|
||||
The pill ("HEUTE", "MORGEN", "IN 2 TAGEN", "DIESE WOCHE", "SPÄTER") sits as
|
||||
a separate row below each card and duplicates the visual signal. Move to a
|
||||
small tag inside the card next to the title, or drop it. _Screen:
|
||||
`02-agenda-1280-viewport.png`._
|
||||
|
||||
**F-33 — `/dashboard` upcoming-list project refs truncate without tooltip** 🟢 BATCH
|
||||
"C-UPC-0002 · UPC-CFI München — Klage Siemens ./. Hu…" — ellipsis but no
|
||||
title attribute, so hovering doesn't reveal the full reference. Add `title=`
|
||||
on the project-ref element. _Screen: `01-dashboard-1280-viewport.png`._
|
||||
|
||||
**F-34 — `/projects/new` has no Cancel button (just back-link)** 🟢 BATCH
|
||||
`/deadlines/new` shows a standard "Abbrechen" + primary submit pair;
|
||||
`/projects/new` only has the form with submit at the bottom — back to list
|
||||
is via the breadcrumb only. Add the Abbrechen button for parity. _Screens:
|
||||
`14-projects-new.png`, `16-deadline-new.png`._
|
||||
|
||||
**F-35 — "Mandant, Streitsache, Patente und Fälle" subtitle on `/projects` does not match the type taxonomy** 🟢 BATCH
|
||||
The actual types are Mandant/Streitsache/Patent/Verfahren/Projekt — no
|
||||
"Fälle". Subtitle copy is stale. _Screen: `04-projects-list-1280.png`._
|
||||
|
||||
**F-36 — "Mandant" type is the default on `/projects/new`** 🟢 BATCH
|
||||
Most projects created in production are Verfahren (per current data). A
|
||||
Mandant project is created rarely (one per client). Better default:
|
||||
Verfahren, or "Bitte wählen…" with required validation. _Screen: `14-projects-new.png`._
|
||||
|
||||
**F-37 — Notiz textarea has no formatting/length hint** 🟢 BATCH
|
||||
`/projects/{id}/notes` textarea has no character counter, no markdown hint,
|
||||
no Strg+Enter shortcut hint. Add a small footer with at least the keyboard
|
||||
hint. _Screen: `12-project-notizen-tab.png`._
|
||||
|
||||
**F-38 — Bottom-nav agenda badge "2" semantics unclear** 🟢 STANDALONE
|
||||
Mobile bottom nav shows "2" badge on the Agenda icon. Not clear if that's
|
||||
"2 today", "2 unread", "2 overdue", "2 this week". Add a `title=` or limit
|
||||
to overdue-only. _Screens: `35-projects-mobile.png`, `36-admin-team-mobile.png`._
|
||||
|
||||
**F-39 — Search counter inconsistency between flat and tree views** 🟢 BATCH
|
||||
`/projects` flat list shows "11 / 11" in the search box; tree view shows
|
||||
just "11". Match the format. _Screen: `05b-projects-tree-actual.png`._
|
||||
|
||||
**F-40 — Glossar filter chips mix English + German** 🟡 BATCH
|
||||
Filter chips: "Alle / Litigation / Prosecution / UPC / EPA / SEP/FRAND /
|
||||
Allgemein". "Litigation" / "Prosecution" are English while "Allgemein" is
|
||||
German. Decide: are these jargon kept in EN intentionally (defensible —
|
||||
patent lawyers use them in EN), or convert all to DE? At least make this
|
||||
decision explicit and consistent. _Screen: `22-glossary.png`._
|
||||
|
||||
**F-41 — Date input on `/deadlines/new` defaults to today (good)** 🟢 OK
|
||||
Not a finding — observed-good behaviour worth keeping.
|
||||
|
||||
**F-42 — `/deadlines` table AKTE column line-wrapping** 🟢 BATCH
|
||||
Project ref + title is one long string ("C-UPC-0001 UPC-CFI München — Klage
|
||||
Siemens ./. Huawei (EP3456789)") that wraps to 2 lines per row, ballooning
|
||||
row height. Split into a small monospace ref pill + a `text-overflow:
|
||||
ellipsis` title with a tooltip. _Screen: `15-deadlines-list.png`._
|
||||
|
||||
**F-43 — `/projects/{id}/parties` empty state is bare** 🟢 BATCH
|
||||
Just "Noch keine Parteien eingetragen." — the "Partei hinzufügen" button is
|
||||
at the top. Add an empty-state CTA card below the message. _Screen:
|
||||
`09-project-parties-1280.png`._
|
||||
|
||||
**F-44 — "Departments / Dezernate" admin card uses slash-mixed languages** 🟢 BATCH
|
||||
Just "Dezernate" suffices. _Screen: `29-admin.png`._
|
||||
|
||||
**F-45 — "Dezernat / Partner" settings field uses slash separator unusual in DE** 🟢 BATCH
|
||||
Reads more naturally as "Dezernat oder Partner". _Screen: `27-settings.png`._
|
||||
|
||||
**F-46 — "Good day, Test Tester" greeting on EN dashboard** 🟢 BATCH
|
||||
"Good day" is correct German→EN literal but stiff. "Hello" / "Hi" reads
|
||||
warmer. _Screen: `32-dashboard-EN.png`._
|
||||
|
||||
**F-47 — `/settings` profile placeholder mixes EN/DE** 🟢 BATCH
|
||||
*"z.B. Associate, Partner, PA"* — Associate is EN, Partner is both, PA is
|
||||
abbrev. Either keep EN-only as legal-jargon convention, or move to all DE.
|
||||
Currently inconsistent. _Screen: `27-settings.png`._
|
||||
|
||||
**F-48 — `/projects/{id}/sub-projects` is 404, but tab label is "Untergeordnet"** 🟢 BATCH
|
||||
URL slug for the Untergeordnet tab is something else (the JS handles it
|
||||
client-side). If a user types `/sub-projects` from intuition they hit the
|
||||
404 page. Either alias the slug or document the canonical URL. _Screen:
|
||||
`08-project-untergeordnet-1280.png`._
|
||||
|
||||
**F-49 — `/changelog` first entry is meta-circular** 🟡 DEFER
|
||||
Top entry titled "Neuigkeiten" describes the page itself. Cute on first
|
||||
load, weird as the entry ages. Drop or replace with content news. _Screen:
|
||||
`26-changelog.png`._
|
||||
|
||||
**F-50 — Mobile bottom-nav overlaps last list item on `/dashboard`** 🟢 BATCH
|
||||
At 375 the lime "Anlegen" FAB sits over the "Lecker Frist" list item in
|
||||
"Kommende Fristen" — the bottom-nav background gradient covers but doesn't
|
||||
fully obscure. Add bottom-padding to `<main>` equal to the bottom-nav
|
||||
height. _Screen: `01-dashboard-375.png`._
|
||||
|
||||
---
|
||||
|
||||
## Top 10 — best value-per-effort
|
||||
|
||||
Ranked by visible impact on the first-5-minutes experience of a HLC patent
|
||||
lawyer. Each is small enough to land in one focused PR.
|
||||
|
||||
1. **F-01 — Strip "Hogan Lovells" / "HL" from the public surface** 🟢
|
||||
Stale firm name on the marketing landing, page title, downloads section.
|
||||
First impression for any new colleague. **The single most embarrassing
|
||||
defect right now.**
|
||||
2. **F-14 — Pick lime as the only primary green; retire forest-green** 🟡
|
||||
The pervasive brand inconsistency. Lime is the brand; forest-green leaks
|
||||
from old design tokens onto every primary CTA. One swap, every page
|
||||
feels coordinated.
|
||||
3. **F-15 — "Projekt archivieren" red → neutral/outline** 🟢
|
||||
Wrong affordance for a reversible action. Will visibly change real-user
|
||||
behaviour (more confident archiving). Trivial CSS change.
|
||||
4. **F-02 — `/admin/team` search input overlapping placeholder bug** 🟢
|
||||
Plainly broken text. Fix in admin-team.tsx.
|
||||
5. **F-04 — `fristen.field.project.choose` raw key on `/deadlines/new`** 🟢
|
||||
Leaking key in a primary form. Add the i18n key.
|
||||
6. **F-07 — Activity log `project_type_changed` / "Type case → litigation"** 🟡
|
||||
Dashboard is the landing page; the activity widget is the most-read
|
||||
surface. Same-class fix as t-paliad-037, just for newer events.
|
||||
7. **F-05 + F-06 — `lang="de"` on date and time inputs** 🟢
|
||||
`mm/dd/yyyy` and `09:00 AM` in a German UI is jarring. Single attribute
|
||||
fix, two places.
|
||||
8. **F-12 + F-26 — "Akte" → "Projekt" on /deadlines + /appointments
|
||||
filters/columns** 🟢
|
||||
Last residue of the rename. Quick relabel, tightens the vocabulary.
|
||||
9. **F-11 — Office values lowercased no-umlaut on /projects/{id}/team** 🟢
|
||||
`duesseldorf`, `munich` rendered raw; offices module already has the
|
||||
localized labels.
|
||||
10. **F-08 — Project tabs use `href="#"`** 🟡
|
||||
Tabs aren't real links. Middle-click + open-in-new-tab don't work.
|
||||
Common power-user gesture; fix is one change in the tab component.
|
||||
|
||||
### Honourable mentions (#11–15)
|
||||
|
||||
11. **F-16 — Type pills calmer colors** 🟡
|
||||
12. **F-22 — Settings notification checkbox layout** 🟢
|
||||
13. **F-09 — `?view=tree` URL parameter respected** 🟢
|
||||
14. **F-13 — `L-2026-001Siemens AG ./.` separator on `/appointments`** 🟢
|
||||
15. **F-03 — `/api/departments?include=members` 500 regression** 🟡
|
||||
(Functional bug, not pure polish — flagged because it's recurring.)
|
||||
|
||||
### Suggested batching
|
||||
|
||||
- **PR-A "stale firm name + activity log + i18n leaks"** — F-01, F-04, F-07,
|
||||
F-12, F-21, F-35.
|
||||
- **PR-B "format + locale"** — F-05, F-06.
|
||||
- **PR-C "brand consistency sweep"** — F-14, F-15, F-31, F-30.
|
||||
- **PR-D "rename residue + small i18n cleanups"** — F-11, F-17, F-18, F-19,
|
||||
F-26, F-44, F-45.
|
||||
- **PR-E "single-page bug fixes"** — F-02 (standalone), F-09 (standalone),
|
||||
F-08 (standalone), F-03 (standalone).
|
||||
|
||||
Everything else (F-23 onwards) can land alongside whichever batch is most
|
||||
adjacent.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting observations
|
||||
|
||||
- **Two greens** is the single biggest visual gain available right now. F-14
|
||||
alone makes the app feel "designed" rather than "drifting".
|
||||
- **i18n leak class** keeps reappearing (Bug 4 in 2026-04-25 smoke fixed
|
||||
some keys; t-paliad-037 fixed `.all` keys; this audit finds `.choose` keys,
|
||||
`project_type_changed` event types, "Deadline updated" event titles, and
|
||||
"Note zu deadline" mixed-language narrative). Worth a one-time scan that
|
||||
greps every component for raw template strings without i18n wrapping —
|
||||
could surface a dozen others I missed.
|
||||
- **Date/time format leakage** comes from native HTML5 inputs ignoring the
|
||||
page's `lang`. One attribute set in one shared component fixes it
|
||||
everywhere.
|
||||
- **Mobile tables** clip silently. Card layout on `<768px` is the canonical
|
||||
fix and would help `/projects`, `/deadlines`, `/appointments`, `/admin/team`
|
||||
all at once.
|
||||
- **Brand: lime is the brand color**, but most "primary" CTAs in the codebase
|
||||
use a darker green. The lime-vs-forest split is roughly: lime = "create new"
|
||||
actions on the working surface (Akte, Frist, Termin, Notiz); forest =
|
||||
knowledge-platform "submit" actions (vorschlagen, suchen, Login). The
|
||||
split is implicit and surprising — pick one and document the rule.
|
||||
|
||||
## Out of scope — flagged for separate work
|
||||
|
||||
- **CLAUDE.md doc drift**: project doc says Phase I (Notizen) is "pending"
|
||||
but `/projects/{id}/notes` ships a working textarea + list. Either Phase I
|
||||
shipped without doc update, or the placeholder ships ahead of full Phase I.
|
||||
Worth a quick verification + doc fix.
|
||||
- **`/api/departments?include=members` 500** is a functional regression, not
|
||||
pure polish — flagged to head as a side-channel bug to triage outside this
|
||||
audit's batches.
|
||||
- **Empty-state CTAs** (F-43 and others) could be a separate "empty state
|
||||
pass" task across the app.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- [x] Doc committed.
|
||||
- [x] 41 screenshots in `tests/screenshots-polish-2026-04-27/`.
|
||||
- [x] Top 10 ranked with rationale and effort buckets.
|
||||
- [ ] Head greenlights individual implementation tasks separately.
|
||||
828
docs/design-approvals-2026-05-06.md
Normal file
@@ -0,0 +1,828 @@
|
||||
# Design — Dual-control approvals (4-Augen-Prüfung) for Deadlines + Appointments
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-06
|
||||
**Task:** t-paliad-138 (Gitea m/paliad#3)
|
||||
**Branch:** `mai/cronus/inventor-dual-control`
|
||||
**Status:** DESIGN READY FOR REVIEW. Awaiting m go/no-go before any coder shift.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
Add a 4-eye principle to `paliad.deadlines` and `paliad.appointments`. Every state-changing action (create / update-of-date-fields / complete / delete) submitted by one team member must be signed off by a qualified second team member from the same project before the change is "approved".
|
||||
|
||||
Six locked design decisions from m (2026-05-06):
|
||||
|
||||
| # | Question | Locked answer |
|
||||
|---|---|---|
|
||||
| Q1 | Where does the qualification level live? | **Reuse `project_teams.role` per-project** (no new firm-wide column). New value `senior_pa` added to the role enum. |
|
||||
| Q1+ | Strict-ladder default? | **Default approval-eligible = {lead, associate}**. Per-project / per-event setting can extend to `senior_pa` or `pa` (so PAs can approve other PAs in some projects). |
|
||||
| Q2 | Hierarchy semantics | **Strict ladder.** Higher level always satisfies lower. |
|
||||
| Q3 | Policy granularity | **Per-(project, entity_type, lifecycle_event)** \— up to 8 settable rows per project. |
|
||||
| Q4 | Edit-trigger fields | **Only date-changing fields.** Deadline: `due_date`, `original_due_date`, `warning_date`. Appointment: `start_at`, `end_at`. All other field changes bypass approval. |
|
||||
| Q5 | Pending-state architecture | **Write-then-approve.** Field changes apply immediately; the entity carries `approval_status='pending'` until an approver flips it. (Delete is the one exception — see §5.4.) |
|
||||
| Q6 | Inbox surface | **Bell icon (sidebar header) + dedicated `/inbox` page** with two tabs: "Zur Genehmigung" / "Meine Anfragen". |
|
||||
| Q7 | Revocation | **Pending-only revoke.** After approval, only path back is a new request. |
|
||||
| Q8 | Single-qualified-approver deadlock | **Refuse + global_admin override.** UI refuses with "Kein qualifizierter Approver verfügbar"; global_admin can manually approve as override (audit-marked). |
|
||||
| Q9 | Audit / chronology | **Both** \— operational `paliad.approval_requests` table + new event types in `paliad.project_events`. Both creator and approver names persist on the entity row. |
|
||||
| Q10 | RLS | **Visible to project team, action gated by service.** Same `can_see_project()` predicate; service layer checks "caller has required role tier AND caller_id != requested_by". |
|
||||
| Q11 | Migration of existing rows | **Mark legacy + skip backfill.** All existing rows get `approval_status='legacy'`. New lifecycle events on legacy rows trigger normal approval flow. |
|
||||
|
||||
Plus m's explicit interjection: **pending state must be visualised everywhere the entity normally surfaces** — list views, agenda, dashboard traffic-light, project detail, CalDAV-synced calendars, and email reminders. Silence on a pending change creates more risk than visible-but-flagged-pending.
|
||||
|
||||
Out of scope for v1: notes, parties, documents, checklists; cross-app generalisation; multi-step n-of-m chains; email/WhatsApp/Telegram approvals (in-app only).
|
||||
|
||||
---
|
||||
|
||||
## 1. Context — what's already in the code
|
||||
|
||||
What this design slots into:
|
||||
|
||||
- **Three-axis principle (m, t-paliad-051, sacrosanct).** "Firm roles ≠ project roles ≠ tool roles."
|
||||
- `paliad.users.job_title` — free-text display. Never gates anything.
|
||||
- `paliad.users.global_role` — `standard` | `global_admin`. Tool-admin gate only.
|
||||
- `paliad.project_teams.role` — `lead | associate | pa | of_counsel | local_counsel | expert | observer`. Per-project membership role.
|
||||
- **Visibility:** `paliad.can_see_project()` SQL function (migration 023) + Go mirror `services.visibilityPredicate()` — global_admin OR any team membership on the project's path. Service-role connection bypasses RLS, so the Go mirror is load-bearing; RLS is defense-in-depth.
|
||||
- **Audit:** `paliad.project_events` (created in migration 005 as `akten_events`, renamed in 018). Every mutation on every project-scoped entity emits one row via `services.insertProjectEventWithMeta()` inside the same tx. Carries `event_type`, `title`, `description`, `metadata jsonb`, `created_by`, `event_date`. Read by `services.AuditService` and by the Verlauf card on each project / deadline / appointment detail page (t-paliad-097, t-paliad-102).
|
||||
- **Entity tables:** `paliad.deadlines` and `paliad.appointments`. Both already carry `created_by uuid REFERENCES auth.users(id)`. Deadlines have `status text CHECK IN ('pending','completed','cancelled','waived')`. Appointments have no status column.
|
||||
- **Service layer:** `DeadlineService.{Create,Update,Complete,Reopen,Delete}`, `AppointmentService.{Create,Update,Delete}`. Each goes through `ProjectService.GetByID(ctx, userID, projectID)` for visibility before mutating. Each emits its `*_created` / `*_updated` / `*_completed` / `*_deleted` event in the same tx.
|
||||
- **Existing patterns this design reuses:**
|
||||
- `paliad.partner_unit_events` audit table (migration 027) — proves the side-table-with-RLS shape works alongside `project_events`.
|
||||
- `paliad.event_types` + `paliad.deadline_event_types` (migration 030) — the picker / multi-select / chip UI pattern is reusable for the "required role" select on the policy authoring page.
|
||||
- `services.visibilityPredicate(alias)` — same shape for the new `approvalEligibleInProject(userID, projectID, requiredRole)` helper.
|
||||
|
||||
This design adds **no new auth/permission axis**. It reuses `project_teams.role` for the qualification gate, per m's Q1 decision. The 3-axis principle holds because the gate uses the existing project axis, not a new firm-wide one.
|
||||
|
||||
---
|
||||
|
||||
## 2. Approval ladder
|
||||
|
||||
### 2.1 Strict ladder over `project_teams.role`
|
||||
|
||||
```
|
||||
level | role | approval-eligible by default?
|
||||
------+------------------+-------------------------------
|
||||
5 | lead | yes — partner-tier on this project
|
||||
4 | of_counsel | yes — senior tier
|
||||
3 | associate | yes ← default required level
|
||||
2 | senior_pa (new) | only if project policy lowers required to 'senior_pa' or below
|
||||
1 | pa | only if project policy lowers required to 'pa'
|
||||
0 | local_counsel | ineligible — external attorney, not in approval scope
|
||||
0 | expert | ineligible — technical witness role
|
||||
0 | observer | ineligible — read-only audit role
|
||||
```
|
||||
|
||||
`senior_pa` is added to the `paliad.project_teams.role` CHECK constraint via migration 054 (see §6.1). It currently has no value in the enum.
|
||||
|
||||
**Strict-ladder rule:** a user with project_teams.role `R` can approve any request whose `required_role` is at level ≤ `level(R)`. So:
|
||||
|
||||
- Default `required_role = 'associate'` (level 3) → eligible approvers: lead, of_counsel, associate.
|
||||
- Override to `required_role = 'senior_pa'` (level 2) → eligible: lead, of_counsel, associate, senior_pa.
|
||||
- Override to `required_role = 'pa'` (level 1) → eligible: lead, of_counsel, associate, senior_pa, pa. This is the "PAs approve other PAs" mode m called for.
|
||||
- Override to `required_role = 'lead'` → only the project lead can approve.
|
||||
|
||||
**Hard rules:**
|
||||
|
||||
1. **Self-approval is hard-blocked.** `caller_id = requested_by` always returns 403, regardless of role. This is enforced at the Go service layer (the only place that mutates approval state) and by a CHECK constraint on the row at decision time (`approved_by != requested_by`).
|
||||
2. **Eligible level 0 = ineligible.** A user with role=local_counsel/expert/observer **cannot** approve any request, even if they're the only team member. They appear in the inbox with "Sie sind nicht qualifiziert" instead of the approve button.
|
||||
3. **`global_admin` is an explicit override path** (§4.2) — not a normal approver. global_admin sign-off is allowed regardless of project_teams.role and audit-marked as `decision_kind='admin_override'`.
|
||||
|
||||
### 2.2 Why not introduce a firm-wide qualification column?
|
||||
|
||||
The issue listed candidates `partner / senior_attorney / attorney / senior_pa / pa / paralegal` and asked whether roles should be global, per-team, or per-project. m chose **per-project** (Q1 = "Reuse project_teams.role"). Rationale (mine, before m chose; reproduced for the record):
|
||||
|
||||
A firm-wide rank column would have:
|
||||
- Cleanly separated from `job_title` (display) and `global_role` (tool admin).
|
||||
- Made authoring rules trivial — one column on `users`, one int compare.
|
||||
- Worked even before a project's team was fully populated.
|
||||
|
||||
But it would have:
|
||||
- Added a 4th identity-axis to maintain (firm rank), violating the spirit of the three-axis principle even if the letter holds.
|
||||
- Forced a firm-wide ladder onto a project context where seniority is already encoded — `lead` on a project IS the partner-tier on that project.
|
||||
- Introduced the question "what if firm rank disagrees with project role" (a senior partner staffed as `observer` on a small case) without a clean answer.
|
||||
|
||||
m's per-project choice is consistent with how the rest of paliad treats authority: the `lead` role on `project_teams` is the source of truth for "who is the partner running this case", and approvals naturally cluster around that.
|
||||
|
||||
### 2.3 What about local_counsel / expert / observer?
|
||||
|
||||
Default: ineligible to approve. Rationale:
|
||||
|
||||
- **local_counsel** is an external attorney (Mitanwalt) — not always a firm employee, often outside the firm's approval chain.
|
||||
- **expert** is a technical / scientific consultant role — not legally qualified to sign off on procedural deadlines.
|
||||
- **observer** is explicitly a read-only role.
|
||||
|
||||
**Escape hatch:** if a project genuinely wants its local_counsel to approve, the team admin can re-add them with `role='associate'` (or whatever tier is intended). The role on `project_teams` is a per-project assignment; the same human can be `local_counsel` on Project A and `associate` on Project B if that's the correct authority on each.
|
||||
|
||||
**Out of scope (follow-up if needed):** a per-project list of "additional approval-eligible roles" that promotes local_counsel/expert into the eligible set without changing their primary project role. Probably not worth the complexity for the few cases where it'd matter.
|
||||
|
||||
---
|
||||
|
||||
## 3. Policy grammar — `paliad.approval_policies`
|
||||
|
||||
### 3.1 Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.approval_policies (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
entity_type text NOT NULL CHECK (entity_type IN ('deadline','appointment')),
|
||||
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create','update','complete','delete')),
|
||||
required_role text NOT NULL CHECK (required_role IN ('lead','of_counsel','associate','senior_pa','pa')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
UNIQUE (project_id, entity_type, lifecycle_event)
|
||||
);
|
||||
|
||||
CREATE INDEX approval_policies_project_idx ON paliad.approval_policies (project_id);
|
||||
```
|
||||
|
||||
Design choices:
|
||||
|
||||
- **Up to 8 rows per project.** `(deadline,create), (deadline,update), (deadline,complete), (deadline,delete), (appointment,create), (appointment,update), (appointment,complete), (appointment,delete)`. UNIQUE composite key enforces this.
|
||||
- **No row = no approval needed for that event.** A project with zero policy rows is in the same operational state as today — no 4-eye anywhere.
|
||||
- **`required_role` is a single value**, not a min-level int. Stored as text matching `project_teams.role` values; the strict ladder is applied in code (see `levelOf(role)` in §3.4). Storing the enum value (rather than an int level) keeps the row readable in `psql` and survives any future ladder reordering.
|
||||
- **Appointment lifecycle includes `complete`**. Today appointments don't have a `completed_at` column or status field. We add one via migration 054 to give `appointment:complete` somewhere to land — see §6.4. (m may choose to defer this; if so, the policy CHECK can drop `complete` for `appointment` and the migration becomes lighter.)
|
||||
|
||||
### 3.2 Inheritance
|
||||
|
||||
**No automatic inheritance from parent project.** A child project (e.g. a single Verfahren under a Litigation parent) does NOT auto-inherit its parent's policy. Reasons:
|
||||
|
||||
- Inheriting would silently change behaviour when projects are reparented (t-paliad-018 already has reparent semantics).
|
||||
- Policy authoring per-Verfahren is the right default — different stages of a litigation may legitimately need different scrutiny.
|
||||
- The path-walking logic for "find the closest ancestor with policy" adds complexity for marginal value.
|
||||
|
||||
**UI affordance:** project detail → Settings → Approvals tab → "Aus Eltern-Projekt übernehmen" button copies the parent's 8 rows into this project. One-shot copy, no live link. Documented as a productivity shortcut.
|
||||
|
||||
### 3.3 Authoring permission
|
||||
|
||||
**v1: global_admin only.** Consistent with the existing /admin/team and /admin/partner-units pattern. Per-project leads cannot edit policy on their own projects in v1.
|
||||
|
||||
**Reasoning:** approval policy is firm-governance-grade — getting it wrong loosens compliance. Concentrating in global_admin is safer for v1. Lifting to "project lead can edit policy on their project" is a one-line gate change.
|
||||
|
||||
**Out of scope follow-up:** lead-can-edit-own-project-policy. File as t-paliad-139 if needed once the v1 ships.
|
||||
|
||||
### 3.4 Service-layer helpers
|
||||
|
||||
```go
|
||||
// internal/services/approval_levels.go
|
||||
|
||||
// levelOf maps a project_teams.role value to the strict-ladder level used
|
||||
// for approval gating. Returns 0 (ineligible) for roles outside the
|
||||
// approval ladder (local_counsel, expert, observer).
|
||||
func levelOf(role string) int {
|
||||
switch role {
|
||||
case "lead": return 5
|
||||
case "of_counsel": return 4
|
||||
case "associate": return 3
|
||||
case "senior_pa": return 2
|
||||
case "pa": return 1
|
||||
default: return 0 // local_counsel, expert, observer, anything new
|
||||
}
|
||||
}
|
||||
|
||||
// canApprove returns true iff:
|
||||
// - caller is not the requester (self-approval blocked)
|
||||
// - caller's project_teams.role on this project has level >= required level
|
||||
// OR caller is global_admin (which is always allowed and audit-marked separately).
|
||||
func (s *ApprovalService) canApprove(ctx, callerID, projectID, requiredRole string, requesterID uuid.UUID) (bool, kind string, err error) {
|
||||
if callerID == requesterID {
|
||||
return false, "", ErrSelfApprovalBlocked
|
||||
}
|
||||
user, err := s.users.GetByID(ctx, callerID)
|
||||
if err != nil { return false, "", err }
|
||||
if user.GlobalRole == "global_admin" {
|
||||
return true, "admin_override", nil
|
||||
}
|
||||
membership, err := s.projects.MembershipFor(ctx, callerID, projectID)
|
||||
if err != nil || membership == nil {
|
||||
return false, "", nil // not on team, cannot approve
|
||||
}
|
||||
if levelOf(membership.Role) >= levelOf(requiredRole) {
|
||||
return true, "peer", nil
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
```
|
||||
|
||||
`decision_kind` values: `peer` (normal in-team sign-off), `admin_override` (global_admin used override path). Stored on `approval_requests.decision_kind`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Lifecycle flow (write-then-approve)
|
||||
|
||||
### 4.1 The four lifecycle events
|
||||
|
||||
For each entity (deadline, appointment), four lifecycle events trigger an approval check:
|
||||
|
||||
1. **create** — new row submitted by user.
|
||||
2. **update** — change to one or more date-bearing fields (allowlist in §4.5).
|
||||
3. **complete** — flip status from `pending` to `completed` on a deadline; flip new `completed_at` (see §6.4) on appointment.
|
||||
4. **delete** — request to remove the row.
|
||||
|
||||
### 4.2 Submission
|
||||
|
||||
User clicks Save / Complete / Delete on the entity. The service layer:
|
||||
|
||||
1. Looks up `paliad.approval_policies(project_id, entity_type, event)`.
|
||||
2. **No row found:** apply mutation immediately (today's behaviour). `approval_status` defaults to `'approved'`. No request row written. Done.
|
||||
3. **Row found:** apply mutation **except for delete** (see §4.3) and additionally:
|
||||
- Set `approval_status = 'pending'` and `pending_request_id = <new uuid>` on the entity row.
|
||||
- Insert one `paliad.approval_requests` row with `lifecycle_event`, `pre_image jsonb` (a snapshot of the now-overwritten field values, used for revert on rejection — see §4.4), `payload jsonb` (echo of what was submitted, for audit), `requested_by = caller`, `requested_at = now()`, `required_role = policy.required_role`, `status = 'pending'`.
|
||||
- Emit `paliad.project_events` row with `event_type = 'deadline_approval_requested'` (or `appointment_approval_requested`) carrying `metadata.approval_request_id = <uuid>`. The Verlauf shows the lifecycle inline.
|
||||
- All four writes happen in **one transaction** (entity update + request insert + event emit).
|
||||
4. **Single-qualified-approver deadlock check.** Before committing, the service runs a count: how many users on this project's team have `levelOf(project_teams.role) >= levelOf(required_role) AND user_id != caller`? If 0, the submission **fails with HTTP 409** and a structured error: `{ "error": "no_qualified_approver", "required_role": "associate", "hint": "add_team_member_or_contact_admin" }`. Frontend translates to a user-facing dialog with two action buttons: "Mehr Team-Mitglieder hinzufügen" (jumps to project team page) and "Admin kontaktieren" (mailto link to global_admin emails). global_admin override is the escape hatch (§4.7).
|
||||
|
||||
### 4.3 Delete is special — stage-then-write
|
||||
|
||||
m's chosen architecture is write-then-approve, but delete cannot be applied immediately and reverted: a hard-delete is irrecoverable.
|
||||
|
||||
**Resolution:** for `lifecycle_event = 'delete'`, the entity row stays in place. We set `approval_status = 'pending'` and link to an `approval_requests` row carrying `lifecycle_event = 'delete'`. The UI marks the row "Zur Löschung beantragt" (see §5.3). On approve: hard-delete the row in a tx (cascades clean up the FK from `approval_requests`). On reject: clear `approval_status` back to `'approved'` and `pending_request_id` to NULL. The deletion never happened.
|
||||
|
||||
This is the one departure from pure write-then-approve. It's a write-then-approve from the user's perspective (they "submit a delete" and the entity behaves as if it's about to disappear) but at the data-layer it's stage-then-write for delete. Documented explicitly to avoid surprise.
|
||||
|
||||
### 4.4 Approval / rejection
|
||||
|
||||
Approver opens `/inbox`, picks a request, clicks Approve (or Reject with optional reason).
|
||||
|
||||
**Approve:**
|
||||
|
||||
1. Service-layer `canApprove(caller, project, request)` check (see §3.4).
|
||||
2. If `decision_kind = 'peer'` or `'admin_override'`, set `approval_requests.status = 'approved'`, `decided_by = caller`, `decided_at = now()`, `decision_kind = …`.
|
||||
3. Update entity row: `approval_status = 'approved'`, clear `pending_request_id`. Set `approved_by = caller`, `approved_at = now()`.
|
||||
4. For `delete`: hard-delete the entity (cascade clears the request FK).
|
||||
5. Emit `paliad.project_events` row with `event_type = 'deadline_approval_approved'` (or `appointment_approval_approved`) carrying `metadata.approval_request_id`, `metadata.decision_kind`. Verlauf line: "Frist X — Genehmigung erteilt von Bert · 2026-05-06".
|
||||
6. Tx commits.
|
||||
|
||||
**Reject:**
|
||||
|
||||
1. Same `canApprove` check.
|
||||
2. Set `approval_requests.status = 'rejected'`, `decided_by`, `decided_at`, `decision_note` (optional reason text from approver).
|
||||
3. **Revert entity** — restore from `pre_image`:
|
||||
- `create`: hard-delete the entity (it never should have been live).
|
||||
- `update`: write `pre_image` field values back over the row.
|
||||
- `complete`: revert deadline `status` to `'pending'`, NULL `completed_at`. Revert appointment `completed_at` to NULL (only meaningful once §6.4 lands).
|
||||
- `delete`: clear `pending_request_id` and `approval_status`. Entity stays live as before.
|
||||
4. Emit `paliad.project_events` row `event_type = 'deadline_approval_rejected'` (or appointment_) with `metadata.approval_request_id`, `metadata.decision_note`. Verlauf line: "Frist X — Genehmigung abgelehnt von Bert · 2026-05-06 — Grund: Datum noch nicht best."
|
||||
5. Tx commits.
|
||||
|
||||
### 4.5 Edit-trigger field allowlist (per Q4)
|
||||
|
||||
The service layer only enters the approval-request flow when an `update` touches the date-bearing fields. All other edits apply immediately as `approval_status='approved'` writes — no request row, no pending state.
|
||||
|
||||
**Deadlines — date-bearing (gates approval):**
|
||||
- `due_date`
|
||||
- `original_due_date`
|
||||
- `warning_date`
|
||||
|
||||
**Deadlines — bypass (no approval):**
|
||||
- `title`, `description`, `notes`
|
||||
- `rule_id`, `rule_code` (legal-basis citation — m chose to bypass; see Q4 trade-off below)
|
||||
- `event_type_ids` (Typ tags via `paliad.deadline_event_types` junction)
|
||||
- `status` other than via the `complete` lifecycle (e.g. cancel, waive — these are out of approval scope per the issue's "all four lifecycle events" framing, which lists complete but not cancel/waive)
|
||||
|
||||
**Appointments — date-bearing (gates approval):**
|
||||
- `start_at`
|
||||
- `end_at`
|
||||
|
||||
**Appointments — bypass (no approval):**
|
||||
- `title`, `description`
|
||||
- `location` (m's Q4 choice excludes location; documented trade-off below)
|
||||
- `appointment_type`
|
||||
|
||||
**Trade-off (m's call):** the looser allowlist means a wrongful change to `rule_code` (legal basis) or `location` (wrong courthouse) won't trigger 4-eye. m's reasoning is implicit but consistent: dates are the highest-stakes mistake category (missed deadline = malpractice exposure), and gating every metadata edit creates approval fatigue that makes approvers rubber-stamp.
|
||||
|
||||
If the team finds this allowlist too loose in practice, the constants in `internal/services/approval_fields.go` (proposed location) are a one-PR widening — no schema change.
|
||||
|
||||
### 4.6 Optimistic-concurrency / superseded requests
|
||||
|
||||
Race scenario: User A submits an `update` request with `pre_image = {due_date: 2026-05-10}`. Before it's approved, user B submits another `update` with their own pre-image. Now there are two pending requests on the same row.
|
||||
|
||||
**Rule:** a row can have at most one pending request at a time. The submission service-layer does:
|
||||
|
||||
```sql
|
||||
UPDATE paliad.deadlines
|
||||
SET ...new field values..., approval_status = 'pending', pending_request_id = $newRequestID
|
||||
WHERE id = $entityID
|
||||
AND approval_status = 'approved' -- only mutate if currently clean
|
||||
RETURNING id;
|
||||
```
|
||||
|
||||
If the UPDATE returns 0 rows (because `approval_status != 'approved'`), the submission fails with HTTP 409 `{ "error": "concurrent_pending", "hint": "wait_for_existing_approval_or_revoke" }`. Frontend shows "Es liegt bereits eine Genehmigungsanfrage auf dieser Frist vor."
|
||||
|
||||
Submitter has options: revoke their own pending (if they own it) and resubmit; or wait for the existing request to settle.
|
||||
|
||||
### 4.7 Single-qualified-approver deadlock — global_admin override path
|
||||
|
||||
Per Q8, the default behaviour is **refuse to submit** when no qualified approver other than the requester exists on the team. Submission is blocked at the API layer.
|
||||
|
||||
**Override mechanism:** any `global_admin` (regardless of project membership) has the approval right. So if the user's team has nobody else qualified, the user can submit anyway IF the project has at least one global_admin who can approve. The submission service runs the deadlock check as:
|
||||
|
||||
```
|
||||
SELECT COUNT(*) FROM paliad.project_teams pt
|
||||
WHERE pt.project_id = $proj
|
||||
AND pt.user_id <> $caller
|
||||
AND pt.role IN (eligible roles for required_role)
|
||||
+
|
||||
SELECT COUNT(*) FROM paliad.users u
|
||||
WHERE u.global_role = 'global_admin'
|
||||
AND u.id <> $caller
|
||||
```
|
||||
|
||||
If sum > 0, submission is allowed. If sum = 0, the 409 fires. In practice, paliad currently has 2 global_admins so sum is rarely 0 — but the design contemplates the case.
|
||||
|
||||
When global_admin signs off, the `decision_kind` on the approval_request row is `'admin_override'` (vs `'peer'`). Verlauf chronology renders this distinctly: "Admin-Sign-off von m · 2026-05-06" rather than "Genehmigt von Bert · 2026-05-06". The audit log timeline filters can pivot on `decision_kind`.
|
||||
|
||||
### 4.8 Revocation (per Q7)
|
||||
|
||||
- **Requester revokes:** while `request.status = 'pending'`, the requester can DELETE their own request. Service-layer reverts the entity from pre_image (same code path as Reject), but instead of marking the request `'rejected'`, marks it `'revoked'`. New `paliad.project_events` event_type `'deadline_approval_revoked'`.
|
||||
- **Approver revokes after approval:** **not supported** per Q7. Once approved, the only path back is a new request — e.g. an over-eager Complete is reversed by a fresh "Reopen" lifecycle event, which itself flows through the approval gate.
|
||||
|
||||
---
|
||||
|
||||
## 5. UI surfaces
|
||||
|
||||
### 5.1 The pending pill — visible everywhere
|
||||
|
||||
Per m's interjection, pending state must surface in every view that shows the entity. Visual treatment:
|
||||
|
||||
- **Pending CREATE** — striped/dashed border on the row, ⚠ icon, label "Erstellung wartet auf Genehmigung von <required_role>+". Counted toward traffic-light buckets (the deadline IS real, just unverified) but rendered with a "tentative" CSS class.
|
||||
- **Pending UPDATE** — solid border, but a yellow chip in the date column saying "Datum geändert — wartet auf Genehmigung". Tooltip on the chip shows the diff: "vorher: 2026-05-10 → 2026-05-12".
|
||||
- **Pending COMPLETE** — solid border, status badge "Erledigt (wartet auf Genehmigung)" with strike-through-pending styling. The traffic-light treats the row as completed (the action-taker thinks they're done) but with the same striped class as create-pending so an approver can see the queue at a glance.
|
||||
- **Pending DELETE** — dashed-red border, label "Zur Löschung beantragt". Date / details still visible but strike-through. Click → details + approval request.
|
||||
|
||||
CSS classes (proposed, in `frontend/src/styles/global.css`):
|
||||
|
||||
```css
|
||||
.entity-row--pending-create { border-style: dashed; border-color: var(--frist-amber); }
|
||||
.entity-row--pending-update { /* solid border, chip handles the signal */ }
|
||||
.entity-row--pending-complete { background: linear-gradient(...striped...); }
|
||||
.entity-row--pending-delete { border-style: dashed; border-color: var(--frist-red); text-decoration: line-through; }
|
||||
|
||||
.approval-pill { display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 2px 8px; border-radius: 9999px;
|
||||
background: var(--bg-warn-soft); color: var(--fg-warn);
|
||||
font-size: 12px; }
|
||||
.approval-pill::before { content: "⚠ "; }
|
||||
```
|
||||
|
||||
i18n keys (DE primary, EN secondary):
|
||||
|
||||
- `approvals.pending_create.label` — "Erstellung wartet auf Genehmigung" / "Awaits approval (creation)"
|
||||
- `approvals.pending_update.label` — "Änderung wartet auf Genehmigung" / "Awaits approval (change)"
|
||||
- `approvals.pending_complete.label` — "Erledigung wartet auf Genehmigung" / "Awaits approval (completion)"
|
||||
- `approvals.pending_delete.label` — "Zur Löschung beantragt" / "Awaits approval (deletion)"
|
||||
- `approvals.required_role.<role>` — "Lead", "Of Counsel", "Associate", "Senior PA", "PA"
|
||||
- `approvals.requested_by` — "Eingereicht von {name}" / "Submitted by {name}"
|
||||
- `approvals.no_approver_dialog.*` — full deadlock dialog strings
|
||||
- `approvals.approve.button` — "Genehmigen" / "Approve"
|
||||
- `approvals.reject.button` — "Ablehnen" / "Reject"
|
||||
- `approvals.revoke.button` — "Zurückziehen" / "Revoke"
|
||||
- `approvals.decision_kind.peer` — "Genehmigt von {name}" / "Approved by {name}"
|
||||
- `approvals.decision_kind.admin_override` — "Admin-Sign-off von {name}" / "Admin sign-off by {name}"
|
||||
|
||||
Surfaces that show the pending pill:
|
||||
|
||||
- `/deadlines` and `/appointments` table rows (one pill per row).
|
||||
- `/agenda` timeline (per-row pill).
|
||||
- `/dashboard` traffic-light card-list previews.
|
||||
- `/projects/{id}` details — Fristen + Termine sections.
|
||||
- `/deadlines/{id}` and `/appointments/{id}` detail pages — full diff display.
|
||||
- CalDAV: pending entries sync to the user's external calendar with title prefix `[PENDING] ` (e.g. `[PENDING] Frist Erwiderung`). Approved entries sync clean.
|
||||
- Email reminders (`internal/services/reminder_service.go`): pending entries get a banner in the mail body and a `[PENDING] ` subject prefix.
|
||||
|
||||
### 5.2 Bell + `/inbox` page (per Q6)
|
||||
|
||||
**Bell** in the sidebar header (next to the user-menu). Shows count of "open requests where I am a qualified approver and not the requester". Click → `/inbox`. Refreshes via the existing dashboard-polling pattern (60s interval; `Last-Modified` ETag if cheap to add).
|
||||
|
||||
**`/inbox` page**, two tabs:
|
||||
|
||||
1. **"Zur Genehmigung"** (`?tab=pending-mine`): list of `approval_requests` where:
|
||||
- `status = 'pending'`
|
||||
- `requested_by != me`
|
||||
- I have eligible role on the project (or I'm global_admin)
|
||||
Sorted by `requested_at` ASC (oldest first — stale requests demand attention). Each item shows: project title, entity title, lifecycle event, requester name, age ("vor 4h"), required-role badge. Inline Approve / Reject buttons, expand-row reveals the diff (for update / complete / delete) or full payload (for create).
|
||||
|
||||
2. **"Meine Anfragen"** (`?tab=mine`): list of `approval_requests` where `requested_by = me`. Status filter pills: pending / approved / rejected / revoked. For pending items, a Revoke button.
|
||||
|
||||
URL structure: `/inbox?tab=pending-mine|mine&status=pending|...&project_id=...`. Back-button friendly.
|
||||
|
||||
Why distinct from email reminder flow: email reminders are outbound notifications (digest of upcoming deadlines). The inbox is a workflow surface — actions are taken there. Sharing infra would conflate two purposes.
|
||||
|
||||
### 5.3 Policy authoring — `/projects/{id}/settings/approvals`
|
||||
|
||||
Tab on the project detail page, gated to global_admin. Rendered as a 2×4 table:
|
||||
|
||||
```
|
||||
CREATE UPDATE (date) COMPLETE DELETE
|
||||
Frist [select] [select] [select] [select]
|
||||
Termin [select] [select] [select] [select]
|
||||
```
|
||||
|
||||
Each `<select>` offers: "Keine Genehmigung erforderlich (default)" / "Lead" / "Of Counsel" / "Associate" / "Senior PA" / "PA". Submitting upserts/deletes rows in `paliad.approval_policies`.
|
||||
|
||||
Helpers:
|
||||
- "Aus Eltern-Projekt übernehmen" button — copies the parent project's policy rows in one click. One-shot copy, no live link.
|
||||
- "Alle auf Associate setzen" button — fills all 8 cells with `associate` for fast onboarding of a new project.
|
||||
|
||||
### 5.4 Diff rendering
|
||||
|
||||
For `update` requests, the `pre_image` jsonb captured at submission and the entity's current values let the UI render a clean diff. For deadlines: a 1-3 line comparison ("Datum: 2026-05-10 → 2026-05-12 · Warnung: 2026-05-08 → 2026-05-10"). Done in pure TS in `frontend/src/client/inbox.ts` consuming the request payload.
|
||||
|
||||
---
|
||||
|
||||
## 6. Schema changes (migration 054)
|
||||
|
||||
### 6.1 Add `senior_pa` to `project_teams.role`
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.project_teams DROP CONSTRAINT IF EXISTS project_teams_role_check;
|
||||
ALTER TABLE paliad.project_teams ADD CONSTRAINT project_teams_role_check
|
||||
CHECK (role IN (
|
||||
'lead','associate','pa','of_counsel',
|
||||
'local_counsel','expert','observer',
|
||||
'senior_pa'
|
||||
));
|
||||
```
|
||||
|
||||
i18n labels for the new role (in DE+EN per existing `team.role.*` keys).
|
||||
|
||||
### 6.2 `paliad.approval_policies`
|
||||
|
||||
See §3.1 — full DDL.
|
||||
|
||||
### 6.3 `paliad.approval_requests`
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.approval_requests (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
entity_type text NOT NULL CHECK (entity_type IN ('deadline','appointment')),
|
||||
-- entity_id is the deadline.id / appointment.id this request operates on.
|
||||
-- For 'create' lifecycle, this is the id of the just-inserted entity row
|
||||
-- (so the request can reference back to it). For 'delete', it's the row
|
||||
-- being requested for removal.
|
||||
entity_id uuid NOT NULL,
|
||||
lifecycle_event text NOT NULL CHECK (lifecycle_event IN ('create','update','complete','delete')),
|
||||
-- For 'update'/'complete'/'delete': pre_image carries the field values
|
||||
-- needed to revert on rejection. For 'create': pre_image IS NULL.
|
||||
pre_image jsonb,
|
||||
-- For audit/visibility, payload echoes the diff or new values that were
|
||||
-- written. Read-only after insert.
|
||||
payload jsonb,
|
||||
requested_by uuid NOT NULL REFERENCES paliad.users(id) ON DELETE RESTRICT,
|
||||
requested_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Snapshot of policy.required_role at request time. Even if the policy
|
||||
-- changes mid-flight, the request honours the level it was submitted under.
|
||||
required_role text NOT NULL CHECK (required_role IN ('lead','of_counsel','associate','senior_pa','pa')),
|
||||
status text NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','approved','rejected','revoked','superseded')),
|
||||
decided_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
decided_at timestamptz,
|
||||
decision_kind text CHECK (decision_kind IS NULL OR decision_kind IN ('peer','admin_override')),
|
||||
decision_note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- Hard CHECK: an approver is never the requester.
|
||||
CHECK (decided_by IS NULL OR decided_by <> requested_by)
|
||||
);
|
||||
|
||||
CREATE INDEX approval_requests_project_status_idx
|
||||
ON paliad.approval_requests (project_id, status);
|
||||
CREATE INDEX approval_requests_entity_idx
|
||||
ON paliad.approval_requests (entity_type, entity_id);
|
||||
CREATE INDEX approval_requests_requested_by_idx
|
||||
ON paliad.approval_requests (requested_by, status);
|
||||
CREATE INDEX approval_requests_pending_idx
|
||||
ON paliad.approval_requests (status, requested_at)
|
||||
WHERE status = 'pending';
|
||||
```
|
||||
|
||||
RLS on `approval_requests`: per Q10, mirror `paliad.deadlines` policy — visible if `paliad.can_see_project(project_id)`. RLS does NOT gate the approve/reject action; that's enforced at the service layer.
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.approval_requests ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY approval_requests_all ON paliad.approval_requests
|
||||
FOR ALL USING (paliad.can_see_project(project_id));
|
||||
```
|
||||
|
||||
### 6.4 New columns on `paliad.deadlines` and `paliad.appointments`
|
||||
|
||||
```sql
|
||||
-- deadlines: approval state + approver tracking
|
||||
ALTER TABLE paliad.deadlines ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
|
||||
CHECK (approval_status IN ('approved','pending','legacy'));
|
||||
ALTER TABLE paliad.deadlines ADD COLUMN pending_request_id uuid
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
|
||||
ALTER TABLE paliad.deadlines ADD COLUMN approved_by uuid
|
||||
REFERENCES paliad.users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE paliad.deadlines ADD COLUMN approved_at timestamptz;
|
||||
|
||||
CREATE INDEX deadlines_approval_status_idx
|
||||
ON paliad.deadlines (approval_status) WHERE approval_status = 'pending';
|
||||
|
||||
-- appointments: same triple
|
||||
ALTER TABLE paliad.appointments ADD COLUMN approval_status text NOT NULL DEFAULT 'approved'
|
||||
CHECK (approval_status IN ('approved','pending','legacy'));
|
||||
ALTER TABLE paliad.appointments ADD COLUMN pending_request_id uuid
|
||||
REFERENCES paliad.approval_requests(id) ON DELETE SET NULL;
|
||||
ALTER TABLE paliad.appointments ADD COLUMN approved_by uuid
|
||||
REFERENCES paliad.users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE paliad.appointments ADD COLUMN approved_at timestamptz;
|
||||
|
||||
-- appointments need a completed_at for the 'complete' lifecycle event to land
|
||||
ALTER TABLE paliad.appointments ADD COLUMN completed_at timestamptz;
|
||||
|
||||
CREATE INDEX appointments_approval_status_idx
|
||||
ON paliad.appointments (approval_status) WHERE approval_status = 'pending';
|
||||
```
|
||||
|
||||
**`appointments.completed_at`** is new. Today appointments don't have a completion concept — they just sit on the calendar. The `complete` lifecycle event for appointments is meaningful when m wants to mark hearings/meetings as actually-happened (e.g. "Mündliche Verhandlung am 2026-05-15 — abgehalten"). If m prefers to drop appointment-complete from the lifecycle list (deadline-complete only), the `completed_at` column drops out and the policy CHECK constraint excludes `(appointment, complete)`.
|
||||
|
||||
This is a clean place for m to make a smaller call: keep appointment:complete (and add `completed_at`), or drop it.
|
||||
|
||||
### 6.5 Backfill
|
||||
|
||||
```sql
|
||||
-- Mark all existing rows as legacy (predates 4-eye).
|
||||
UPDATE paliad.deadlines SET approval_status = 'legacy';
|
||||
UPDATE paliad.appointments SET approval_status = 'legacy';
|
||||
```
|
||||
|
||||
`approved_by`/`approved_at` stay NULL on legacy rows. `created_by` is already populated since migration 005 (the column has been required from day one).
|
||||
|
||||
**No retroactive approval** — m's Q11 choice. Legacy rows are read-only-clean. The next mutation on a legacy row that hits an active policy follows the normal flow (e.g. editing a date on a legacy deadline triggers `update` approval; the row becomes `approval_status='pending'` and goes through the gate; once approved, `approval_status='approved'`).
|
||||
|
||||
### 6.6 Down migration
|
||||
|
||||
The down migration drops the four new columns + `completed_at` + `approval_policies` + `approval_requests` + restores the `project_teams.role` CHECK without `senior_pa`. If any user has been re-roled to `senior_pa`, the down migration will fail loudly until they're migrated to another role — intentional, mirrors the t-paliad-051 down strategy.
|
||||
|
||||
---
|
||||
|
||||
## 7. Service-layer integration
|
||||
|
||||
### 7.1 New service: `ApprovalService`
|
||||
|
||||
```go
|
||||
// internal/services/approval_service.go
|
||||
|
||||
type ApprovalService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// SubmitCreate is invoked by DeadlineService.Create / AppointmentService.Create
|
||||
// inside the existing entity-write tx. If a policy applies, it inserts the
|
||||
// approval_requests row and sets entity.approval_status = 'pending' + entity.
|
||||
// pending_request_id. Returns (requestID, isPending, err).
|
||||
func (s *ApprovalService) SubmitCreate(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
|
||||
|
||||
// Same shape for Update / Complete / Delete. Update takes a preImage map.
|
||||
func (s *ApprovalService) SubmitUpdate(ctx, tx, projectID, entityType, entityID, requesterID, preImage map[string]any) (uuid.UUID, bool, error)
|
||||
func (s *ApprovalService) SubmitComplete(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
|
||||
func (s *ApprovalService) SubmitDelete(ctx, tx, projectID, entityType, entityID, requesterID) (uuid.UUID, bool, error)
|
||||
|
||||
// Approve / Reject / Revoke — invoked by the inbox handler.
|
||||
func (s *ApprovalService) Approve(ctx, requestID, callerID, note string) error
|
||||
func (s *ApprovalService) Reject(ctx, requestID, callerID, note string) error
|
||||
func (s *ApprovalService) Revoke(ctx, requestID, callerID string) error
|
||||
|
||||
// ListInbox returns the pending-mine and my-submitted views.
|
||||
func (s *ApprovalService) ListPendingForApprover(ctx, callerID, filter) ([]ApprovalRequestView, error)
|
||||
func (s *ApprovalService) ListSubmittedByUser(ctx, callerID, filter) ([]ApprovalRequestView, error)
|
||||
```
|
||||
|
||||
### 7.2 Wiring into existing services
|
||||
|
||||
**`DeadlineService.Create`** today:
|
||||
1. ProjectService.GetByID gate (visibility check)
|
||||
2. Begin tx
|
||||
3. INSERT into paliad.deadlines
|
||||
4. Attach event_types junction rows
|
||||
5. insertProjectEventWithMeta(deadline_created)
|
||||
6. Commit
|
||||
|
||||
After integration:
|
||||
1. ProjectService.GetByID gate
|
||||
2. Begin tx
|
||||
3. INSERT into paliad.deadlines (approval_status defaults to 'approved')
|
||||
4. **`approvals.SubmitCreate(ctx, tx, projectID, "deadline", id, userID)`** — if policy applies, this:
|
||||
- Updates approval_status='pending', pending_request_id=… on the just-inserted row
|
||||
- INSERTs approval_requests row
|
||||
- Performs deadlock count, fails the tx if 0 qualified approvers exist
|
||||
5. Attach event_types junction rows
|
||||
6. insertProjectEventWithMeta(deadline_created) — unchanged
|
||||
7. **insertProjectEventWithMeta(deadline_approval_requested)** if approval is pending
|
||||
8. Commit
|
||||
|
||||
Same shape for `Update`, `Complete`, `Delete` on both DeadlineService and AppointmentService. The `Complete` call site is `MarkComplete`/`Reopen` in DeadlineService (today); reopen would be modelled as a fresh "create-style" approval if it lands on a legacy row, or as part of "update" lifecycle on the `status` field — but `status` is not in the date-bearing allowlist so reopen goes through immediately. **Reopen does NOT trigger 4-eye** under this design (Q4 = date-fields-only). If m wants reopen-needs-approval, add `status` to the allowlist or treat reopen as its own lifecycle event.
|
||||
|
||||
### 7.3 Read-path changes
|
||||
|
||||
Existing list/summary queries (`ListVisibleForUser`, `SummaryCounts`) need to:
|
||||
|
||||
- Hydrate `approval_status`, `approved_by`, `approved_at`, and the linked `approval_requests.lifecycle_event` (via JOIN) for each row.
|
||||
- Pass these through to the frontend so the pending pill and traffic-light styling can render.
|
||||
|
||||
Bucket math (t-paliad-106 5-bucket harmonisation) is **unchanged** — pending CREATEs still bucket by `due_date` like any other; the visual just adds the pending pill. Pending DELETEs still appear in their bucket until the delete is approved.
|
||||
|
||||
`/api/inbox/pending-mine` and `/api/inbox/mine` are new endpoints, served by `internal/handlers/inbox.go`.
|
||||
|
||||
### 7.4 Visibility gating for the inbox
|
||||
|
||||
The pending-mine list is gated by:
|
||||
|
||||
```sql
|
||||
SELECT ar.* FROM paliad.approval_requests ar
|
||||
JOIN paliad.projects p ON p.id = ar.project_id
|
||||
WHERE ar.status = 'pending'
|
||||
AND ar.requested_by != $callerID
|
||||
AND <visibilityPredicate>(p) for callerID
|
||||
AND (
|
||||
-- caller is global_admin
|
||||
EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = $callerID AND u.global_role = 'global_admin')
|
||||
OR
|
||||
-- caller has eligible role on this specific project
|
||||
EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $callerID
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
AND levelOf(pt.role) >= levelOf(ar.required_role))
|
||||
)
|
||||
ORDER BY ar.requested_at ASC;
|
||||
```
|
||||
|
||||
`levelOf` in SQL is a small immutable function:
|
||||
|
||||
```sql
|
||||
CREATE FUNCTION paliad.approval_role_level(role text) RETURNS int LANGUAGE SQL IMMUTABLE AS $$
|
||||
SELECT CASE role
|
||||
WHEN 'lead' THEN 5
|
||||
WHEN 'of_counsel' THEN 4
|
||||
WHEN 'associate' THEN 3
|
||||
WHEN 'senior_pa' THEN 2
|
||||
WHEN 'pa' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
$$;
|
||||
```
|
||||
|
||||
Stable values; mirrors the Go `levelOf`. Used in the inbox SQL and in any future RLS policy. Migration ships both.
|
||||
|
||||
---
|
||||
|
||||
## 8. Audit / chronology integration
|
||||
|
||||
Per Q9, the existing `paliad.project_events` audit gains four new event_type values per entity:
|
||||
|
||||
- `deadline_approval_requested` — a request was submitted. Metadata: `{ approval_request_id, lifecycle_event, required_role }`.
|
||||
- `deadline_approval_approved` — request approved. Metadata: `{ approval_request_id, decision_kind, decided_by_email }`.
|
||||
- `deadline_approval_rejected` — request rejected. Metadata: `{ approval_request_id, decision_note }`.
|
||||
- `deadline_approval_revoked` — requester revoked their own pending. Metadata: `{ approval_request_id }`.
|
||||
|
||||
Same four for appointments (`appointment_approval_*`).
|
||||
|
||||
These appear in:
|
||||
|
||||
- The `paliad.project_events` Verlauf card on `/projects/{id}` (via existing render path; new translateEvent cases needed in `frontend/src/client/projects-detail.ts`).
|
||||
- The `paliad.project_events` Verlauf card on `/deadlines/{id}` and `/appointments/{id}` (same pattern).
|
||||
- The cross-project `AuditService.ListEntries` timeline at `/admin/audit-log` (already unions project_events; new event types ride along automatically).
|
||||
- Dashboard recent-activity rail (filter through existing `translateEvent` to render the correct sentence).
|
||||
|
||||
**Both names persist on the entity** per the issue's m-locked requirement: `created_by` (already there) + `approved_by` (new). Verlauf renders for an approved deadline:
|
||||
|
||||
```
|
||||
Frist erstellt — eingereicht von Anna 2026-05-06 14:23
|
||||
· genehmigt von Bert 2026-05-06 14:31
|
||||
```
|
||||
|
||||
This is two project_events rows rendered as a paired card in the Verlauf. The frontend pairs them by `metadata.approval_request_id`.
|
||||
|
||||
---
|
||||
|
||||
## 9. RLS / security plan
|
||||
|
||||
Per Q10:
|
||||
|
||||
1. **`approval_requests`** — RLS = `paliad.can_see_project(project_id)`. Same predicate as `deadlines`/`appointments`. Anyone on the project can read pending requests (transparency).
|
||||
2. **`approval_policies`** — RLS = `paliad.can_see_project(project_id)` for SELECT; INSERT/UPDATE/DELETE gated to `global_role = 'global_admin'` (consistent with /admin/team / /admin/partner-units precedent).
|
||||
3. **The `approve`/`reject`/`revoke` action** — service-layer gate only. The pgx pool runs as service role and bypasses RLS, so the check happens in `ApprovalService.canApprove()` (§3.4). RLS provides defense-in-depth for any future direct-DB query path.
|
||||
4. **Self-approval block** — enforced both at the service layer and via a CHECK constraint on `approval_requests` (`decided_by IS NULL OR decided_by <> requested_by`). Two layers because either alone is insufficient (a SQL bug bypasses the service; a service bug bypasses the CHECK).
|
||||
|
||||
The path-walking team-membership + global_admin predicate (`visibilityPredicate`) extends naturally to "approvable-by-me" via the inline JOIN shown in §7.4. No new SQL function needed; the inline form is read-only on the inbox query path.
|
||||
|
||||
**Out of scope follow-up:** if any future direct-DB tooling needs to query "approvable by me", extract a `paliad.can_approve_in_project(user_id, project_id, required_role)` SQL function. For v1, the inline JOIN is sufficient and avoids adding a function that no migration currently calls.
|
||||
|
||||
---
|
||||
|
||||
## 10. Migration plan
|
||||
|
||||
### 10.1 Single migration, single PR
|
||||
|
||||
Migration 054 (`054_approvals.{up,down}.sql`):
|
||||
|
||||
1. Add `senior_pa` to `project_teams.role` CHECK (§6.1).
|
||||
2. Create `paliad.approval_role_level(text) RETURNS int` SQL function.
|
||||
3. Create `paliad.approval_policies` table (§6.2) + indexes + RLS.
|
||||
4. Create `paliad.approval_requests` table (§6.3) + indexes + RLS.
|
||||
5. Add new columns on `paliad.deadlines` and `paliad.appointments` (§6.4) + indexes.
|
||||
6. Mark all existing rows `approval_status='legacy'` (§6.5).
|
||||
|
||||
No data move. No FK hijinks. ms-level apply on a 200-ish-row deadlines table.
|
||||
|
||||
### 10.2 Implementation phasing
|
||||
|
||||
The PR is large but clean. Recommended split into commits (single branch, single PR):
|
||||
|
||||
1. **Commit 1 — Migration 054.** Schema + backfill. No code changes. Runs cleanly on prod; existing flows don't read the new columns yet.
|
||||
2. **Commit 2 — `ApprovalService` core.** Submit / Approve / Reject / Revoke, deadlock check, pre_image capture, request lifecycle. Unit tests (table-driven over the strict-ladder + self-approval rules, deadlock count edge cases).
|
||||
3. **Commit 3 — Wire into `DeadlineService` + `AppointmentService`.** Mutation paths gain the SubmitCreate/Update/Complete/Delete hooks. Read paths hydrate approval_status. Adds new event_types to project_events emit path. Live-DB integration test: TEST_DATABASE_URL covering submit→approve / submit→reject / submit→revoke / single-approver-deadlock / global-admin-override.
|
||||
4. **Commit 4 — Policy authoring page.** `/projects/{id}/settings/approvals` tab + handler + frontend. global_admin-only gate.
|
||||
5. **Commit 5 — Inbox.** `/inbox` page + bell icon + `/api/inbox/*` endpoints + frontend list rendering with diff display.
|
||||
6. **Commit 6 — Pending pills + traffic-light variants.** CSS + i18n + per-surface pill rendering on /deadlines, /appointments, /agenda, /dashboard, /projects/{id}, detail pages.
|
||||
7. **Commit 7 — CalDAV `[PENDING] ` prefix + email-reminder pending banner.** Updates `caldav_service.go` and `mail_service.go` formatting. Integration tests on iCal output and rendered email body.
|
||||
8. **Commit 8 — Verlauf rendering of approval lifecycle.** translateEvent cases for the four new event_types. Pair-card rendering for request+decision events.
|
||||
|
||||
Each commit is testable in isolation; commits 1–3 are merge-safe even before the UI lands (legacy rows + pending state hidden by default = no behaviour change on existing flows because no project has policies until commit 4 ships).
|
||||
|
||||
### 10.3 Roll-out
|
||||
|
||||
Suggested:
|
||||
|
||||
1. Migration 054 lands → no behaviour change (no policies exist yet).
|
||||
2. Pick one pilot project, set policy `(deadline,*)=associate`. Smoke through one CREATE / UPDATE / COMPLETE / DELETE cycle as a non-admin user. Verify pending pills, inbox, approver flow, audit chronology.
|
||||
3. Once validated, m authors policies on real client projects. Each project opts in by adding rows.
|
||||
4. Backfill any free-form leftover later if needed (admin scripts).
|
||||
|
||||
---
|
||||
|
||||
## 11. Trade-offs and known limitations
|
||||
|
||||
### 11.1 Write-then-approve dilution risk
|
||||
|
||||
Per Q5 m chose write-then-approve. This means a pending CREATE is "live" in lists / dashboard / agenda / CalDAV / email reminders before approval. A wrongful create that's eventually rejected briefly polluted the user's mental model and external calendars.
|
||||
|
||||
**Mitigations:**
|
||||
- Pending pill is highly visible (striped border, ⚠ icon).
|
||||
- CalDAV title prefix `[PENDING] ` makes external surfaces honest.
|
||||
- Rejected creates emit `*_approval_rejected` event in Verlauf so the "what happened to that deadline" question has a paper trail.
|
||||
- Approval flow surfaces immediately in inbox (bell badge), so latency between submit and approve is short.
|
||||
|
||||
The alternative (stage-then-write) was strictly safer but m rejected it; the strict-safer architecture would have forced each Frist to live in `approval_requests` until approved, which means views had to UNION the entity table with the requests table — heavy read-path changes and the kind of complexity that compounds into bugs.
|
||||
|
||||
### 11.2 Date-fields-only edit allowlist
|
||||
|
||||
m chose Q4 = "Only date-changing fields". Trade-off: a wrongful change to `rule_code` (legal basis) or `location` (wrong courthouse) bypasses 4-eye. The ladder-based approval-fatigue argument (every metadata edit triggering approvals causes rubber-stamping) is the case for the looser scope.
|
||||
|
||||
If the team finds this too loose in practice, extending the allowlist is a one-line constants change in `internal/services/approval_fields.go` — documented as the place to widen.
|
||||
|
||||
### 11.3 No inheritance from parent project
|
||||
|
||||
§3.2 — a child project doesn't auto-inherit its parent's policy. Trade-off: explicit per-project authoring (more control, more clicks). The "Aus Eltern-Projekt übernehmen" button in the authoring UI (§5.3) reduces the friction.
|
||||
|
||||
### 11.4 v1 is global_admin-only for policy authoring
|
||||
|
||||
Per §3.3, only global_admins can create/edit policies. Project leads cannot edit their own project's policy. Trade-off: tighter governance vs. lead self-service. Lifting to "lead can edit" is a one-line gate change (file as t-paliad-139).
|
||||
|
||||
### 11.5 senior_pa is the only new role enum value
|
||||
|
||||
§6.1 only adds `senior_pa`. Other firm-rank candidates from the issue (`partner`, `senior_attorney`, `attorney`, `paralegal`) were redundant: `lead` already represents partner-tier on a project, `of_counsel` covers senior-attorney, `associate` covers attorney, and paralegal sits below pa (mapped to `observer` in v1). If those distinctions matter later, additional values can be added without breaking existing rows.
|
||||
|
||||
### 11.6 Reopen is not a separate lifecycle
|
||||
|
||||
Today reopening a deadline (revert from `completed` to `pending`) is a status-only change. With Q4 = date-fields-only, reopen does NOT trigger 4-eye. If m wants reopen-needs-approval, it can be modelled as a 5th lifecycle event or as a special-case status-field entry in the allowlist. Documented for future tightening.
|
||||
|
||||
### 11.7 Approval timeout
|
||||
|
||||
No automatic timeout on pending requests. A request can sit pending forever. UI surfaces age ("vor 4 Tagen") to nudge approvers. Future addition: nightly digest email to approvers with a list of pending items > 24h old. Out of scope for v1.
|
||||
|
||||
---
|
||||
|
||||
## 12. Implementation recommendation
|
||||
|
||||
Recommended implementer: **cronus** (this same worktree). Rationale: shipped t-paliad-088 (Event Types — schema + service + handlers + frontend, similar shape), t-paliad-110 (events unification — read-path with new columns hydrated and rendered), t-paliad-122 (courts entity with role-tier-like ladder over countries+regimes). Pattern fluency is high.
|
||||
|
||||
Alternative: split — cronus does commits 1–3 (schema + service core + service-layer wiring) on `mai/cronus/approvals-impl-1`. Then a fresh coder (curie or fritz) does commits 4–8 (UI + inbox + pills + CalDAV + email) on a sibling branch. Trade-off: smaller PRs, but two coordination handovers.
|
||||
|
||||
Head decides.
|
||||
|
||||
---
|
||||
|
||||
## 13. End-of-design checklist
|
||||
|
||||
- [x] Locked constraints summarised (§0)
|
||||
- [x] Existing-code grounding (§1)
|
||||
- [x] Role taxonomy / hierarchy (§2)
|
||||
- [x] Rule grammar (§3)
|
||||
- [x] Lifecycle flow + edit allowlist + deadlock + revocation (§4)
|
||||
- [x] UI surfaces (§5)
|
||||
- [x] Schema (§6)
|
||||
- [x] Service-layer integration (§7)
|
||||
- [x] Audit / chronology (§8)
|
||||
- [x] RLS / security (§9)
|
||||
- [x] Migration plan + phasing (§10)
|
||||
- [x] Trade-offs (§11)
|
||||
- [x] Implementation recommendation (§12)
|
||||
|
||||
**Inventor stays parked.** Design committed; awaiting m's go/no-go before any coder shift starts. No `/mai-coder` self-load. The `DESIGN READY FOR REVIEW` signal is sent via `mai report completed` so the head can gate.
|
||||
504
docs/design-command-palette.md
Normal file
@@ -0,0 +1,504 @@
|
||||
# Cmd/Ctrl+K Command Palette — Design
|
||||
|
||||
**Task:** t-paliad-044
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-04-26
|
||||
**Status:** Design — awaiting m's go/no-go before coder shift
|
||||
|
||||
---
|
||||
|
||||
## Decision: Option B — full command palette (with strict scope guardrails)
|
||||
|
||||
The brief offered two scopes. Rationale for picking B:
|
||||
|
||||
1. **`pwa-baseline.md` is explicit** — multi-entity sites ship a command palette;
|
||||
single-entity sites can skip it. Paliad has 8 entity types (projects, deadlines,
|
||||
appointments, glossary, courts, checklists, links, users), so it is squarely in
|
||||
the "ship a palette" bucket.
|
||||
2. **80% of the infrastructure already exists.** `frontend/src/client/search.ts`
|
||||
has sectioned grouped results, keyboard navigation (↑↓ / ↵ / Esc), i18n
|
||||
group headers, debounce + AbortController, an in-flight cancellation pattern,
|
||||
and language-switch re-render. Adding an *Actions* section on top is
|
||||
incremental, not a rewrite.
|
||||
3. **Patent lawyers are heavy keyboard users on desktop.** The HLC / HLC-Munich
|
||||
audience drafts long documents; Cmd+K → "Neue Frist" without leaving the
|
||||
keyboard is genuinely valuable. Sidebar nav is already always-visible on
|
||||
desktop, so the *navigate-to* actions are quality-of-life — but the *create*
|
||||
actions ("Neue Frist", "Neuer Termin", "Neues Projekt") are real time saves.
|
||||
4. **Going A first feels like a half-step.** A "/" key alias for Cmd+K is five
|
||||
lines of code, but everyone who hits Cmd+K and sees only entity search will
|
||||
wonder where "Gehe zu Dashboard" / "Neue Frist anlegen" are. We'd be back
|
||||
here in two weeks anyway.
|
||||
5. **Template value.** Paliad is the first paliad-stack PWA to fully implement
|
||||
the pwa-baseline `SearchPalette` reference. Doing it right here makes the
|
||||
pattern reusable for the next mAi PWA project.
|
||||
|
||||
---
|
||||
|
||||
## Scope guardrails (what is NOT in this design)
|
||||
|
||||
- ❌ Fuzzy matching library — substring match on DE+EN labels is sufficient
|
||||
for ~20 actions and small entity result sets. Add `fuse.js` only if the catalog
|
||||
passes ~50 entries, which is unlikely to happen in 2026.
|
||||
- ❌ Recently-used persistence / localStorage MRU — defer. We can add a
|
||||
`paliad-palette-recent` key later if telemetry shows users repeating the same
|
||||
3-4 actions.
|
||||
- ❌ Action groups beyond Aktionen / Projekte / Fristen / … — no
|
||||
meta-categories like "Werkzeuge", "Wissen". Keep flat.
|
||||
- ❌ Extension API or plugin registry — the action catalog is a single static
|
||||
array in `palette-actions.ts`. Future sections can be added by editing that
|
||||
file; no need for a registration callback.
|
||||
- ❌ Cross-project search — out of scope per task brief.
|
||||
- ❌ AI-powered ranking — out of scope per task brief.
|
||||
- ❌ Action shortcut keys beyond Cmd+K itself (e.g. `g d` to go to Dashboard).
|
||||
Maybe later; not now.
|
||||
- ❌ Recent entities — show entity results only when the user types.
|
||||
|
||||
---
|
||||
|
||||
## Trigger surface
|
||||
|
||||
| Trigger | Behavior | Platform |
|
||||
|---------------------|----------------------------------------------|----------|
|
||||
| `Cmd+K` (Mac) | Open palette + focus input. `preventDefault`. | desktop |
|
||||
| `Ctrl+K` (Win/Lin) | Same. | desktop |
|
||||
| `/` (slash) | Same — kept for muscle memory (shipped t-paliad-026). | desktop |
|
||||
| Click sidebar input | Same — focuses the input directly. | desktop |
|
||||
| `Esc` | Close + clear input. | all |
|
||||
| BottomNav menu → drawer → search input | Existing path on mobile. | mobile |
|
||||
|
||||
### Why not a dedicated mobile slot
|
||||
|
||||
The BottomNav (5 slots: Start / Projekte / Anlegen / Agenda / Menü) is full.
|
||||
Replacing one would degrade an established pattern. The mobile sidebar drawer
|
||||
(opened via Menü or hamburger) already contains the same `#global-search-input`,
|
||||
so a tap-search path exists. **Mobile users get the palette via the drawer, not
|
||||
a dedicated button.** Revisit if telemetry shows mobile users searching often
|
||||
enough to justify a topbar search icon.
|
||||
|
||||
### Browser-native Ctrl+K suppression
|
||||
|
||||
`Ctrl+K` in Firefox/Chrome focuses the URL bar's "search engine" submenu (rare
|
||||
but exists). In Safari, `Cmd+L` focuses the URL bar but `Cmd+K` is unbound.
|
||||
We `preventDefault()` on the document-level keydown handler whenever the key
|
||||
combo matches and **only** when a textarea / input is not already focused with
|
||||
a non-`#global-search-input` element — same skip-rule as the existing `/`
|
||||
shortcut.
|
||||
|
||||
---
|
||||
|
||||
## UX shape
|
||||
|
||||
### Empty state (Cmd+K just pressed, input empty)
|
||||
|
||||
Show all actions, sectioned under "Aktionen". Don't fetch entity search.
|
||||
The user can see the catalog at a glance — this is the "discoverability mode"
|
||||
of the palette.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 🔍 ____________________________________________ │ ← #global-search-input
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ AKTIONEN │
|
||||
│ 📊 Gehe zu Dashboard │
|
||||
│ 📁 Gehe zu Projekte │
|
||||
│ ⏰ Gehe zu Fristen │
|
||||
│ 📅 Gehe zu Termine │
|
||||
│ 🗓 Gehe zu Agenda │
|
||||
│ 📖 Gehe zu Glossar │
|
||||
│ 🏛 Gehe zu Gerichte │
|
||||
│ 🔗 Gehe zu Links │
|
||||
│ ✓ Gehe zu Checklisten │
|
||||
│ ⬇ Gehe zu Downloads │
|
||||
│ ⚙ Gehe zu Einstellungen │
|
||||
│ ➕ Neue Frist anlegen │
|
||||
│ ➕ Neuer Termin anlegen │
|
||||
│ ➕ Neues Projekt anlegen │
|
||||
│ 🌐 Sprache umschalten (DE → EN) │
|
||||
│ 📌 Sidebar anheften / lösen │
|
||||
│ ✉ Kolleg:in einladen │
|
||||
│ ↪ Abmelden │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ ↑↓ Navigieren · ↵ Öffnen · Esc Schließen │ ← footer hint
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Filtered state (user typed at least 1 char)
|
||||
|
||||
Both Actions (filtered by substring on DE+EN labels) AND entity search results
|
||||
(via existing `/api/search?q=...`) render together, Actions on top.
|
||||
|
||||
```
|
||||
Query: "frist"
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 🔍 frist │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ AKTIONEN │
|
||||
│ ⏰ Gehe zu Fristen │
|
||||
│ ➕ Neue Frist anlegen │
|
||||
│ FRISTEN │
|
||||
│ ⏰ Klagebeantwortung — UPC-2024-0042 │
|
||||
│ ⏰ Replik einreichen — Patent EP1234567 │
|
||||
│ GLOSSAR │
|
||||
│ 📖 Frist (Definition + Berechnung) │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Footer keyboard hints
|
||||
|
||||
A small `<div class="palette-footer">` below the overlay results, visible
|
||||
whenever the palette is open. Key hints in DE/EN:
|
||||
|
||||
- DE: `↑↓ Navigieren · ↵ Öffnen · Esc Schließen`
|
||||
- EN: `↑↓ Navigate · ↵ Open · Esc Close`
|
||||
|
||||
---
|
||||
|
||||
## Action catalog (initial set)
|
||||
|
||||
20 actions, divided across two sub-types under "Aktionen" but rendered as one
|
||||
flat list (no sub-headers):
|
||||
|
||||
### Navigate
|
||||
|
||||
| ID | DE | EN | URL |
|
||||
|-----------------------|-------------------------------|-----------------------------|------------------------|
|
||||
| `nav.dashboard` | Gehe zu Dashboard | Go to Dashboard | `/dashboard` |
|
||||
| `nav.projects` | Gehe zu Projekte | Go to Projects | `/projects` |
|
||||
| `nav.deadlines` | Gehe zu Fristen | Go to Deadlines | `/deadlines` |
|
||||
| `nav.appointments` | Gehe zu Termine | Go to Appointments | `/appointments` |
|
||||
| `nav.agenda` | Gehe zu Agenda | Go to Agenda | `/agenda` |
|
||||
| `nav.team` | Gehe zu Team | Go to Team | `/team` |
|
||||
| `nav.glossary` | Gehe zu Glossar | Go to Glossary | `/glossary` |
|
||||
| `nav.courts` | Gehe zu Gerichte | Go to Courts | `/courts` |
|
||||
| `nav.links` | Gehe zu Links | Go to Links | `/links` |
|
||||
| `nav.checklists` | Gehe zu Checklisten | Go to Checklists | `/checklists` |
|
||||
| `nav.downloads` | Gehe zu Downloads | Go to Downloads | `/downloads` |
|
||||
| `nav.settings` | Gehe zu Einstellungen | Go to Settings | `/settings` |
|
||||
|
||||
### Create
|
||||
|
||||
| ID | DE | EN | URL |
|
||||
|-----------------------|-------------------------------|-----------------------------|------------------------|
|
||||
| `create.deadline` | Neue Frist anlegen | New deadline | `/deadlines/new` |
|
||||
| `create.appointment` | Neuer Termin anlegen | New appointment | `/appointments/new` |
|
||||
| `create.project` | Neues Projekt anlegen | New project | `/projects/new` |
|
||||
|
||||
### Toggle / action
|
||||
|
||||
| ID | DE | EN | Behavior |
|
||||
|-----------------------|-------------------------------|-----------------------------|-------------------------------|
|
||||
| `toggle.lang` | Sprache umschalten | Toggle language | Click `data-lang-toggle` for the OTHER language. |
|
||||
| `toggle.pin` | Sidebar anheften / lösen | Pin / unpin sidebar | Click `.sidebar-pin`. |
|
||||
| `app.invite` | Kolleg:in einladen | Invite a colleague | Click `#sidebar-invite-btn`. |
|
||||
| `app.logout` | Abmelden | Logout | Navigate to `/logout`. |
|
||||
|
||||
The catalog lives as a single `const ACTIONS: PaletteAction[]` array in a new
|
||||
file `frontend/src/client/palette-actions.ts`. Adding/removing actions = editing
|
||||
the array. Keep the file small and obvious.
|
||||
|
||||
### Filtering rule
|
||||
|
||||
Substring match (case-insensitive, language-agnostic — match against BOTH the
|
||||
current-language label AND the other-language label, so an English-speaker who
|
||||
types "Frist" still finds the deadline action). No fuzzy distance, no token
|
||||
permutations. Sort filtered results by `prefix-match > substring-match`, ties
|
||||
broken by catalog order.
|
||||
|
||||
---
|
||||
|
||||
## Component architecture
|
||||
|
||||
### Files touched / created
|
||||
|
||||
| File | Change |
|
||||
|-------------------------------------------------|-------------------------------------|
|
||||
| `frontend/src/client/search.ts` | Extended: Cmd+K binding, empty-state action render, footer hint, action filtering. |
|
||||
| `frontend/src/client/palette-actions.ts` | **NEW.** Static action catalog + `runAction(id)` dispatcher. |
|
||||
| `frontend/src/components/Sidebar.tsx` | Add palette footer markup inside `#global-search-overlay`. Update `<kbd>` shortcut hint to show both `/` and `⌘K` (or just keep `/` — minor UX choice). |
|
||||
| `frontend/src/client/i18n.ts` | Add ~20 i18n keys: `palette.action.*`, `palette.section.actions`, `palette.footer.*`. |
|
||||
| `frontend/src/styles/sidebar.css` (or wherever overlay styles live) | Add `.search-group-actions` (subtle accent), `.palette-footer` styles, action-icon styles. |
|
||||
|
||||
### Decision: extend `search.ts`, don't create a new `palette.ts`
|
||||
|
||||
- Single source of truth for the overlay's keyboard / focus / debounce logic.
|
||||
- Avoids two files racing to mutate `#global-search-overlay`.
|
||||
- The mental model is "search.ts owns the palette" — palette is just search
|
||||
with actions on top.
|
||||
- `palette-actions.ts` is a **data file** (catalog + dispatcher), not a
|
||||
controller. Keeps the action catalog easy to browse and edit.
|
||||
|
||||
### High-level flow
|
||||
|
||||
```
|
||||
[Cmd+K pressed]
|
||||
↓
|
||||
search.ts: focus input, openPalette(empty=true)
|
||||
↓
|
||||
render(): show all actions, no entity fetch, footer hint visible
|
||||
↓
|
||||
[user types "f"]
|
||||
↓
|
||||
input handler debounces 200ms → runSearch("f")
|
||||
↓
|
||||
runSearch():
|
||||
1. filteredActions = filterActions(query) ← synchronous, instant
|
||||
2. entityResults = fetch /api/search?q=f ← async
|
||||
3. render({ actions: filteredActions, entities: entityResults })
|
||||
↓
|
||||
[user presses ↵]
|
||||
↓
|
||||
openActive():
|
||||
if active.kind === "action": runAction(active.id)
|
||||
else : window.location.href = active.url
|
||||
↓
|
||||
[user presses Esc] → closeOverlay()
|
||||
```
|
||||
|
||||
### `PaletteAction` type
|
||||
|
||||
```ts
|
||||
type PaletteAction = {
|
||||
id: string; // stable id, used for icon lookup
|
||||
i18nKey: string; // e.g. "palette.action.nav.dashboard"
|
||||
fallbackLabel: { de: string; en: string };
|
||||
iconKey: ActionIconKey; // small svg, see below
|
||||
group: "navigate" | "create" | "toggle"; // for sort priority only
|
||||
run: () => void; // dispatcher closure
|
||||
};
|
||||
```
|
||||
|
||||
### `runAction(id)` dispatcher
|
||||
|
||||
A switch statement that maps action id → DOM action. Examples:
|
||||
|
||||
```ts
|
||||
function runAction(id: string): void {
|
||||
switch (id) {
|
||||
case "nav.dashboard":
|
||||
window.location.href = "/dashboard";
|
||||
break;
|
||||
case "create.deadline":
|
||||
window.location.href = "/deadlines/new";
|
||||
break;
|
||||
case "toggle.lang": {
|
||||
const cur = document.documentElement.getAttribute("lang") || "de";
|
||||
const next = cur === "de" ? "en" : "de";
|
||||
document.querySelector<HTMLButtonElement>(`[data-lang-toggle="${next}"]`)?.click();
|
||||
break;
|
||||
}
|
||||
case "toggle.pin":
|
||||
document.querySelector<HTMLButtonElement>(".sidebar-pin")?.click();
|
||||
break;
|
||||
case "app.invite":
|
||||
document.getElementById("sidebar-invite-btn")?.click();
|
||||
break;
|
||||
case "app.logout":
|
||||
window.location.href = "/logout";
|
||||
break;
|
||||
// …
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The dispatcher reuses existing DOM handlers (lang toggle, pin, invite modal)
|
||||
so we don't duplicate state. If the underlying button moves, the dispatcher
|
||||
breaks — that's acceptable: each action is one line of indirection, and the
|
||||
file is small enough to grep.
|
||||
|
||||
### Keyboard binding for Cmd+K
|
||||
|
||||
```ts
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const isCmdK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
|
||||
if (!isCmdK) return;
|
||||
// Allow inputs to consume Cmd+K when they want to (e.g. a future rich-text
|
||||
// editor's link insert) ONLY if they explicitly handle it. By default, we
|
||||
// intercept globally — paliad has no Cmd+K conflict elsewhere today.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
input.focus();
|
||||
input.select();
|
||||
openPaletteEmpty();
|
||||
});
|
||||
```
|
||||
|
||||
`/` keeps its existing skip-when-typing rule (don't fire if user is mid-edit).
|
||||
`Cmd+K` does NOT skip — power users explicitly intend to open the palette
|
||||
even from inside a text input.
|
||||
|
||||
---
|
||||
|
||||
## Render changes inside `search.ts`
|
||||
|
||||
### `render()` becomes `renderResults({ actions, entities, query })`
|
||||
|
||||
```ts
|
||||
function renderResults(opts: {
|
||||
actions: PaletteAction[],
|
||||
entities: SearchResponse | null,
|
||||
query: string,
|
||||
}, overlay: HTMLElement): void {
|
||||
flatResults = [];
|
||||
activeIndex = -1;
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
if (opts.actions.length > 0) {
|
||||
sections.push(renderActionsSection(opts.actions, opts.query));
|
||||
}
|
||||
if (opts.entities) {
|
||||
for (const group of GROUP_ORDER) {
|
||||
const items = opts.entities[group.key] as SearchResult[];
|
||||
if (!items || items.length === 0) continue;
|
||||
sections.push(renderEntityGroup(group, items, opts.query));
|
||||
}
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
overlay.innerHTML = `<div class="search-empty">…</div>`;
|
||||
} else {
|
||||
overlay.innerHTML = sections.join("") + renderFooter();
|
||||
bindResultClicks(overlay);
|
||||
}
|
||||
overlay.style.display = "block";
|
||||
}
|
||||
```
|
||||
|
||||
The `flatResults` array becomes `Array<{ kind: "action" | "entity", … }>` so
|
||||
`openActive()` can dispatch correctly.
|
||||
|
||||
### Footer
|
||||
|
||||
```html
|
||||
<div class="palette-footer">
|
||||
<span><kbd>↑↓</kbd> <span data-i18n="palette.footer.navigate">Navigieren</span></span>
|
||||
<span><kbd>↵</kbd> <span data-i18n="palette.footer.open">Öffnen</span></span>
|
||||
<span><kbd>Esc</kbd> <span data-i18n="palette.footer.close">Schließen</span></span>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mobile considerations
|
||||
|
||||
- BottomNav stays as-is (5 slots, full).
|
||||
- Hamburger / drawer path to search remains the existing pattern — no change
|
||||
required.
|
||||
- The palette overlay's `position: fixed` already handles small viewports.
|
||||
Verify the Actions section + footer fit without scrolling on a 360×640
|
||||
test viewport during implementation; if not, drop the footer on mobile via
|
||||
`@media (max-width: 480px) { .palette-footer { display: none; } }`.
|
||||
- `Cmd+K` is desktop-only; mobile users never see the keybind.
|
||||
|
||||
---
|
||||
|
||||
## i18n additions
|
||||
|
||||
```ts
|
||||
// de
|
||||
"palette.section.actions": "Aktionen",
|
||||
"palette.action.nav.dashboard": "Gehe zu Dashboard",
|
||||
"palette.action.nav.projects": "Gehe zu Projekte",
|
||||
"palette.action.nav.deadlines": "Gehe zu Fristen",
|
||||
// … (all 20 actions)
|
||||
"palette.footer.navigate": "Navigieren",
|
||||
"palette.footer.open": "Öffnen",
|
||||
"palette.footer.close": "Schließen",
|
||||
|
||||
// en
|
||||
"palette.section.actions": "Actions",
|
||||
"palette.action.nav.dashboard": "Go to Dashboard",
|
||||
// …
|
||||
"palette.footer.navigate": "Navigate",
|
||||
"palette.footer.open": "Open",
|
||||
"palette.footer.close": "Close",
|
||||
```
|
||||
|
||||
Use the existing `t(key)` helper from `i18n.ts`. Match the pattern from
|
||||
`GROUP_ORDER` in search.ts.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance criteria (re-stated from task brief)
|
||||
|
||||
- ✅ `Cmd+K` (Mac) opens palette
|
||||
- ✅ `Ctrl+K` (Win/Lin) opens palette
|
||||
- ✅ `/` shortcut still works (existing behavior preserved)
|
||||
- ✅ `preventDefault()` suppresses browser-native Ctrl+K
|
||||
- ✅ Actions filter live as user types
|
||||
- ✅ Entity results render alongside actions when query non-empty
|
||||
- ✅ ↑↓ navigates the merged result list (actions + entities)
|
||||
- ✅ ↵ opens / runs active item
|
||||
- ✅ Esc closes
|
||||
- ✅ Footer shows kbd hints, switches language with global toggle
|
||||
- ✅ Console clean (no errors, no warnings beyond what's already there)
|
||||
- ✅ `go build/vet/test ./...` clean
|
||||
- ✅ `bun run build` clean
|
||||
- ✅ Mobile fallback documented (no new BottomNav slot)
|
||||
|
||||
---
|
||||
|
||||
## Implementation plan (for the coder shift)
|
||||
|
||||
1. Add `frontend/src/client/palette-actions.ts` with the catalog + dispatcher.
|
||||
2. Extend `frontend/src/client/search.ts`:
|
||||
- Add Cmd+K binding (separate from `/` binding).
|
||||
- Change `runSearch()` to also produce filtered actions; render actions
|
||||
section first.
|
||||
- Add empty-state branch (open palette → show all actions, no fetch).
|
||||
- Update `flatResults` to be `Array<{ kind, … }>` so Enter dispatches.
|
||||
- Render footer with kbd hints.
|
||||
3. Update `frontend/src/components/Sidebar.tsx` `#global-search-overlay`
|
||||
markup if needed (probably none — overlay is built dynamically).
|
||||
4. Add i18n keys in `frontend/src/client/i18n.ts` (DE + EN, ~25 keys).
|
||||
5. Add CSS for `.search-group-actions`, `.palette-footer`, action icon
|
||||
colors. Reuse existing `.search-result` / `.search-group` styles where
|
||||
possible.
|
||||
6. Add a small SVG icon for each action `iconKey` (reuse sidebar nav icons
|
||||
where they map 1:1 — `ICON_GAUGE` for dashboard, `ICON_FOLDER` for
|
||||
projects, etc.).
|
||||
7. Manual smoke (local `bun run build` + `go run ./cmd/server`):
|
||||
- Cmd+K with empty input → all actions visible
|
||||
- Type "frist" → action filter + entity search both render
|
||||
- ↑↓ wraps; ↵ on action runs; ↵ on entity navigates
|
||||
- Esc clears; clicking outside clears
|
||||
- DE/EN toggle re-renders labels
|
||||
- `/` still works
|
||||
- Browser URL bar does NOT focus on Cmd+K
|
||||
8. `go build ./...`, `go vet ./...`, `go test ./...`, `bun run build`.
|
||||
9. Commit: `feat(palette): Cmd/Ctrl+K command palette with actions + entities (t-paliad-044)`.
|
||||
10. Push to `mai/cronus/cmd-ctrl-k-command`, self-merge into main with
|
||||
`--no-ff` (regression cleanup is in, no conflicts expected).
|
||||
11. Verify on prod paliad.de via Playwright after Dokploy auto-deploy
|
||||
(test creds: `tester@hlc.de` / `xdMmC7iCeDSTFmPXAlAyY0`).
|
||||
|
||||
---
|
||||
|
||||
## Risks + mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|-------------------------------------------------------|----------------------------------------------------------------------|
|
||||
| Cmd+K conflicts with future rich-text editor | Single global handler; if a real conflict appears, gate by `closest('.no-cmdk')` opt-out. |
|
||||
| Action labels wrong language on first render | Reuse `t()` + `onLangChange()` handler that already exists in search.ts. |
|
||||
| Browser URL-bar focus on Ctrl+K (Firefox) | `preventDefault()` + `stopPropagation()` in document keydown handler. |
|
||||
| Action dispatcher breaks if sidebar button moves | One-line indirection per action; trivial to fix; covered by manual smoke. |
|
||||
| Mobile BottomNav doesn't expose palette | Documented decision — drawer path exists; revisit if usage shows need. |
|
||||
| Existing `flatResults` consumers (Enter handler) | Updated in same change — single file controls navigation. |
|
||||
| Action catalog grows beyond ~50 | Add fuzzy match later; not now. |
|
||||
|
||||
---
|
||||
|
||||
## Implementer recommendation
|
||||
|
||||
cronus (myself) is a good fit to implement this:
|
||||
- Designed it; minimum context-loading cost.
|
||||
- Single-file-cluster change (search.ts + 1 new + 1 i18n + a few markup tweaks).
|
||||
- No DB / backend touch; pure frontend client-side change.
|
||||
- Can self-merge once m approves and t-paliad-043 stays green.
|
||||
|
||||
Alternative: knuth (frontend-strong, shipped t-paliad-026 global search).
|
||||
Either works; this design doc carries enough detail that the implementer choice
|
||||
is fungible.
|
||||
|
||||
**Decision is m's. I will not start coding until the head signals approval.**
|
||||
160
docs/design-courts-per-country-holidays-2026-05-05.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Courts entity + per-country holiday computation
|
||||
|
||||
**Task:** t-paliad-122
|
||||
**Status:** ON-HOLD until trigger condition met (see "When to do it"). Design locked by m on 2026-05-05 18:51; this doc archives the design plus live-codebase findings so a future implementer doesn't have to re-derive them.
|
||||
**Inventor:** cronus (2026-05-05 23:46)
|
||||
|
||||
## TL;DR
|
||||
|
||||
Today, every `paliad` deadline calculation gates on one combined holiday list (DE federal hardcoded + whatever rows happen to be in `paliad.holidays` for that year, regardless of country). That works while every active user is in a German jurisdiction. It silently breaks the moment a UPC LD outside DE (Paris, Helsinki, Milan, Den Haag, …) or an EPO closure-day calendar comes into scope, because the right calendar to apply depends on **the court the proceeding is filed in**, not on the proceeding type.
|
||||
|
||||
m's locked model:
|
||||
|
||||
1. Holidays are scoped per country (`paliad.holidays.country` already exists).
|
||||
2. A new `paliad.courts` entity carries `country` and is FK'd from anything that needs jurisdiction-aware deadline math.
|
||||
3. The Fristenrechner takes a `court_id` (not a `country_code` directly), resolves court → country, and gates `IsNonWorkingDay` on that country's holidays.
|
||||
4. Jurisdiction lives on the **court (forum)**, not on the proceeding type. UPC_INF can sit in München LD (DE), Paris LD (FR) or Helsinki LD (FI) — same proceeding, three calendars.
|
||||
|
||||
## What's true today (live-code verification, 2026-05-05)
|
||||
|
||||
Verified against the worktree at `mai/cronus/inventor-holidays-per` and the `paliad.*` schema on the youpc Supabase. The design rests on these:
|
||||
|
||||
- **`paliad.holidays.country`** — exists, NOT NULL, default `'DE'`. Verified via `information_schema.columns`. m's claim "(already shaped this way)" stands.
|
||||
- **`paliad.courts`** — does **not** exist. The schema has `holidays`, `proceeding_types`, `deadline_concepts`, `deadline_rules`, `event_types`, `projects` etc., but no `courts` table. Confirmed via `information_schema.tables`. The migration must create it.
|
||||
- **`paliad.proceeding_types.jurisdiction`** — exists as a `text` column with values `'UPC' | 'DE' | 'EPA' | 'DPMA'`. **This is the legal regime, not the country.** It answers "which procedural law applies" (UPC RoP vs. ZPO vs. EPC vs. PatG); the new `courts.country` will answer "which national holiday calendar applies". The two are orthogonal: `UPC_INF` has `jurisdiction='UPC'` (regime) and could be filed in any of nine countries (calendar). The existing column is **not redundant** under the new model and should not be removed; it should be renamed to `regime` in a follow-up if and only if the dual meaning starts confusing future readers (see "Optional rename" below).
|
||||
- **Static court catalog already exists** — `internal/handlers/courts.go` carries 41 hand-curated `Court` entries (Gerichtsverzeichnis knowledge tool) with stable `ID` (kebab-case, e.g. `upc-ld-paris`, `upc-cd-munich`, `de-bgh`), `Country` (ISO-3166 alpha-2), and `Type` (`UPC-LD`, `DE-BGH`, …). These ARE the seeds for `paliad.courts`. No new curation work needed for the initial migration.
|
||||
- **`HolidayService.IsNonWorkingDay(date time.Time) bool`** — current signature, no country/court param. Lives at `internal/services/holidays.go:124`.
|
||||
- **`HolidayService.loadYear(year int)`** — does **not** filter the SQL by country. Returns every row for that year, regardless of country. Latent bug if anyone seeds non-DE rows ahead of the courts entity arriving — they'll silently apply to DE-jurisdictional calculations. See "Latent bugs" below.
|
||||
- **`germanFederalHolidays(year)`** is hardcoded as a merge in `loadYear` (`internal/services/holidays.go:92`) so a misconfigured DB never silently drops Christmas. Under per-country holidays, this merge must become country-conditional.
|
||||
- **`Holiday` cache struct** drops the `Country` field — `dbHoliday` reads it but the public `Holiday` (built at `internal/services/holidays.go:77`) doesn't carry it forward. The cache shape must grow `Country` for per-country lookup to work without re-querying.
|
||||
- **`FristenrechnerService.Calculate(ctx, proceedingCode, triggerDateStr, opts)`** — current signature at `internal/services/fristenrechner.go:116`, no court param.
|
||||
|
||||
## Right data model
|
||||
|
||||
### `paliad.courts`
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.courts (
|
||||
id text PRIMARY KEY, -- kebab-case stable ID, e.g. 'upc-ld-paris'
|
||||
code text NOT NULL, -- short code, e.g. 'UPC-LD-Paris', for display / log lines
|
||||
name_de text NOT NULL,
|
||||
name_en text NOT NULL,
|
||||
country text NOT NULL, -- ISO-3166 alpha-2, FK target for paliad.holidays.country
|
||||
court_type text NOT NULL, -- 'UPC-LD' | 'UPC-CD' | 'UPC-CoA' | 'UPC-RD' | 'DE-LG' | 'DE-OLG' | 'DE-BGH' | 'DE-BPatG' | 'DE-DPMA' | 'EPA' | 'NAT'
|
||||
parent_id text REFERENCES paliad.courts(id), -- e.g. all UPC LDs → 'upc-cfi'; UPC CoA stands alone
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX courts_country_idx ON paliad.courts(country);
|
||||
CREATE INDEX courts_type_idx ON paliad.courts(court_type);
|
||||
```
|
||||
|
||||
Seeds come straight from `internal/handlers/courts.go` (the 41 entries already there). The Gerichtsverzeichnis handler stays as-is and continues to be the source for the rich `Court{}` metadata (address, phone, languages, source URLs); `paliad.courts` is the **deadline-computation slice** of that catalog and only carries what holiday math needs. Follow-up admin UI can promote courts to authoritative-in-DB later if maintenance friction warrants.
|
||||
|
||||
`court_type` is denormalised text (matching the existing `CourtTypes` enum in Go) rather than a separate `paliad.court_types` table — the type set is small, stable, and already canonicalised in `internal/handlers/courts.go:CourtTypes`.
|
||||
|
||||
### `paliad.holidays.country` keeps its current shape
|
||||
|
||||
No schema change. We just start writing rows for FR / FI / IT / NL / AT / etc. as those courts come into scope, and the merge of `germanFederalHolidays(year)` becomes conditional on `country='DE'`.
|
||||
|
||||
### Court FK on existing rows
|
||||
|
||||
Touch points in order of impact:
|
||||
|
||||
- **Project rows that today carry a free-text `court` field** (per `docs/design-data-model-v2.md`, the `verfahren`-typed projekte have a `court text` column). On migration, attempt to map `court` → `courts.id` heuristically (lower-case match, kebabify), backfill `court_id`, leave `court` text in place for legacy reads, and gate the Fristenrechner picker on the new FK only.
|
||||
- **`deadlines` rows** — currently no court FK. Add `court_id` nullable, populate on creation from the parent project's resolved court. Existing rows can stay NULL; the Fristenrechner UI re-resolves at calc time.
|
||||
- **`event_deadlines`, `event_types`, `deadline_rules`** — none gain a court column. The court is a property of the *proceeding the deadline is computed for*, not of the rule template.
|
||||
|
||||
## Right service shape
|
||||
|
||||
### `HolidayService` becomes country-aware
|
||||
|
||||
```go
|
||||
// IsNonWorkingDay returns true on weekends or closure-type holidays
|
||||
// applicable to the given country. countryCode is ISO-3166 alpha-2.
|
||||
func (s *HolidayService) IsNonWorkingDay(date time.Time, countryCode string) bool
|
||||
|
||||
// AdjustForNonWorkingDays / AdjustForNonWorkingDaysWithReason gain the same param
|
||||
func (s *HolidayService) AdjustForNonWorkingDays(date time.Time, countryCode string) (...)
|
||||
```
|
||||
|
||||
Internally:
|
||||
|
||||
- `loadYear` adds a `country = ANY($2)` filter. The cache key becomes `(year, country)` — or, slightly slicker, the cache stays keyed by `year` but the `Holiday` struct grows a `Country` field and the lookup helpers filter on it. Cache-by-year wins on hit rate (one fetch per year regardless of how many countries are touched in a request), so prefer growing the struct.
|
||||
- `germanFederalHolidays(year)` merge becomes `if countryCode == "DE" { merge(...) }`. Belt-and-braces: also seed `paliad.holidays` properly with German federal entries via migration so the Go fallback can eventually be retired.
|
||||
- A two-letter country code `""` is treated as a hard error — callers must always say which country they mean. Don't paper over an unknown court with a silent DE default; that's how the bug class this task fixes recurs.
|
||||
|
||||
### `FristenrechnerService.Calculate` takes `courtID`
|
||||
|
||||
```go
|
||||
func (s *FristenrechnerService) Calculate(
|
||||
ctx context.Context,
|
||||
proceedingCode, triggerDateStr string,
|
||||
courtID string, // NEW — required when proceeding can land in multiple courts; empty for unambiguous DE-only proceedings (BPatG nullity etc.)
|
||||
opts CalcOptions,
|
||||
) (*UIResponse, error)
|
||||
```
|
||||
|
||||
Resolution path inside `Calculate`:
|
||||
|
||||
1. If `courtID == ""`: look up the proceeding type, find its single canonical court (e.g. `DE_NULL_BGH` → `de-bgh`); error if the proceeding can land in multiple courts.
|
||||
2. Resolve `courtID → countryCode` via `paliad.courts`.
|
||||
3. Pass `countryCode` to every `IsNonWorkingDay` / `AdjustForNonWorkingDays` call inside the calculator and the rule walker.
|
||||
|
||||
### UI: court picker on the Fristenrechner form
|
||||
|
||||
Show a court dropdown only when the selected proceeding type has more than one possible court (today: every UPC-flavoured proceeding type — `UPC_INF`, `UPC_REV`, `UPC_APP`, `UPC_PI`, `UPC_DAMAGES`, `UPC_DISCOVERY`, `UPC_APP_ORDERS`, `UPC_COST_APPEAL`). For DE-only proceedings (`DE_NULL`, `DE_NULL_BGH`, `DE_INF_BGH`, `DPMA_*`, `EPA_*`) keep the form as-is and resolve the court server-side.
|
||||
|
||||
The picker pulls from `paliad.courts` filtered by court type compatible with the proceeding code, ordered by a hand-curated importance score (HLC offices first → München LD, Düsseldorf LD, Paris LD, …). When `proceeding_types.jurisdiction='UPC'`, valid court types are `UPC-LD | UPC-CD | UPC-CoA | UPC-RD`; when `'DE'`, `DE-LG | DE-OLG | DE-BGH`; etc. The mapping `(jurisdiction → []court_type)` is compact enough to live in Go alongside the existing `CourtTypes`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- ~~`jurisdictions text[]` column on proceeding_types~~ — m: "We can't map jurisdictions to proceeding types" (2026-05-05 18:51).
|
||||
- ~~`proceeding_type_jurisdictions` join table~~ — same.
|
||||
- ~~Hard-coded `switch proceedingCode` in Go~~ — same.
|
||||
- **Per-court rule overrides** — already handled by the t-131 `deadline_concepts.per_context` jsonb. Out of scope here; lean on it if a court has a non-standard duration. Don't replicate that mechanism.
|
||||
- **Promoting the static `[]Court` slice in `internal/handlers/courts.go` to DB-authoritative** — orthogonal. The static slice continues to back the Gerichtsverzeichnis page; `paliad.courts` is the deadline-computation slice. They're sibling artefacts, not master/replica.
|
||||
|
||||
## Latent bugs to fix as part of this work
|
||||
|
||||
1. **`loadYear` doesn't filter by country.** Today, if anyone seeds a non-DE row into `paliad.holidays` (e.g. trying out FR holidays for an upcoming Paris LD case), it will silently apply to every DE deadline calculation that year. Fix: SQL-filter by country and require a country argument to `loadYear` / `IsNonWorkingDay`.
|
||||
2. **Vacation-block walker is country-blind.** `findVacationBlock` (`internal/services/holidays.go:259`) walks across the merged year list with no country filter. After this work, vacation entries have a country too — the walker should only consider vacations for the active country. (Today the only vacations are UPC summer/winter, country-stamped DE in seeded data; harmless until non-DE court vacations land.)
|
||||
3. **`paliad.holidays` has no FK on country.** A typo'd country code (`'De'` instead of `'DE'`) would silently create an orphan calendar that no court resolves to. Fix: either CHECK constraint listing the alpha-2 codes paliad supports, or a `paliad.countries (code text PRIMARY KEY)` lookup table. Lean toward the lookup table because the courts table will FK to the same set anyway.
|
||||
|
||||
## Optional rename — defer
|
||||
|
||||
`proceeding_types.jurisdiction` → `proceeding_types.regime` would make the dual meaning unambiguous (regime = procedural law, country = holiday calendar). **Not part of this task.** It's a wide rename across migrations, models, services, handlers, and frontend payloads, with no functional benefit until someone gets confused. Leave the column name; document the dual meaning in the column comment when the migration ships.
|
||||
|
||||
## When to do it (trigger conditions, restated for the implementer)
|
||||
|
||||
This task unlocks the moment any of the following becomes real:
|
||||
|
||||
- A user files a deadline-bearing event in a non-DE UPC LD (Paris, Helsinki, Milan, Den Haag, Brussels, Wien, Lisboa, Ljubljana, Kopenhagen) or in the UPC RD Stockholm.
|
||||
- The user wants EPO closure days modelled separately from German federal holidays (today they overlap heavily but diverge for things like the EPO-internal "shut between Christmas and New Year" rule).
|
||||
- Cross-border practice picks up to the point that a DE firm regularly files in NL LD or FR LD.
|
||||
|
||||
Until then — don't pre-build. The schema change is small and the verification surface is large; doing it ahead of demand wastes a coder shift and adds another migration to the rollback story without buying anything users feel.
|
||||
|
||||
## Implementation outline (when triggered)
|
||||
|
||||
Order matters — each step is a self-contained, RoP-safe slice that an implementer can ship and merge before starting the next.
|
||||
|
||||
1. **Migration `053_courts.up.sql`** — create `paliad.courts`, seed from `internal/handlers/courts.go` (one INSERT per static-list entry, bilingual names from NameDE/NameEN, type from existing Type field, country direct, parent_id linked where the static list expresses hierarchy). Add `paliad.countries` lookup with the eight ISO codes paliad needs initially: DE, FR, IT, NL, BE, FI, PT, AT, SI, DK, SE, LU.
|
||||
2. **Migration `054_holidays_country_fk.up.sql`** — add `holidays.country REFERENCES countries(code)`. CHECK that every existing row's country is in the lookup (must be true; default 'DE' is already in the seed list).
|
||||
3. **Migration `055_proceedings_court_fk.up.sql`** — add nullable `projects.court_id REFERENCES courts(id)` for `verfahren`-typed rows; backfill via heuristic match against the legacy free-text `court` column; flag unmapped rows in a `\paliad.unmapped_courts` view for manual triage. Don't drop the legacy `court` text yet.
|
||||
4. **HolidayService refactor** — grow `Holiday` struct with `Country`, change cache shape, add country param to `IsNonWorkingDay` / `AdjustForNonWorkingDays` / `findVacationBlock`. Keep the German-federal merge but gate on country. Update every call site (deadline_calculator, fristenrechner, deadline_service, event_deadline_service); the compile error is your checklist.
|
||||
5. **FristenrechnerService refactor** — add `courtID` parameter to `Calculate`; resolve court → country at the top of the function; thread country through every helper.
|
||||
6. **API + UI** — extend the calc endpoint to accept `courtId`; add the picker to the Fristenrechner form (only renders when proceeding type has multiple compatible courts); persist court choice on calc-result bookmarks.
|
||||
7. **Seed data for at least one non-DE country** — pick whichever triggered the unlock (FR if Paris LD; NL if Den Haag LD; etc.); seed both public holidays and any UPC vacation entries country-stamped to that code.
|
||||
8. **Test coverage** — table-driven test in `internal/services/holidays_test.go` covering: DE court → DE holidays; FR court → FR holidays; UPC LD München (DE) → DE holidays; UPC LD Paris (FR) → FR holidays; unknown court → error; missing country argument → error. Plus a Go coverage test asserting every active proceeding type resolves to at least one court.
|
||||
|
||||
## Reference
|
||||
|
||||
- t-paliad-119 — adjustment-reason explainer (what a user sees today when a deadline shifts).
|
||||
- t-paliad-121 — UPC court vacations are informational, not closure-type. Same precedent: vacation entries stay in DB but `IsNonWorkingDay` excludes them.
|
||||
- t-paliad-131 — `deadline_concepts.per_context` jsonb already supports per-context overrides; if a court demands a non-standard duration, use that mechanism rather than a new column on `courts`.
|
||||
- m's design call: 2026-05-05 18:51 — courts own jurisdiction (country), not proceeding types.
|
||||
- `internal/handlers/courts.go` — static court catalog (41 entries) with `(ID, NameDE, NameEN, Country, Type)` ready to seed `paliad.courts`.
|
||||
- `internal/services/holidays.go` — current HolidayService; country-blindness lives at lines 63–98 and 116–130.
|
||||
- `internal/services/fristenrechner.go:116` — current `Calculate` signature.
|
||||
920
docs/design-data-model-v2.md
Normal file
@@ -0,0 +1,920 @@
|
||||
# Data Model v2 — Clients, nestable Projects, Teams
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-04-20
|
||||
**Task:** t-paliad-023
|
||||
**Status:** Design draft for review. Supersedes the flat `paliad.akten` model in `design-kanzlai-integration.md` §2–§3.
|
||||
**Scope:** Schema + migration plan. No implementation in this change.
|
||||
|
||||
---
|
||||
|
||||
## Executive summary
|
||||
|
||||
**Recommendation.** Replace the flat `paliad.akten` table with a two-table core:
|
||||
|
||||
1. `paliad.mandanten` — clients (companies or people who instruct HLC).
|
||||
2. `paliad.projekte` — a **single, self-referential, typed tree** of all work. Every row has a `project_type` (`mandat`, `litigation`, `patent`, `verfahren`, `projekt`), an optional `parent_project_id`, and a required `client_id` that points to the Mandant at the root of the tree. Fristen, Termine, Notizen, Dokumente and Parteien all hang off a **single polymorphic `project_id`** — "polymorphic" only in the sense that any node in the tree can own them, not in the sense of multi-table FKs.
|
||||
|
||||
**Teams** become explicit rows. `paliad.teams` holds both Dezernate (structural, one partner-led unit per row) and — as a separate concept — Project Teams (ad-hoc, per-project roster). The `paliad.users.dezernat` free-text field is superseded by `paliad.team_mitglieder`. The `paliad.akten.collaborators uuid[]` array on every Akte is replaced by `paliad.projekt_mitglieder`, giving us per-user roles inside a project and a sane target for audit + invitations.
|
||||
|
||||
**Visibility** stays office-scoped, but the predicate now walks the tree: seeing *any* node in a project grants the viewer access to the whole connected tree (root, siblings, descendants). This matches how lawyers actually use the data — a Munich associate put on one UPC Case of a Siemens litigation must see the parent litigation and the sibling Cases to do their job, and a lead partner put on the litigation root must see everything below.
|
||||
|
||||
**Naming.** Stay German, matching everything shipped so far — `mandanten`, `projekte`, `teams`, `team_mitglieder`, `projekt_mitglieder`. German plural for table names, singular for Go structs (`Mandant`, `Projekt`, `Team`). `User` stays English (Supabase concept).
|
||||
|
||||
**Migration** is phased and non-destructive. Existing `paliad.akten` rows survive as `projekte` rows with `project_type='verfahren'` (best match for the flat-Akte pattern), the same primary key UUIDs, and `client_id` nullable during a cleanup window (the partner UI collects real Mandant assignment). Every child table (`fristen`, `termine`, `notizen`, `dokumente`, `parteien`, `akten_events`, `checklist_instances`) gets its FK column renamed from `akte_id` to `project_id` in a follow-on migration — no data move, just a DDL rename. The existing `/akten` URLs stay live as aliases of `/projekte`.
|
||||
|
||||
**This is the foundation for Paliad v2.** It is opinionated. The trade-offs are called out inline.
|
||||
|
||||
---
|
||||
|
||||
## 1. Entity-Relationship
|
||||
|
||||
### 1.1 Mermaid
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
USERS ||--o{ TEAM_MITGLIEDER : "belongs to"
|
||||
USERS ||--o{ PROJEKT_MITGLIEDER : "staffed on"
|
||||
TEAMS ||--o{ TEAM_MITGLIEDER : "has members"
|
||||
|
||||
MANDANTEN ||--o{ PROJEKTE : "owns"
|
||||
PROJEKTE ||--o{ PROJEKTE : "parent of"
|
||||
PROJEKTE ||--o{ PROJEKT_MITGLIEDER : "has team"
|
||||
|
||||
PROJEKTE ||--o{ FRISTEN : "1:N"
|
||||
PROJEKTE ||--o{ TERMINE : "1:N (nullable)"
|
||||
PROJEKTE ||--o{ NOTIZEN : "1:N (polymorphic parent)"
|
||||
PROJEKTE ||--o{ DOKUMENTE : "1:N"
|
||||
PROJEKTE ||--o{ PARTEIEN : "1:N"
|
||||
PROJEKTE ||--o{ AKTEN_EVENTS : "1:N (audit)"
|
||||
PROJEKTE ||--o{ CHECKLIST_INSTANCES : "1:N (nullable)"
|
||||
|
||||
FRISTEN ||--o{ NOTIZEN : "parent (optional)"
|
||||
TERMINE ||--o{ NOTIZEN : "parent (optional)"
|
||||
AKTEN_EVENTS ||--o{ NOTIZEN : "parent (optional)"
|
||||
|
||||
TEAMS {
|
||||
uuid id PK
|
||||
text type "dezernat | project_team"
|
||||
text name
|
||||
uuid partner_id FK "for dezernat; NULL for project team"
|
||||
uuid projekt_id FK "for project team; NULL for dezernat"
|
||||
text office "seat of the dezernat; NULL for project team"
|
||||
bool is_active
|
||||
}
|
||||
|
||||
MANDANTEN {
|
||||
uuid id PK
|
||||
text name
|
||||
text legal_form "e.g., AG, GmbH, Einzelerfinder"
|
||||
text industry
|
||||
text country
|
||||
text billing_reference
|
||||
jsonb key_contacts
|
||||
text owning_office "Default office for new Projekte"
|
||||
uuid[] collaborators "Firm-wide people with explicit Mandant access"
|
||||
bool firm_wide_visible
|
||||
text status "active | archived"
|
||||
}
|
||||
|
||||
PROJEKTE {
|
||||
uuid id PK
|
||||
uuid client_id FK "nullable during migration"
|
||||
uuid parent_project_id FK "self"
|
||||
text project_type "mandat|litigation|patent|verfahren|projekt"
|
||||
text title
|
||||
text reference "human-readable ref (Aktenzeichen)"
|
||||
text external_ref "EP no., UPC docket, etc."
|
||||
text court
|
||||
text court_ref
|
||||
text status "active | pending | closed | archived"
|
||||
text owning_office
|
||||
bool firm_wide_visible
|
||||
uuid created_by FK
|
||||
jsonb metadata
|
||||
ltree path "materialised ancestor path"
|
||||
int depth "0 = root"
|
||||
}
|
||||
|
||||
PROJEKT_MITGLIEDER {
|
||||
uuid projekt_id PK FK
|
||||
uuid user_id PK FK
|
||||
text role "lead|associate|pa|of_counsel|local_counsel|expert|observer"
|
||||
timestamptz added_at
|
||||
uuid added_by FK
|
||||
}
|
||||
|
||||
TEAM_MITGLIEDER {
|
||||
uuid team_id PK FK
|
||||
uuid user_id PK FK
|
||||
text role "partner|associate|pa|trainee|of_counsel|secretariat"
|
||||
timestamptz added_at
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 ASCII worked example
|
||||
|
||||
Showing the Siemens portfolio from the brief. `[P]` = partner, `[A]` = associate, `[LC]` = local counsel. Node IDs are illustrative.
|
||||
|
||||
```
|
||||
MANDANTEN: M1 "Siemens AG" (industry=industrial, owning_office=munich)
|
||||
│
|
||||
└── PROJEKTE (client_id=M1)
|
||||
│
|
||||
└── P0 project_type=mandat "Siemens — Overall relationship" (path=P0, depth=0)
|
||||
│ owning_office=munich
|
||||
│ Team: [P Lead=partner_a@munich] [A associate_b@munich]
|
||||
│
|
||||
└── P1 project_type=litigation "Siemens v. Huawei — SEP Portfolio" (path=P0.P1, depth=1)
|
||||
│ owning_office=munich, firm_wide_visible=false
|
||||
│ Team: [P Lead=partner_a@munich] [A associate_c@duesseldorf] [LC local_uk@london]
|
||||
│
|
||||
├── P2 project_type=patent "EP 1 234 567" (path=P0.P1.P2, depth=2)
|
||||
│ │ external_ref=EP1234567
|
||||
│ │
|
||||
│ ├── P3 project_type=verfahren "UPC Infringement UPC_CFI_123/2026"(path=P0.P1.P2.P3, depth=3)
|
||||
│ │ court=UPC_CFI_Munich, court_ref=UPC_CFI_123/2026
|
||||
│ │ └─ Fristen, Termine, Notizen, Dokumente all project_id=P3
|
||||
│ │
|
||||
│ ├── P4 project_type=verfahren "EPO Opposition W 0001/26"
|
||||
│ └── P5 project_type=verfahren "BPatG Nullity 3 Ni 45/26"
|
||||
│
|
||||
├── P6 project_type=patent "EP 2 345 678"
|
||||
│ └── P7 project_type=verfahren "UPC Infringement UPC_CFI_456/2026"
|
||||
│
|
||||
└── P8 project_type=patent "EP 3 456 789"
|
||||
└── P9 project_type=verfahren "LG München I 21 O 12345/26"
|
||||
```
|
||||
|
||||
The key insight: **every box is a row in `paliad.projekte`**. Fristen/Termine/Notizen live at whichever node is the right home. Dashboard aggregates across all nodes the user can see by walking the visibility predicate.
|
||||
|
||||
---
|
||||
|
||||
## 2. Table schemas
|
||||
|
||||
### 2.1 `paliad.mandanten`
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.mandanten (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
legal_form text, -- 'AG', 'GmbH', 'Inc.', 'Einzelerfinder', …
|
||||
industry text, -- free text, not an enum
|
||||
country text, -- ISO 3166-1 alpha-2 ('DE','US',…)
|
||||
billing_reference text, -- matches the firm-wide billing system
|
||||
key_contacts jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
-- [{"name":"…","role":"…","email":"…","phone":"…"}]
|
||||
owning_office text NOT NULL CHECK (owning_office IN (
|
||||
'munich','duesseldorf','hamburg',
|
||||
'amsterdam','london','paris','milan')),
|
||||
-- Visibility knobs, analogous to paliad.akten today:
|
||||
collaborators uuid[] NOT NULL DEFAULT '{}',
|
||||
firm_wide_visible boolean NOT NULL DEFAULT false,
|
||||
status text NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active','archived')),
|
||||
metadata jsonb NOT NULL DEFAULT '{}',
|
||||
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX mandanten_owning_office_idx ON paliad.mandanten (owning_office);
|
||||
CREATE INDEX mandanten_firm_wide_idx ON paliad.mandanten (firm_wide_visible) WHERE firm_wide_visible = true;
|
||||
CREATE INDEX mandanten_collaborators_gin ON paliad.mandanten USING GIN (collaborators);
|
||||
CREATE INDEX mandanten_name_trgm ON paliad.mandanten USING GIN (name gin_trgm_ops);
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `key_contacts` is JSONB not a child table. Contacts don't have their own identity (they're denormalised name/email/phone); pulling them into `paliad.contacts` would be overkill and block nothing. If HLC later wants CRM-style contact records, promote to a table in a follow-on.
|
||||
- `owning_office` on `mandanten` is the **default** for new Projekte; individual Projekte can override.
|
||||
- Duplicated visibility knobs (`collaborators`, `firm_wide_visible`) are intentional: a user can have Mandant-level visibility without being on any particular Projekt (e.g., the relationship partner who hasn't been staffed on a specific case yet). The predicate OR-fans these in.
|
||||
|
||||
### 2.2 `paliad.projekte`
|
||||
|
||||
```sql
|
||||
-- Enable the ltree extension in Supabase (first migration to use it).
|
||||
CREATE EXTENSION IF NOT EXISTS ltree;
|
||||
|
||||
-- project_type is a text + CHECK, not an enum type. Enums are painful to extend
|
||||
-- in Postgres migrations; text + CHECK gives us the same validation with room
|
||||
-- to add a new type by replacing the constraint.
|
||||
CREATE TABLE paliad.projekte (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id uuid REFERENCES paliad.mandanten(id) ON DELETE RESTRICT,
|
||||
-- NULLABLE during migration;
|
||||
-- enforced NOT NULL in a later phase.
|
||||
parent_project_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE,
|
||||
|
||||
project_type text NOT NULL CHECK (project_type IN
|
||||
('mandat','litigation','patent','verfahren','projekt')),
|
||||
title text NOT NULL,
|
||||
reference text, -- firm-internal human ref (Aktenzeichen)
|
||||
external_ref text, -- EP no., UPC docket, BPatG ref, …
|
||||
court text,
|
||||
court_ref text,
|
||||
|
||||
status text NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active','pending','closed','archived')),
|
||||
|
||||
owning_office text NOT NULL CHECK (owning_office IN (
|
||||
'munich','duesseldorf','hamburg',
|
||||
'amsterdam','london','paris','milan')),
|
||||
firm_wide_visible boolean NOT NULL DEFAULT false,
|
||||
|
||||
-- Materialised tree state. Maintained by a BEFORE INSERT/UPDATE trigger.
|
||||
-- `path` is the ltree of ancestor ids ending with this row's id.
|
||||
path ltree NOT NULL,
|
||||
depth int NOT NULL CHECK (depth >= 0),
|
||||
|
||||
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
metadata jsonb NOT NULL DEFAULT '{}',
|
||||
ai_summary text, -- unused today; kept from akten
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- Sanity: a project's client must match its parent's client (and root of
|
||||
-- tree carries the single source of truth).
|
||||
CONSTRAINT projekte_parent_self_differs CHECK (parent_project_id IS NULL OR parent_project_id <> id)
|
||||
);
|
||||
|
||||
-- Trees are navigated by path. GiST over ltree makes ancestor / descendant
|
||||
-- lookups <@ / @> work in O(log n) — critical for the visibility predicate.
|
||||
CREATE INDEX projekte_path_gist ON paliad.projekte USING GIST (path);
|
||||
CREATE INDEX projekte_parent_idx ON paliad.projekte (parent_project_id);
|
||||
CREATE INDEX projekte_client_idx ON paliad.projekte (client_id);
|
||||
CREATE INDEX projekte_client_type_idx ON paliad.projekte (client_id, project_type)
|
||||
WHERE status <> 'archived';
|
||||
CREATE INDEX projekte_owning_office_idx ON paliad.projekte (owning_office);
|
||||
CREATE INDEX projekte_firm_wide_idx ON paliad.projekte (firm_wide_visible) WHERE firm_wide_visible = true;
|
||||
CREATE INDEX projekte_status_idx ON paliad.projekte (status);
|
||||
CREATE INDEX projekte_reference_trgm ON paliad.projekte USING GIN (reference gin_trgm_ops);
|
||||
CREATE INDEX projekte_title_trgm ON paliad.projekte USING GIN (title gin_trgm_ops);
|
||||
```
|
||||
|
||||
**Why ltree and not a recursive CTE?** RLS is called once per candidate row on every SELECT. A recursive CTE per row is O(depth) repeated per predicate call. `path @> ancestor_path` uses the GiST index and collapses to one index scan. This is the biggest performance decision in the doc; ltree is the right tool.
|
||||
|
||||
**Why a materialised path *and* `parent_project_id`?** The parent FK is the source of truth for the tree (used by `ON DELETE CASCADE`). The `path` + `depth` columns are derived state maintained by a trigger. Keeping both is redundant on purpose — the FK guarantees referential integrity; the path gives us fast traversal. Updates to `parent_project_id` re-compute path for the subtree in the trigger.
|
||||
|
||||
**Tree trigger sketch:**
|
||||
|
||||
```sql
|
||||
CREATE FUNCTION paliad.projekte_sync_path() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
parent_path ltree;
|
||||
BEGIN
|
||||
IF NEW.parent_project_id IS NULL THEN
|
||||
NEW.path := text2ltree(replace(NEW.id::text, '-', '_'));
|
||||
NEW.depth := 0;
|
||||
ELSE
|
||||
SELECT path, depth + 1 INTO parent_path, NEW.depth
|
||||
FROM paliad.projekte
|
||||
WHERE id = NEW.parent_project_id;
|
||||
IF parent_path IS NULL THEN
|
||||
RAISE EXCEPTION 'parent project % not found', NEW.parent_project_id;
|
||||
END IF;
|
||||
NEW.path := parent_path || text2ltree(replace(NEW.id::text, '-', '_'));
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER projekte_sync_path_ins
|
||||
BEFORE INSERT ON paliad.projekte
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.projekte_sync_path();
|
||||
|
||||
-- On parent change, re-path both this row and every descendant.
|
||||
CREATE FUNCTION paliad.projekte_rewrite_subtree() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.parent_project_id IS DISTINCT FROM OLD.parent_project_id THEN
|
||||
PERFORM paliad.projekte_sync_path() FROM paliad.projekte WHERE id = NEW.id;
|
||||
UPDATE paliad.projekte
|
||||
SET path = NEW.path || subpath(path, nlevel(OLD.path) - 1),
|
||||
depth = NEW.depth + (nlevel(path) - nlevel(OLD.path))
|
||||
WHERE path <@ OLD.path
|
||||
AND id <> NEW.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
```
|
||||
|
||||
(Sketch — implementer will refine. The constraint that uuid-hyphens aren't valid in ltree labels means we encode UUIDs with `-`→`_`. Alternative: use `hashtext(id::text)::text` to keep labels short — discuss with implementer.)
|
||||
|
||||
### 2.3 `paliad.teams`
|
||||
|
||||
**Two kinds, one table.** The shape is similar enough that splitting into `dezernate` + `project_teams` would mostly duplicate columns. A `type` column + partial CHECK constraints is cheaper.
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.teams (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
type text NOT NULL CHECK (type IN ('dezernat','project_team')),
|
||||
name text NOT NULL,
|
||||
|
||||
-- For type = 'dezernat':
|
||||
partner_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
office text CHECK (office IS NULL OR office IN (
|
||||
'munich','duesseldorf','hamburg',
|
||||
'amsterdam','london','paris','milan')),
|
||||
|
||||
-- For type = 'project_team':
|
||||
projekt_id uuid REFERENCES paliad.projekte(id) ON DELETE CASCADE,
|
||||
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
metadata jsonb NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- Shape invariants per type.
|
||||
CONSTRAINT teams_dezernat_shape CHECK (
|
||||
type <> 'dezernat'
|
||||
OR (partner_id IS NOT NULL AND office IS NOT NULL AND projekt_id IS NULL)
|
||||
),
|
||||
CONSTRAINT teams_project_team_shape CHECK (
|
||||
type <> 'project_team'
|
||||
OR (projekt_id IS NOT NULL AND partner_id IS NULL AND office IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- One project_team per Projekt (if any).
|
||||
CREATE UNIQUE INDEX teams_one_team_per_projekt
|
||||
ON paliad.teams (projekt_id) WHERE type = 'project_team';
|
||||
|
||||
CREATE INDEX teams_partner_idx ON paliad.teams (partner_id) WHERE type = 'dezernat';
|
||||
CREATE INDEX teams_office_idx ON paliad.teams (office) WHERE type = 'dezernat';
|
||||
CREATE INDEX teams_projekt_idx ON paliad.teams (projekt_id) WHERE type = 'project_team';
|
||||
```
|
||||
|
||||
**Critique already anticipated.** Mixing two entities in one table is a smell. I accept the smell because: (a) the queries we actually run split cleanly by `type`; (b) the join from a user to "every team I'm on" wants a single table; (c) project teams and dezernate both feed the same `team_mitglieder` roster table and the same per-team role enum overlap is ~80%. If we discover real divergence (project teams grow a `stage` field, dezernate grow a `parent_dezernat_id`), split then.
|
||||
|
||||
### 2.4 `paliad.team_mitglieder`
|
||||
|
||||
Roster for **both** kinds of team. Dezernat and project-team memberships coexist — a user is typically in exactly one Dezernat *and* on multiple project teams.
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.team_mitglieder (
|
||||
team_id uuid NOT NULL REFERENCES paliad.teams(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
role text NOT NULL CHECK (role IN (
|
||||
'partner','associate','pa','trainee','of_counsel',
|
||||
'secretariat','lead','local_counsel','expert','observer'
|
||||
)),
|
||||
added_at timestamptz NOT NULL DEFAULT now(),
|
||||
added_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
PRIMARY KEY (team_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX team_mitglieder_user_idx ON paliad.team_mitglieder (user_id);
|
||||
```
|
||||
|
||||
Role values overlap intentionally: `partner`, `associate`, `pa`, `trainee`, `of_counsel`, `secretariat` are the typical Dezernat roles; `lead`, `associate`, `pa`, `of_counsel`, `local_counsel`, `expert`, `observer` are the typical project-team roles. The union is finite and small — don't over-engineer with separate role enums per team type.
|
||||
|
||||
### 2.5 `paliad.projekt_mitglieder` (replaces `collaborators uuid[]`)
|
||||
|
||||
I recommend **flattening project-team rosters into a dedicated junction table** instead of going through `teams` + `team_mitglieder` for the project-team case. Reason: project-team membership is the hot path for RLS. A dedicated two-column junction with a covering index beats any indirection through `teams`.
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.projekt_mitglieder (
|
||||
projekt_id uuid NOT NULL REFERENCES paliad.projekte(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
role text NOT NULL CHECK (role IN (
|
||||
'lead','associate','pa','of_counsel','local_counsel','expert','observer'
|
||||
)),
|
||||
added_at timestamptz NOT NULL DEFAULT now(),
|
||||
added_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
PRIMARY KEY (projekt_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX projekt_mitglieder_user_idx ON paliad.projekt_mitglieder (user_id);
|
||||
```
|
||||
|
||||
**So what's `paliad.teams` type='project_team' for?** Two things the junction can't express alone: (a) the *identity* of the team as a first-class object (naming, status, metadata, invitations); (b) a place to attach team-level settings (e.g., a project-team Slack channel URL, a CalDAV group calendar id). If we decide we don't need either, `teams` shrinks to dezernate only and `project_team` rows go away. That's fine too — implementation can start with junction-only and add `teams` rows on demand.
|
||||
|
||||
**Opinion:** start with `projekt_mitglieder` only. Add `teams` rows for project teams if/when we ship team-scoped features (project Slack channel, group calendar). For the purposes of visibility and role, the junction table is sufficient.
|
||||
|
||||
### 2.6 Child tables — single `project_id` polymorphic FK
|
||||
|
||||
No polymorphic magic. Every node in the tree is a `projekte` row. A Frist attached to "the Siemens SEP litigation" just FKs to the litigation-level projekt. A Frist on the root Mandat-level projekt is rare but expressible. Polymorphic multi-FK tables (with multiple nullable parent columns + a CHECK) are a pattern we have today on `notizen`, and they create RLS pain — we avoid extending it.
|
||||
|
||||
Revised child tables (columns that change only):
|
||||
|
||||
| Table | Today | v2 |
|
||||
|---|---|---|
|
||||
| `paliad.parteien` | `akte_id NOT NULL` | `project_id NOT NULL` |
|
||||
| `paliad.fristen` | `akte_id NOT NULL` | `project_id NOT NULL` |
|
||||
| `paliad.termine` | `akte_id NULL` | `project_id NULL` (personal stays NULL) |
|
||||
| `paliad.dokumente` | `akte_id NOT NULL` | `project_id NOT NULL` |
|
||||
| `paliad.akten_events` | `akte_id NOT NULL` | `project_id NOT NULL` (table stays `akten_events` for history; see §10) |
|
||||
| `paliad.checklist_instances` | `akte_id NULL` | `project_id NULL` (personal stays NULL) |
|
||||
| `paliad.notizen` | 4 nullable FKs (akte/frist/termin/event), 1-of CHECK | **Keep as-is** — still polymorphic across frist/termin/event/*project*; `akte_id`→`project_id`. |
|
||||
|
||||
`notizen` stays polymorphic because notes attach to three different kinds of entity (Projekt, Frist, Termin, AktenEvent) — a note on a Frist is *not* the same thing as a note on the owning Projekt. The alternative (always attach at Projekt and store `frist_id` / `termin_id` in metadata) loses referential integrity; reject it.
|
||||
|
||||
### 2.7 `paliad.users` changes
|
||||
|
||||
- **Drop:** `dezernat text` (free-text, only introduced in migration 015).
|
||||
- **Keep:** `office`, `role`, `practice_group`, `lang`, `email_preferences`.
|
||||
- **New:** nothing — Dezernat membership moves to `team_mitglieder` with `team_id` pointing at a `teams` row of type `dezernat`.
|
||||
|
||||
Migration 015 left `dezernat` as free text precisely because the partner might not have registered yet. v2 keeps the freedom differently: during onboarding, the user either (a) picks an existing Dezernat from a dropdown (fed from `paliad.teams WHERE type='dezernat'`) or (b) types a new one, and the onboarding service auto-creates a `teams` row with `partner_id = NULL` and a note "claim this partnership by signing up with role=partner". The partner claims on their own first-login.
|
||||
|
||||
---
|
||||
|
||||
## 3. Polymorphic FK strategy (the explicit recommendation)
|
||||
|
||||
> **Single `project_id` on all child tables (except `notizen`).**
|
||||
|
||||
Rationale:
|
||||
- Everything a Frist/Termin/Dokument/Partei could "attach to" is now a row in `paliad.projekte`. A Mandant-level Frist FKs to the `project_type='mandat'` root. A verfahren-level Frist FKs to the `project_type='verfahren'` leaf. No discriminator column, no CHECK constraint juggling.
|
||||
- RLS becomes one predicate: `paliad.can_see_project(project_id)`. Today's `can_see_akte()` + `notiz_is_visible()` split goes away for 6 of 7 child tables.
|
||||
- Client visibility (a Mandant-level Frist like "send yearly renewal reminder") is uniform: it just lives on the Mandant-level projekt — no `client_id` FK on child tables, no third polymorphic branch.
|
||||
- Query aggregation across a client's work ("show me all deadlines in the next 30 days for Siemens") is a single JOIN: `fristen JOIN projekte ON fristen.project_id = projekte.id WHERE projekte.path <@ (SELECT path FROM projekte WHERE id = <mandat-projekt-id>)`.
|
||||
|
||||
Alternatives I considered and rejected:
|
||||
- **Multiple nullable FKs (`client_id`, `litigation_id`, `patent_id`, `verfahren_id`) with a 1-of CHECK.** Reproduces the notizen pain for every child table. Harder to index, harder for RLS. Rejected.
|
||||
- **`parent_type text` + `parent_id uuid` (classic polymorphic)**. Kills foreign-key integrity. Rejected.
|
||||
- **Separate child tables per level (`mandat_fristen`, `litigation_fristen`, …)**. Absurd proliferation. Rejected on sight.
|
||||
|
||||
`notizen` is the one exception because a note genuinely attaches to one of four *kinds of entity*, not to four different positions in the same tree. Keep the 4-FK-one-nullable shape (akte_id → **project_id**, frist_id, termin_id, akten_event_id; CHECK = 1-of-4).
|
||||
|
||||
---
|
||||
|
||||
## 4. Visibility model
|
||||
|
||||
### 4.1 Design principles
|
||||
|
||||
1. Visibility is **tree-connected**: if you can see one node, you can see the whole tree (root → all descendants). Mimics how litigation teams actually work.
|
||||
2. Office-scoping stays **at the project level**, not the Mandant level, because different Projekte under one client may legitimately belong to different offices (e.g., the client's Munich patent prosecution vs. their Düsseldorf enforcement).
|
||||
3. Project-team membership **grants visibility**, including for users outside `owning_office`.
|
||||
4. Mandant-level visibility (`mandanten.collaborators`, `mandanten.firm_wide_visible`) grants visibility to the **Mandant** and its **entire project tree**. This is the firm-wide or relationship-partner override.
|
||||
5. `admin` role sees everything.
|
||||
|
||||
### 4.2 The predicate, in English
|
||||
|
||||
A user U can see a Projekt P iff **any** of the following:
|
||||
|
||||
- `P.firm_wide_visible = true`, **or**
|
||||
- `P.owning_office = U.office`, **or**
|
||||
- U is in `projekt_mitglieder` for P, **or**
|
||||
- U is in `projekt_mitglieder` for **any ancestor or descendant of P** (tree-connected visibility), **or**
|
||||
- `P.client_id` points to a Mandant M where:
|
||||
- `M.firm_wide_visible = true`, **or**
|
||||
- U's uuid ∈ `M.collaborators`, **or**
|
||||
- `U.role = 'admin'`.
|
||||
|
||||
A user U can see a Mandant M iff **any** of:
|
||||
- `M.firm_wide_visible = true`, **or**
|
||||
- `M.owning_office = U.office`, **or**
|
||||
- U's uuid ∈ `M.collaborators`, **or**
|
||||
- U can see **any** Projekt under M (inductive), **or**
|
||||
- `U.role = 'admin'`.
|
||||
|
||||
### 4.3 SQL predicate
|
||||
|
||||
```sql
|
||||
-- Canonical visibility predicate for projects. Used in RLS and mirrored at
|
||||
-- the service layer (AkteService.ListVisibleForUser equivalent).
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_project(_project_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = paliad, public
|
||||
AS $$
|
||||
WITH me AS (
|
||||
SELECT id, office, role FROM paliad.users WHERE id = auth.uid()
|
||||
),
|
||||
tgt AS (
|
||||
SELECT id, client_id, owning_office, firm_wide_visible, path
|
||||
FROM paliad.projekte
|
||||
WHERE id = _project_id
|
||||
)
|
||||
SELECT EXISTS (SELECT 1 FROM tgt WHERE tgt.firm_wide_visible)
|
||||
OR EXISTS (SELECT 1 FROM tgt, me WHERE tgt.owning_office = me.office)
|
||||
OR EXISTS (SELECT 1 FROM me WHERE me.role = 'admin')
|
||||
OR EXISTS (
|
||||
-- membership at target, or at any ancestor/descendant
|
||||
SELECT 1 FROM paliad.projekt_mitglieder pm, tgt
|
||||
WHERE pm.user_id = auth.uid()
|
||||
AND pm.projekt_id IN (
|
||||
SELECT id FROM paliad.projekte
|
||||
WHERE path @> tgt.path OR path <@ tgt.path
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.mandanten m, tgt
|
||||
WHERE m.id = tgt.client_id
|
||||
AND (m.firm_wide_visible
|
||||
OR auth.uid() = ANY (m.collaborators))
|
||||
);
|
||||
$$;
|
||||
```
|
||||
|
||||
The `path @> tgt.path OR path <@ tgt.path` clause is the tree-connected check: any project whose path is an ancestor or descendant of the target's path. Uses the GiST index on `projekte.path`.
|
||||
|
||||
**Performance note.** For a litigation tree of ~20 nodes with ~100 members, the predicate runs a handful of index probes per row. Measurably worse than today's flat `can_see_akte()` (one EXISTS), but still sub-millisecond in Postgres. If we ever see RLS cost dominate a listing page, the follow-on is to cache "visible project ids for user X" in a session-scoped CTE at the application layer (same trick we use in `ListVisibleForUser`).
|
||||
|
||||
### 4.4 Cross-office teams — concretely
|
||||
|
||||
Example: Munich partner (Dezernat A) leads; Düsseldorf associate and London local counsel are staffed in.
|
||||
|
||||
- The Litigation-level Projekt is created with `owning_office = 'munich'`.
|
||||
- The Munich partner, the Düsseldorf associate, and the London local counsel all get rows in `projekt_mitglieder` (roles `lead`, `associate`, `local_counsel`).
|
||||
- Result: the Düsseldorf associate can see the whole litigation tree even though `owning_office <> 'duesseldorf'`. Munich-office colleagues not on the team can still see it (office-scope). London colleagues not on the team cannot see it.
|
||||
|
||||
**Edge case: "Chinese-walled" cases.** If a single Akte needs to be hidden from the rest of `owning_office` (conflict of interest), `owning_office` can't carry the day. Add a boolean `restricted` column in a later iteration that flips the predicate to team-only. Don't build now — wait for the first real conflict.
|
||||
|
||||
---
|
||||
|
||||
## 5. Migration plan
|
||||
|
||||
Non-destructive, phased. Data survives at every step.
|
||||
|
||||
### Phase 1 — Add new tables (no FK rewrites)
|
||||
|
||||
Migration `018_v2_core_tables`:
|
||||
- `CREATE EXTENSION IF NOT EXISTS ltree;`
|
||||
- Create `paliad.mandanten`, `paliad.projekte`, `paliad.teams`, `paliad.team_mitglieder`, `paliad.projekt_mitglieder`.
|
||||
- Create the path trigger.
|
||||
- No change to `paliad.akten` or its children yet. Old code keeps running.
|
||||
|
||||
Acceptance: `\dt paliad.*` shows the new tables. Smoke test: insert a Mandant + one Projekt tree, query via `path @>`.
|
||||
|
||||
### Phase 2 — Backfill `paliad.projekte` from `paliad.akten` (synthetic Mandanten)
|
||||
|
||||
Migration `019_v2_backfill_projects_from_akten`:
|
||||
- For each distinct `owning_office` with any Akte, create one synthetic Mandant: `name = 'Unbekannter Mandant (<office>)'`, `status = 'active'`, `owning_office = <office>`, `metadata = {"synthetic": true}`.
|
||||
- Insert a `paliad.projekte` row for every `paliad.akten` row. **Same UUID** (`projekte.id = akten.id`), `client_id = synthetic mandant of matching office`, `parent_project_id = NULL`, `project_type = 'verfahren'` (best-match to current flat Akte semantics — most are single proceedings), `title`, `reference = aktenzeichen`, `owning_office`, `firm_wide_visible`, `created_by` copied 1:1. The path trigger populates `path` and `depth`.
|
||||
- Also backfill `projekt_mitglieder` from `paliad.akten.collaborators` (array → rows with `role='associate'`).
|
||||
|
||||
Acceptance: `(SELECT COUNT(*) FROM paliad.projekte) = (SELECT COUNT(*) FROM paliad.akten)`; every akten id survives as a projekte id. Visibility-predicate returns the same answers as `can_see_akte` for every (user, akte) pair (spot-check).
|
||||
|
||||
### Phase 3 — Rename FK columns on child tables to `project_id`
|
||||
|
||||
Migration `020_v2_rename_akte_id_to_project_id`:
|
||||
- For `parteien`, `fristen`, `termine`, `dokumente`, `akten_events`, `checklist_instances`: `ALTER TABLE … RENAME COLUMN akte_id TO project_id;` and `ALTER TABLE … RENAME CONSTRAINT <akte_fk> TO <project_fk>;` plus rewrite the REFERENCES target from `paliad.akten` to `paliad.projekte`.
|
||||
- For `notizen`: `RENAME COLUMN akte_id TO project_id;` similarly; keep `frist_id/termin_id/akten_event_id` intact.
|
||||
- Because of the shared UUID trick in Phase 2, no data moves. Indexes are renamed in the same migration.
|
||||
|
||||
Acceptance: `\d paliad.fristen` shows `project_id uuid NOT NULL REFERENCES paliad.projekte(id)`. Existing SELECTs joining to `paliad.akten` now break — that's the signal to cut the application code over in Phase 4.
|
||||
|
||||
### Phase 4 — Cut application code over to `paliad.projekte`
|
||||
|
||||
- Rename Go types: `models.Akte` → `models.Projekt`. Keep `models.Akte` as a deprecated type alias for one release for external API compatibility if needed.
|
||||
- `services.AkteService` → `services.ProjektService`. Preserve method signatures; internals switch to `paliad.projekte`.
|
||||
- Update handlers. `/api/akten` becomes an alias to `/api/projekte` (same handler, same JSON shape during transition — `projekte` additionally exposes `client_id`, `parent_project_id`, `project_type`, `path` fields).
|
||||
- Update the dashboard query to aggregate by `projekte` (tree-walk already shown in §4.3).
|
||||
|
||||
Acceptance: the app runs end-to-end on the new schema; old routes still resolve; old JSON shapes still accepted (new fields additive).
|
||||
|
||||
### Phase 5 — New Mandant UI + partner cleanup
|
||||
|
||||
- `/mandanten` list + detail pages. Partners assign every synthetic-Mandant project to a real Mandant row (bulk "Change Mandant" on the project-detail page, or a dedicated migration UI at `/einstellungen/migration`).
|
||||
- After every `projekte.client_id` in `metadata->>'synthetic'=true` has been reassigned, drop the synthetic Mandanten and enforce `paliad.projekte.client_id SET NOT NULL` (migration 021).
|
||||
|
||||
Acceptance: no synthetic Mandanten remain. `client_id` is NOT NULL.
|
||||
|
||||
### Phase 6 — Decommission `paliad.akten`
|
||||
|
||||
- `DROP TABLE paliad.akten` (migration 022). Everything that referenced it has been rewritten.
|
||||
- Drop the legacy `paliad.can_see_akte()` function; the one-to-one function becomes `can_see_project()`.
|
||||
|
||||
Acceptance: `\dt paliad.akten` → not found. All FK constraints still satisfy.
|
||||
|
||||
### Rollback
|
||||
|
||||
Every migration has a `down`:
|
||||
- Phases 3 and 6 are destructive DDL (drop column rename → rename back; drop table → re-create). Data preservation in those down-migrations is **not guaranteed** after the migration completes; the safe rollback window is "before Phase 3 runs in production". Document loudly.
|
||||
- Phases 1, 2, 4, 5 are additive or app-level, rollback by reverting code or running `DELETE FROM paliad.projekte WHERE …`.
|
||||
|
||||
---
|
||||
|
||||
## 6. RLS policy updates
|
||||
|
||||
### 6.1 New policies
|
||||
|
||||
`paliad.projekte` — enable RLS. Policies:
|
||||
|
||||
```sql
|
||||
CREATE POLICY projekte_select ON paliad.projekte
|
||||
FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(id));
|
||||
|
||||
-- Non-admins can only create Projekte rooted in an office they belong to
|
||||
-- (or a tree whose existing parent they can already see).
|
||||
CREATE POLICY projekte_insert ON paliad.projekte
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
-- Creating under an existing parent? — must already see it.
|
||||
(parent_project_id IS NOT NULL AND paliad.can_see_project(parent_project_id))
|
||||
OR
|
||||
-- Root project: own office, or admin.
|
||||
(parent_project_id IS NULL
|
||||
AND (owning_office = (SELECT office FROM paliad.users WHERE id = auth.uid())
|
||||
OR (SELECT role FROM paliad.users WHERE id = auth.uid()) = 'admin'))
|
||||
);
|
||||
|
||||
CREATE POLICY projekte_update ON paliad.projekte
|
||||
FOR UPDATE TO authenticated
|
||||
USING (paliad.can_see_project(id))
|
||||
WITH CHECK (paliad.can_see_project(id));
|
||||
|
||||
-- Delete: partner/admin only. Cascades down the tree.
|
||||
CREATE POLICY projekte_delete ON paliad.projekte
|
||||
FOR DELETE TO authenticated
|
||||
USING (
|
||||
paliad.can_see_project(id)
|
||||
AND (SELECT role FROM paliad.users WHERE id = auth.uid()) IN ('partner','admin')
|
||||
);
|
||||
```
|
||||
|
||||
`paliad.mandanten` — enable RLS. Visibility: any user who can see at least one of the Mandant's Projekte, or who is in `collaborators`, or `firm_wide_visible`, or admin.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION paliad.can_see_mandant(_mandant_id uuid)
|
||||
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path = paliad, public AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.mandanten m, paliad.users u
|
||||
WHERE m.id = _mandant_id
|
||||
AND u.id = auth.uid()
|
||||
AND (
|
||||
m.firm_wide_visible
|
||||
OR m.owning_office = u.office
|
||||
OR auth.uid() = ANY (m.collaborators)
|
||||
OR u.role = 'admin'
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM paliad.projekte p
|
||||
WHERE p.client_id = _mandant_id
|
||||
AND paliad.can_see_project(p.id)
|
||||
)
|
||||
)
|
||||
);
|
||||
$$;
|
||||
|
||||
CREATE POLICY mandanten_select ON paliad.mandanten
|
||||
FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_mandant(id));
|
||||
|
||||
CREATE POLICY mandanten_insert ON paliad.mandanten
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
owning_office = (SELECT office FROM paliad.users WHERE id = auth.uid())
|
||||
OR (SELECT role FROM paliad.users WHERE id = auth.uid()) = 'admin'
|
||||
);
|
||||
|
||||
-- Update/Delete policies analogous; delete is partner/admin-gated.
|
||||
```
|
||||
|
||||
### 6.2 Child-table policies — converge on `can_see_project`
|
||||
|
||||
Every child table's policy changes from `paliad.can_see_akte(akte_id)` to `paliad.can_see_project(project_id)`. `notizen` loses its dedicated `notiz_is_visible()` helper in favour of an inline check that dispatches by which FK is set:
|
||||
|
||||
```sql
|
||||
CREATE POLICY notizen_all ON paliad.notizen
|
||||
FOR ALL TO authenticated
|
||||
USING (
|
||||
CASE
|
||||
WHEN project_id IS NOT NULL THEN paliad.can_see_project(project_id)
|
||||
WHEN frist_id IS NOT NULL THEN paliad.can_see_project(
|
||||
(SELECT project_id FROM paliad.fristen WHERE id = frist_id))
|
||||
WHEN termin_id IS NOT NULL THEN
|
||||
CASE
|
||||
WHEN (SELECT project_id FROM paliad.termine WHERE id = termin_id) IS NULL
|
||||
THEN (SELECT created_by FROM paliad.termine WHERE id = termin_id) = auth.uid()
|
||||
ELSE paliad.can_see_project(
|
||||
(SELECT project_id FROM paliad.termine WHERE id = termin_id))
|
||||
END
|
||||
WHEN akten_event_id IS NOT NULL THEN paliad.can_see_project(
|
||||
(SELECT project_id FROM paliad.akten_events WHERE id = akten_event_id))
|
||||
ELSE false
|
||||
END
|
||||
)
|
||||
WITH CHECK (...same...);
|
||||
```
|
||||
|
||||
(We may extract a helper `notiz_is_visible(project_id, frist_id, termin_id, akten_event_id)` again — symmetric to today's.)
|
||||
|
||||
### 6.3 Admin bootstrap
|
||||
|
||||
Unchanged. The `pg_advisory_xact_lock(7346298141)` onboarding gate that lets the first user self-assign `role='admin'` still works. Nothing to do.
|
||||
|
||||
### 6.4 Defense-in-depth at the service layer
|
||||
|
||||
Same pattern as today: every service mirrors the predicate in `ListVisibleForUser` for indexed performance. The SQL is wordier for the tree variant — we do it once in `ProjektService.listVisibleIDsForUser` (returns a `map[uuid.UUID]struct{}`) and re-use across list endpoints.
|
||||
|
||||
---
|
||||
|
||||
## 7. API surface changes
|
||||
|
||||
### 7.1 New endpoints
|
||||
|
||||
| Method + path | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/mandanten` | List Mandanten the user can see. |
|
||||
| `POST /api/mandanten` | Create Mandant. |
|
||||
| `GET /api/mandanten/{id}` | Detail. |
|
||||
| `PATCH /api/mandanten/{id}` | Update. |
|
||||
| `DELETE /api/mandanten/{id}` | Delete (partner/admin only). |
|
||||
| `GET /api/mandanten/{id}/projekte` | List root-level Projekte under this Mandant. |
|
||||
| `GET /api/projekte` | List top-level visible Projekte (flat root list). |
|
||||
| `POST /api/projekte` | Create a Projekt (optionally nested via `parent_project_id`). |
|
||||
| `GET /api/projekte/{id}` | Detail + immediate children. |
|
||||
| `GET /api/projekte/{id}/tree` | Full subtree (depth-first). |
|
||||
| `PATCH /api/projekte/{id}` | Update. |
|
||||
| `DELETE /api/projekte/{id}` | Cascade delete subtree (partner/admin). |
|
||||
| `GET /api/projekte/{id}/kinder` | Direct children (for lazy-loaded UI). |
|
||||
| `POST /api/projekte/{id}/team` | Add project team member. |
|
||||
| `DELETE /api/projekte/{id}/team/{user_id}` | Remove member. |
|
||||
| `GET /api/teams` | List Dezernate (+ optionally project teams). |
|
||||
| `POST /api/teams` | Create Dezernat (admin-gated). |
|
||||
| `GET /api/teams/{id}/mitglieder` | List members. |
|
||||
|
||||
### 7.2 Aliased endpoints
|
||||
|
||||
During transition:
|
||||
- `GET /api/akten` → alias of `GET /api/projekte?project_type=verfahren` (or all, with `akte_type`/`court` fields surfaced) for clients still using the old shape.
|
||||
- `POST /api/akten` → alias of `POST /api/projekte` with `project_type='verfahren'` default.
|
||||
- `/api/akten/{id}/fristen` → alias of `/api/projekte/{id}/fristen`.
|
||||
|
||||
Remove aliases after 60 days + 1 green deployment.
|
||||
|
||||
### 7.3 Retired endpoints
|
||||
|
||||
None immediately. Keeping aliases means no 404s for the UI during the Phase 4 cutover.
|
||||
|
||||
### 7.4 New query parameters
|
||||
|
||||
- `?client_id=<uuid>` on `GET /api/projekte` — scope to one Mandant's tree.
|
||||
- `?project_type=<type>` — filter by type.
|
||||
- `?ancestor=<uuid>` — subtree of a given root (uses `path <@`).
|
||||
- `?include_children=true` on `GET /api/projekte/{id}` — one-shot detail + subtree (cheap because of the path index).
|
||||
|
||||
---
|
||||
|
||||
## 8. UI implications
|
||||
|
||||
### 8.1 Sidebar
|
||||
|
||||
Insert "Mandanten" above "Akten" in the "ARBEIT" group. Rename "Akten" → "Projekte" in a second phase once partners get used to the concept — initially, keep "Akten" as the label and add the Mandanten entry only.
|
||||
|
||||
```
|
||||
— ARBEIT —
|
||||
Dashboard
|
||||
Mandanten ← NEW
|
||||
Projekte ← renamed from "Akten" (phase 2)
|
||||
Fristen
|
||||
Termine
|
||||
```
|
||||
|
||||
### 8.2 New pages
|
||||
|
||||
- `/mandanten` — list. Columns: Name, Büro, #Projekte, #aktive Fristen, letzte Aktivität.
|
||||
- `/mandanten/neu` — create form.
|
||||
- `/mandanten/{id}` — detail with tabs: Übersicht, Projekte (the tree at this root), Fristen (aggregated), Termine (aggregated), Notizen (aggregated), Team (aggregated from all child projekt_mitglieder).
|
||||
- `/projekte` — flat list of root projects + filter by Mandant, type, office.
|
||||
- `/projekte/{id}` — detail. Tabs today (`verlauf`, `parteien`, `fristen`, `termine`, `dokumente`, `notizen`, `checklisten`) stay. **New first tab: "Untergeordnet"** — renders the subtree of child projekte as a collapsible list. A "Neues Untervorhaben" button under each node creates a child.
|
||||
- `/projekte/neu` — create. Form adapts to `project_type`: `mandat`/`litigation`/`patent` surface no `court` / `court_ref`; `verfahren` does.
|
||||
|
||||
### 8.3 Detail-page tree rendering
|
||||
|
||||
On `/projekte/{id}` the sidebar (left sub-nav) renders the ancestor path (breadcrumbs: Mandant → Litigation → Patent → Verfahren). Children render in the Untergeordnet tab as a collapsible tree. Keep click-depth low: clicking a child navigates to that child's detail page; siblings render as flat siblings on the current page.
|
||||
|
||||
### 8.4 Team editor
|
||||
|
||||
On `/projekte/{id}` under the Team tab: list of team members with role, "Mitglied hinzufügen" modal with autocomplete fed by `GET /api/users`. Removing the `collaborators uuid[]` array means the multi-pick UI gets a proper role column and an "added_by/at" audit line, which today's model can't show.
|
||||
|
||||
### 8.5 Dashboard impact
|
||||
|
||||
Dashboard queries aggregate across **every** Projekt the user can see (all tree levels). Fristen summary widget: `SELECT * FROM paliad.fristen f JOIN paliad.projekte p ON f.project_id = p.id WHERE can_see_project(p.id) AND f.status='pending' AND f.due_date <= now() + interval '30 days'`. Same tree-agnostic pattern for Termine.
|
||||
|
||||
"Neu auf ..." — add a Mandanten-level aggregate: "Siemens AG: 3 neue Fristen diese Woche, 1 Verhandlung am Donnerstag". Requires a `client_id` join via `projekte` — same index pattern.
|
||||
|
||||
### 8.6 Fristenrechner "Save to Akte"
|
||||
|
||||
Rename button to "Zur Verfahren-Akte speichern" (or simpler: "Zur Akte speichern" still works because Verfahren are the most common target). The target picker is now a two-step autocomplete: Mandant → Projekt (scoped to that Mandant's tree). Or skip Mandant picker entirely and autocomplete across all visible projekte — simpler, lets the user jump straight to the right leaf by typing the court-ref.
|
||||
|
||||
### 8.7 Checklisten Akten-link
|
||||
|
||||
Minimal change: `akte_id` → `project_id` on the service layer and UI picker. Picker is the same cross-tree autocomplete.
|
||||
|
||||
### 8.8 CalDAV sync
|
||||
|
||||
No material change. Today each Termin's iCal includes the Akte's `aktenzeichen` in the DESCRIPTION. v2 includes the full path: `Siemens AG · Siemens v. Huawei · EP 1 234 567 · UPC_CFI_123/2026`. Lawyers will find events in their calendar far more easily.
|
||||
|
||||
---
|
||||
|
||||
## 9. Impact on existing features
|
||||
|
||||
| Feature | Change |
|
||||
|---|---|
|
||||
| Dashboard | Queries shift from `akten` to `projekte`; widgets aggregate tree-wide. Mandanten-level "Neu auf ..." widget added. |
|
||||
| Fristenrechner | Save-to-Projekt instead of save-to-Akte. Autocomplete is cross-tree (all visible projekte). |
|
||||
| Fristen list | Columns show `Mandant · Projekt` chain instead of flat Aktenzeichen. Filter by Mandant. |
|
||||
| Termine list | Same. |
|
||||
| Notizen | FK rename only (akte_id → project_id). Polymorphic shape preserved. |
|
||||
| Checklisten | FK rename only. Picker widened to cross-tree autocomplete. |
|
||||
| CalDAV | Richer DESCRIPTION (full path). Termin ↔ calendar event mapping unchanged. |
|
||||
| Akten detail page | Gains Untergeordnet tab + tree sub-nav. Existing tabs keep working. |
|
||||
| Audit trail (Verlauf) | `akten_events` stays as a table name (history); `akte_id` → `project_id`. New event types: `projekt_nested`, `projekt_reparented`, `mandant_assigned`, `team_member_added`, `team_member_removed`. |
|
||||
| Dokumente (placeholder) | No change today (still placeholder). Future implementation attaches to the right level of the tree. |
|
||||
|
||||
---
|
||||
|
||||
## 10. Naming conventions — German, with one English holdover
|
||||
|
||||
**Decision: German throughout. Match everything shipped so far.**
|
||||
|
||||
| Concept | DB table | Go struct | Go service | URL | German UI |
|
||||
|---|---|---|---|---|---|
|
||||
| Client | `paliad.mandanten` | `Mandant` | `MandantService` | `/mandanten` | "Mandant" / "Mandanten" |
|
||||
| Project (generic) | `paliad.projekte` | `Projekt` | `ProjektService` | `/projekte` | "Projekt" / "Projekte" |
|
||||
| Project sub-type "Mandat" | (row in projekte) | — | — | (row variant) | "Mandat" (Gesamtbeziehung) |
|
||||
| Project sub-type "Litigation" | (row in projekte) | — | — | (row variant) | "Streitsache" |
|
||||
| Project sub-type "Patent" | (row in projekte) | — | — | (row variant) | "Patent" |
|
||||
| Project sub-type "Verfahren" | (row in projekte) | — | — | (row variant) | "Verfahren" |
|
||||
| Project sub-type "Projekt" | (row in projekte) | — | — | (row variant) | "Projekt" (generisch) |
|
||||
| Team (structural / project) | `paliad.teams` | `Team` | `TeamService` | `/teams` (admin) | "Team" / "Teams" |
|
||||
| Team member | `paliad.team_mitglieder` | `TeamMitglied` | — | (sub) | "Teammitglied" |
|
||||
| Project roster | `paliad.projekt_mitglieder` | `ProjektMitglied` | — | (sub of Projekt) | "Projektmitglied" |
|
||||
| Party | `paliad.parteien` | `Partei` | `ParteienService` | (sub of Projekt) | "Partei" / "Parteien" |
|
||||
| Deadline | `paliad.fristen` | `Frist` | `FristService` | `/fristen` | "Frist" / "Fristen" |
|
||||
| Appointment | `paliad.termine` | `Termin` | `TerminService` | `/termine` | "Termin" / "Termine" |
|
||||
| Document | `paliad.dokumente` | `Dokument` | `DokumentService` | (sub) | "Dokument" |
|
||||
| Audit event | `paliad.akten_events` | `AkteEvent` | (in `ProjektService`) | n/a | "Verlauf" |
|
||||
| Note | `paliad.notizen` | `Notiz` | `NotizService` | cross-cutting | "Notiz" / "Notizen" |
|
||||
| User | `paliad.users` | `User` | `UserService` | n/a | n/a |
|
||||
|
||||
**The one English holdover:** `paliad.akten_events` table name. Rationale:
|
||||
- It's the audit-trail table name already shipped.
|
||||
- "Verlauf" is the UI label; the table name is invisible to users.
|
||||
- Migrating to `projekt_events` would churn migration history for no gain, and any historical tools (Grafana, ad-hoc SQL) keep working. Preserve the name; update the comment and the Go struct semantics.
|
||||
|
||||
**Why not `projekt_*` everywhere?**
|
||||
- `projekt_mitglieder` reads cleanly; kept.
|
||||
- `projekt_events` would be clean too but see above — churn:benefit ratio unfavourable.
|
||||
|
||||
**URL aliases.** `/akten` and `/akten/{id}` keep redirecting to `/projekte` and `/projekte/{id}` indefinitely (bookmark preservation). No hard break. 301 from `/akten/neu` → `/projekte/neu`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions & deferrable decisions
|
||||
|
||||
1. **Patent registry.** Should `external_ref` on a `patent`-type projekt be a FK to a firm-wide `paliad.patente` table (EP number + metadata, shared across Mandanten)? Not today — a single litigation's view of a patent is legitimately separate from the firm-wide "patents we've ever seen" list. Revisit when HLC asks for firm-wide IP inventory. If/when built, add `patent_registry_id uuid` on `projekte` where `project_type='patent'`.
|
||||
|
||||
2. **Conflict of interest / Chinese walls.** Partner-level override to restrict a single project to its team roster only (strip office-scope). Not built now; add `restricted boolean` in a follow-on migration, and extend the predicate to skip the office-scope branch when `restricted = true`.
|
||||
|
||||
3. **Practice-group scoping.** `paliad.users.practice_group` is already free-text. If HLC splits Patents Litigation vs. Patents Prosecution and wants wall-like isolation, add `visible_to_groups text[]` on `projekte` + predicate extension. Not now.
|
||||
|
||||
4. **Matrix management.** A user belongs to one Dezernat (structural) today. If partners share associates (e.g., a "tax-patent" associate is on both the Patents and the Tax Dezernate), relax `team_mitglieder` — it already allows multiple memberships. The onboarding UI currently picks one Dezernat; relax when needed.
|
||||
|
||||
5. **Billing hooks.** `mandanten.billing_reference` is provisioned but not wired. Deliberate: HLC has firm-wide billing; Paliad does not compete. Field exists so the UI can show it and the future Outlook/Exchange integration can look it up.
|
||||
|
||||
6. **External collaborators.** A Milan boutique working on a case today would go into `projekt_mitglieder` only if they have Supabase accounts. Building external-party access (email-only, scoped, audit-logged) is a post-foundation feature; deferred.
|
||||
|
||||
7. **Hard delete vs. archive.** `status='archived'` on Mandanten and Projekte exists; hard-delete is cascade via FK. Consider a `archived_at` + soft-delete semantics once we have retention-policy rules. Not now.
|
||||
|
||||
8. **ltree label encoding.** UUIDs with hyphens aren't valid ltree labels. Replace `-` with `_`, or hash. Implementer's call; both work, hash is shorter but loses traceability.
|
||||
|
||||
---
|
||||
|
||||
## 12. Trade-off summary (for the head)
|
||||
|
||||
| Choice | Alternative | Why I picked this | Cost |
|
||||
|---|---|---|---|
|
||||
| Single `projekte` tree with type enum | Separate tables per type (mandate/litigation/patent/case) | Polymorphic FK pain, cross-tree queries, UI shared components | `project_type` CHECK has to grow carefully |
|
||||
| ltree materialised path | Recursive CTE | RLS is the hottest call site; O(log n) tree queries matter | Extension dependency; label encoding quirk |
|
||||
| Single `project_id` on child tables | Multi-level polymorphic FKs | RLS simplicity, uniform service code | Discipline: every Fristen/Termin has a Projekt, even "client-level" rare cases |
|
||||
| `mandanten` as a separate table | Project with `type='mandat'` as the conceptual client | Clients have no deadlines/termine/parteien; they're a different shape. Also: Mandant outlives any specific matter. | One extra table |
|
||||
| `teams` shared between Dezernat + project_team | Two separate tables | Single roster table (`team_mitglieder`), UI/service reuse | Partial CHECK constraints are a minor smell |
|
||||
| `projekt_mitglieder` junction in addition to `teams` | Route project-team membership through `teams` | Hot path for RLS wants a dedicated two-column junction | Small duplication of concept |
|
||||
| German naming | Mixed EN/DE | Continuity with everything shipped; audience speaks German | German plural forms (`mandanten`, `projekte`) in URLs |
|
||||
| Tree-connected visibility | Downward-only (seeing ancestor grants ancestor+descendants only) | Matches how associate-on-one-case actually needs parent context | Slightly bigger RLS query |
|
||||
| Phased non-destructive migration with preserved UUIDs | Dump-transform-reload | Zero downtime; every child-table row survives untouched | Requires discipline: match UUIDs in the backfill exactly |
|
||||
|
||||
---
|
||||
|
||||
## 13. Who implements?
|
||||
|
||||
Recommendation: **I (cronus) can implement the foundation** — migrations 018–022, the predicate function, the new `ProjektService`, and the API alias shim. Reasons:
|
||||
- I wrote the design; I know the edge cases.
|
||||
- The schema work is security-critical (RLS policy + path trigger); having design-context on the implementer cuts review cycles.
|
||||
- The pragmatic split: cronus does schema + services + aliases + RLS (Phase 1–4). A parallel coder worker does the Mandanten UI (`/mandanten` list + detail + create + partner cleanup wizard) in Phase 5. Cronus does Phase 6 decommission.
|
||||
|
||||
If the head prefers to keep cronus on design duty and hand implementation to a coder, the design is detailed enough to hand off — every schema has columns, constraints, triggers, RLS snippets, and migration acceptance criteria. I'd still want to review the RLS + path trigger PR before merge.
|
||||
|
||||
---
|
||||
|
||||
## 14. Acceptance criteria for the design itself
|
||||
|
||||
A "yes" on this design means head agrees to:
|
||||
|
||||
- [ ] Mandanten as a first-class table (not just a Projekt type).
|
||||
- [ ] Single `projekte` tree with 5-value type enum.
|
||||
- [ ] ltree materialised path + GiST index.
|
||||
- [ ] Single `project_id` FK on fristen/termine/dokumente/parteien/akten_events/checklist_instances; `notizen` keeps its polymorphic shape with `akte_id` renamed to `project_id`.
|
||||
- [ ] Tree-connected visibility predicate (ancestors + descendants both reachable from any team node).
|
||||
- [ ] `paliad.teams` as a single table for Dezernat + project-team, with the two-kind shape CHECK.
|
||||
- [ ] `projekt_mitglieder` as a hot-path junction, *and* optional `teams` rows of type `project_team` for team-level features.
|
||||
- [ ] Phased migration with preserved UUIDs between `akten` and `projekte` rows.
|
||||
- [ ] German naming throughout; `akten_events` table name preserved for continuity.
|
||||
- [ ] `/akten` URLs alias to `/projekte` indefinitely.
|
||||
593
docs/design-email-templates-2026-04-29.md
Normal file
@@ -0,0 +1,593 @@
|
||||
# Admin Email-Templates editor — design
|
||||
|
||||
**Task:** t-paliad-072 (ritchie, inventor)
|
||||
**Date:** 2026-04-29
|
||||
**Status:** design — awaiting m's go/no-go before coder shift
|
||||
|
||||
## Problem statement
|
||||
|
||||
Today the three email templates Paliad sends (`invitation`, `deadline_digest`,
|
||||
plus the shared `base.html` wrapper) live in `internal/templates/email/*.html`,
|
||||
embedded into the binary at build time and rendered by `MailService`
|
||||
(`internal/services/mail_service.go`). Editing copy — even fixing a typo in
|
||||
the German `Heute fällig` heading — requires a code change, PR, merge to main,
|
||||
Dokploy redeploy.
|
||||
|
||||
The `/admin` landing page already advertises an "Email-Templates" card as
|
||||
"Kommt bald" (`frontend/src/admin.tsx:36-42`). This task fills it in: an
|
||||
admin can read each template, edit subject + body, preview against sample
|
||||
data, save without a deploy, and roll back if a save was wrong.
|
||||
|
||||
---
|
||||
|
||||
## 1. Storage decision
|
||||
|
||||
**Decision: DB-backed, with the embedded files as the fallback default.**
|
||||
|
||||
The task brief recommends DB unless m explicitly wants filesystem-only, and
|
||||
the whole rationale for surfacing a card on `/admin` is in-place editing. A
|
||||
filesystem-only "preview + variable docs" page would be a different feature
|
||||
(more like `/admin/email-templates/docs`) and doesn't fit the card we
|
||||
promised.
|
||||
|
||||
The embedded files **stay**. They are:
|
||||
|
||||
1. The seed source (initial DB rows are populated from them).
|
||||
2. The render-time fallback when a DB row is missing or malformed (so a
|
||||
broken save can never wedge an entire send path — see §3).
|
||||
3. The "Reset to default" target (always available, always parseable).
|
||||
|
||||
### Schema
|
||||
|
||||
Two new tables in a single migration `026_email_templates.up.sql`. RLS
|
||||
enabled with no policies — service-only access, same pattern as
|
||||
`paliad.invitations` / `paliad.reminder_log`.
|
||||
|
||||
```sql
|
||||
-- Active template body per (key, lang). Exactly one row per pair, kept
|
||||
-- current by UPSERT on save. Absence == use embedded fallback.
|
||||
CREATE TABLE paliad.email_templates (
|
||||
key text NOT NULL,
|
||||
lang text NOT NULL CHECK (lang IN ('de', 'en')),
|
||||
subject text NOT NULL, -- text/template source
|
||||
body text NOT NULL, -- html/template source ({{define "content"}})
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
PRIMARY KEY (key, lang)
|
||||
);
|
||||
|
||||
-- Append-only version log. Captures every save (and every reset). The
|
||||
-- service garbage-collects to the most recent VERSION_RETENTION rows per
|
||||
-- (key, lang) inside the same transaction as the save.
|
||||
CREATE TABLE paliad.email_template_versions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key text NOT NULL,
|
||||
lang text NOT NULL CHECK (lang IN ('de', 'en')),
|
||||
subject text NOT NULL,
|
||||
body text NOT NULL,
|
||||
saved_at timestamptz NOT NULL DEFAULT now(),
|
||||
saved_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
note text NOT NULL DEFAULT '' -- '', 'reset', 'restore from <version_id>'
|
||||
);
|
||||
|
||||
CREATE INDEX email_template_versions_key_lang_idx
|
||||
ON paliad.email_template_versions (key, lang, saved_at DESC);
|
||||
|
||||
ALTER TABLE paliad.email_templates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.email_template_versions ENABLE ROW LEVEL SECURITY;
|
||||
```
|
||||
|
||||
`key` is one of `invitation`, `deadline_digest`, `base` — the existing
|
||||
template-name set. `lang` is `de` or `en`. The pair `('base', 'de')` is the
|
||||
shared wrapper (it has lang-conditional bits in it today, but those become
|
||||
language-specific copies after the split — see §1.3).
|
||||
|
||||
`VERSION_RETENTION = 20` per (key, lang). After 20 the oldest is deleted on
|
||||
save. Trade-off: 20 saves per language per template means at most
|
||||
3 templates × 2 languages × 20 = 120 rows total in steady state. Negligible
|
||||
storage; gives admins room to recover from "I edited this five times in a row
|
||||
to get the wording right and then realised the third version was correct".
|
||||
|
||||
### Migration plan
|
||||
|
||||
1. **Migration 026 (this PR)** — creates both tables. Does **not** seed any
|
||||
rows. First-render-after-deploy reads embedded fallbacks; first save from
|
||||
the editor inserts the active row.
|
||||
2. **Embedded file split (this PR)** — replace each existing
|
||||
`<name>.html` with `<name>.de.html` and `<name>.en.html`. The current
|
||||
bilingual files use `{{if eq .Lang "en"}}…{{else}}…{{end}}` blocks; we
|
||||
split each branch into its own file. After this migration **no template
|
||||
contains a `Lang` conditional** — language is selected by file (or DB
|
||||
row) lookup, not by an in-template branch. This makes the editor UX
|
||||
simple ("you're editing the German invitation" — one file, no nested
|
||||
conditionals to confuse the reader).
|
||||
3. **MailService refactor** — `RenderTemplate` looks up `EmailTemplateService.GetActive(key, lang)` first; on miss reads the embedded `<key>.<lang>.html`. `base` is loaded the same way (so the wrapper is editable). The render itself stays `html/template` over the cloned base.
|
||||
4. **Subject becomes data, not code.** The hard-coded `inviteSubject` and `buildDigestSubject` in Go go away. Each template's DB row has a `subject` column whose contents are a `text/template` source (not `html/template` — subject lines aren't HTML). Caller passes the same `Data` map; service renders subject and body from the same payload.
|
||||
|
||||
*Trade-off*: today's subject logic for `deadline_digest` is conditional
|
||||
("SYSTEMAUSFALL" vs "URGENT" vs plain count). It moves into the template
|
||||
syntax verbatim. Admins editing the subject see the conditional clearly
|
||||
and can adjust the framing. **Mitigation against admin breakage**: save
|
||||
validates the subject template parses cleanly *and* renders without error
|
||||
against the same sample data the body preview uses (§3.4).
|
||||
|
||||
### Why not split DE/EN at the file level only (no DB)?
|
||||
|
||||
Considered. Rejected because the rejected version is "the editor card is a
|
||||
preview + docs page, no editing". That removes the only feature the card was
|
||||
named after. If m vetoes DB-backed editing, the fallback is to swap this card
|
||||
for "Email-Templates (Vorschau + Variablen)" — but that's a different
|
||||
product decision and I'd want to confirm before building toward it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Editor UX
|
||||
|
||||
### Page layout
|
||||
|
||||
`GET /admin/email-templates` — gated identically to `/admin/team`:
|
||||
`auth.RequireAdminFunc(users, gateOnboarded(handleAdminEmailTemplatesPage))`.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Sidebar │
|
||||
│ │
|
||||
│ Email-Templates [Vorschau ↻]│
|
||||
│ Vorlagen für Einladungen, Erinnerungen und Layout-Wrapper. │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Einladung │ │ Fristen- │ │ Basis │ │
|
||||
│ │ invitation │ │ Sammelmail │ │ base │ │
|
||||
│ │ │ │ deadline_… │ │ Layout- │ │
|
||||
│ │ Zuletzt: │ │ │ │ Wrapper │ │
|
||||
│ │ 2026-04-12 │ │ Standard │ │ Standard │ │
|
||||
│ │ Standard │ │ │ │ │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Three template cards. Each card shows: human title, internal key,
|
||||
"Zuletzt geändert" date (or "Standard" if no DB override), language badges
|
||||
(DE/EN — clicking enters the editor for that language).
|
||||
|
||||
### Editor view
|
||||
|
||||
`/admin/email-templates/{key}?lang=de` (lang query, defaults to `de`).
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ ← Zurück Einladung — Deutsch [DE] [EN] │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ ┌───────────────────────────────┐ │
|
||||
│ │ Betreff │ │ VORSCHAU │ │
|
||||
│ │ ┌───────────────────────────┐ │ │ ┌─────────────────────────┐ │ │
|
||||
│ │ │ Einladung von {{.Inviter…│ │ │ │ <iframe rendered HTML> │ │ │
|
||||
│ │ └───────────────────────────┘ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ HTML-Body │ │ └─────────────────────────┘ │ │
|
||||
│ │ ┌───────────────────────────┐ │ │ │ │
|
||||
│ │ │ {{define "content"}} │ │ │ Betreff (gerendert): │ │
|
||||
│ │ │ <h1>{{.InviterName}}… │ │ │ Einladung von Maria Schmidt │ │
|
||||
│ │ │ … │ │ │ │ │
|
||||
│ │ └───────────────────────────┘ │ │ [Vorschau aktualisieren] │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Verfügbare Variablen ⓘ │ │ │ │
|
||||
│ │ ┌───────────────────────────┐ │ │ │ │
|
||||
│ │ │ .InviterName Maria S… │ │ │ │ │
|
||||
│ │ │ .InviterEmail maria@hl… │ │ │ │ │
|
||||
│ │ │ .ToEmail neu@hlc.de │ │ │ │ │
|
||||
│ │ │ .Message "Komm rein"│ │ │ │ │
|
||||
│ │ │ .RegisterURL https://… │ │ │ │ │
|
||||
│ │ │ .Firm HLC │ │ │ │ │
|
||||
│ │ └───────────────────────────┘ │ │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ [Speichern] [Auf Standard │ │ [Versionen ▾] │ │
|
||||
│ │ zurücksetzen] │ │ │ │
|
||||
│ └─────────────────────────────────┘ └───────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Three columns at desktop width, stacked on mobile (mobile-edit will be rare;
|
||||
acceptable to deprioritise).
|
||||
|
||||
#### Editing controls
|
||||
|
||||
- **Subject input** — single-line text input. Holds a `text/template` source.
|
||||
Variable hints from the variable list autocomplete on `{{` (v1 can skip the
|
||||
autocomplete and just rely on the variable list).
|
||||
- **Body textarea** — full raw HTML. Tall (~24 rows). Monospace. No syntax
|
||||
highlighting in v1 (textarea is fine — task brief).
|
||||
- **Variable list** — read-only list of available `{{.Foo}}` placeholders for
|
||||
this template, with the sample value next to each so the admin can see
|
||||
what they're substituting. List is hard-coded server-side per template
|
||||
key (§5).
|
||||
- **Lang toggle [DE] [EN]** — switching prompts "Ungespeicherte Änderungen
|
||||
verwerfen?" if the editor is dirty. Saves remain per-language.
|
||||
- **Preview pane** — iframe, sandboxed (`sandbox="allow-same-origin"` only —
|
||||
no scripts, no top-nav). Server renders with sample data and returns
|
||||
HTML; client `srcdoc=`s it. Updates on debounce (500ms after typing
|
||||
stops) **and** on explicit "Vorschau aktualisieren" click. Subject
|
||||
rendered above the iframe.
|
||||
- **Save button** — disabled until dirty. POSTs subject + body to
|
||||
`PUT /api/admin/email-templates/{key}/{lang}`. Server validates parse
|
||||
and a render against sample data before accepting; bad templates return
|
||||
422 with the parse error.
|
||||
- **Reset button** — confirm modal, then `POST /api/admin/email-templates/{key}/{lang}/reset` deletes the active row (versions stay). Editor reloads with embedded fallback content.
|
||||
- **Versionen dropdown** — opens a side panel listing the most recent 20
|
||||
versions for (key, lang). Each row: timestamp, who saved, optional note,
|
||||
"Vorschau" + "Wiederherstellen" buttons. Restoring is a save with note
|
||||
`restore from <version_id>`.
|
||||
|
||||
#### State machine on the client
|
||||
|
||||
```
|
||||
loading -> ready (active row + sample variables fetched)
|
||||
ready -> dirty (any input change)
|
||||
dirty -> previewing (debounce / button click → POST preview)
|
||||
previewing -> ready (success — but stays dirty until save)
|
||||
dirty -> saving (Save click → PUT)
|
||||
saving -> ready (success: clear dirty, reload active row)
|
||||
saving -> save_error (4xx → show parse error inline above subject input)
|
||||
ready -> resetting (Reset confirm → POST reset)
|
||||
resetting -> ready (success: reload, clear dirty)
|
||||
```
|
||||
|
||||
No autosave. Patent lawyers will edit, preview, edit, preview, then commit
|
||||
intentionally — autosave would clutter the version log with intermediate junk.
|
||||
|
||||
---
|
||||
|
||||
## 3. Preview surface design
|
||||
|
||||
### Sample data per template
|
||||
|
||||
Hard-coded server-side in `internal/services/email_template_samples.go`. One
|
||||
function per template key, returning a `map[string]any` plus a "sample
|
||||
subject context" for `text/template` rendering. Not user-editable in v1
|
||||
(deferred — task brief out-of-scope is silent on this, but customising
|
||||
sample data is a lot of UI for marginal value).
|
||||
|
||||
**`invitation`** sample:
|
||||
```go
|
||||
{
|
||||
"InviterName": "Maria Schmidt",
|
||||
"InviterEmail": "maria.schmidt@hlc.com",
|
||||
"ToEmail": "neu.kollege@hlc.de",
|
||||
"Message": "Hallo Kolleg:in, ich glaube Paliad würde dir gefallen — schau es dir an.",
|
||||
"RegisterURL": "https://paliad.de/login",
|
||||
"Firm": "HLC",
|
||||
}
|
||||
```
|
||||
|
||||
**`deadline_digest`** sample (morning slot, 1 overdue + 2 today + 1 weekly):
|
||||
```go
|
||||
{
|
||||
"Slot": "morning",
|
||||
"IsEvening": false,
|
||||
"Overdue": []map[string]any{{
|
||||
"DueDate": "2026-04-27", "Title": "Beschwerde gegen EP-Anmeldung",
|
||||
"ProjectReference": "HL-2024-0083", "ProjectTitle": "Acme vs Beta GmbH",
|
||||
"OwnerName": "Maria Schmidt", "IsOtherOwner": true,
|
||||
"URL": "https://paliad.de/deadlines/sample-1",
|
||||
}},
|
||||
"DueToday": []map[string]any{
|
||||
{ "DueDate": "2026-04-29", "Title": "Klageerwiderung einreichen", ... },
|
||||
{ "DueDate": "2026-04-29", "Title": "Vollmacht prüfen", ... },
|
||||
},
|
||||
"DueWarning": []map[string]any{
|
||||
{ "DueDate": "2026-05-06", "Title": "Stellungnahme vorbereiten", ... },
|
||||
},
|
||||
"OverdueCount": 1, "DueTodayCount": 2, "DueWarningCount": 1,
|
||||
"DeadlinesURL": "https://paliad.de/deadlines",
|
||||
"Firm": "HLC",
|
||||
}
|
||||
```
|
||||
|
||||
A `?slot=evening` toggle on the preview endpoint flips `IsEvening: true` so
|
||||
the admin can see how the same body renders for the evening DRINGEND slot.
|
||||
|
||||
**`base`** sample — minimal: `Subject: "Beispielbetreff", Lang: "de", Firm: "HLC"`, plus a placeholder content block (`<p>Inhalt der spezifischen Mail …</p>`).
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST /api/admin/email-templates/{key}/{lang}/preview
|
||||
Body: { subject, body, slot? }
|
||||
```
|
||||
|
||||
Server:
|
||||
1. Looks up `samples[key]` (404 if unknown key).
|
||||
2. Validates `body` parses as an `html/template` (returns 422 + parse error on failure).
|
||||
3. Validates `subject` parses as a `text/template`.
|
||||
4. Renders body inside the active `base` (DB row or embedded fallback for the same lang).
|
||||
5. Renders subject against the same data map.
|
||||
6. Returns `{ subject_rendered, html_rendered }`. Client `srcdoc=`s the HTML.
|
||||
|
||||
Latency budget: < 100ms for sample rendering. No external I/O — all
|
||||
in-process `html/template` execution.
|
||||
|
||||
### Why iframe (not innerHTML)
|
||||
|
||||
Email HTML uses inline styles aggressively that would otherwise leak into
|
||||
the editor's chrome (table-resets, `body` background colours, custom font
|
||||
stacks). Iframe gives a clean rendering boundary that matches what an email
|
||||
client would see. `sandbox` strips JS so a hostile template (impossible in
|
||||
v1 — only admins write — but defense-in-depth) can't escape.
|
||||
|
||||
---
|
||||
|
||||
## 4. Permission model
|
||||
|
||||
Identical to `/admin/team` (the existing precedent):
|
||||
|
||||
- **Page route**: `protected.HandleFunc("GET /admin/email-templates", adminGate(users, gateOnboarded(handleAdminEmailTemplatesPage)))`
|
||||
- **Editor route**: `protected.HandleFunc("GET /admin/email-templates/{key}", adminGate(users, gateOnboarded(handleAdminEmailTemplatesEdit)))`
|
||||
- **API routes**: all under `/api/admin/email-templates/...`, gated by `adminGate(users, ...)`.
|
||||
|
||||
`adminGate` is the existing `auth.RequireAdminFunc(users, h)` from
|
||||
`internal/auth/require_admin.go`. It checks `paliad.users.global_role =
|
||||
'global_admin'` (post-migration 023). Non-admins on the API path get 403
|
||||
JSON; non-admins on the page paths get 302 to `/dashboard?forbidden=admin`.
|
||||
Unauth gets 302 to `/login` from the outer Middleware before the admin gate
|
||||
runs. No new auth machinery.
|
||||
|
||||
The `updated_by` / `saved_by` audit columns capture the acting admin's
|
||||
`auth.users.id` so future "who broke the invitation template" questions
|
||||
have an answer in the version log.
|
||||
|
||||
---
|
||||
|
||||
## 5. Variable docs per template
|
||||
|
||||
Single source of truth: `internal/services/email_template_variables.go`,
|
||||
shipped alongside the sample-data file. Each template gets a typed list:
|
||||
|
||||
```go
|
||||
type Variable struct {
|
||||
Name string // ".InviterName"
|
||||
Type string // "string" | "date" | "url" | "[]Row"
|
||||
Description string // "Anzeigename der einladenden Person"
|
||||
Sample string // "Maria Schmidt"
|
||||
}
|
||||
|
||||
var Variables = map[string][]Variable{
|
||||
"invitation": { … },
|
||||
"deadline_digest": { … },
|
||||
"base": { … },
|
||||
}
|
||||
```
|
||||
|
||||
Served from `GET /api/admin/email-templates/{key}/variables` so the editor
|
||||
sidebar can render the list with samples without duplicating the schema in
|
||||
TypeScript.
|
||||
|
||||
### Per-template variable contracts
|
||||
|
||||
**`invitation`** (lang ∈ {de, en}):
|
||||
|
||||
| Variable | Type | Sample | Description |
|
||||
|---|---|---|---|
|
||||
| `.Lang` | `string` | `"de"` | Sprache der gerenderten Mail. Nicht direkt verwenden — wird im Body nicht mehr per `{{if}}` benötigt, da DE/EN getrennte Templates haben. |
|
||||
| `.Firm` | `string` | `"HLC"` | Firmenname (aus `FIRM_NAME`). |
|
||||
| `.InviterName` | `string` | `"Maria Schmidt"` | Anzeigename der einladenden Person. |
|
||||
| `.InviterEmail` | `string` | `"maria.schmidt@hlc.com"` | E-Mail der einladenden Person. |
|
||||
| `.ToEmail` | `string` | `"neu.kollege@hlc.de"` | Empfänger:in der Einladung. |
|
||||
| `.Message` | `string` | `"Hallo Kolleg:in …"` | Optionale persönliche Nachricht; leer wenn nichts angegeben. `{{if .Message}}…{{end}}` umschliesst den Block. |
|
||||
| `.RegisterURL` | `string` | `"https://paliad.de/login"` | Zielseite für den Anmelde-Button. |
|
||||
| `.Subject` | `string` | `"Einladung von Maria Schmidt zu Paliad"` | Vom System aus dem `subject`-Feld gerendert; der Body verwendet ihn typischerweise nicht, das `<title>` der `base` schon. |
|
||||
|
||||
**`deadline_digest`** (lang ∈ {de, en}):
|
||||
|
||||
| Variable | Type | Sample | Description |
|
||||
|---|---|---|---|
|
||||
| `.Lang`, `.Firm` | `string` | wie oben | wie oben |
|
||||
| `.Slot` | `string` | `"morning"` / `"evening"` | Trigger-Slot. Im Body meist über `.IsEvening` benutzt. |
|
||||
| `.IsEvening` | `bool` | `false` | True wenn Abend-Slot — steuert die DRINGEND-Headline. |
|
||||
| `.Overdue` | `[]Row` | siehe unten | Überfällige Fristen. |
|
||||
| `.OverdueCount` | `int` | `1` | Länge von `.Overdue`, vorgerechnet für die Überschrift. |
|
||||
| `.DueToday` | `[]Row` | … | Heute fällig. |
|
||||
| `.DueTodayCount` | `int` | `2` | … |
|
||||
| `.DueWarning` | `[]Row` | … | In ≤ 1 Woche fällig. |
|
||||
| `.DueWarningCount` | `int` | `1` | … |
|
||||
| `.DeadlinesURL` | `string` | `"https://paliad.de/deadlines"` | Ziel des „Alle Fristen" Buttons. |
|
||||
|
||||
`Row` (innerhalb `range`):
|
||||
|
||||
| Feld | Typ | Sample | Beschreibung |
|
||||
|---|---|---|---|
|
||||
| `.DueDate` | `string` | `"2026-04-29"` | Fälligkeitsdatum, ISO. |
|
||||
| `.Title` | `string` | `"Klageerwiderung einreichen"` | Frist-Titel. |
|
||||
| `.ProjectReference` | `string` | `"HL-2024-0083"` | Akten-/Projekt-Aktenzeichen. |
|
||||
| `.ProjectTitle` | `string` | `"Acme vs Beta GmbH"` | Projekt-Titel; kann leer sein. |
|
||||
| `.OwnerName` | `string` | `"Maria Schmidt"` | Eigentümer:in der Frist. |
|
||||
| `.IsOtherOwner` | `bool` | `true` | True wenn die Frist *nicht* dem:der Empfänger:in gehört (Anzeige der Eigentümer-Zeile). |
|
||||
| `.URL` | `string` | `"https://paliad.de/deadlines/<uuid>"` | Direktlink zur Frist. |
|
||||
|
||||
**`base`** (lang ∈ {de, en}):
|
||||
|
||||
| Variable | Type | Sample | Description |
|
||||
|---|---|---|---|
|
||||
| `.Lang` | `string` | `"de"` | `<html lang="…">` Attribut. |
|
||||
| `.Subject` | `string` | `"Einladung von Maria Schmidt"` | Wird ins `<title>` der Mail eingesetzt. |
|
||||
| `.Firm` | `string` | `"HLC"` | Footer-Branding. |
|
||||
|
||||
`base` rendert via `{{block "content" .}}{{end}}` den Body des spezifischen
|
||||
Templates. Diese Block-Direktive **darf nicht entfernt werden** — der
|
||||
Editor muss sie validieren (siehe §6 Test plan).
|
||||
|
||||
---
|
||||
|
||||
## 6. Test plan
|
||||
|
||||
### Unit tests — service
|
||||
|
||||
`internal/services/email_template_service_test.go` (new):
|
||||
|
||||
- `GetActive` returns embedded fallback when no DB row.
|
||||
- `GetActive` returns DB row when present.
|
||||
- `Save` parses subject + body with `text/template` / `html/template`.
|
||||
- `Save` rejects bad template syntax (`{{ .Foo` unterminated → 422 path).
|
||||
- `Save` rejects body that doesn't redefine `{{define "content"}}` for non-base keys (otherwise the `base` block wouldn't fill).
|
||||
- `Save` rejects `base` body that removes the `{{block "content" .}}{{end}}` directive (would silently produce an empty inner body).
|
||||
- `Save` writes one row to `email_template_versions` per call.
|
||||
- `Save` triggers retention GC: after 21 saves to the same (key, lang), only 20 rows remain.
|
||||
- `Reset` deletes the active row but leaves versions intact.
|
||||
- `RestoreVersion` copies a historical row into active and adds a new version with note `restore from <id>`.
|
||||
|
||||
### Unit tests — handlers
|
||||
|
||||
`internal/handlers/email_templates_test.go` (new):
|
||||
|
||||
- `GET /admin/email-templates` and `/admin/email-templates/{key}` both return 302 to `/login` for unauth, 403 for non-admin, 200 for admin.
|
||||
- `GET /api/admin/email-templates` returns the canonical key list with active/lang info.
|
||||
- `GET /api/admin/email-templates/{key}/variables` returns the variable contract.
|
||||
- `POST /api/admin/email-templates/{key}/{lang}/preview`:
|
||||
- 200 with rendered subject + body for valid input.
|
||||
- 422 with parse error for bad subject template.
|
||||
- 422 with parse error for bad body template.
|
||||
- 404 for unknown key.
|
||||
- `PUT /api/admin/email-templates/{key}/{lang}` saves and returns the new version row id; rejects bad templates with 422.
|
||||
- `POST /api/admin/email-templates/{key}/{lang}/reset` deletes active row.
|
||||
- `GET /api/admin/email-templates/{key}/{lang}/versions` returns the version log, newest first.
|
||||
- `POST /api/admin/email-templates/{key}/{lang}/restore/{version_id}` restores.
|
||||
|
||||
### Integration test
|
||||
|
||||
`internal/services/mail_service_db_test.go` (new):
|
||||
|
||||
- With a DB-backed `EmailTemplateService`: insert a custom invitation row → `MailService.RenderTemplate(invitation, de)` returns the custom row, not the embedded fallback.
|
||||
- Delete the row → next render falls back to embedded.
|
||||
- Insert a syntactically broken row directly via SQL (bypassing the service validation that would normally reject it) → `RenderTemplate` falls back to embedded and logs an error. **This is the core safety property**: a corrupt DB row never breaks email delivery.
|
||||
|
||||
### Manual smoke test (Playwright, optional v1)
|
||||
|
||||
1. Login as `tester@hlc.de` / `xdMmC7iCeDSTFmPXAlAyY0` (admin).
|
||||
2. Visit `/admin` → see "Email-Templates" card now linked, not "Kommt bald".
|
||||
3. Click → land on `/admin/email-templates` with three template cards.
|
||||
4. Click "Einladung" → editor with German content.
|
||||
5. Edit subject to `Test {{.InviterName}}` → preview pane shows `Test Maria Schmidt`.
|
||||
6. Save → success toast, "Zuletzt geändert" date updates.
|
||||
7. Send a real test invitation via the sidebar invite modal to `m@flexsiebels.de` → verify the new subject lands.
|
||||
8. Open Versionen → restore previous → invitation reverts.
|
||||
9. Reset to default → DB row deleted, fallback restored.
|
||||
10. Logout, login as a non-admin → `/admin/email-templates` 302s to `/dashboard?forbidden=admin`.
|
||||
|
||||
### Smoke gate before merge
|
||||
|
||||
`go test ./internal/services/... ./internal/handlers/...` clean,
|
||||
`go build ./...` clean, `cd frontend && bun run build` clean.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation order
|
||||
|
||||
Six logical chunks. Coder shift implementer's call whether to land them as
|
||||
one PR or split.
|
||||
|
||||
1. **Migration 026** + embedded file split (DE/EN). Server still uses
|
||||
embedded files; nothing else changes. Verifies the split renders
|
||||
identically to the bilingual originals (golden tests in
|
||||
`mail_service_test.go` already exercise both languages — keep them).
|
||||
2. **EmailTemplateService** — `GetActive`, `Save`, `Reset`, `Versions`,
|
||||
`Restore`, retention GC, sample data + variable docs.
|
||||
3. **MailService refactor** — replace embedded-only render with service
|
||||
lookup; subject moves from Go-built strings to template render. Update
|
||||
the two callers (`invite_service.go`, `reminder_service.go`) to pass
|
||||
subject *data* instead of the formatted subject string. Verify
|
||||
`buildDigestSubject` is fully removed.
|
||||
4. **Handlers + API routes** — both page handlers (`/admin/email-templates`,
|
||||
`/admin/email-templates/{key}`) plus the eight API endpoints.
|
||||
5. **Frontend** — `frontend/src/admin-email-templates.tsx` +
|
||||
`frontend/src/admin-email-templates-edit.tsx` + their `client/*.ts`
|
||||
counterparts; `admin.tsx` flips the placeholder card; `i18n.ts` gains
|
||||
the new strings.
|
||||
6. **Smoke** — manual Playwright run with `tester@hlc.de`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions for m — RESOLVED 2026-04-29
|
||||
|
||||
1. **DB-backed editing confirmed?** → **YES, DB.**
|
||||
2. **Subjects move into templates (admin-editable)?** → **YES, customisable.**
|
||||
Mitigation kept: seeded `deadline_digest` subject ships with a
|
||||
`{{/* keep the SYSTEMAUSFALL phrasing — see docs/design-reminder-redesign-2026-04-28.md */}}`
|
||||
comment so the next admin who edits the SLO-critical framing sees the
|
||||
rationale.
|
||||
3. **`base.html` editable, or locked?** → **A. Editable like the others.**
|
||||
Version log + reset-to-default + render-time fallback on parse error
|
||||
are the safety net.
|
||||
4. **Versioning depth** → **20 per (key, lang). Confirmed.**
|
||||
5. **`note` field on version rows + optional "Notiz" input on save?** →
|
||||
**YES, keep.**
|
||||
|
||||
All decisions baked into §1–§7. No remaining blockers from the inventor
|
||||
side; coder shift can start once head greenlights.
|
||||
|
||||
---
|
||||
|
||||
## 9. Out of scope (deferred, per task brief)
|
||||
|
||||
- New template types beyond the existing three (password reset, account
|
||||
locked, etc.) — defer until those flows exist.
|
||||
- Per-firm overrides — `FIRM_NAME` already templates "HLC" → "anything"
|
||||
but per-firm full-template branching is not needed today (paliad serves
|
||||
one firm per deployment).
|
||||
- A/B testing — not justified for transactional mail at this volume.
|
||||
- WYSIWYG editor — explicit out-of-scope. Plain textarea is the v1.
|
||||
- Editable sample data — admins use a fixed sample set in v1.
|
||||
- Side-by-side DE / EN editing — language toggle in v1, not a split view.
|
||||
- Plain-text body editing — text fallback is auto-derived by `htmlToText`;
|
||||
exposing it as an editable field is a future-feature.
|
||||
|
||||
---
|
||||
|
||||
## 10. Coder fit
|
||||
|
||||
The implementation is mostly straight-line: migration, service, handlers,
|
||||
frontend. The interesting risks are (a) the embedded-file DE/EN split must
|
||||
golden-match the existing bilingual render byte-for-byte where possible
|
||||
(or with explainable diffs), and (b) the MailService fallback path must be
|
||||
provably safe — bad DB row → embedded render, never a 500 inside the
|
||||
reminder ticker. Both are testable.
|
||||
|
||||
Suggested coder for the implementation shift: same role/skill that landed
|
||||
t-paliad-021 (knuth) or whoever currently has the warmest cache on
|
||||
`mail_service.go` and `reminder_service.go`. I'm fine to implement this
|
||||
myself if head wants — but no strong preference; head decides.
|
||||
|
||||
---
|
||||
|
||||
## 11. Files (for the implementing coder)
|
||||
|
||||
### New
|
||||
|
||||
- `internal/db/migrations/026_email_templates.up.sql`
|
||||
- `internal/db/migrations/026_email_templates.down.sql`
|
||||
- `internal/services/email_template_service.go`
|
||||
- `internal/services/email_template_service_test.go`
|
||||
- `internal/services/email_template_samples.go`
|
||||
- `internal/services/email_template_variables.go`
|
||||
- `internal/services/mail_service_db_test.go`
|
||||
- `internal/handlers/email_templates.go`
|
||||
- `internal/handlers/email_templates_test.go`
|
||||
- `internal/templates/email/invitation.de.html` + `invitation.en.html`
|
||||
- `internal/templates/email/deadline_digest.de.html` + `.en.html`
|
||||
- `internal/templates/email/base.de.html` + `base.en.html`
|
||||
- `frontend/src/admin-email-templates.tsx`
|
||||
- `frontend/src/admin-email-templates-edit.tsx`
|
||||
- `frontend/src/client/admin-email-templates.ts`
|
||||
- `frontend/src/client/admin-email-templates-edit.ts`
|
||||
|
||||
### Edit
|
||||
|
||||
- `internal/templates/email.go` — embed pattern stays; embed glob already covers `*.html` so the per-lang split files come for free.
|
||||
- `internal/services/mail_service.go` — `RenderTemplate` consults `EmailTemplateService` first, falls back to embedded; `SendTemplate` accepts a subject template + data, stops requiring a pre-formatted subject string.
|
||||
- `internal/services/invite_service.go` — drop `inviteSubject`; pass subject via the data map.
|
||||
- `internal/services/reminder_service.go` — drop `buildDigestSubject`; pass slot/counts via the data map.
|
||||
- `internal/services/mail_service_test.go` — adjust for new subject path.
|
||||
- `internal/handlers/handlers.go` — register the new routes alongside the existing `/admin/team` block.
|
||||
- `cmd/server/main.go` — wire `EmailTemplateService`, pass it to `NewMailService` (or set on `MailService` post-construct).
|
||||
- `frontend/src/admin.tsx` — flip the "Email-Templates" placeholder card from `admin-card-soon` to a real `card card-link` pointing at `/admin/email-templates`. Re-sequence `PLANNED` so it drops to three entries.
|
||||
- `frontend/src/client/i18n.ts` — drop "kommt bald" framing from email_templates; add new strings: `admin.email_templates.title`, `.heading`, `.subtitle`, `.list.last_modified`, `.list.default`, `.editor.subject`, `.editor.body`, `.editor.variables`, `.editor.preview`, `.editor.save`, `.editor.reset`, `.editor.reset_confirm`, `.editor.versions`, `.editor.restore`, `.editor.restore_confirm`, `.editor.dirty_warn`, `.editor.parse_error`, `.editor.note_optional`, all DE + EN.
|
||||
- `frontend/build.ts` — add `renderAdminEmailTemplates` + `renderAdminEmailTemplatesEdit` entry points and bundle the two client TS files.
|
||||
461
docs/design-event-types-2026-04-30.md
Normal file
@@ -0,0 +1,461 @@
|
||||
# Event Types for deadlines + submissions — design
|
||||
|
||||
**Task:** t-paliad-088
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-04-30
|
||||
**Status:** RESOLVED 2026-04-30 12:23 — m greenlit all 7 questions. See §12 for the resolution table. Awaiting head's coder assignment.
|
||||
|
||||
m's directive (2026-04-30 11:56):
|
||||
|
||||
> "let's add an inventor for 'Event Types', in particular deadlines and submissions — I want to be able to select from existing Event Types when creating a deadline but also add a new custom one if it does not exist. This also needs to be filterable in the overview"
|
||||
|
||||
## TL;DR — resolved decisions
|
||||
|
||||
1. **Concept:** "Event Type" is the **categorization tag** on a deadline. **Event Types lead**, with an optional bridge FK to `paliad.trigger_events` for the seeded UPC rows (m's call: "event_types should lead and later we can connect things to it"). Submissions are explicitly the primary use case — m's words: *"those are the event types I mean, mainly"*. trigger_events stays as separate calc-engine state (UPC-only verbatim youpc imports).
|
||||
2. **Schema:** new table `paliad.event_types` + nullable FK `paliad.deadlines.event_type_id`. The bridge `event_types.trigger_event_id bigint NULL REFERENCES paliad.trigger_events(id)` populates only for seeded UPC rows; user customs and non-UPC entries leave it NULL. Broader scope from day one (UPC + EPO + DPMA + DE-national + contract).
|
||||
3. **Submissions live as Event Types.** No separate `paliad.submissions` table. `event_types.category='submission'` carries the discrimination. A future Schriftsatz-Verwaltung surface pivots on that filter.
|
||||
4. **Picker:** typeahead `<select>`-flavoured combobox with grouping by category, plus inline "+ Neuen Typ hinzufügen…" → small modal. Reuses the `/tools/fristenrechner` trigger-picker style.
|
||||
5. **Filter on `/deadlines` is MULTI-SELECT.** Trigger button styled like an `<select>`; click opens a listbox panel with search + "Alle" toggle + checkbox list grouped by category. Backend: `?event_type=uuid1,uuid2,…` (UNION within Event Types, AND-intersected with Status/Projekt). Special value `none` for "Ohne Typ"; combinable with selected types. **Same multi-select filter on `/agenda`**.
|
||||
6. **Permissions:** any authenticated user can create both private AND firm-wide types. Admins moderate firm-wide via archive after the fact (m's call: lighter-weight onboarding > admin gating).
|
||||
7. **Backfill:** existing 10 deadlines get `event_type_id=NULL`, render as "Ohne Typ". Migration 030 ships ~40 curated firm-wide seeds (~25 UPC submissions + ~10 UPC decisions/orders + ~10 non-UPC EPO/DPMA/DE-national/contract). Spreadsheet attached to the implementation PR.
|
||||
|
||||
## 1 · Concept clarification
|
||||
|
||||
### What lives where today
|
||||
|
||||
| Surface | Concept | Examples | Cardinality | Origin |
|
||||
|---|---|---|---|---|
|
||||
| `paliad.trigger_events` (migration 028) | "What just happened" — anchor for `event_deadlines` calc | `service_of_complaint`, `decision_handed_down`, `statement_of_defence` | 102 rows, UPC only | Imported verbatim from `youpc.data.events` for diffable re-syncs |
|
||||
| `paliad.event_deadlines` (migration 028) | "After event X, deadline Y fires" rules | RoP.029 1-mo Reply after Defence | 70 rows | Imported verbatim from `youpc.data.deadlines` |
|
||||
| `/tools/fristenrechner` trigger-picker (PR-2) | UI input over `trigger_events` | "Was kommt nach 'Statement of defence'?" | — | Public knowledge tool |
|
||||
| `paliad.deadlines` (migration 003+) | Persistent per-project scheduled deadline | Free-text title, due_date, project_id, optional `rule_id` → `deadline_rules` | 10 rows in production | User-created, sometimes Fristenrechner-seeded |
|
||||
|
||||
### What m is asking for
|
||||
|
||||
A **categorization on `paliad.deadlines`** so the user can:
|
||||
- pick from a known taxonomy when creating a deadline,
|
||||
- add a custom type if missing,
|
||||
- filter the `/deadlines` list by type.
|
||||
|
||||
That is unambiguously a **taxonomy column on `deadlines`**, not a trigger event.
|
||||
|
||||
### Are Event Types == trigger_events with a UX rename?
|
||||
|
||||
**No.** Three reasons:
|
||||
|
||||
1. **Scope.** `trigger_events` is **UPC-only** (102 rows from youpc's UPC corpus — see §verified data below). Paliad's deadlines also cover **EPO opposition/appeal**, **DPMA**, **German national court** (LG/OLG Düsseldorf, München), and **contract/IP-licensing renewal dates**. The trigger_events corpus has zero EPO-opposition events, zero DPMA events, and only a handful of cross-jurisdiction items (`Decision of the EPO` is still in the UPC unitary-effect context). Renaming it would falsely suggest paliad covers all jurisdictions.
|
||||
2. **Diffability invariant.** Memory note from t-paliad-086: *"IDs preserved verbatim from youpc data.events / data.deadlines / data.deadline_rule_codes for diffable re-syncs."* Letting users insert custom rows into `trigger_events` would either break this (id collisions) or require a separate id range — both compromise the import contract. trigger_events is canonical-imports state, not user-extensible.
|
||||
3. **Different semantics.** A trigger_event is "the event that just occurred, used as anchor". An Event Type on a deadline is "what this deadline categorically IS". For 70-ish rows they coincide (a deadline whose Type is "Reply to Defence" would naturally have `trigger_event_id` pointing to the same code). For decisions/orders they don't really coincide — `decision_handed_down` as a trigger anchors *future* deadlines, but as an Event Type it labels *the date the decision is expected*. Both views are useful. Conflating them collapses the distinction.
|
||||
|
||||
### Are Event Types == "submissions" as a sibling entity?
|
||||
|
||||
**No.** Not now.
|
||||
|
||||
A submission *is* a deadline-bearing item ("filed Statement of Defence on 2026-08-15" — that's a deadline whose category is submission). Splitting submissions into their own table either duplicates data or forces a migration of existing deadlines, both for negative gain. **The Event Type's `category` column carries the discrimination** (`submission` | `decision` | `order` | `service` | `fee` | `hearing` | `other`). A future Schriftsatz-Verwaltung surface (out of scope here) can pivot on `WHERE event_types.category='submission'`.
|
||||
|
||||
Re-evaluate this if/when m wants a true Schriftsatz-Verwaltung with submission-specific fields (file uploads, version tracking, recipient party, language) that don't fit on a generic deadline row. **That's a separate task; flagging here so we don't have to migrate twice.**
|
||||
|
||||
### Bridge to trigger_events
|
||||
|
||||
`paliad.event_types` carries an optional `trigger_event_id bigint REFERENCES paliad.trigger_events(id)`. For the seeded firm-wide types we populate it; for user customs and non-UPC types it stays NULL. This:
|
||||
|
||||
- preserves provenance for the UPC-seeded types,
|
||||
- enables future polish: "this deadline's Event Type matches a trigger event → offer to compute downstream deadlines via Fristenrechner",
|
||||
- doesn't force a circular dependency (Event Types can exist without a trigger_event).
|
||||
|
||||
## 2 · Schema decision
|
||||
|
||||
### Migration 030 — `paliad.event_types` + FK on `paliad.deadlines`
|
||||
|
||||
```sql
|
||||
-- internal/db/migrations/030_event_types.up.sql
|
||||
|
||||
CREATE TABLE paliad.event_types (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL,
|
||||
label_de text NOT NULL,
|
||||
label_en text NOT NULL,
|
||||
category text NOT NULL DEFAULT 'submission'
|
||||
CHECK (category IN ('submission','decision','order','service','fee','hearing','other')),
|
||||
jurisdiction text
|
||||
CHECK (jurisdiction IS NULL OR jurisdiction IN ('UPC','EPO','DPMA','DE','any')),
|
||||
description text,
|
||||
trigger_event_id bigint REFERENCES paliad.trigger_events(id) ON DELETE SET NULL,
|
||||
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
is_firm_wide boolean NOT NULL DEFAULT false,
|
||||
archived_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Slug uniqueness: firm-wide types share one namespace; private types are scoped per user.
|
||||
CREATE UNIQUE INDEX event_types_firm_slug_idx
|
||||
ON paliad.event_types(slug)
|
||||
WHERE is_firm_wide = true AND archived_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX event_types_private_slug_idx
|
||||
ON paliad.event_types(created_by, slug)
|
||||
WHERE is_firm_wide = false AND archived_at IS NULL;
|
||||
|
||||
CREATE INDEX event_types_category_idx ON paliad.event_types(category);
|
||||
CREATE INDEX event_types_jurisdiction_idx ON paliad.event_types(jurisdiction) WHERE jurisdiction IS NOT NULL;
|
||||
|
||||
-- FK on deadlines
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD COLUMN event_type_id uuid
|
||||
REFERENCES paliad.event_types(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX deadlines_event_type_idx
|
||||
ON paliad.deadlines(event_type_id)
|
||||
WHERE event_type_id IS NOT NULL;
|
||||
|
||||
-- updated_at trigger (mirrors existing paliad.set_updated_at pattern)
|
||||
CREATE TRIGGER event_types_set_updated_at
|
||||
BEFORE UPDATE ON paliad.event_types
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.set_updated_at();
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE paliad.event_types ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Read: firm-wide types visible to all authenticated users; private types only to author.
|
||||
CREATE POLICY event_types_select ON paliad.event_types
|
||||
FOR SELECT TO authenticated
|
||||
USING (
|
||||
archived_at IS NULL
|
||||
AND (is_firm_wide = true OR created_by = auth.uid())
|
||||
);
|
||||
|
||||
-- Insert: any authenticated user can insert any row, as long as created_by = self.
|
||||
-- Firm-wide types are open to all users; admins moderate via archive after the fact.
|
||||
CREATE POLICY event_types_insert ON paliad.event_types
|
||||
FOR INSERT TO authenticated
|
||||
WITH CHECK (created_by = auth.uid());
|
||||
|
||||
-- Update: author owns their own rows (private or firm-wide they created).
|
||||
-- global_admin can update / archive any firm-wide row regardless of authorship.
|
||||
CREATE POLICY event_types_update_owner ON paliad.event_types
|
||||
FOR UPDATE TO authenticated
|
||||
USING (created_by = auth.uid())
|
||||
WITH CHECK (created_by = auth.uid());
|
||||
|
||||
CREATE POLICY event_types_update_admin ON paliad.event_types
|
||||
FOR UPDATE TO authenticated
|
||||
USING (
|
||||
is_firm_wide = true
|
||||
AND EXISTS (SELECT 1 FROM paliad.users u WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
);
|
||||
|
||||
-- Delete: never. Use archived_at.
|
||||
```
|
||||
|
||||
### Why a separate table (not a `deadlines.event_type` text column)
|
||||
|
||||
A free-text column would let users type the same concept three ways ("Reply", "reply", "Erwiderung") and **the filter would silently miss matches**. The whole point of typing is that the same name resolves to one row. A table also lets us:
|
||||
|
||||
- ship 30+ curated firm-wide labels with bilingual text,
|
||||
- carry `category` + `jurisdiction` metadata for grouped picker,
|
||||
- cross-link to `trigger_events` for the Fristenrechner-handoff polish,
|
||||
- support archiving (firm renames "Beschwerdebegründung" → leave old rows untouched, archive the type).
|
||||
|
||||
### Why optional FK and not NOT NULL on deadlines
|
||||
|
||||
Existing 10 deadlines have no event_type. Backfilling automatically would either guess or be wrong. NULL = "Ohne Typ" works fine — the filter row has an "Alle" default and an "Ohne Typ" option. Users tag retrospectively when they edit a deadline.
|
||||
|
||||
### Slug strategy
|
||||
|
||||
Slug is auto-derived from `label_de` (kebab-case) on insert if not supplied; user-private slugs are scoped per user so two users can each have their own "klage" without colliding. Firm-wide slugs share one namespace — global_admins coordinate.
|
||||
|
||||
## 3 · Picker UX
|
||||
|
||||
### Where the picker appears
|
||||
|
||||
- `/deadlines/new` — new optional field below "Titel", above the date+rule row.
|
||||
- `/deadlines/{id}` — edit modal, same field shape.
|
||||
- *(Future polish, NOT in scope)* `/tools/fristenrechner` "Send to deadline" button — pre-fills `event_type_id` from the originating trigger_event.
|
||||
|
||||
### Visual shape (matches existing `.akten-form` field-row pattern)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Typ (optional) │
|
||||
│ ┌─────────────────────────────────────────────────┬───┐ │
|
||||
│ │ Bitte wählen oder tippen… │ ▼ │ │
|
||||
│ └─────────────────────────────────────────────────┴───┘ │
|
||||
│ │
|
||||
│ When opened (with no input): │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Eigene │ │
|
||||
│ │ ⭐ Mein Mahnschriftsatz-Template │ │
|
||||
│ │ Eingaben (UPC) │ │
|
||||
│ │ • Statement of Defence │ │
|
||||
│ │ • Reply to the Defence │ │
|
||||
│ │ • Counterclaim for Revocation │ │
|
||||
│ │ • Statement of Appeal │ │
|
||||
│ │ ... (~30) │ │
|
||||
│ │ Entscheidungen │ │
|
||||
│ │ • Decision on the merits │ │
|
||||
│ │ • Decision on costs │ │
|
||||
│ │ Anordnungen │ │
|
||||
│ │ • Case management order (Service) │ │
|
||||
│ │ Gebühren │ │
|
||||
│ │ • Annuity payment (DPMA) │ │
|
||||
│ │ • EP renewal fee │ │
|
||||
│ │ ─────────────────────────────────────────────────── │ │
|
||||
│ │ + Neuen Typ hinzufügen… │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The trigger-event picker on `/tools/fristenrechner` (PR-2) already implements typeahead-over-list. Reuse its filter logic and visual style; differences:
|
||||
- **Grouped by category** with sticky group headers (the trigger picker is flat).
|
||||
- **"Eigene" group** at top (private types of the current user) with a star icon.
|
||||
- **"+ Neuen Typ hinzufügen…" footer row** triggers the add modal.
|
||||
|
||||
### Custom-add modal
|
||||
|
||||
Lightweight `<dialog>` (matches the existing `.modal` pattern from t-paliad-049):
|
||||
|
||||
```
|
||||
┌─ Neuen Event-Typ anlegen ─────────────────────────┐
|
||||
│ │
|
||||
│ Bezeichnung (DE) * │
|
||||
│ [_______________________________________] │
|
||||
│ │
|
||||
│ Bezeichnung (EN, optional) │
|
||||
│ [_______________________________________] │
|
||||
│ │
|
||||
│ Kategorie * │
|
||||
│ [Eingabe ▼] (Eingabe / Entscheidung / │
|
||||
│ Anordnung / Zustellung / │
|
||||
│ Gebühr / Sitzung / Sonstiges) │
|
||||
│ │
|
||||
│ Jurisdiktion (optional) │
|
||||
│ [— ▼] (UPC / EPA / DPMA / DE / —) │
|
||||
│ │
|
||||
│ ☐ Firmenweit verfügbar machen * │
|
||||
│ (* nur für Admins sichtbar) │
|
||||
│ │
|
||||
│ [ Abbrechen ] [ Anlegen ] │
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Behaviour:
|
||||
- If user typed text in the picker before clicking "+ Neuen Typ", that text pre-fills "Bezeichnung (DE)".
|
||||
- "Firmenweit" checkbox is only rendered for users where `currentUser.global_role === 'global_admin'`. Non-admin private-only.
|
||||
- On submit: `POST /api/event-types`, on 201 the picker re-fetches its option list and selects the new id.
|
||||
- Error 409 (slug collision): show inline error "Ein Typ mit diesem Namen existiert bereits".
|
||||
|
||||
### Why a modal vs. inline expansion
|
||||
|
||||
Inline expansion would push the deadline-create form down by 4 fields and feel cramped. A modal is the existing paliad pattern (project edit, invitation flow). Smaller scope: 1 form, 1 button, escape-to-close.
|
||||
|
||||
### Why not free-text fallback that auto-creates
|
||||
|
||||
Two reasons:
|
||||
1. **Typo-driven duplication.** A user types "Klage" → a row is created → next time they type "Klagen" → another row. Within a week the firm has 12 rows for one concept. The deliberate "+ Neuen Typ" affordance forces the user to confirm "yes, this is new" and to set category/jurisdiction.
|
||||
2. **Permission asymmetry.** Auto-create defaults to private; users who actually want firm-wide need an explicit toggle. The modal makes that visible.
|
||||
|
||||
## 4 · Filter UX on `/deadlines` (and `/agenda`)
|
||||
|
||||
### `/deadlines` (primary scope) — multi-select
|
||||
|
||||
m's call (Q4): match the existing `<select>`-row pattern visually, but make Event Types **multi-select**. Status/Projekt stay single-select. New `EventTypeMultiSelect` component:
|
||||
|
||||
```
|
||||
.akten-filter-row
|
||||
├─ <label>Typ</label>
|
||||
└─ <button class="akten-select akten-multi-trigger" aria-haspopup="listbox">
|
||||
<span class="akten-multi-label">Alle</span> ← / "3 Typen" / single label
|
||||
<span class="akten-multi-chevron">▾</span>
|
||||
</button>
|
||||
|
||||
opens (popover, anchored to the trigger, click-outside dismisses):
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 🔍 [Suche…] │
|
||||
│ ☐ Alle / ☐ — Ohne Typ — │
|
||||
│ ─────────────────────────────────────────│
|
||||
│ Eingaben (UPC) │
|
||||
│ ☐ Statement of Defence │
|
||||
│ ☐ Reply to the Defence │
|
||||
│ ☐ Counterclaim for Revocation … │
|
||||
│ Entscheidungen │
|
||||
│ ☐ Decision on the merits … │
|
||||
│ Anordnungen │
|
||||
│ Gebühren │
|
||||
│ Eigene │
|
||||
│ ☐ Mein Mahnschriftsatz-Template │
|
||||
│ ─────────────────────────────────────────│
|
||||
│ [ Zurücksetzen ] [ Anwenden ] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Behaviour:**
|
||||
- Default state: "Alle" toggle on, list disabled. Toggling any list item turns "Alle" off and ticks the row.
|
||||
- "— Ohne Typ —" is a special row separate from "Alle"; ticking it adds `event_type_id IS NULL` rows alongside whatever specific types are ticked.
|
||||
- Trigger label shows "Alle", "Ohne Typ", "Statement of Defence", or "3 Typen" depending on count.
|
||||
- Search box filters the list across `label_de` + `label_en`.
|
||||
- "Anwenden" (or click-outside) commits; "Zurücksetzen" returns to "Alle".
|
||||
- Mobile: popover becomes a full-width sheet from the bottom (reuses the existing `.modal-mobile-bottom` class if it exists, else a one-CSS-rule bottom sheet).
|
||||
|
||||
**Backend / query param:**
|
||||
- `?event_type=<uuid>,<uuid>,none` — comma-separated UUIDs and/or the literal `none` keyword. Empty / absent = "Alle".
|
||||
- Service layer parses to `(event_type_id IN (uuid1,uuid2,…) OR event_type_id IS NULL [if 'none' present])`, AND-intersected with the existing status / project predicates.
|
||||
- State persists in URL — bookmark + back-button preserve the filter.
|
||||
|
||||
### Add a `Typ` column to the deadlines table?
|
||||
|
||||
Yes — small column showing `label_de` (or `label_en` per current language). Column hides via `.akten-table--hide-status`-style toggle (t-paliad-073 pattern: hide when every visible row shares the same value).
|
||||
|
||||
### `/agenda` — same multi-select
|
||||
|
||||
m's call (Q5): ship `/agenda` filter in the same task. Same `EventTypeMultiSelect` component, mounted as a second filter row below the existing Type chip row (deadlines/appointments). `AgendaService.List` accepts the same `?event_type=` param; appointments are unaffected (they have no event_type) — they're returned regardless when "appointments" is in the type-of-item filter.
|
||||
|
||||
## 5 · Permission model
|
||||
|
||||
| Action | Permission |
|
||||
|---|---|
|
||||
| Read firm-wide types | any authenticated user |
|
||||
| Read own private types | only the author |
|
||||
| Create private type (`is_firm_wide=false`) | any authenticated user (`created_by=self`) |
|
||||
| Create firm-wide type (`is_firm_wide=true`) | **any authenticated user** (m's Q6: lighter-weight onboarding, admin moderates after the fact) |
|
||||
| Edit own type (private or firm-wide) | the author |
|
||||
| Edit / archive any firm-wide type | `global_role='global_admin'` (moderation lever) |
|
||||
| Archive a type | same as edit |
|
||||
| Delete a type | never (set `archived_at` instead) |
|
||||
|
||||
Enforced at two layers:
|
||||
|
||||
- **RLS policies** (§2 above) — the safety net.
|
||||
- **Service layer** — `EventTypeService.Create` validates the slug shape and uniqueness before insert, rejects `created_by` mismatches with 400.
|
||||
|
||||
### Why this looser firm-wide-create policy
|
||||
|
||||
m's call. Two consequences worth naming:
|
||||
|
||||
1. **Drift risk:** users will create overlapping firm-wide types ("Klage", "Klageeinreichung", "Klage erheben"). Mitigation: search-prevent-duplicate in the add modal — if a fuzzy match (`%label_de%`) already exists firm-wide, surface "Existiert vermutlich schon: …" with a "Trotzdem anlegen" override. Reduces but doesn't eliminate.
|
||||
2. **Moderation backlog:** admins need a lightweight surface to scan + archive. Not in scope for this task; flag as follow-up *t-paliad-089: admin Event-Type moderation panel*.
|
||||
|
||||
### Why allow user-private types at all
|
||||
|
||||
Each lawyer has a couple of personal categories that nobody else needs ("Reminder for myself when X", "My standing template for Y"). Private types stay out of others' pickers; the user's own picker shows them under "Eigene".
|
||||
|
||||
## 6 · Backfill strategy
|
||||
|
||||
### Existing data
|
||||
|
||||
`SELECT count(*) FROM paliad.deadlines` → 10 rows (production, 2026-04-30).
|
||||
|
||||
All get `event_type_id=NULL` after migration 030. Render in the UI as "Ohne Typ" (separate option in the filter, displayed as a faint chip next to the title in the table).
|
||||
|
||||
### Seeded firm-wide types
|
||||
|
||||
Migration 030 seeds ~40 firm-wide types in a separate `030b_seed_event_types.up.sql` (or appended to 030 — single-migration ok if it stays readable). Three pools:
|
||||
|
||||
1. **UPC submissions (~25 rows)** — picked from `paliad.trigger_events` codes for the most common procedural submissions (Statement of Claim/Defence, Counterclaim, Reply, Rejoinder, Statement of Appeal, Statement of grounds of appeal, Cross-appeal, Application to amend the patent, Defence to revocation, Application for damages, Application for cost decision, Protective Letter, Preliminary Objection). Each row gets `trigger_event_id` populated.
|
||||
2. **UPC decisions/orders (~10 rows)** — Decision on the merits, Decision on costs, Case management order, Order of the judge-rapporteur, Final decision, Summons to oral hearing, Service of complaint. `trigger_event_id` populated.
|
||||
3. **Non-UPC (~10 rows, hand-written, no `trigger_event_id`)** — EPO opposition filing, EPO opposition reply, EPO appeal filing, EPO appeal grounds, EP annuity payment, DPMA examination request, DPMA opposition, German national court Klageerwiderung, German national court Beschwerde, IP-licence renewal date.
|
||||
|
||||
I will NOT seed all 102 trigger_events as Event Types — most are highly specific procedural sub-events ("Rejoinder to the Reply, Reply to the Defence to an Application to amend the patent" — that level of granularity belongs in the calc engine, not the picker dropdown). The curated subset of ~25 captures the 80 % case; users with niche needs add private types.
|
||||
|
||||
The exact seed list lives in the migration; I'll attach the spreadsheet to the implementation PR.
|
||||
|
||||
### No automatic title-based backfill
|
||||
|
||||
Tempting: parse `paliad.deadlines.title` against seeded `label_de`/`label_en`. **Don't.**
|
||||
- 10 production rows; not worth the script.
|
||||
- High false-positive risk ("Reply" matches at least 8 different seed types).
|
||||
- Better UX: when a user opens a typed-`NULL` deadline in edit mode, the form shows "Typ: — Ohne Typ —" with the picker open, prompting them to tag.
|
||||
|
||||
If/when the row count grows past ~200, revisit with a manual reconciliation script run by an admin.
|
||||
|
||||
## 7 · API endpoints
|
||||
|
||||
```
|
||||
GET /api/event-types → list (firm-wide ∪ own private), filterable by ?category= and ?jurisdiction=
|
||||
POST /api/event-types → create; body {label_de, label_en?, category, jurisdiction?, is_firm_wide?}
|
||||
PATCH /api/event-types/{id} → edit (label/category/jurisdiction/archived_at); RLS enforces ownership
|
||||
GET /api/deadlines?event_type_id= → already handled if we add the param to the list handler
|
||||
GET /api/agenda?event_type_id= → same on the agenda handler
|
||||
```
|
||||
|
||||
`POST /api/event-types` returns 201 with the created row; 403 for non-admin trying `is_firm_wide=true`; 409 on slug collision; 422 on missing label_de or invalid category. Standard paliad envelope.
|
||||
|
||||
The existing `paliad.deadlines` POST handler (`/api/deadlines`) gets a new optional `event_type_id` field — validate it points to a row the user can see (firm-wide or own private).
|
||||
|
||||
## 8 · Test plan
|
||||
|
||||
### Unit / Go
|
||||
|
||||
- `EventTypeService.Create` happy path (private type by regular user).
|
||||
- `EventTypeService.Create` 403 when regular user tries `is_firm_wide=true`.
|
||||
- `EventTypeService.Create` 409 on slug collision (firm-wide same slug; per-user same slug).
|
||||
- `EventTypeService.List` returns firm-wide ∪ own-private; not other users' private.
|
||||
- `DeadlineService.Create` accepts `event_type_id`; rejects FK pointing at someone else's private type.
|
||||
- `DeadlineService.List` filter by `event_type_id` intersects with status + project filters.
|
||||
- `AgendaService.List` filter by `event_type_id`.
|
||||
|
||||
### Integration / Playwright
|
||||
|
||||
Login as `tester@hlc.de`:
|
||||
|
||||
1. **Pick existing type:** `/deadlines/new` → fill title + project + due → open Typ picker → select "Statement of Defence" → submit → `/deadlines` shows row with Typ column = "Statement of Defence".
|
||||
2. **Filter:** `/deadlines` → set Typ filter to "Statement of Defence" → list narrows to 1 row → set to "Alle" → all rows back.
|
||||
3. **Custom-add (private):** `/deadlines/new` → open Typ picker → click "+ Neuen Typ hinzufügen" → modal → name "Mein Test-Typ", category Eingabe → Anlegen → modal closes, picker re-opens with new option selected → submit deadline → `/deadlines` shows it.
|
||||
4. **Privacy:** custom private types only visible to creator. Verify with API call as second test account if available; else assert via direct SQL after the test.
|
||||
5. **No-type filter:** filter "— Ohne Typ —" returns the pre-existing 10 rows that were never tagged.
|
||||
6. **Mobile snapshot:** filter row wraps cleanly on 375px viewport.
|
||||
7. **DE/EN switch:** language toggle re-renders both picker and filter labels (`label_de`/`label_en` swap, optgroup labels swap).
|
||||
8. **Archive flow (admin):** as global_admin, edit a firm-wide type → set archived_at → existing deadlines keep their `event_type_id` (label still renders for legacy rows) but the type no longer appears in the picker for new deadlines.
|
||||
|
||||
### Manual smoke
|
||||
|
||||
- New deadline reachable through Fristenrechner "Send to deadline" path (if/once that flow exists) carries event_type_id matching the trigger event.
|
||||
|
||||
## 9 · Coordination
|
||||
|
||||
- **t-paliad-086 (curie, shipped):** trigger_events table is the seed source for the curated firm-wide types. The Fristenrechner trigger picker on `/tools/fristenrechner` STAYS as-is — it's a calc tool, not a categorization tool. No conflict.
|
||||
- **t-paliad-087 (brunel, in flight):** light-grey BG sweep on `global.css`. Low overlap — this task adds new picker styles + the custom-add modal; brunel touches existing surfaces. Coordinate via merge order: brunel merges first (bigger surface area), then this task rebases.
|
||||
- **Tier 2 Fristenrechner ports** (damages, cost-appeal, cross-appeal, lay-open, leave-to-appeal): unrelated; their trigger_events rows (already imported in PR-1) become eligible seed candidates if/when the curated list expands.
|
||||
|
||||
## 10 · Migration outline (single PR, ~5 commits)
|
||||
|
||||
1. **Schema + seeds** — `030_event_types.up.sql` (table, indexes, triggers, RLS policies, ~40 seed rows). `030_event_types.down.sql` reverses cleanly.
|
||||
2. **Models + service** — `internal/models/event_type.go`, `internal/services/event_type_service.go`. Wire into `cmd/server/main.go` services bundle.
|
||||
3. **Handlers + routes** — `internal/handlers/event_types.go` (CRUD), update `internal/handlers/deadlines.go` to accept `event_type_id`, update `internal/handlers/agenda.go` to accept `?event_type_id`.
|
||||
4. **Frontend picker + modal** — `frontend/src/components/EventTypePicker.tsx` (shared) + `frontend/src/components/EventTypeAddModal.tsx`. Wire into `deadlines-new.tsx` and `deadlines-detail.tsx` edit modal. ~30 i18n keys (DE+EN) under `event_types.*` and `deadlines.field.event_type.*`.
|
||||
5. **Frontend filter + table column** — `deadlines.tsx` adds the `<select>` filter row + Typ column; `client/deadlines.ts` handles the `?event_type=` query param. `agenda.tsx` adds the pill-row variant; `client/agenda.ts` handles its query param.
|
||||
|
||||
Tests live alongside each layer. Verify via `bun run build` + `go test ./...` + Playwright smoke.
|
||||
|
||||
## 11 · Alternatives considered, not picked
|
||||
|
||||
| Alternative | Why not |
|
||||
|---|---|
|
||||
| Reuse `trigger_events` directly with a `created_by` column | Breaks the verbatim-import-for-diffability invariant; mixes calc state with user state; bigint vs uuid id space friction. |
|
||||
| Free-text `deadlines.event_type` column with self-distinct lookup | Typo-driven duplicates kill the filter UX; no metadata (category/jurisdiction); no privacy boundary. |
|
||||
| `paliad.submissions` as a sibling entity to deadlines | Forces migration of existing rows; duplicates fields (due_date, project_id, created_by); a submission *is* a deadline-bearing item. Defer until real submission-specific fields (file uploads, recipient party) are needed. |
|
||||
| Multi-select filter (UNION across multiple Event Types) | **PICKED.** m's Q4 call — Event Types specifically benefits from multi-select (a user often wants "show me all my Replies, Rejoinders, and Defences"). Status/Projekt stay single-select; the asymmetry is intentional. |
|
||||
| Auto-create on free-text in picker | Generates noise; can't ask the user category/jurisdiction; wrong default permission. Keep the explicit "+ Neuen Typ" affordance. |
|
||||
| Hierarchical Event Types (parent type → sub-type) | Over-engineered for 40 seeds + handful of customs. Use `category` for the one level of grouping users care about. |
|
||||
|
||||
## 12 · Open questions — RESOLVED 2026-04-30 12:23
|
||||
|
||||
| # | Question | m's call |
|
||||
|---|---|---|
|
||||
| 1 | Concept boundary — broader (UPC + EPO + DPMA + DE + contract) or UPC-only first cut? | **A — broader from day one** |
|
||||
| 2 | Schema — new `paliad.event_types` table + FK? | **A — yes**, with the bridge `event_types.trigger_event_id → paliad.trigger_events(id)` populated only for seeded UPC rows. *m: "event_types should lead and later we can connect things to it"* |
|
||||
| 3 | Submissions — defer separate table? | **A — yes, defer**. *m: "those are the event types I mean, mainly"* |
|
||||
| 4 | Filter style — `<select>` matching existing pattern? | **A, but multi-select.** Custom listbox-panel multi-select component (see §4 above). Status/Projekt stay single-select. |
|
||||
| 5 | `/agenda` filter — same task? | **A — same task** |
|
||||
| 6 | Firm-wide type permission floor — global_admin only? | **B — any authenticated user can create firm-wide; admins moderate via archive after the fact.** Mitigation: duplicate-warning in the add modal. Follow-up: *t-paliad-089: admin moderation panel*. |
|
||||
| 7 | Seed list — ~40 curated rows in migration 030? | **A — yes**, spreadsheet on the implementation PR for review. |
|
||||
|
||||
**Status:** all gate-blocking calls answered. Awaiting head's coder assignment. Inventor stays parked.
|
||||
|
||||
## 13 · Inventor recommendation on implementer
|
||||
|
||||
cronus did this design (data-model area). Either cronus or curie would be a good fit to implement: cronus knows the `trigger_events` corpus from this design pass; curie just shipped the trigger_events import (t-paliad-086) and knows the calc-engine context. Single coder is fine — the surface is one table, one FK, one picker, one filter, one modal. **Head decides**, not me.
|
||||
|
||||
---
|
||||
|
||||
**End of design.** Awaiting m's go/no-go on §12 #1, #2, #3, #4. Will not begin implementation until greenlit.
|
||||
629
docs/design-events-unification-2026-05-04.md
Normal file
@@ -0,0 +1,629 @@
|
||||
# Design: Unify Fristen + Termine as filtered views of one Events page
|
||||
|
||||
**Task:** t-paliad-109
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-04
|
||||
**Status:** DRAFT — awaiting m's go/no-go on §F open questions
|
||||
**Branch:** `mai/cronus/design-unify-fristen`
|
||||
|
||||
---
|
||||
|
||||
## 0. Premise check (read this first)
|
||||
|
||||
The task brief describes the current state as:
|
||||
|
||||
> - `/deadlines` — backend `DeadlineService`, table `paliad.deadlines`
|
||||
> - `/agenda` — backend `AppointmentService`, table `paliad.appointments`
|
||||
|
||||
The first half is correct. **The second half is wrong against the live codebase**, and the design has to start from the real shape:
|
||||
|
||||
| Route | What it actually is today | Backend |
|
||||
|---|---|---|
|
||||
| `/deadlines` | Fristen list (table + 5 summary cards). Deadline-only. | `DeadlineService` |
|
||||
| `/appointments` | **Termine list (table + 3 summary cards). Appointment-only.** | `AppointmentService` |
|
||||
| `/agenda` | **Cross-type timeline (already unified)** — day-grouped feed with chip filters Beides / Nur Fristen / Nur Termine, range chips 7/14/30/90, event-type multi-select. | `AgendaService` (not `AppointmentService`) |
|
||||
|
||||
So three list-ish surfaces exist, not two. The two table surfaces (`/deadlines` and `/appointments`) are the ones that diverge cosmetically and structurally; `/agenda` is a genuinely different visual paradigm (timeline grouped by day, no table) and a genuinely different backend (`AgendaService` already unions both event types).
|
||||
|
||||
Sidebar today (`frontend/src/components/Sidebar.tsx:111–122`):
|
||||
|
||||
```
|
||||
Übersicht: Dashboard, Agenda, Team
|
||||
Arbeit: Projekte, Fristen (/deadlines), Termine (/appointments)
|
||||
```
|
||||
|
||||
So **Agenda is already a sibling overview-style entry** distinct from the work-day list pair Fristen/Termine. The design below treats the unification target as the **Fristen ↔ Termine list pair**, not the timeline. Whether `/agenda` collapses into the new shape is its own question (Q3 in §F).
|
||||
|
||||
This premise correction was caught before locking the design — it determines the shape of A/B/C/D below. m should sanity-check it (Q1 in §F).
|
||||
|
||||
---
|
||||
|
||||
## 1. m's intent (as I read it)
|
||||
|
||||
> "Fristen and Termine should be **two predefined filters of the same Events view**, sharing the same layout. The Dashboard should reflect the same model."
|
||||
|
||||
Three things in that sentence:
|
||||
|
||||
1. **Predefined filters** — the user-facing names "Fristen" and "Termine" stay; under the hood each is `?type=deadline` / `?type=appointment` of one Events page.
|
||||
2. **Same layout** — the table chrome, summary cards, filter row, "+ Neu" button all come from one component, not two.
|
||||
3. **Dashboard reflects the same model** — the deadline summary expands to a unified Events summary (or gains a parallel Termine block keyed off the same backend shape).
|
||||
|
||||
The smallest-diff path that delivers that intent is **A1 + B1** below: keep the two URLs, render the same component, share one backend service that returns a discriminated `Event` row.
|
||||
|
||||
---
|
||||
|
||||
## 2. Recommended design (TL;DR)
|
||||
|
||||
| Area | Recommendation | Smallest-diff alternative considered & rejected |
|
||||
|---|---|---|
|
||||
| **Routes** | Keep `/deadlines` and `/appointments` URLs; both render the same `EventsPage` component with `?type=deadline` or `?type=appointment` baked in by the handler. | A2 (introduce `/events`, redirect from old URLs) — louder external change, more deploy-time friction. |
|
||||
| **Sidebar** | No change. "Fristen" still links to `/deadlines`, "Termine" still links to `/appointments`. | Collapsing to a single "Ereignisse" entry — renames a thing users know; m's phrasing was "two predefined filters", not "one entry". |
|
||||
| **Page header** | Renders "Fristen" or "Termine" (driven by default type). Below: a 3-chip toggle (Fristen / Termine / Beides) lets users widen. | A header named "Events" — fights m's intent that the names stay. |
|
||||
| **Backend** | New `EventService.ListVisibleForUser` that union-loads from both tables and returns `[]EventListItem`. Sits next to `AgendaService` (which keeps the timeline shape). | Reusing `AgendaService` directly — its struct is timeline-shaped (no `EventTypeIDs`, no rule fields, hides completed deadlines). Extension is bigger than greenfield. |
|
||||
| **Endpoint** | New `GET /api/events?type=&status=&project_id=&event_type=&from=&to=`. Existing `/api/deadlines` and `/api/appointments` keep working until ~v2 cleanup. | Folding both old endpoints into `/api/events` — needless break for clients we still ship (calendars, dashboards, project-detail panes). |
|
||||
| **Detail pages** | **Stay separate** (`/deadlines/{id}`, `/appointments/{id}`). The unification is list-only. | Unify detail pages too — out of scope per task brief §13; deadline-edit and appointment-edit have nearly disjoint forms. |
|
||||
| **Dashboard** | Add a parallel **Termine summary** rail (Heute / Diese Woche / Später, mirroring `/appointments` summary today). Keep the deadline 5-bucket rail. Both rails read from `/api/events/summary` (new). | Cram appointments into the 5-bucket model — "Überfällig" doesn't really apply to past meetings; degrades meaning. |
|
||||
| **`/agenda`** | **Out of scope for this round.** Keep the timeline as-is; revisit in a follow-up once Events list is stable. | Retire `/agenda` now — too much UX surface area for one PR; m hasn't asked for it. |
|
||||
|
||||
The rest of this doc is the detail behind those rows.
|
||||
|
||||
---
|
||||
|
||||
## 3. Section A — Information architecture
|
||||
|
||||
### Q1. Canonical route
|
||||
|
||||
**Recommendation: A1 — keep both URLs, share one component.**
|
||||
|
||||
```
|
||||
GET /deadlines → renderEventsPage({ defaultType: "deadline" })
|
||||
GET /appointments → renderEventsPage({ defaultType: "appointment"})
|
||||
```
|
||||
|
||||
Both handlers serve the same TSX page, both bundle the same `client/events.ts`. The only difference is a one-line attribute `<body data-default-type="deadline">` (or `"appointment"`) read by `events.ts` on init.
|
||||
|
||||
A `?type=` query param can override the default — that lets the 3-chip toggle ("Fristen / Termine / Beides") work without re-routing. The URL on `/deadlines?type=appointment` is mildly weird but harmless; the alternative is full `pushState` to switch routes when toggling, which fights browser history.
|
||||
|
||||
Three options considered:
|
||||
|
||||
| Option | Smallest diff? | Notes |
|
||||
|---|---|---|
|
||||
| **A1** Two routes, one component, default-type per handler | ✅ smallest | No redirect machinery, no broken bookmarks, no sidebar churn. |
|
||||
| A2 `/events` canonical + redirects | ✗ medium | 302 from `/deadlines` and `/appointments`. Every internal link, every bookmarked URL, every email-template link redirects once. Workable, but louder. |
|
||||
| A3 `/events` only + sidebar collapse to "Ereignisse" | ✗ largest | Renames a thing users know. Conflicts with m's "two predefined filters" framing. |
|
||||
|
||||
### Q2. Branding
|
||||
|
||||
**Recommendation: keep "Fristen" and "Termine" as user-facing names.**
|
||||
|
||||
The page `<h1>` reads "Fristen" or "Termine" depending on the default type. The 3-chip toggle below the header is labeled `[Fristen] [Termine] [Beides]`. When the user is in "Beides" mode, the `<h1>` stays whichever they came from (don't rewrite it on toggle — would jitter). The page `<title>` follows the same rule.
|
||||
|
||||
Why not introduce "Events / Ereignisse" as a top-level label: m said "two predefined filters", not "one new concept". Calling the page "Events" while sidebar entries say "Fristen" / "Termine" creates a two-vocabulary problem; calling everything "Ereignisse" demands users learn a new label.
|
||||
|
||||
The internal vocabulary in code (`EventService`, `EventListItem`, `/api/events`) stays English-Events per the system-language convention. User-facing strings stay German Fristen/Termine.
|
||||
|
||||
### Q3. Sidebar nav
|
||||
|
||||
**Recommendation: no change.**
|
||||
|
||||
Sidebar today:
|
||||
```
|
||||
Arbeit:
|
||||
Projekte /projects
|
||||
Fristen /deadlines
|
||||
Termine /appointments
|
||||
```
|
||||
|
||||
Both entries continue to point at their existing URLs. The 3-chip toggle on the page is the gateway to "Beides".
|
||||
|
||||
Collapsing to a single "Ereignisse" entry means losing the muscle-memory shortcut to the deadline-only or appointment-only view. The 3-chip toggle is one click further than a sidebar entry; for a high-frequency view that's a regression.
|
||||
|
||||
If we ever want a single entry, the path is: ship the unification, watch usage, then collapse if telemetry says nobody uses one of the two pre-filtered URLs.
|
||||
|
||||
---
|
||||
|
||||
## 4. Section B — Data model
|
||||
|
||||
### Q4. Backend service shape
|
||||
|
||||
**Recommendation: B1 — new `EventService` that delegates internally.**
|
||||
|
||||
```go
|
||||
// internal/services/event_service.go
|
||||
type EventService struct {
|
||||
db *sqlx.DB
|
||||
deadlines *DeadlineService
|
||||
appointments *AppointmentService
|
||||
eventTypes *EventTypeService
|
||||
}
|
||||
|
||||
func NewEventService(db, d, a, et) *EventService
|
||||
|
||||
type EventListFilter struct {
|
||||
Type EventTypeFilter // "" | "deadline" | "appointment"
|
||||
Status DeadlineStatusFilter // applies only to deadlines
|
||||
ProjectID *uuid.UUID
|
||||
EventTypeIDs []uuid.UUID // applies only to deadlines
|
||||
IncludeUntyped bool // applies only to deadlines
|
||||
AppointmentType *string // applies only to appointments
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
}
|
||||
|
||||
func (s *EventService) ListVisibleForUser(ctx, userID, filter) ([]EventListItem, error)
|
||||
func (s *EventService) SummaryCounts(ctx, userID, filter) (EventSummary, error)
|
||||
```
|
||||
|
||||
Internally, `ListVisibleForUser`:
|
||||
|
||||
1. If `filter.Type == "appointment"` → call only `AppointmentService.ListVisibleForUser`, project to `EventListItem`.
|
||||
2. If `filter.Type == "deadline"` → call only `DeadlineService.ListVisibleForUser`, project to `EventListItem`.
|
||||
3. If `filter.Type == ""` (Beides) → call both, merge, sort by canonical date.
|
||||
|
||||
Status filter only takes effect when the result includes deadlines; when filter.Type=="appointment" with a Status set, the handler should return 400 (or quietly drop it — Q11 in §F).
|
||||
|
||||
Three options considered:
|
||||
|
||||
| Option | Notes |
|
||||
|---|---|
|
||||
| **B1** New `EventService` delegating to existing services | Single ownership, clean API surface. ~150 LoC. The two existing services keep their callers (project detail pages, dashboard subqueries). |
|
||||
| B2 Union at the handler layer | Filter logic split. Hard to test the merge. Same query gets duplicated for `/api/events/summary`. |
|
||||
| B3 Extend one of the existing services | Awkward — neither `DeadlineService.ListAllEvents` nor `AppointmentService.ListAllEvents` reads naturally. Adds an unrelated dep (each service would need to know about the other). |
|
||||
|
||||
Why not reuse `AgendaService`: it's the right shape for timelines (`AgendaItem` with urgency annotation, completed-deadlines hidden, no rule/event-type fields on the row). Extending it to also feed the table view would require adding `EventTypeIDs`, `RuleCode`, `RuleName`, `Description`, `Notes`, an `IncludeCompleted` flag, and Status filtering — at which point it stops being agenda-shaped. Cleaner to leave `AgendaService` for the timeline and introduce a sibling.
|
||||
|
||||
### Q5. The unified row type
|
||||
|
||||
**Recommendation: discriminated tagged union with type-specific optional fields.**
|
||||
|
||||
```ts
|
||||
// frontend type — same shape as Go's EventListItem JSON
|
||||
type EventListItem =
|
||||
| DeadlineEvent
|
||||
| AppointmentEvent;
|
||||
|
||||
interface EventBase {
|
||||
id: string;
|
||||
type: "deadline" | "appointment";
|
||||
title: string;
|
||||
description?: string;
|
||||
date: string; // ISO 8601 — canonical sort key (deadline: due_date 00:00 UTC; appointment: start_at)
|
||||
date_label: string; // pre-formatted for table cell, e.g. "31.05.2026" or "31.05. 14:00–15:00"
|
||||
urgency: "overdue" | "today" | "tomorrow" | "this_week" | "next_week" | "later" | "completed";
|
||||
project_id?: string;
|
||||
project_title?: string;
|
||||
project_reference?: string;
|
||||
project_type?: string;
|
||||
}
|
||||
|
||||
interface DeadlineEvent extends EventBase {
|
||||
type: "deadline";
|
||||
due_date: string; // YYYY-MM-DD
|
||||
status: "pending" | "completed";
|
||||
completed_at?: string;
|
||||
source: "manual" | "fristenrechner" | "import";
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
rule_name?: string;
|
||||
rule_name_en?: string;
|
||||
event_type_ids: string[];
|
||||
has_ccr?: boolean; // condition_flag = 'with_ccr' (UPC_INF)
|
||||
}
|
||||
|
||||
interface AppointmentEvent extends EventBase {
|
||||
type: "appointment";
|
||||
start_at: string;
|
||||
end_at?: string;
|
||||
location?: string;
|
||||
appointment_type?: "hearing" | "meeting" | "consultation" | "deadline_hearing";
|
||||
}
|
||||
```
|
||||
|
||||
Go-side mirror:
|
||||
|
||||
```go
|
||||
type EventListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Type string `json:"type"` // "deadline" | "appointment"
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Date time.Time `json:"date"`
|
||||
DateLabel string `json:"date_label"`
|
||||
Urgency string `json:"urgency"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
ProjectTitle *string `json:"project_title,omitempty"`
|
||||
ProjectReference *string `json:"project_reference,omitempty"`
|
||||
ProjectType *string `json:"project_type,omitempty"`
|
||||
|
||||
// Deadline-only (zero-valued / nil for appointments)
|
||||
DueDate *string `json:"due_date,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
Source *string `json:"source,omitempty"`
|
||||
RuleID *uuid.UUID `json:"rule_id,omitempty"`
|
||||
RuleCode *string `json:"rule_code,omitempty"`
|
||||
RuleName *string `json:"rule_name,omitempty"`
|
||||
RuleNameEN *string `json:"rule_name_en,omitempty"`
|
||||
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
HasCCR *bool `json:"has_ccr,omitempty"`
|
||||
|
||||
// Appointment-only
|
||||
StartAt *time.Time `json:"start_at,omitempty"`
|
||||
EndAt *time.Time `json:"end_at,omitempty"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
AppointmentType *string `json:"appointment_type,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Why a flat struct with optionals instead of `Deadline *DeadlineFields; Appointment *AppointmentFields`: the agenda already proved (in `AgendaItem`) that flat-with-optionals reads cleaner across both Go service code and frontend rendering. The frontend type-narrows on `type === "deadline"` and TS infers the rest.
|
||||
|
||||
JSON example — one of each:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "8c3a…",
|
||||
"type": "deadline",
|
||||
"title": "Statement of Defence",
|
||||
"date": "2026-08-31T00:00:00Z",
|
||||
"date_label": "31.08.2026",
|
||||
"urgency": "next_week",
|
||||
"project_id": "1f…",
|
||||
"project_title": "Acme v. Foo",
|
||||
"project_reference": "0001234.0000567",
|
||||
"project_type": "case",
|
||||
"due_date": "2026-08-31",
|
||||
"status": "pending",
|
||||
"source": "fristenrechner",
|
||||
"rule_id": "…",
|
||||
"rule_code": "RoP.023",
|
||||
"rule_name": "Statement of Defence",
|
||||
"event_type_ids": ["af…", "bd…"],
|
||||
"has_ccr": false
|
||||
},
|
||||
{
|
||||
"id": "9d4b…",
|
||||
"type": "appointment",
|
||||
"title": "Mündliche Verhandlung — Acme v. Foo",
|
||||
"date": "2026-09-15T09:00:00Z",
|
||||
"date_label": "15.09.2026 09:00–11:00",
|
||||
"urgency": "later",
|
||||
"project_id": "1f…",
|
||||
"project_title": "Acme v. Foo",
|
||||
"project_reference": "0001234.0000567",
|
||||
"project_type": "case",
|
||||
"start_at": "2026-09-15T09:00:00Z",
|
||||
"end_at": "2026-09-15T11:00:00Z",
|
||||
"location": "UPC LD München, Cincinnatistraße 64",
|
||||
"appointment_type": "hearing"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Q6. Date semantics
|
||||
|
||||
**Recommendation: one `date` column, one `date_label` column.**
|
||||
|
||||
- `date` is always the canonical sort key, RFC3339 UTC.
|
||||
- Deadlines: `2026-08-31T00:00:00Z` (midnight UTC of `due_date`).
|
||||
- Appointments: `start_at` verbatim.
|
||||
- `date_label` is the pre-localized human string for the table cell.
|
||||
- Deadlines: `"31.08.2026"` (no time component — deadlines are date-only).
|
||||
- Appointments without `end_at`: `"15.09.2026 09:00"`.
|
||||
- Appointments with `end_at` same-day: `"15.09.2026 09:00–11:00"`.
|
||||
- Appointments with `end_at` next-day: `"15.09.2026 09:00 → 16.09.2026 11:00"`.
|
||||
|
||||
The label is computed server-side so the table rows render identically across DE/EN (i18n only swaps date format) without each frontend pass having to special-case start/end vs single-date.
|
||||
|
||||
The column header reads "Fällig / Beginn" (Fristen / Termine) in single-type mode and "Datum" in Beides mode (Q11 in §F asks m to confirm).
|
||||
|
||||
---
|
||||
|
||||
## 5. Section C — UI
|
||||
|
||||
### Q7. The 5-bucket summary
|
||||
|
||||
**Recommendation: bucket model is type-aware. Deadlines keep 5 buckets, Appointments use 3 buckets, "Beides" shows two rails.**
|
||||
|
||||
The deadline 5-bucket model (Überfällig / Heute / Diese Woche / Nächste Woche / Erledigt — t-paliad-106) is genuinely deadline-shaped: "Überfällig" means **a deadline that passed without being completed**. Past appointments are not "overdue" — they've happened, and that's fine. So:
|
||||
|
||||
| Mode | Bucket rail |
|
||||
|---|---|
|
||||
| `?type=deadline` (Fristen) | 5 cards — Überfällig / Heute / Diese Woche / Nächste Woche / Erledigt (today's behavior, unchanged). |
|
||||
| `?type=appointment` (Termine) | 3 cards — Heute / Diese Woche / Später (today's behavior on `/appointments`, unchanged). |
|
||||
| `?type=` (Beides) | **Two rails stacked**: Fristen rail (5 cards, deadlines only) on top, Termine rail (3 cards, appointments only) below. Each card filters to that bucket within its own type. |
|
||||
|
||||
This stays honest about what each card means and avoids a stretched 4-column compromise that lies about appointments being "Überfällig". The Beides view does pay a vertical-space cost (~120px for the second rail); the alternative compromise feels worse.
|
||||
|
||||
If m wants a *common* 4-bucket compromise (Heute / Diese Woche / Nächste Woche / Erledigt+Vergangen), Q12 asks. My recommendation is the two-rail approach.
|
||||
|
||||
### Q8. Filter row
|
||||
|
||||
**Recommendation: one filter row that shows the *union* of relevant filters; rules toggle visibility/active-state per type.**
|
||||
|
||||
```
|
||||
[Type chips: Fristen | Termine | Beides ] ← driver
|
||||
Filters when type=deadline:
|
||||
Status (single-select) | Projekt (single-select) | Typ (event-type multi-select)
|
||||
Filters when type=appointment:
|
||||
Termin-Typ (single-select) | Projekt (single-select) | Von | Bis
|
||||
Filters when type=Beides:
|
||||
Projekt (single-select) | Von | Bis | Typ (event-type multi-select, applies to deadlines only with a tooltip)
|
||||
+ a status selector that's disabled with hint "Nur Fristen"
|
||||
```
|
||||
|
||||
Concretely:
|
||||
|
||||
- **Projekt** (single-select) — always visible, always active. Same behavior as today.
|
||||
- **Status** (deadline-only) — visible in `?type=deadline` and `?type=`; in `?type=appointment` it's hidden. In Beides, it filters deadlines AND silently passes appointments through (with a tooltip explaining).
|
||||
- **Typ (event-type multi-select)** — visible in `?type=deadline` and `?type=`; hidden in `?type=appointment`. Today's `event_type_id` model is deadline-only.
|
||||
- **Termin-Typ** (hearing/meeting/consultation/deadline_hearing) — visible in `?type=appointment`; hidden in deadline-only mode and Beides (low value, would mean "only appointments of type X plus all deadlines" which is incoherent).
|
||||
- **Von / Bis** — already on `/appointments`. Add to the unified view across all type modes (gives users a way to scope deadlines too — currently deadlines don't have a date range filter, only buckets).
|
||||
|
||||
Hidden, not greyed-out, when a filter doesn't apply. Greyed-out adds noise and invites confusion. Filters re-appear instantly on chip toggle (no page reload).
|
||||
|
||||
### Q9. Columns that differ
|
||||
|
||||
**Recommendation: type-conditional columns — visible whenever ≥1 row in the current view has data for that column.**
|
||||
|
||||
Single-type mode is straightforward: render exactly today's columns.
|
||||
|
||||
Beides mode: render the *union* of columns, but apply the existing **hide-on-uniform** pattern (`.entity-table--hide-event-type` from t-paliad-088, generalized):
|
||||
|
||||
```
|
||||
| Type icon | Datum | Titel | Projekt | Regel¹ | Typ¹ | Ort² | Termin-Typ² | Status |
|
||||
¹ deadline-only — hidden in pure-appointment view
|
||||
² appointment-only — hidden in pure-deadline view
|
||||
```
|
||||
|
||||
Cell content per column:
|
||||
|
||||
| Column | Deadline row | Appointment row |
|
||||
|---|---|---|
|
||||
| Type icon | 🕐 (CLOCK) | 📅 (CALENDAR) |
|
||||
| Datum | "31.08.2026" | "15.09.2026 09:00–11:00" |
|
||||
| Titel | deadline title | appointment title |
|
||||
| Projekt | reference + title (or "—" for personal Termine) | same |
|
||||
| Regel | rule_code (e.g. "RoP.023") | empty (column shown only if any row has it) |
|
||||
| Typ | event-type chip cluster | empty |
|
||||
| Ort | empty | location text |
|
||||
| Termin-Typ | empty | "Verhandlung" / "Besprechung" / etc. |
|
||||
| Status | "Offen" / "Erledigt" / OVERDUE badge | empty (or maybe "vergangen" — Q14 in §F) |
|
||||
|
||||
The CCR flag (UPC_INF condition_flag='with_ccr', t-paliad-086 PR-3) is a deadline detail that today shows as a small "CCR" pill on the deadline detail page. In the list view it stays as a row-level pill in the Titel cell — same as today on `/deadlines`.
|
||||
|
||||
### Q10. The "+ Neu" button
|
||||
|
||||
**Recommendation: type-aware default with a quick-switch dropdown.**
|
||||
|
||||
In `?type=deadline`: button reads "Neue Frist" → `/deadlines/new`.
|
||||
In `?type=appointment`: button reads "Neuer Termin" → `/appointments/new`.
|
||||
In `?type=` (Beides): button reads "+ Neu" with a small dropdown caret → opens a 2-option menu (Neue Frist / Neuer Termin) that routes to the existing form pages.
|
||||
|
||||
Why not a type-picker modal: it's an extra click for the common case (user knows what they're creating). Why not two side-by-side buttons in Beides mode: button-pair clutters the header and makes the "Beides" mode feel structurally different (it's just a filter view, not a different mode of being).
|
||||
|
||||
Detail/create pages stay separate (per task brief §13 + §E13 below). The unification is list + filter, not form.
|
||||
|
||||
---
|
||||
|
||||
## 6. Section D — Dashboard
|
||||
|
||||
### Q11. Termine on the Dashboard
|
||||
|
||||
**Recommendation: Add a Termine summary rail; keep the deadline rail.**
|
||||
|
||||
Today the Dashboard has:
|
||||
- 5-card deadline summary (Überfällig / Heute / Diese Woche / Nächste Woche / Erledigt) → links to `/deadlines?status=…`
|
||||
- "Kommende Fristen" + "Kommende Termine" two-column 7-day list (already cross-type)
|
||||
- Activity feed
|
||||
|
||||
What to add:
|
||||
- **3-card Termine summary** (Heute / Diese Woche / Später) → links to `/appointments?range=today` etc.
|
||||
- Both card rails read from a new `GET /api/events/summary` that returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"deadlines": { "overdue": 3, "today": 2, "this_week": 5, "next_week": 1, "completed_this_week": 2 },
|
||||
"appointments": { "today": 1, "this_week": 4, "later": 12 }
|
||||
}
|
||||
```
|
||||
|
||||
The two-column 7-day list stays — it's already cross-type and reads well. The activity feed stays.
|
||||
|
||||
Visual ordering on Dashboard:
|
||||
```
|
||||
[Greeting]
|
||||
[Fristen summary — 5 cards]
|
||||
[Termine summary — 3 cards] ← new
|
||||
[Meine Akten matter card]
|
||||
[Kommende Fristen | Kommende Termine]
|
||||
[Letzte Aktivität]
|
||||
```
|
||||
|
||||
The Termine rail goes directly under the Fristen rail because the two are conceptually the same "what's coming up?" question split by type.
|
||||
|
||||
### Q12. Bucket-model translation
|
||||
|
||||
**Recommendation: the buckets stay type-specific (no shared 4-bucket compromise).**
|
||||
|
||||
Trying to fit appointments into the deadline 5-bucket model:
|
||||
|
||||
| Deadline bucket | Appointment fit? |
|
||||
|---|---|
|
||||
| Überfällig (past, not completed) | ✗ — appointments either happened or didn't; "past" isn't urgent. |
|
||||
| Heute | ✓ |
|
||||
| Diese Woche | ✓ |
|
||||
| Nächste Woche | △ — `/appointments` today uses "Später" (anything past this week). The bucket is fine but the cutoff is different. |
|
||||
| Erledigt | △ — "vergangen" maybe, but the semantics differ. |
|
||||
|
||||
The honest answer is the two surfaces have different time horizons (deadlines obsess over "overdue", appointments don't) and squeezing them into one bucket grid would erase that. The two-rail approach in §C7 is the cleanest expression.
|
||||
|
||||
---
|
||||
|
||||
## 7. Section E — Migration & rollout
|
||||
|
||||
### Q13. Verlauf / detail-page links
|
||||
|
||||
**Recommendation: detail pages stay type-specific. Verlauf links unchanged.**
|
||||
|
||||
t-paliad-102 wired `eventDetailHref()` and `activityHref()` to point at `/deadlines/{id}` and `/appointments/{id}` based on event metadata. Those keep working — only the LIST view unifies. No frontend Verlauf change needed.
|
||||
|
||||
If a future round wants to unify detail pages too, that's t-paliad-110 territory; the deadline-edit and appointment-edit forms are quite different (event_type chips, rule code, complete/reopen vs CalDAV time pickers, location, type dropdown).
|
||||
|
||||
### Q14. Data migration
|
||||
|
||||
**Recommendation: none. Both tables stay; only the read side joins.**
|
||||
|
||||
`paliad.deadlines` and `paliad.appointments` keep their schemas. `EventService` reads from both and projects to `EventListItem` at request time. Migration 030+ stays untouched.
|
||||
|
||||
The only schema-adjacent change worth flagging: when we add **per-row "Erledigt" semantics for appointments** (Q14 in §F asks), we'd need a new column `paliad.appointments.completed_at` or similar. Today there's no such concept (a past appointment is just past). I'd defer this to a follow-up unless m wants it now.
|
||||
|
||||
### Rollout (PR shape)
|
||||
|
||||
Single feature PR on `mai/<coder>/events-unification`, ~5 commits:
|
||||
|
||||
1. **Backend: EventService + endpoint.** New `internal/services/event_service.go` (delegating to existing services), new `internal/handlers/events.go` (`GET /api/events`, `GET /api/events/summary`), wire into `Services` struct.
|
||||
2. **Backend: EventService tests.** Unit tests for the merge/sort logic, type-filter, status-filter behavior, summary counts.
|
||||
3. **Frontend: shared EventsPage component + client/events.ts.** New `frontend/src/events.tsx` (the shared TSX), new `frontend/src/client/events.ts` (the runtime). Shared filter row, shared bucket-rail, shared table renderer.
|
||||
4. **Frontend: rewire `/deadlines` and `/appointments` handlers** to render `EventsPage` with the right `defaultType`. Drop `frontend/src/deadlines.tsx` + `frontend/src/appointments.tsx` (their build entries replaced by `events`). Update `bun build` config + Go template glue.
|
||||
5. **Frontend: Dashboard Termine summary rail.** Read `/api/events/summary`, render 3 cards under the existing Fristen rail.
|
||||
|
||||
Plus i18n keys (DE+EN) for the new strings: type-chip labels, the 3-chip toggle, "+ Neu" dropdown labels, Dashboard Termine rail. Roughly ~12 new keys.
|
||||
|
||||
Old endpoints (`GET /api/deadlines`, `GET /api/appointments`) **stay** — they're used by `/deadlines/calendar`, `/appointments/calendar`, `/projects/{id}` detail panes, mobile/PWA. Don't churn callers we don't have to.
|
||||
|
||||
Estimated PR scope: ~600 LoC backend + ~900 LoC frontend (most of it consolidation, not new code) + ~150 LoC tests. Numbers approximate.
|
||||
|
||||
---
|
||||
|
||||
## 8. Mock — unified table layout
|
||||
|
||||
ASCII mock of `?type=` (Beides) view, after 3-chip toggle, both rails visible:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Fristen [Kalender] [Neue Frist] │ ← H1 reflects entry route
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ [⏰ Fristen] [📅 Termine] [Beides ●] │ ← 3-chip toggle
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ Fristen auf einen Blick │
|
||||
│ ┌────────┬───────┬────────────┬────────────┬──────────┐ │
|
||||
│ │ 3 │ 2 │ 5 │ 1 │ 2 │ │
|
||||
│ │Überfäl.│ Heute │Diese Woche │Nächste W. │ Erledigt │ │
|
||||
│ └────────┴───────┴────────────┴────────────┴──────────┘ │
|
||||
│ Termine │
|
||||
│ ┌───────┬────────────┬─────────┐ │
|
||||
│ │ 1 │ 4 │ 12 │ │
|
||||
│ │ Heute │Diese Woche │ Später │ │
|
||||
│ └───────┴────────────┴─────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ Projekt: [Alle ▾] Von: [____] Bis: [____] Typ: [Alle ▾] Status: [—] │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │ │ Datum │ Titel │ Projekt │ Regel │ Typ│Ort │T-Typ │ Status │
|
||||
├─┼─┼──────────────────────┼────────────────────┼───────────┼────────┼────┼─────────────────────┼───────────┼────────────┤
|
||||
│☐│⏰│ 28.05.2026 │ Statement of Def. │ ACM 0001 │RoP.023 │SoD │ │ │ Offen │
|
||||
│☐│⏰│ 31.05.2026 OVERDUE │ Reply to Defence │ ACM 0001 │RoP.029a│Repl│ │ │ Offen │
|
||||
│ │📅│ 01.06.2026 09:00–11:00│ MV Acme v. Foo │ ACM 0001 │ │ │UPC LD MUC, … │Verhandlung│ │
|
||||
│☐│⏰│ 03.06.2026 │ Schriftsatz Beweis │ ACM 0001 │ │SoD │ │ │ Offen │
|
||||
│ │📅│ 05.06.2026 14:00 │ Strategiebespr. │ — │ │ │Zoom │Besprechung│ │
|
||||
└─┴─┴──────────────────────┴────────────────────┴───────────┴────────┴────┴─────────────────────┴───────────┴────────────┘
|
||||
```
|
||||
|
||||
In `?type=deadline` mode, the Termine summary rail and the Ort/T-Typ columns vanish; in `?type=appointment` mode, the Fristen rail vanishes plus Regel/Typ/Status; the table becomes today's pure deadline / pure appointment table.
|
||||
|
||||
The leftmost ☐ column is the deadline-complete checkbox (deadline rows only — appointments don't have a complete affordance today; Q14 in §F asks).
|
||||
|
||||
---
|
||||
|
||||
## 9. Section F — Open questions for m
|
||||
|
||||
These are blocking. I've put a recommendation under each so the decision is small.
|
||||
|
||||
**Q1. Premise correction.** The task brief described `/agenda` as the appointment list backed by `AppointmentService`. The live system has `/appointments` as the appointment list and `/agenda` as a third pre-existing cross-type timeline view. The design above treats the unification target as **`/deadlines` ↔ `/appointments`**, with `/agenda` left alone. **Confirm this read.** *(Reco: confirm.)*
|
||||
|
||||
**Q2. Sidebar.** Keep "Fristen" + "Termine" as separate sidebar entries, both pointing at the unified component? Or collapse to one "Ereignisse" entry? *(Reco: keep separate.)*
|
||||
|
||||
**Q3. `/agenda` fate.** Out of scope for this round (timeline stays as-is) — confirm? Or do you want the timeline retired in favor of the new Events list + Beides toggle? If retired, the cards on Dashboard linking to `/agenda` need rerouting. *(Reco: leave as-is for this round.)*
|
||||
|
||||
**Q4. Page header in Beides mode.** When the user toggles to Beides on `/deadlines`, does the `<h1>` stay "Fristen" (recommended), switch to "Ereignisse", or rewrite to "Fristen & Termine"? *(Reco: stay "Fristen" — the route owns the heading; the chip toggle is a within-page filter.)*
|
||||
|
||||
**Q5. URL on type-chip toggle.** When the user toggles to "Beides" on `/deadlines`, the URL becomes `/deadlines?type=` — slightly weird. Acceptable, or should the toggle redirect to a canonical `/events` route? *(Reco: accept the weirdness; bookmarks survive.)*
|
||||
|
||||
**Q6. "Neu" button in Beides mode.** Recommended: single button "+ Neu" with a 2-option dropdown (Neue Frist / Neuer Termin). Acceptable, or do you want two side-by-side buttons? *(Reco: dropdown.)*
|
||||
|
||||
**Q7. Filter row visibility.** In Beides mode, deadline-only filters (Status, Typ multi-select) are visible-but-mark-deadline-only. Appointment-only filter (Termin-Typ) is hidden. Confirm this asymmetry. *(Reco: confirm.)*
|
||||
|
||||
**Q8. Date range filter on deadlines.** Today `/deadlines` has no Von/Bis range — only buckets. Adding it as part of the unified filter row would slightly change deadline UX. OK? *(Reco: yes, gives users another way to scope; doesn't replace buckets.)*
|
||||
|
||||
**Q9. Type icon column.** I'm proposing a leftmost type icon column (⏰ vs 📅) in Beides mode for at-a-glance. Useful or noise? *(Reco: useful in Beides; auto-hide in single-type mode.)*
|
||||
|
||||
**Q10. Dashboard Termine summary cards.** Add a 3-card Termine rail (Heute / Diese Woche / Später) under the existing 5-card Fristen rail. Confirm. *(Reco: add it.)*
|
||||
|
||||
**Q11. Status filter semantics in Beides.** When type=Beides and Status="Erledigt" is set, what should appointments do? Three options:
|
||||
- (a) Hide all appointments (status filter only matches completed deadlines).
|
||||
- (b) Show all appointments untouched, plus completed deadlines.
|
||||
- (c) Disable the Status selector with a tooltip "Status gilt nur für Fristen". *(Reco: c — simplest mental model.)*
|
||||
|
||||
**Q12. 5-bucket vs 3-bucket vs shared-4-bucket.** I recommended the two-rail approach in Beides (deadline 5-bucket + appointment 3-bucket stacked). Are you OK with two rails, or do you want a single shared bucket model (e.g. drop "Überfällig" and use a 4-card Heute/Diese Woche/Nächste Woche/Erledigt+Vergangen across both)? *(Reco: two rails — honest about each type's semantics.)*
|
||||
|
||||
**Q13. Date column header label in Beides.** "Datum" (generic) vs keeping "Fällig / Beginn" double-header. *(Reco: "Datum"; the type icon column tells users what it means.)*
|
||||
|
||||
**Q14. "Erledigt" for appointments.** Today appointments have no completion concept — past appointments just exist. Do you want to add `appointments.completed_at` so users can mark a Verhandlung as "done" and have it leave the active table? Or leave appointments without that — they fall off the active range filter naturally? *(Reco: defer to a follow-up; not part of this unification.)*
|
||||
|
||||
**Q15. API endpoint cohabitation.** Keep `GET /api/deadlines` and `GET /api/appointments` alongside the new `GET /api/events`? *(Reco: keep both; the calendar and project-detail pages still call them. Retire on a separate v2 cleanup once confidence is high.)*
|
||||
|
||||
**Q16. Detail-page unification.** Out of scope per task brief §13. Confirm — I want to be sure m's "same Events view" framing didn't extend to detail pages. *(Reco: out of scope; deadline-edit and appointment-edit forms have nearly disjoint fields.)*
|
||||
|
||||
**Q17. Granularity on event-type filter in Beides.** Event-type filter (multi-select chip cluster) only matches deadlines (appointments don't have event types). When applied in Beides, do appointments get included anyway, or do they get filtered out (logically: "show only events that have one of these types")? *(Reco: appointments pass through unchanged; the filter is a deadline-side narrower, not a global narrower. Tooltip clarifies.)*
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of scope
|
||||
|
||||
- Detail pages (`/deadlines/{id}`, `/appointments/{id}`) — stay separate.
|
||||
- `/agenda` timeline — stays as-is for this round.
|
||||
- `/deadlines/calendar` and `/appointments/calendar` — month-grid views; not affected by list unification.
|
||||
- Forms (`/deadlines/new`, `/appointments/new`) — stay separate.
|
||||
- Reminder service, CalDAV sync, project-detail panes — read from old endpoints; unaffected.
|
||||
- Adding `completed_at` to appointments — defer per Q14.
|
||||
|
||||
---
|
||||
|
||||
## 11. Files the implementer will touch
|
||||
|
||||
(For the head's planning; not authoritative.)
|
||||
|
||||
**New files:**
|
||||
- `internal/services/event_service.go`
|
||||
- `internal/services/event_service_test.go`
|
||||
- `internal/handlers/events.go`
|
||||
- `frontend/src/events.tsx`
|
||||
- `frontend/src/client/events.ts`
|
||||
|
||||
**Modified:**
|
||||
- `internal/handlers/handlers.go` — wire new service + endpoints; rewire `/deadlines` and `/appointments` page handlers to render `events.tsx`.
|
||||
- `internal/handlers/dashboard.go` — extend payload with appointment summary (or call new `/api/events/summary`).
|
||||
- `frontend/src/dashboard.tsx` — add Termine 3-card rail.
|
||||
- `frontend/src/client/dashboard.ts` — fetch + render Termine summary.
|
||||
- `frontend/src/i18n.ts` (or wherever keys live) — ~12 new DE/EN keys.
|
||||
- `frontend/build.ts` — drop `deadlines.tsx`/`appointments.tsx` build entries; add `events.tsx`.
|
||||
|
||||
**Deleted (replaced):**
|
||||
- `frontend/src/deadlines.tsx`
|
||||
- `frontend/src/client/deadlines.ts`
|
||||
- `frontend/src/appointments.tsx`
|
||||
- `frontend/src/client/appointments.ts`
|
||||
|
||||
**Untouched:**
|
||||
- `internal/services/deadline_service.go` (still called by `EventService`)
|
||||
- `internal/services/appointment_service.go` (still called by `EventService`)
|
||||
- `internal/services/agenda_service.go` (still serves `/agenda` timeline)
|
||||
- All detail / form / calendar pages.
|
||||
|
||||
---
|
||||
|
||||
## 12. Inventor stays parked
|
||||
|
||||
This is design-only per the inventor → coder gate. After m greenlights §F, head decides whether to load `/mai-coder` on me or assign elsewhere. cronus has the deepest event-types context (t-paliad-088) and bucket math context (t-paliad-106) so cronus or curie are natural fits, but the head decides.
|
||||
|
||||
— cronus
|
||||
1015
docs/design-hierarchy-aggregation-2026-05-06.md
Normal file
@@ -429,7 +429,7 @@ No react-query, no Tailwind v4. Use existing `global.css` patterns.
|
||||
|
||||
### Build and deploy
|
||||
|
||||
- Existing flow stays: push to `main` on `mAi/paliad` → Gitea webhook → Dokploy auto-deploy.
|
||||
- Existing flow stays: push to `main` on `m/paliad` → Gitea webhook → Dokploy auto-deploy.
|
||||
- Dockerfile changes: add migration step to entrypoint (run `migrate up` against `DATABASE_URL` before starting the HTTP server).
|
||||
- New env vars in Dokploy:
|
||||
- `DATABASE_URL` (youpc Supabase Postgres conn string)
|
||||
@@ -710,4 +710,67 @@ Once this design is approved, who implements?
|
||||
|
||||
---
|
||||
|
||||
## 11. Post-Integration Status (added 2026-04-17)
|
||||
|
||||
Recorded after Phase J documentation pass on branch `mai/ritchie/phase-j-roadmap-rewrite`.
|
||||
|
||||
### Shipped phases
|
||||
|
||||
| Phase | Scope | Status | Merge |
|
||||
|---|---|---|---|
|
||||
| A | Database foundation, visibility model, migration tooling | ✅ Shipped | `1b2ef28` (2026-04-16) |
|
||||
| B | sqlx pool, services, Akten/Frist endpoints | ✅ Shipped | `bcc4939` (2026-04-16) |
|
||||
| C | Fristenrechner → DB-backed | ✅ Shipped | `d1909c7` (2026-04-16) |
|
||||
| D | Akten CRUD + onboarding + collaborator UI | ✅ Shipped | `4296da5` (2026-04-16) |
|
||||
| E | Persistent Frist management UI | ✅ Shipped | `316dc9f` (2026-04-16) |
|
||||
| F | Termine + CalDAV sync (AES-GCM at rest) | ✅ Shipped | `b56ef66` (2026-04-17) |
|
||||
| G | Dashboard (server-rendered) | ✅ Shipped | `b79ef25` (2026-04-16) |
|
||||
| H | AI Frist-Extraktion | ⏸ **Deferred** | branch `mai/ritchie/phase-h-ai-deadline` — not merged |
|
||||
| I | Notizen (polymorphic service + UI) | ⬜ Pending | Schema only (migrations 005/007); service and UI not started |
|
||||
| J | Roadmap rewrite + KanzlAI retirement | 🟡 **Docs only** — infra pending | see below |
|
||||
|
||||
### Phase H — deferred (not cancelled)
|
||||
|
||||
Decision by m on 2026-04-16: *"We don't want Anthropic API. We put this off for a while."* The work on branch `mai/ritchie/phase-h-ai-deadline` (commit `f539102`) covers the extraction path end-to-end, but will not be merged until the Anthropic API decision is revisited. The Dokumente tab on Akten detail stays as a "Kommt bald" placeholder. No `ANTHROPIC_API_KEY` on Dokploy.
|
||||
|
||||
Document upload + Supabase Storage alone (without AI) remains an open question — potentially worth shipping as a standalone Dokumente feature even with AI deferred.
|
||||
|
||||
### Phase I — pending
|
||||
|
||||
`paliad.notizen` table with polymorphic FK + CHECK constraint and RLS is already in place (migrations 005 and 007). The service (`notiz_service.go`), handlers, and the shared `NotizenList` TSX component are not yet built. Picks up as ~4h of focused work when the cross-cutting notes become the next friction point.
|
||||
|
||||
### Phase J — partial (documentation done; infra retirement pending)
|
||||
|
||||
**Done in this Phase J pass (2026-04-17, branch `mai/ritchie/phase-j-roadmap-rewrite`):**
|
||||
|
||||
- `docs/feature-roadmap.md` rewritten per §5 of this doc: all-in-one positioning, Phase 0 Aktenverwaltung section with completed items, "What Paliad Is" replaces "What patholo Is NOT", dropped §2.3 UPC Rechtsprechung (youpc.org link covers it), updated prioritized backlog with done markers, Phase H marked deferred, Architecture Notes data-strategy updated for `paliad` schema + office-scoped RLS.
|
||||
- `.claude/CLAUDE.md` refreshed with current feature list, env vars (`DATABASE_URL`, `CALDAV_ENCRYPTION_KEY`), and phase status.
|
||||
- `README.md` refreshed with current feature list, full migration inventory, env vars, and project layout.
|
||||
- This "Post-Integration Status" section added.
|
||||
|
||||
**Still pending — requires head + m coordination (NOT in this Phase J task scope):**
|
||||
|
||||
- Add `kanzlai.msbls.de` domain to Paliad Dokploy compose with 301 redirect rule.
|
||||
- Stop and delete the KanzlAI Dokploy app.
|
||||
- Archive the `m/KanzlAI-mGMT` Gitea repo (set read-only / archived).
|
||||
- Merge-or-separate decision for `mai.projects.kanzlai` vs. `mai.projects.paliad`.
|
||||
- `DROP SCHEMA kanzlai CASCADE` on youpc Supabase after final verification.
|
||||
- Memory: write a consolidation episode in the `paliad` group and supersede KanzlAI episodes (noted as a followup for m).
|
||||
|
||||
These are ops actions with real blast radius (public domain cutover, shared-DB schema drop, repo archival) and should not run unattended from a documentation task.
|
||||
|
||||
### Email gate: still hardcoded
|
||||
|
||||
The design §2 specified an env-configurable whitelist `[@hoganlovells.com, @hlc.com, @hlc.de]`. Current code (`internal/handlers/auth.go:115`) still hardcodes `hoganlovells.com`. Move to env config before HLC emails come online — trivial change, just hasn't happened yet.
|
||||
|
||||
### Visibility model: verified in use across shipped phases
|
||||
|
||||
The `paliad.can_see_akte(akte_id)` predicate is the single source of truth and is reused by every RLS policy and mirrored by `AkteService.GetByID` at the application layer. `FristService`, `TerminService`, and `ParteienService` all route through `AkteService.GetByID` before operating on their own rows. No duplication. Architecture invariant held through Phases D, E, F.
|
||||
|
||||
### CalDAV: manual Outlook plan still open
|
||||
|
||||
Phase F verified CalDAV against `dav.msbls.de` and Apple iCloud. HLC lives on Outlook + Exchange where CalDAV support is limited or off by default. The Phase K plan (EWS / Microsoft Graph backend behind the same sync abstraction) remains the fallback. Reassess after first real HLC user feedback.
|
||||
|
||||
---
|
||||
|
||||
*End of design.*
|
||||
|
||||
687
docs/design-partner-units-2026-04-29.md
Normal file
@@ -0,0 +1,687 @@
|
||||
# Partner Units — rename + admin management UI
|
||||
|
||||
**Task:** t-paliad-070
|
||||
**Inventor:** cronus (mai/cronus/partner-units-rename worktree)
|
||||
**Date:** 2026-04-29
|
||||
**Status:** DESIGN v2 — m answered the open questions 21:44 Wed 29.04. Revised doc below; awaiting head greenlight before coder shift.
|
||||
|
||||
## m's answers (21:44 Wed 29.04.) summarised
|
||||
|
||||
1. **Naming**: `partner_unit` everywhere (snake_case for DB/JSON, `PartnerUnit` for Go types, `partner-unit(s)` for kebab-URLs).
|
||||
2. **Rename API too**: `paliad.departments` → `paliad.partner_units`, `paliad.department_members` → `paliad.partner_unit_members`, `/api/departments/*` → `/api/partner-units/*`. Full consistency.
|
||||
3. **Settings admin section**: remove (don't duplicate).
|
||||
4. **Audit emit**: yes, in this PR.
|
||||
5. **Free-text column drop**: yes — drop `users.dezernat` entirely instead of renaming. Phase 2 collapses into Phase 1.
|
||||
|
||||
This dramatically expands the rename scope but produces a single coherent end-state (no transitional German names anywhere, no duplicate-state debt). Single PR is now even more important — splitting would leave the code in an unrunnable mid-rename state for any non-trivial duration.
|
||||
|
||||
---
|
||||
|
||||
## 1. The two concerns
|
||||
|
||||
m wants:
|
||||
|
||||
1. The user-facing concept "Dezernate" renamed to **"Partner units"** everywhere.
|
||||
2. The placeholder card on `/admin` ("Dezernate / Kommt bald") replaced with a real
|
||||
`/admin/departments` management surface.
|
||||
|
||||
These two concerns share the same code surface, so this design treats them as one PR.
|
||||
|
||||
---
|
||||
|
||||
## 2. Live-state inventory (2026-04-29)
|
||||
|
||||
What already exists:
|
||||
|
||||
| Layer | Status |
|
||||
|---|---|
|
||||
| **DB tables** | `paliad.departments` and `paliad.department_members` already English (renamed in migrations 020 + 024). RLS policies, FKs, indexes already English. |
|
||||
| **DB column** | `paliad.users.dezernat` — German legacy, free-text `text` column added in migration 015. |
|
||||
| **Go service** | `internal/services/department_service.go` — full CRUD + member management. Admin-gated via `requireAdmin` (`global_role='global_admin'`). |
|
||||
| **Go handlers** | `internal/handlers/departments.go` — 8 routes registered under `/api/departments/*`. |
|
||||
| **Frontend admin CRUD** | Already shipped — but **inside `/settings?tab=dezernat`**, not on a dedicated admin page. Visible only to global_admin (gated client-side via `me.global_role`). |
|
||||
| **Admin landing** | `/admin` shows a "Geplant / Kommt bald" Dezernate card pointing nowhere. |
|
||||
| **Admin team page** | `/admin/team` has a "Dezernat" free-text column and edit input bound to `paliad.users.dezernat`. |
|
||||
| **Onboarding** | Asks for "Dezernat / Partner" as free text, persists to `users.dezernat`. |
|
||||
| **Settings profile tab** | Asks for "Dezernat oder Partner" free text. |
|
||||
| **Team directory** | `/team` groups colleagues by `users.dezernat` free-text fallback when `paliad.departments` membership is missing. |
|
||||
|
||||
The duplicate-state debt is real: the same concept lives in two places —
|
||||
the structured `paliad.departments` registry (admin-managed) and the free-text
|
||||
`paliad.users.dezernat` column (user-typed). Migration 019 backfilled the
|
||||
former from the latter, but they have been drifting apart since. **Resolving
|
||||
that drift is out of scope for this task** — flagged as Phase 2.
|
||||
|
||||
Counts (`grep -l`):
|
||||
- 7 Go files mention `dezernat` / `Dezernat`
|
||||
- 10 frontend files (`.ts` / `.tsx`)
|
||||
- 2 SQL migrations (015 = column add, 019 = seed function)
|
||||
- ~80 i18n strings
|
||||
|
||||
---
|
||||
|
||||
## 3. Naming decisions (per m)
|
||||
|
||||
### 3.1 User-facing label (cross-language)
|
||||
|
||||
**"Partner unit" / "Partner units"** — same English phrase in DE and EN.
|
||||
Capitalised loanword in DE strings ("Partner Unit anlegen", "Partner Units
|
||||
verwalten").
|
||||
|
||||
### 3.2 Internal names — full rename to `partner_unit`
|
||||
|
||||
Per m's "lets fix departments even in api?!", everything Department-shaped
|
||||
on the structured side renames too. End state:
|
||||
|
||||
| Surface | Before | After |
|
||||
|---|---|---|
|
||||
| Table | `paliad.departments` | `paliad.partner_units` |
|
||||
| Junction table | `paliad.department_members` | `paliad.partner_unit_members` |
|
||||
| FK column on junction | `department_id` | `partner_unit_id` |
|
||||
| Constraint names | `departments_*`, `department_members_*` | `partner_units_*`, `partner_unit_members_*` |
|
||||
| Index names | same prefix | same prefix |
|
||||
| RLS policy names | `departments_select` etc. | `partner_units_select` etc. |
|
||||
| Go type | `models.Department` | `models.PartnerUnit` |
|
||||
| Go type | `services.DepartmentMember` | `services.PartnerUnitMember` |
|
||||
| Go type | `services.DepartmentWithMembers` | `services.PartnerUnitWithMembers` |
|
||||
| Go service | `DepartmentService` (`Service.Department`) | `PartnerUnitService` (`Service.PartnerUnit`) |
|
||||
| Go file | `internal/services/department_service.go` | `internal/services/partner_unit_service.go` |
|
||||
| Go file | `internal/handlers/departments.go` | `internal/handlers/partner_units.go` |
|
||||
| API path | `/api/departments` | `/api/partner-units` |
|
||||
| API path | `/api/departments/{id}/members` | `/api/partner-units/{id}/members` |
|
||||
| Admin URL | `/admin/departments` | `/admin/partner-units` |
|
||||
| TSX file | (new) `admin-partner-units.tsx` | same |
|
||||
| Client TS | (new) `client/admin-partner-units.ts` | same |
|
||||
| JSON keys | `department_id`, `lead_user_id`, `members[]` | `partner_unit_id`, `lead_user_id`, `members[]` |
|
||||
| i18n keys | `dezernat.*` | `partner_unit.*` |
|
||||
| CSS classes | `.dezernat-*` | `.partner-unit-*` |
|
||||
| CSS classes | (none today) | `.partner-unit-*` |
|
||||
|
||||
### 3.3 The `users.dezernat` free-text column
|
||||
|
||||
**Drop entirely** (per m's answer 5). Migration also re-runs migration 019's
|
||||
seed logic immediately before the drop, to capture any drift since 019 ran
|
||||
(users who edited their `dezernat` value via `/settings` after 019 won't
|
||||
have a corresponding `partner_unit_members` row). Idempotent
|
||||
`ON CONFLICT DO NOTHING`.
|
||||
|
||||
This means **the onboarding form stops asking for a free-text Dezernat/
|
||||
Partner field** and **the settings profile tab stops surfacing it**.
|
||||
|
||||
Replacement UX (lightweight — same PR):
|
||||
- **Onboarding**: replace the free-text `dezernat` input with a `<select>`
|
||||
populated from `GET /api/partner-units` (anonymous-readable; the public
|
||||
list is fine to expose). First option = "(noch keine zuordnung / not
|
||||
assigned yet)" maps to no membership. The select writes a
|
||||
`partner_unit_id` to the create-user payload, and the user-creation flow
|
||||
inserts a row in `paliad.partner_unit_members` if a unit was picked.
|
||||
- **Settings profile tab**: drop the field entirely. Membership management
|
||||
for non-admins lives on the existing "Mein Partner Units" read-only view
|
||||
(which stays — see §4.4). If a user wants to change their own membership,
|
||||
they ask an admin (matches the "global_admin only" model in §5).
|
||||
- **Admin-team table**: drop the "Dezernat" column and the inline-edit input
|
||||
for it. Admin sees memberships via the dedicated `/admin/partner-units`
|
||||
page; the team page already has membership chips shown (per F-44 — verify
|
||||
during smoke). Reduces double-source-of-truth confusion.
|
||||
- **Team directory grouping**: the `/team` "Nach Dezernat" group keeps its
|
||||
partner-unit grouping (now reading only from structured `partner_unit_members`),
|
||||
drops the free-text fallback bucket.
|
||||
|
||||
### 3.4 What does NOT rename
|
||||
|
||||
- `lead_user_id` (column on partner_units) — generic FK name, not
|
||||
Department-flavoured.
|
||||
- `office` (column on partner_units) — generic.
|
||||
- The 8 HTTP routes' shape — only the path changes; verbs/handler names
|
||||
rename (`handleListDepartments` → `handleListPartnerUnits`).
|
||||
- `paliad.users.office`, `paliad.users.additional_offices` — orthogonal.
|
||||
|
||||
### 3.5 URL strategy
|
||||
|
||||
- `/settings?tab=dezernat` — tab is removed (admin section moves to
|
||||
`/admin/partner-units`, "my unit" view becomes a card on the profile tab).
|
||||
No redirect needed (settings tabs aren't externally bookmarked).
|
||||
- `/admin/partner-units` is the new admin page. The old placeholder card
|
||||
was a no-op, no legacy URL to redirect from.
|
||||
- `/api/departments/*` — no legacy redirect. The API is internal to the
|
||||
bundled JS (no third-party consumer); a one-shot rename without aliases is
|
||||
safe. Should there ever be an integration in flight, add a 301 alias in
|
||||
`internal/handlers/redirects.go` mirroring the existing `/dezernate`
|
||||
redirect.
|
||||
|
||||
---
|
||||
|
||||
## 4. The new `/admin/partner-units` page
|
||||
|
||||
### 4.1 Surface
|
||||
|
||||
A dedicated admin page mirroring `/admin/team`'s aesthetic:
|
||||
|
||||
- **Page title:** "Partner Units verwalten" / "Manage Partner Units"
|
||||
- **Top bar:** count of partner units, plus a primary "Neue Partner Unit anlegen"
|
||||
button (opens an inline form panel below the table — matches admin-team's
|
||||
invite/onboard pattern).
|
||||
- **Table:** columns = Name · Office · Lead (display name + email) · Members
|
||||
count · Actions. One row per partner unit, ordered by office then name.
|
||||
- **Inline edit:** click a row → expand below for {edit name / change office /
|
||||
change lead / view+manage members}. Same disclosure pattern as the existing
|
||||
settings admin section, but lifted to a top-level admin page with breathing
|
||||
room.
|
||||
- **Member management:** typeahead "add member" input (re-uses the same
|
||||
`/api/users` endpoint `loadUserOptions()` already calls). Each member row
|
||||
has a remove button with confirmation. Optional "make lead" pin if the
|
||||
member is a lead candidate (`job_title` containing "Partner" — soft hint,
|
||||
not a gate).
|
||||
- **Delete:** danger button with confirm. Cascades memberships (FK on
|
||||
`department_members`).
|
||||
|
||||
Wireframe (ASCII):
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ Admin > Partner Units [+ Neue Partner Unit] │
|
||||
├────────────────────────────────────────────────────────────────────────┤
|
||||
│ Suche: [____________] Office: [Alle ▼] │
|
||||
├────────────────────────────────────────────────────────────────────────┤
|
||||
│ Name Office Lead Mitglieder Aktion │
|
||||
│ Team Müller München Dr. M. Müller 7 ▾ ✏ 🗑 │
|
||||
│ Team Schmidt München Dr. A. Schmidt 3 ▾ ✏ 🗑 │
|
||||
│ Team Lopez Düsseldorf J. Lopez 5 ▾ ✏ 🗑 │
|
||||
│ ... │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Click ▾ on a row to expand:
|
||||
┌─ Mitglieder verwalten — Team Müller ────────────────────────────────────┐
|
||||
│ • Dr. M. Müller muller@hlc.de ★ Lead │
|
||||
│ • A. Bauer bauer@hlc.de [Entfernen] │
|
||||
│ • C. Kim kim@hlc.de [Entfernen] │
|
||||
│ ... │
|
||||
│ [Mitglied hinzufügen: __________________ ▼] [Hinzufügen] │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 Files to create
|
||||
|
||||
- `frontend/src/admin-partner-units.tsx` — page render, mirrors
|
||||
`admin-team.tsx` shape: container + tool-header + filters + table.
|
||||
- `frontend/src/client/admin-partner-units.ts` — fetch, render, edit,
|
||||
delete, member CRUD. Reuses the office list endpoint, `/api/users`
|
||||
for the typeahead, `t()` for i18n, sidebar + bottom-nav init.
|
||||
- `frontend/build.ts` entry — `renderAdminPartnerUnits` →
|
||||
`dist/admin-partner-units.html`, `dist/assets/admin-partner-units.js`.
|
||||
- `internal/handlers/admin_partner_units.go` —
|
||||
`handleAdminPartnerUnitsPage` (one-liner ServeFile, mirrors
|
||||
`handleAdminTeamPage`).
|
||||
|
||||
### 4.3 Files to edit
|
||||
|
||||
- `internal/handlers/handlers.go` — register `GET /admin/partner-units`
|
||||
inside the existing `if svc != nil && svc.Users != nil` block, gated by
|
||||
`auth.RequireAdminFunc(svc.Users, gateOnboarded(handleAdminPartnerUnitsPage))`.
|
||||
Re-register the 8 `/api/partner-units/*` routes (renamed from
|
||||
`/api/departments/*`).
|
||||
- `frontend/src/admin.tsx` — flip the Partner-Units card from the
|
||||
"Geplant" section to the "Verfügbar" section, with
|
||||
`href="/admin/partner-units"`, remove the `admin-card-soon` class and the
|
||||
"Kommt bald" badge. Icon stays `ICON_BUILDING`.
|
||||
- `frontend/src/components/Sidebar.tsx` — add a third admin nav item
|
||||
inside `#sidebar-admin-group`: `navItem("/admin/partner-units",
|
||||
ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)`.
|
||||
- `frontend/src/client/i18n.ts` — replace `dezernat.*` and add new keys
|
||||
(see §6).
|
||||
|
||||
### 4.4 Settings page cleanup
|
||||
|
||||
The current `/settings?tab=dezernat` has TWO panels:
|
||||
- "Mein Dezernat" (read-only, shows the user's own units) — **keep** as a
|
||||
card on the profile tab (no longer needs its own tab; the only reason it
|
||||
had one was the admin CRUD section). Renamed to "Meine Partner Units".
|
||||
- "Dezernate verwalten (Admin)" (full CRUD) — **remove**. Replaced by
|
||||
`/admin/partner-units`. Reduces duplication and matches the "admin tools
|
||||
live under /admin" convention established by t-paliad-050.
|
||||
|
||||
Net code delta in `settings.tsx` + `settings.ts`: removes ~250 lines (admin
|
||||
CRUD moves to new page; read-only "my units" card moves into profile tab as
|
||||
~30 lines). The `dezernat` profile-input field is removed entirely (no
|
||||
replacement on the profile tab; users manage membership via admin requests).
|
||||
|
||||
The settings tab list shrinks from 4 to 3: `profil`, `benachrichtigungen`,
|
||||
`caldav`. URL `/settings?tab=dezernat` 404s gracefully (the tab resolver in
|
||||
`appointments_pages.go` falls back to `profil`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Permission model
|
||||
|
||||
| Action | Today | After |
|
||||
|---|---|---|
|
||||
| List partner units (read) | any authenticated user | unchanged |
|
||||
| Get partner unit details | any authenticated user | unchanged |
|
||||
| List members | any authenticated user | unchanged |
|
||||
| Get own memberships | any authenticated user | unchanged |
|
||||
| Create | global_admin only | unchanged |
|
||||
| Update | global_admin only | unchanged |
|
||||
| Delete | global_admin only | unchanged |
|
||||
| Add member | global_admin only | unchanged |
|
||||
| Remove member | global_admin only | unchanged |
|
||||
|
||||
No permission-model changes. Service-level `requireAdmin` already enforces
|
||||
`global_role='global_admin'` for every write.
|
||||
|
||||
**Out of scope (defer):** allowing the partner unit's lead user to manage
|
||||
their own unit's members. m's brief asks "who can assign members? (global_admin
|
||||
+ the unit's lead/partner?)" — recommendation: defer. Today there are no
|
||||
real partners with `lead_user_id` set in prod, and m has been actively
|
||||
pruning permission complexity. Add later when there's a clear request.
|
||||
|
||||
---
|
||||
|
||||
## 6. i18n strings
|
||||
|
||||
**Drop entirely** (no replacement — surfaces are removed):
|
||||
- `einstellungen.profil.dezernat`, `einstellungen.profil.dezernat.placeholder`
|
||||
(settings profile field is gone)
|
||||
- `einstellungen.tab.dezernat` (tab is gone)
|
||||
- `onboarding.dezernat`, `onboarding.dezernat.placeholder` (free-text input is
|
||||
replaced with a select; new keys: `onboarding.partner_unit`,
|
||||
`onboarding.partner_unit.placeholder`, `onboarding.partner_unit.unassigned`)
|
||||
- `admin.team.col.dezernat` (column removed from admin-team)
|
||||
- `admin.team.direct_add.dezernat` (input removed from add-form)
|
||||
- `dezernat.error.user_required`, `dezernat.field.office`, `dezernat.field.name`,
|
||||
`dezernat.admin.heading`, `dezernat.admin.new`, `dezernat.admin.create` —
|
||||
these belonged to the settings admin section that moves to the new page;
|
||||
same strings re-keyed under `admin.partner_units.*`.
|
||||
- `team.dept.unassigned` ("Ohne Dezernat") — replaced with
|
||||
`team.partner_unit.unassigned` ("Ohne Partner Unit")
|
||||
|
||||
**Add (new admin page):**
|
||||
- `nav.admin.partner_units` = "Partner Units"
|
||||
- `admin.partner_units.title`, `admin.partner_units.heading`,
|
||||
`admin.partner_units.subtitle`
|
||||
- `admin.partner_units.col.name`, `.col.office`, `.col.lead`, `.col.members`,
|
||||
`.col.actions`
|
||||
- `admin.partner_units.new`, `admin.partner_units.new.heading`,
|
||||
`admin.partner_units.create`, `admin.partner_units.cancel`,
|
||||
`admin.partner_units.delete`, `admin.partner_units.confirm_delete`
|
||||
- `admin.partner_units.member.add`, `.member.remove`, `.member.confirm_remove`,
|
||||
`.member.placeholder`, `.member.empty`, `.member.loading`
|
||||
- `admin.partner_units.error.name_required`, `.error.user_required`
|
||||
- `admin.partner_units.empty` ("Noch keine Partner Units angelegt.")
|
||||
|
||||
**Rename (settings profile-tab "my partner units" card):**
|
||||
- `dezernat.heading` → `partner_unit.heading` ("Meine Partner Units")
|
||||
- `dezernat.subtitle` → `partner_unit.subtitle`
|
||||
- `dezernat.none` → `partner_unit.none`
|
||||
- `dezernat.members_label` → `partner_unit.members_label`
|
||||
|
||||
**Update copy** (no key change):
|
||||
- `admin.card.departments.title` → "Partner Units" (was "Dezernate") — and
|
||||
the key itself renames to `admin.card.partner_units.title` for consistency
|
||||
- `admin.card.departments.desc` → "Partner Units anlegen und Mitglieder
|
||||
verwalten." → key renames to `admin.card.partner_units.desc`
|
||||
- `admin.card.feature_flags.desc` — German body mentions "Dezernat",
|
||||
rewrite as "Partner Unit"
|
||||
- `team.subtitle` and `team.group.department` — German bodies say
|
||||
"Dezernat", rewrite
|
||||
|
||||
DE strings use "Partner Unit" / "Partner Units" verbatim (capitalised
|
||||
loanword). EN uses the same.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration plan
|
||||
|
||||
### 7.1 Migration 026: rename tables + drop free-text column
|
||||
|
||||
One migration, ordered statements, all wrapped in a single tx by migrate.v4:
|
||||
|
||||
```sql
|
||||
-- 026_rename_to_partner_units.up.sql
|
||||
BEGIN; -- migrate.v4 wraps automatically; explicit BEGIN for psql -1 fallback
|
||||
|
||||
-- 1. Best-effort second seed: pick up any users whose dezernat free-text
|
||||
-- drifted after migration 019 ran. Idempotent.
|
||||
INSERT INTO paliad.departments (id, name, lead_user_id, office, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), btrim(u.dezernat), NULL, MIN(u.office), now(), now()
|
||||
FROM paliad.users u
|
||||
WHERE u.dezernat IS NOT NULL AND btrim(u.dezernat) <> ''
|
||||
GROUP BY btrim(u.dezernat)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO paliad.department_members (department_id, user_id, created_at)
|
||||
SELECT d.id, u.id, now()
|
||||
FROM paliad.users u
|
||||
JOIN paliad.departments d ON d.name = btrim(u.dezernat)
|
||||
WHERE u.dezernat IS NOT NULL AND btrim(u.dezernat) <> ''
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 2. Drop the free-text column.
|
||||
ALTER TABLE paliad.users DROP COLUMN dezernat;
|
||||
|
||||
-- 3. Rename tables.
|
||||
ALTER TABLE paliad.departments RENAME TO partner_units;
|
||||
ALTER TABLE paliad.department_members RENAME TO partner_unit_members;
|
||||
|
||||
-- 4. Rename column on the junction.
|
||||
ALTER TABLE paliad.partner_unit_members RENAME COLUMN department_id TO partner_unit_id;
|
||||
|
||||
-- 5. Rename constraints (pkey/fkey/check). Postgres auto-renames the
|
||||
-- underlying index for pkey/uniq constraints.
|
||||
ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_pkey TO partner_units_pkey;
|
||||
ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_lead_user_id_fkey TO partner_units_lead_user_id_fkey;
|
||||
ALTER TABLE paliad.partner_units RENAME CONSTRAINT departments_office_check TO partner_units_office_check;
|
||||
ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_pkey TO partner_unit_members_pkey;
|
||||
ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_department_id_fkey TO partner_unit_members_partner_unit_id_fkey;
|
||||
ALTER TABLE paliad.partner_unit_members RENAME CONSTRAINT department_members_user_id_fkey TO partner_unit_members_user_id_fkey;
|
||||
|
||||
-- 6. Rename non-pkey indexes.
|
||||
ALTER INDEX paliad.departments_office_idx RENAME TO partner_units_office_idx;
|
||||
ALTER INDEX paliad.departments_lead_idx RENAME TO partner_units_lead_idx;
|
||||
ALTER INDEX paliad.department_members_user_idx RENAME TO partner_unit_members_user_idx;
|
||||
|
||||
-- 7. Rename RLS policies.
|
||||
ALTER POLICY departments_select ON paliad.partner_units RENAME TO partner_units_select;
|
||||
ALTER POLICY departments_write ON paliad.partner_units RENAME TO partner_units_write;
|
||||
ALTER POLICY department_members_select ON paliad.partner_unit_members RENAME TO partner_unit_members_select;
|
||||
ALTER POLICY department_members_write ON paliad.partner_unit_members RENAME TO partner_unit_members_write;
|
||||
|
||||
-- 8. Audit table for partner-unit events. Per §8 — minimal schema, no UI yet.
|
||||
CREATE TABLE paliad.partner_unit_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
partner_unit_id uuid NULL REFERENCES paliad.partner_units(id) ON DELETE SET NULL,
|
||||
actor_id uuid NOT NULL REFERENCES auth.users(id),
|
||||
event_type text NOT NULL CHECK (event_type IN (
|
||||
'created', 'updated', 'deleted', 'member_added', 'member_removed'
|
||||
)),
|
||||
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX partner_unit_events_unit_idx ON paliad.partner_unit_events(partner_unit_id, created_at DESC);
|
||||
CREATE INDEX partner_unit_events_actor_idx ON paliad.partner_unit_events(actor_id, created_at DESC);
|
||||
|
||||
-- RLS: any authenticated user can read (matches /api/partner-units read
|
||||
-- access); only global_admin can write (writes happen inside service
|
||||
-- methods that already gate with requireAdmin, so RLS is defence-in-depth).
|
||||
ALTER TABLE paliad.partner_unit_events ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY partner_unit_events_select ON paliad.partner_unit_events
|
||||
FOR SELECT USING (auth.uid() IS NOT NULL);
|
||||
CREATE POLICY partner_unit_events_write ON paliad.partner_unit_events
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin')
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
**Down migration** is the symmetric reverse of steps 8 → 1, with one caveat:
|
||||
step 1 (the seed) cannot be perfectly reversed. The `paliad.users.dezernat`
|
||||
column is recreated with NULLs; original values are lost. This is acceptable
|
||||
because the data is preserved structurally in `partner_unit_members`.
|
||||
|
||||
If a true rollback is ever needed and per-user free-text values must be
|
||||
restored, an admin script can re-seed from `partner_unit_members`:
|
||||
`UPDATE paliad.users u SET dezernat = (SELECT pu.name FROM ... LIMIT 1)`.
|
||||
Documented in the down migration as a comment, not auto-run.
|
||||
|
||||
### 7.2 Code cutover
|
||||
|
||||
migrate.v4 wraps the up migration in a single tx. If anything in the rename
|
||||
chain fails (e.g. a constraint name mismatch on a freshly-provisioned DB
|
||||
that didn't go through 020+024), the entire migration aborts and the dirty
|
||||
flag is set. To minimise that risk, the constraint/index/policy rename
|
||||
statements are wrapped in `DO $$ ... EXCEPTION WHEN undefined_object THEN
|
||||
NULL END $$` blocks (same idempotency pattern migration 024 used).
|
||||
|
||||
Order of operations:
|
||||
1. Push code (with migration 026 in `embed.FS`) to main.
|
||||
2. Dokploy auto-deploys; the new binary's `migrate.Up()` runs migration 026
|
||||
atomically before binding the listener.
|
||||
3. Verify `/api/partner-units` returns the renamed table contents; `/admin/partner-units`
|
||||
renders; `paliad.users.dezernat` no longer exists.
|
||||
|
||||
Migration risk is moderate (multi-statement, table rename + column drop +
|
||||
new audit table) but contained: every statement is idempotent or
|
||||
exception-trapped, and it all runs inside one tx so a partial apply is
|
||||
impossible.
|
||||
|
||||
### 7.3 Rollback
|
||||
|
||||
`migrate down 1` reverses everything. The data loss noted above (free-text
|
||||
column re-created with NULLs) is acceptable per §3.3 — structured
|
||||
membership rows are the source of truth post-rename.
|
||||
|
||||
---
|
||||
|
||||
## 8. Audit logging — emitted in this PR
|
||||
|
||||
Per m's "audit emit? sure, why not", this PR ships audit emission. To stay
|
||||
small and not pre-empt t-paliad-071's eventual cross-cutting audit design,
|
||||
the emission goes to a dedicated `paliad.partner_unit_events` table (see
|
||||
migration 026 step 8) rather than a global audit table. t-paliad-071 can
|
||||
later subsume it (UNION ALL into a global view, or migrate rows into a
|
||||
unified table).
|
||||
|
||||
### Events emitted
|
||||
|
||||
Each event is INSERTed in the same tx as the originating mutation.
|
||||
|
||||
| Event | When | Payload |
|
||||
|---|---|---|
|
||||
| `created` | `Create` succeeds | `{name, office, lead_user_id}` |
|
||||
| `updated` | `Update` writes ≥1 column | `{before: {…}, after: {…}, fields: ["name","office",…]}` |
|
||||
| `deleted` | `Delete` succeeds (before cascade) | `{name, office, lead_user_id, member_count}` |
|
||||
| `member_added` | `AddMember` actually inserts | `{user_id, user_email, user_display_name}` |
|
||||
| `member_removed` | `RemoveMember` actually deletes ≥1 row | `{user_id}` |
|
||||
|
||||
`actor_id` is the `callerID` already passed to every service method.
|
||||
`partner_unit_id` is set to NULL on `deleted` after the unit row is gone
|
||||
(FK has `ON DELETE SET NULL`), so the historical event row survives.
|
||||
|
||||
### No new endpoint in this PR
|
||||
|
||||
The `partner_unit_events` table is queryable via `/api/partner-units/{id}/events`
|
||||
in a follow-up — keeping that endpoint out of scope here aligns with the
|
||||
"ship audit emit, defer audit UX" framing. If t-paliad-071 wants to expose
|
||||
events through a unified audit surface, that's the right home.
|
||||
|
||||
### Service-side wiring
|
||||
|
||||
A single helper inside `PartnerUnitService`:
|
||||
|
||||
```go
|
||||
func (s *PartnerUnitService) emit(ctx context.Context, tx *sqlx.Tx,
|
||||
actorID uuid.UUID, unitID *uuid.UUID, eventType string, payload any) error {
|
||||
p, err := json.Marshal(payload)
|
||||
if err != nil { return err }
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.partner_unit_events
|
||||
(partner_unit_id, actor_id, event_type, payload)
|
||||
VALUES ($1, $2, $3, $4)`, unitID, actorID, eventType, p)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
Each mutating method opens a tx (currently they don't — they use
|
||||
`db.ExecContext` directly), runs the mutation + emit, commits. Adds ~5
|
||||
lines per method × 5 methods = ~25 lines of audit plumbing.
|
||||
|
||||
---
|
||||
|
||||
## 9. Test plan
|
||||
|
||||
### 9.1 Build gauntlet
|
||||
|
||||
- `go build ./...`
|
||||
- `go vet ./...`
|
||||
- `go test ./...` (existing user_service_test.go uses `dezernat` test name —
|
||||
rename to `department` to match)
|
||||
- `cd frontend && bun run build`
|
||||
|
||||
### 9.2 Manual smoke (paliad.de as `tester@hlc.de`)
|
||||
|
||||
1. Log in as global_admin.
|
||||
2. Visit `/admin` — confirm "Partner Units" card under "Verfügbar" (not
|
||||
"Geplant"), no "Kommt bald" badge.
|
||||
3. Click → land on `/admin/partner-units` — confirm table renders existing
|
||||
units (with names migration 019 + the second seed produced).
|
||||
4. Create a new unit "Test Unit Cronus" (Munich, no lead). Confirm a
|
||||
`created` row appears in `paliad.partner_unit_events`.
|
||||
5. Edit name → "Test Unit Cronus (renamed)". Confirm `updated` event row.
|
||||
6. Add tester@hlc.de as member; confirm `member_added` event; chip appears
|
||||
on `/team` directory grouping.
|
||||
7. Remove member. Confirm `member_removed` event.
|
||||
8. Delete the test unit; confirm row disappears from the table; confirm
|
||||
`deleted` event row exists with `partner_unit_id IS NULL` (orphaned by
|
||||
ON DELETE SET NULL).
|
||||
9. Visit `/settings` — confirm tab list is `Profil | Benachrichtigungen |
|
||||
CalDAV` (no Dezernat tab). Profile tab has "Meine Partner Units" card;
|
||||
no free-text dezernat input.
|
||||
10. Visit `/team` — confirm grouping by Partner Unit (not Dezernat) and
|
||||
"Ohne Partner Unit" fallback label.
|
||||
11. Visit `/admin/team` — confirm Dezernat column is gone; add-form has no
|
||||
Dezernat input.
|
||||
12. Visit `/onboarding` (with a fresh auth.users-only account) — confirm
|
||||
the free-text Dezernat input is replaced with a partner-unit `<select>`.
|
||||
13. Sign out, sign back in as a non-admin — confirm `/admin/partner-units`
|
||||
returns 302 to `/dashboard?forbidden=admin`, sidebar admin section is
|
||||
hidden.
|
||||
|
||||
### 9.3 Playwright (optional — confirm with head)
|
||||
|
||||
If Playwright smoke is desired, mirror t-paliad-050's admin-team pattern:
|
||||
navigate, create, edit, delete, screenshot. Add an SQL assertion step that
|
||||
checks `partner_unit_events` row counts after each action.
|
||||
|
||||
---
|
||||
|
||||
## 10. PR strategy
|
||||
|
||||
**Single PR, single merge to main.**
|
||||
|
||||
Reasoning:
|
||||
- The rename touches the same files as the new admin page (admin.tsx,
|
||||
i18n.ts, settings.tsx, admin-team.tsx, sidebar.tsx, onboarding.tsx,
|
||||
team.tsx). Splitting forces ugly rebases.
|
||||
- The migration is multi-statement but single-tx — no risk of partial apply.
|
||||
- The user-facing label change is consistent only after the WHOLE diff lands.
|
||||
A split would land "internal rename" with old labels still saying "Dezernat",
|
||||
then "label change" — confusing during the gap.
|
||||
- Settings has a redirect dependency (`/settings?tab=dezernat` 404 fallback)
|
||||
that's only safe once the entire dezernat surface is gone.
|
||||
|
||||
Branch already in place: `mai/cronus/partner-units-rename`.
|
||||
|
||||
Estimated diff size: ~2200 lines net. Heavier than v1 because the
|
||||
structured-side rename (Department → PartnerUnit) cascades through
|
||||
service/handler/types/SQL/tests, plus onboarding form rebuild, plus audit
|
||||
table + emit plumbing. No new dependencies.
|
||||
|
||||
---
|
||||
|
||||
## 11. Out of scope (deferred)
|
||||
|
||||
- **Hierarchical partner units** — flat list only. Per brief.
|
||||
- **Per-partner-unit branding** (logo, colour) — defer.
|
||||
- **Non-admin permission model** (lead manages own unit's members) — defer.
|
||||
- **Audit UI** (a viewer for `partner_unit_events`) — defer to t-paliad-071.
|
||||
Emission lands here; consumption + a unified events surface lands there.
|
||||
- **Other entities' audit emission** — only partner units in this PR.
|
||||
Projects already have `project_events`; deadlines/appointments already
|
||||
emit. No global cross-entity audit yet.
|
||||
- **Onboarding "create new partner unit" inline** — the new select offers
|
||||
existing units + "(noch keine zuordnung)". A user wanting a new unit asks
|
||||
an admin or self-promotes via `/admin/partner-units` post-onboarding (only
|
||||
global_admin sees that page). Inline create-during-onboarding is a small
|
||||
follow-up if friction surfaces.
|
||||
|
||||
---
|
||||
|
||||
## 12. Open questions — RESOLVED 21:44 Wed 29.04. (m's answers)
|
||||
|
||||
| # | Question | m's answer |
|
||||
|---|---|---|
|
||||
| 1 | Column rename target | **partner_unit** (became "drop entirely" after Q5) |
|
||||
| 2 | API + URL rename | **yes — fix departments in api too** |
|
||||
| 3 | Settings admin section removal | **yes** ("you do you") |
|
||||
| 4 | Audit emit in this PR | **yes** ("sure why not") |
|
||||
| 5 | Drop free-text column | **yes** ("makes sense") |
|
||||
|
||||
No remaining open questions. Design is now greenlit pending head's gate
|
||||
review of this v2 doc.
|
||||
|
||||
---
|
||||
|
||||
## 13. Files (final)
|
||||
|
||||
### New
|
||||
- `internal/db/migrations/026_rename_to_partner_units.up.sql`
|
||||
- `internal/db/migrations/026_rename_to_partner_units.down.sql`
|
||||
- `internal/services/partner_unit_service.go` (renamed from
|
||||
`department_service.go` via `git mv` so blame survives — content rewritten
|
||||
for type + SQL renames + audit emit)
|
||||
- `internal/handlers/partner_units.go` (renamed from `departments.go`)
|
||||
- `internal/handlers/admin_partner_units.go` — page-serve handler
|
||||
- `frontend/src/admin-partner-units.tsx`
|
||||
- `frontend/src/client/admin-partner-units.ts`
|
||||
|
||||
### Edit (Go)
|
||||
- `internal/services/services.go` — wire `PartnerUnit *PartnerUnitService`.
|
||||
- `internal/services/user_service.go` — drop `Dezernat` field from struct,
|
||||
drop dezernat from SQL columns, drop dezernat from CreateUserInput +
|
||||
UpdateUserInput, etc.
|
||||
- `internal/services/user_service_test.go` — drop dezernat assertions;
|
||||
add partner_unit_id + member-row assertions if onboarding/admin-create
|
||||
paths now insert membership.
|
||||
- `internal/models/models.go` — drop `User.Dezernat`; rename
|
||||
`Department` → `PartnerUnit`, `DepartmentMember` → `PartnerUnitMember`.
|
||||
- `internal/handlers/admin_users.go` — drop dezernat from admin
|
||||
create/update payloads.
|
||||
- `internal/handlers/handlers.go` — re-register `/api/partner-units/*`,
|
||||
add `GET /admin/partner-units`, drop `dbSvc.department` field, add
|
||||
`dbSvc.partnerUnit`.
|
||||
- `internal/handlers/redirects.go` — drop the `/dezernate` → `/departments`
|
||||
entry (the path is dead post-rename) OR keep for one cycle; flag in PR
|
||||
description.
|
||||
- `internal/handlers/appointments_pages.go` — drop the `"dezernat"` /
|
||||
`"department"` tab aliases entirely (tab is gone). Default fallback handles
|
||||
`/settings?tab=dezernat` gracefully.
|
||||
|
||||
### Edit (frontend)
|
||||
- `frontend/src/admin.tsx` — flip the Partner-Units card from "Geplant" to
|
||||
"Verfügbar".
|
||||
- `frontend/src/admin-team.tsx` — drop the "Dezernat" column and the
|
||||
add-form input.
|
||||
- `frontend/src/client/admin-team.ts` — drop dezernat from payload + render.
|
||||
- `frontend/src/onboarding.tsx` — replace free-text input with `<select>`
|
||||
populated from `/api/partner-units`, plus an "(noch keine zuordnung)"
|
||||
option. Label is `onboarding.partner_unit`.
|
||||
- `frontend/src/client/onboarding.ts` — submit `partner_unit_id` instead of
|
||||
`dezernat`. The user-create endpoint now accepts an optional `partner_unit_id`
|
||||
and inserts a membership row in the same tx.
|
||||
- `frontend/src/settings.tsx` — drop the dezernat tab, drop the dezernat
|
||||
profile-field input, add a "Meine Partner Units" card on the profile tab.
|
||||
- `frontend/src/client/settings.ts` — drop `dezernat` from `TabName` and
|
||||
`TABS`, drop ~250 lines of admin CRUD + free-text plumbing, replace with
|
||||
~40 lines for the read-only "my units" card.
|
||||
- `frontend/src/team.tsx` + `frontend/src/client/team.ts` — labels and
|
||||
drop the free-text fallback bucket; group only by structured
|
||||
`partner_unit_members`.
|
||||
- `frontend/src/components/Sidebar.tsx` — add `/admin/partner-units` nav
|
||||
item with `nav.admin.partner_units` label.
|
||||
- `frontend/src/client/i18n.ts` — drop ~30 dezernat keys × 2 langs;
|
||||
add ~25 partner_unit keys × 2 langs.
|
||||
- `frontend/src/styles/global.css` — `.dezernat-*` → `.partner-unit-*`.
|
||||
- `frontend/build.ts` — new `renderAdminPartnerUnits` entry.
|
||||
|
||||
---
|
||||
|
||||
## 14. Inventor → coder gate
|
||||
|
||||
Stop after this design + a `mai report completed "DESIGN READY FOR REVIEW…"`.
|
||||
Awaiting m's go/no-go on the open questions in §12 before any code change.
|
||||
|
||||
Recommended implementer: **cronus** (this same worktree, already on
|
||||
`mai/cronus/partner-units-rename`). Mechanical rename + one new page is
|
||||
straightforward Sonnet work; the design context doesn't need to transfer
|
||||
to a fresh worker.
|
||||
340
docs/design-permissions-vs-roles.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# Design: Separate Job Title from Global Permissions
|
||||
|
||||
**Status:** Draft for review (m + head)
|
||||
**Author:** cronus (mai inventor)
|
||||
**Date:** 2026-04-27
|
||||
**Task:** t-paliad-051
|
||||
|
||||
## Problem
|
||||
|
||||
Three orthogonal concepts share one column today:
|
||||
|
||||
1. **Job title** — Partner / Counsel / Associate / PA / Trainee / Sekretariat / "Counsel Knowledge Lawyer" / … . Free-text since migration 015. Display only.
|
||||
2. **Global permissions** — currently "is the user a global admin?" piggy-backed on the same column with `paliad.users.role = 'admin'` checks across Go, SQL, and JS.
|
||||
3. **Per-project role** — `paliad.project_teams.role` ∈ {lead, associate, pa, of_counsel, local_counsel, expert, observer, admin}. Already separated, fine, **out of scope**.
|
||||
|
||||
The collision bites whenever someone tries to record their real job title without losing admin access. Concrete trigger: m's job title is "Counsel Knowledge Lawyer". If he sets that as his `role`, he loses every `role='admin'` gate in the codebase. Today the admin-team page (t-paliad-050) actually hard-rejects setting `role='admin'` from the UI (`AdminUpdateUser` raises `ErrAdminBootstrapOnly`), so any UI-driven edit of m's row would silently demote him.
|
||||
|
||||
Live state confirmed 2026-04-27 14:25 against `100.99.98.201:11833`:
|
||||
|
||||
| email | role | display_name |
|
||||
|---|---|---|
|
||||
| matthias.siebels@hoganlovells.com | admin | Matthias |
|
||||
| tester@hlc.de | admin | Test Tester |
|
||||
| 29 stub colleagues | associate | … |
|
||||
|
||||
So: 31 rows total, 2 admins, 29 associates. No `partner` rows in production today even though the gate exists in code.
|
||||
|
||||
## Goal
|
||||
|
||||
Split `paliad.users.role` into:
|
||||
|
||||
- **`paliad.users.job_title`** — free text, display only. Replaces today's `role`.
|
||||
- **`paliad.users.global_role`** — enum-via-CHECK, currently `'standard' | 'global_admin'`. New column, drives every `role='admin'`-style permission check.
|
||||
|
||||
Per-project `paliad.project_teams.role` is untouched.
|
||||
|
||||
After the change m can carry `job_title='Counsel Knowledge Lawyer'` AND `global_role='global_admin'` simultaneously.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Naming
|
||||
|
||||
- Rename `paliad.users.role` → `paliad.users.job_title`.
|
||||
- Add `paliad.users.global_role text NOT NULL DEFAULT 'standard'` with `CHECK (global_role IN ('standard','global_admin'))`.
|
||||
|
||||
### 2. Why enum-via-CHECK over a `permissions text[]`
|
||||
|
||||
| | text-with-CHECK | text[] permissions |
|
||||
|---|---|---|
|
||||
| New permission | edit one CHECK constraint | none |
|
||||
| Code surface | `u.global_role = 'global_admin'` | `'global_admin' = ANY(u.permissions)` |
|
||||
| Multi-grant | impossible by construction | natural |
|
||||
| Validation | DB-level | service-level only |
|
||||
| Today's needs (1 permission) | trivial | over-engineered |
|
||||
|
||||
We have one permission today and m's brief says "possibly more later, design with that in mind". An enum keeps every call site short and DB-validated; growing the CHECK to add `billing_admin` is a one-line migration and `IN (...)` checks compose fine. If we ever genuinely need to grant 2+ permissions to one user, swap the column type to `text[]` in a future migration — call sites change from `=` to `ANY(...)`, mechanical. Nothing about today's choice forecloses that.
|
||||
|
||||
**Lean: enum-via-CHECK.** Matches m's stated lean. Ships smaller. Easy to widen later.
|
||||
|
||||
### 3. Why not "global_admin is just another job_title value"
|
||||
|
||||
Considered: keep `role` free-text, just normalize so `job_title='global_admin'` means both the title and the permission. Rejected because (a) the title `Counsel Knowledge Lawyer` and the permission `global_admin` are independent — a user can have one without the other (m wants both); (b) the admin-team UI restriction (`ErrAdminBootstrapOnly`) is exactly the symptom of trying to overload one column for two concerns. We're solving the conflation, not preserving it under a new name.
|
||||
|
||||
### 4. The "partner" gate — DROPPED entirely (m's three-axis principle)
|
||||
|
||||
Mid-implementation m clarified the principle: "firm roles are not project roles are not tool roles". Several places gated on `user.Role IN ('partner','admin')`:
|
||||
|
||||
- `internal/services/party_service.go:100` — delete party
|
||||
- `internal/services/note_service.go:195` — note ops
|
||||
- `internal/services/appointment_service.go:199` — appointment update/delete
|
||||
- `internal/services/project_service.go:617` — project ops
|
||||
- `internal/services/checklist_instance_service.go:301` — checklist ops
|
||||
- `internal/services/deadline_service.go:437` — delete deadline
|
||||
- migrations 018 / 021 RLS policies — `users.role IN ('partner','admin')` on `projects_delete`, `project_teams_delete`
|
||||
- `frontend/src/client/projects-detail.ts:555,1206`, `deadlines-detail.ts:194,208`, `deadlines.ts:69`, `notes.ts:104` — UI gates
|
||||
|
||||
These conflate "Partner" (a firm role / job title) with permission-to-mutate (a tool role). m: firm role and tool role must be orthogonal; the firm role is **display only** and **must never gate ops**.
|
||||
|
||||
**Decision:** drop the partner half of every gate. Each of these checks becomes "global_admin only". Production impact is zero — no prod row has `role='partner'` today, so nobody loses a capability they actually had.
|
||||
|
||||
After-rename gate shape:
|
||||
```go
|
||||
// before
|
||||
if user.Role != "partner" && user.Role != "admin" { return ErrForbidden }
|
||||
// after
|
||||
if user.GlobalRole != "global_admin" { return ErrForbidden }
|
||||
```
|
||||
|
||||
If a future tool-role system grants partner-level mutations to specific users, it adds a fresh dimension cleanly (text[] permissions or named enums) without touching `job_title`. YAGNI for now — there are no rows that need it.
|
||||
|
||||
**Helper deleted:** the design's first draft kept an `IsPartnerOrGlobalAdmin(u)` helper. It's gone — the gate is just `user.GlobalRole == "global_admin"` everywhere.
|
||||
|
||||
### 5. Data migration
|
||||
|
||||
Single up migration (023). Idempotent. No backfill data shape worth keeping in code beyond:
|
||||
|
||||
```sql
|
||||
-- 023_split_job_title_and_global_role.up.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Add new column with default so existing rows pick up 'standard'.
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN IF NOT EXISTS global_role text NOT NULL DEFAULT 'standard';
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
DROP CONSTRAINT IF EXISTS users_global_role_check;
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
ADD CONSTRAINT users_global_role_check
|
||||
CHECK (global_role IN ('standard','global_admin'));
|
||||
|
||||
-- Promote anyone who currently has role='admin'.
|
||||
UPDATE paliad.users SET global_role = 'global_admin' WHERE role = 'admin';
|
||||
|
||||
-- Wipe role='admin' to NULL (admins no longer carry a job title — they didn't
|
||||
-- pick one, the column was overloaded). Real job titles for the 2 current
|
||||
-- admins (m + tester) get fixed up by a separate manual UPDATE inside the
|
||||
-- same transaction, since we know them and the migration ran end-to-end is
|
||||
-- the right place to do it.
|
||||
UPDATE paliad.users SET role = NULL WHERE role = 'admin';
|
||||
|
||||
UPDATE paliad.users
|
||||
SET role = 'Counsel Knowledge Lawyer'
|
||||
WHERE email = 'matthias.siebels@hoganlovells.com';
|
||||
-- tester@hlc.de stays role=NULL — it's a synthetic admin account, no real
|
||||
-- job title. Admin-team UI will render NULL as "—".
|
||||
|
||||
-- Rename the column. Doing this last so the explicit UPDATEs above stay
|
||||
-- readable; if the rename were first, every UPDATE would refer to job_title
|
||||
-- and the diff is harder to review.
|
||||
ALTER TABLE paliad.users
|
||||
RENAME COLUMN role TO job_title;
|
||||
|
||||
-- The CHECK (role <> '') from migration 015 must come along to job_title,
|
||||
-- but with a tweak: NULL is now allowed (admins without a job title).
|
||||
ALTER TABLE paliad.users
|
||||
DROP CONSTRAINT IF EXISTS users_role_check;
|
||||
|
||||
ALTER TABLE paliad.users
|
||||
ADD CONSTRAINT users_job_title_check
|
||||
CHECK (job_title IS NULL OR job_title <> '');
|
||||
|
||||
-- can_see_project must follow.
|
||||
DROP FUNCTION IF EXISTS paliad.can_see_project(uuid) CASCADE;
|
||||
|
||||
CREATE FUNCTION paliad.can_see_project(_project_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path = paliad, public
|
||||
AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.projects target
|
||||
JOIN paliad.project_teams pt
|
||||
ON pt.user_id = auth.uid()
|
||||
AND pt.project_id = ANY(string_to_array(target.path, '.')::uuid[])
|
||||
WHERE target.id = _project_id
|
||||
);
|
||||
$$;
|
||||
|
||||
-- Rebuild RLS policies that the CASCADE drops. Identical to migration 021
|
||||
-- except every `u.role = 'admin'` becomes `u.global_role = 'global_admin'`
|
||||
-- and every `u.role IN ('partner','admin')` becomes
|
||||
-- `(u.job_title = 'partner' OR u.global_role = 'global_admin')`.
|
||||
-- (Full body in the migration file; not reprinted here for brevity.)
|
||||
|
||||
COMMIT;
|
||||
```
|
||||
|
||||
Down migration is the symmetric reverse: rename `job_title` → `role`, copy `global_admin` rows back to `role='admin'`, drop `global_role`, restore the original `can_see_project` body. Ugly but tractable; ~31 rows, no realistic reason to roll back.
|
||||
|
||||
### 6. Code surface — where every `'admin'` lives
|
||||
|
||||
Inventoried `grep -rn "'admin'\\|\\\"admin\\\"\\|user.Role" --include="*.go" --include="*.sql" --include="*.ts" --include="*.tsx"` and bucketed:
|
||||
|
||||
#### A. Global-admin gates (these MUST migrate)
|
||||
|
||||
**Go services**
|
||||
- `internal/services/dashboard_service.go:154,211,241,270` — SQL `$2 = 'admin'`, callers pass `user.Role` as `$2` at lines 186, 219, 250, 279
|
||||
- `internal/services/agenda_service.go:135,201` — same pattern
|
||||
- `internal/services/appointment_service.go:487` — same pattern, caller line 492
|
||||
- `internal/services/project_service.go:725,736,747` — three `visibilityPredicate*` helpers
|
||||
- `internal/services/deadline_service.go:405` — `if user.Role == "admin"` short-circuit
|
||||
- `internal/services/department_service.go:304` — `requireAdmin`
|
||||
- `internal/services/user_service.go:149,232,346,388,500,638,641` — bootstrap + IsAdmin + assignment guards
|
||||
|
||||
**Go handlers**
|
||||
- `internal/handlers/onboarding.go:63` — error message
|
||||
- `internal/handlers/users.go:91` — error message
|
||||
- `internal/handlers/admin_users.go:75,113` — error message (twice)
|
||||
|
||||
**Auth**
|
||||
- `internal/auth/require_admin.go:19` — comment only
|
||||
|
||||
**SQL migrations (existing — for awareness; only migration 023 touches these)**
|
||||
- `internal/db/migrations/006_visibility.up.sql:33` — historical, superseded
|
||||
- `internal/db/migrations/007_rls_policies.up.sql:44,58` — historical, superseded
|
||||
- `internal/db/migrations/018_projects_v2.up.sql:414,497,530,544,548,560,565` — historical, superseded
|
||||
- `internal/db/migrations/021_fix_function_bodies_after_rename.up.sql:46,126,155` — current live state of `can_see_project` + RLS; migration 023 replaces
|
||||
|
||||
**Frontend**
|
||||
- `frontend/src/client/sidebar.ts:299,309` — sidebar admin-section reveal
|
||||
- `frontend/src/client/settings.ts:572,582` — admin-only controls on settings page
|
||||
- `frontend/src/client/admin-team.ts:117,156,177,179,220,221` — table render + sort
|
||||
- `frontend/src/client/deadlines-detail.ts:190,193,207` — admin/partner gate on UI
|
||||
- `frontend/src/client/projects-detail.ts:555,1206` — admin/partner gate on UI
|
||||
|
||||
#### B. Job-title labels (these stay as `job_title`)
|
||||
|
||||
- `frontend/src/admin-team.tsx:74,121` — table header / form label "Rolle"
|
||||
- `frontend/src/admin-team.tsx:122` — direct-add input still says "role" (rename to `job_title` server-side, label stays "Rolle / Job title")
|
||||
- `frontend/src/onboarding.tsx:48,52,53,57` — onboarding form label / field name
|
||||
- `frontend/src/client/admin-team.ts:170,179,309,365,372` — datalist + form handling
|
||||
- `frontend/src/client/i18n.ts` — every `admin.team.col.role` / `onboarding.role.*` string
|
||||
|
||||
The form FIELD NAMES on the wire (`{display_name, office, role, ...}`) become `job_title` after the rename. Both client and server change in one commit.
|
||||
|
||||
#### C. Per-project role (NOT touched)
|
||||
|
||||
- `internal/services/deadline_service.go:417` — `pt.role IN ('admin', 'lead')` — this is `project_teams.role`, unrelated.
|
||||
- All other `pt.role` references in handlers/services.
|
||||
|
||||
### 7. API surface
|
||||
|
||||
`/api/me` payload before:
|
||||
```json
|
||||
{ "id": "…", "email": "…", "role": "admin", … }
|
||||
```
|
||||
|
||||
After:
|
||||
```json
|
||||
{ "id": "…", "email": "…", "job_title": "Counsel Knowledge Lawyer", "global_role": "global_admin", … }
|
||||
```
|
||||
|
||||
`/api/admin/users` and `/api/admin/users/{id}` similarly expose both fields. `PATCH /api/me` accepts `{job_title}` (no `role`); `PATCH /api/admin/users/{id}` accepts `{job_title, global_role}` — server enforces that only existing global_admins can change `global_role`, and refuses to demote the last global_admin (mirror of the existing last-admin protection in `AdminDeleteUser`).
|
||||
|
||||
`POST /api/admin/users` accepts `{email, display_name, office, job_title, dezernat, lang}` only — `global_role` defaults to `'standard'`. Promotion is a separate `PATCH` action so it can't be smuggled into create.
|
||||
|
||||
Self-service `POST /api/onboarding` accepts `{display_name, office, job_title, dezernat}` — `global_role` defaults to `'standard'`. The bootstrap path (first row of `paliad.users`) flips `global_role='global_admin'` instead of setting `role='admin'`. Same `pg_advisory_xact_lock(7346298141)` guard.
|
||||
|
||||
### 8. UI surface
|
||||
|
||||
#### Onboarding (`/onboarding`)
|
||||
- Field label: "Berufsbezeichnung / Job title" (was "Rolle")
|
||||
- Field name in DOM: `job_title`
|
||||
- Datalist suggestions stay (Partner / Associate / PA / Of Counsel / Referendar/in / Trainee / wiss. Mitarbeiter/in / Sekretariat). Add: Counsel, Knowledge Lawyer, Counsel Knowledge Lawyer.
|
||||
- No `global_role` field — that defaults to 'standard'.
|
||||
|
||||
#### Settings (`/einstellungen`)
|
||||
- "Rolle" → "Berufsbezeichnung / Job title", same input.
|
||||
- New read-only "Berechtigung / Permission" line below: shows `Standard` or `Global Admin`. Not editable from settings (must use admin page).
|
||||
|
||||
#### Admin team (`/admin/team`)
|
||||
- New column header (after Office, before Dezernat): "Berechtigung / Permission".
|
||||
- Cell content: badge — `Standard` (neutral) or `Global Admin` (lime, the brand accent).
|
||||
- Cell behavior: click toggles a dropdown with the two enum values. Saving issues `PATCH /api/admin/users/{id}` with `{global_role}`.
|
||||
- "Rolle" column heading + cell content stays — but the cell now renders `job_title` (free text, may be NULL → render as "—").
|
||||
- Direct-add modal: rename "Rolle" input to "Berufsbezeichnung / Job title", drop the special "Associate" default (keep the placeholder), bind `name="job_title"`.
|
||||
- Sort: existing "admins first" sort key flips to `global_role='global_admin'` first.
|
||||
- Last-global_admin protection: dropdown disabled (with tooltip) when the row is the last surviving global_admin.
|
||||
|
||||
#### Sidebar
|
||||
- The admin-section reveal in `sidebar.ts:initAdminGroup()` flips the predicate from `me.role === "admin"` to `me.global_role === "global_admin"`.
|
||||
|
||||
#### Deadline / project detail pages
|
||||
- The `me.role === "admin" || me.role === "partner"` gates become `me.global_role === "global_admin" || me.job_title === "partner"` (preserving today's broken-but-harmless behavior; see §4).
|
||||
|
||||
### 9. Bootstrap rule
|
||||
|
||||
Today: first `paliad.users` row may self-assign `role='admin'`, guarded by `pg_advisory_xact_lock(7346298141)`.
|
||||
|
||||
After: first `paliad.users` row may set `global_role='global_admin'`. Same lock, same constant, new column. Onboarding payload includes no `global_role` — the service decides based on the row count and overrides the default 'standard' for the first inserter.
|
||||
|
||||
### 10. Backwards compat
|
||||
|
||||
**Decision: clean rename, no compat shim.** Justification:
|
||||
|
||||
- 31 production rows, all in our control.
|
||||
- Wire format changes (`role` → `job_title`, new `global_role`) cross client + server simultaneously in one merge. No staged rollout needed for an internal tool.
|
||||
- Old session cookies with cached `me.role` values get refreshed on the next `/api/me` call, which the client makes on every page load.
|
||||
- The `paliad.users.role` column stops existing after migration 023. Any ad-hoc query / report keyed on `role='admin'` breaks loudly — that's the point.
|
||||
|
||||
If future me wants compat-during-deploy: add a generated column `role text GENERATED ALWAYS AS (job_title) STORED` for one release, drop in the next. Not doing that now.
|
||||
|
||||
### 11. Test plan
|
||||
|
||||
**Unit**
|
||||
- `internal/services/user_service_test.go`
|
||||
- `Create` with `count=0` → `global_role='global_admin'` AND `job_title=<input>` (or NULL if empty)
|
||||
- `Create` with `count>0` → `global_role='standard'`
|
||||
- `UpdateProfile` cannot set `global_role` (field absent from `UpdateProfileInput`)
|
||||
- `AdminUpdateUser` can set `global_role`; rejects when caller is not global_admin (handler-level test); rejects demotion of last global_admin (service-level test, mirror of `AdminDeleteUser`'s last-admin protection)
|
||||
- `IsAdmin` reads `global_role`
|
||||
- `internal/auth/require_admin_test.go` — already covers the `IsAdmin` surface; no changes needed beyond the swap of test fixture's seeded column.
|
||||
|
||||
**Integration / smoke**
|
||||
- Manual: log in as tester@hlc.de — confirm sidebar `/admin/team` entry appears, page loads, table shows m as global_admin + "Counsel Knowledge Lawyer" job title.
|
||||
- Manual: set m's `job_title` via admin page to something else, confirm `global_role` is unchanged.
|
||||
- Manual: try to demote tester (last global_admin in this case if you've already demoted m) — expect rejection.
|
||||
- DB-level: `SELECT email, job_title, global_role FROM paliad.users` after migration. Expected:
|
||||
- 2 rows global_admin (m, tester), m.job_title='Counsel Knowledge Lawyer', tester.job_title IS NULL
|
||||
- 29 rows standard with job_title='associate'
|
||||
- `go build ./... && go vet ./... && go test ./...` clean.
|
||||
- `cd frontend && bun run build` clean.
|
||||
|
||||
## Out of scope (recap)
|
||||
|
||||
- Fine-grained permissions (`partner`, `billing_admin`) — design leaves room (CHECK can grow; or migrate to `text[]` later) but ships only `global_admin`.
|
||||
- Cleaning up the "partner" gate conflation (§4) — gate stays job-title-driven for now. File follow-up.
|
||||
- Permission inheritance from `project_teams` to global — explicitly orthogonal.
|
||||
- Role-based UI customization beyond hide/show — defer.
|
||||
|
||||
## Open questions for m
|
||||
|
||||
1. **m's `job_title` value** — task brief says "Counsel Knowledge Lawyer". Confirmed? (Migration writes that exact string.)
|
||||
2. **tester's `job_title`** — migration sets NULL. Alternative: 'Admin' literal. Lean: NULL — tester is a synthetic admin without a real title; "Admin" as a job title perpetuates the conflation we're solving.
|
||||
3. **Default `global_role` on new sign-ups beyond the bootstrap** — confirmed `'standard'`.
|
||||
4. **The 'partner' job-title gate** — leave as-is for this PR? (My recommendation: yes, file follow-up.)
|
||||
|
||||
## Implementation phase plan (after greenlight)
|
||||
|
||||
Single mai/cronus/separate-job-title-from branch, single PR, single merge to main.
|
||||
|
||||
1. `internal/db/migrations/023_split_job_title_and_global_role.{up,down}.sql` — schema + data + can_see_project + RLS rebuild.
|
||||
2. `internal/models/models.go` — `User.Role` → `User.JobTitle` (keep `db:"job_title"` `json:"job_title"`); add `User.GlobalRole string \`db:"global_role" json:"global_role"\``. Make `JobTitle` a `*string` since admins may have NULL.
|
||||
3. `internal/services/user_service.go` — every `role` reference, including `userColumns`, the bootstrap branch (assign `global_role`), `IsAdmin` (reads `global_role`), `UpdateProfileInput`/`AdminUpdateInput` (drop `Role`, add `JobTitle *string` and `GlobalRole *string` for admin-only).
|
||||
4. `internal/services/{dashboard,agenda,appointment,project,deadline,department,party,note,checklist_instance}_service.go` — swap the SQL `$N = 'admin'` → `$N = 'global_admin'` and the call sites pass `user.GlobalRole` instead of `user.Role`. Partner gates change to `user.JobTitle != "partner" && user.GlobalRole != "global_admin"` (per §4).
|
||||
5. `internal/handlers/{onboarding,users,admin_users}.go` — update error messages; payload field renames.
|
||||
6. `internal/auth/require_admin.go` — comment update only (the `AdminLookup.IsAdmin` interface is unchanged because it abstracts behind the boolean).
|
||||
7. `frontend/src/admin-team.tsx`, `frontend/src/onboarding.tsx` — labels + field names (`role` → `job_title`); add Permission column on admin-team.
|
||||
8. `frontend/src/client/admin-team.ts`, `frontend/src/client/onboarding.ts`, `frontend/src/client/sidebar.ts`, `frontend/src/client/settings.ts`, `frontend/src/client/deadlines-detail.ts`, `frontend/src/client/projects-detail.ts` — every `me.role === "admin"` → `me.global_role === "global_admin"`; every form-field `role` → `job_title`; add the global_role dropdown widget.
|
||||
9. `frontend/src/client/i18n.ts` — DE+EN strings for "Berufsbezeichnung", "Berechtigung", "Standard", "Global Admin".
|
||||
10. Tests — update fixtures + add the cases in §11.
|
||||
|
||||
Self-merge to main authorized once `go build/vet/test ./...` and `bun run build` are clean and a smoke pass against ydb confirms acceptance §1–§9 of the task brief.
|
||||
478
docs/design-pwa-bottom-nav.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# Design: PWA Mobile BottomNav + Drawer
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-04-26
|
||||
**Task:** t-paliad-041
|
||||
**Status:** Design complete — awaiting m's go/no-go before implementation
|
||||
**Reference:** `~/dev/web/docs/pwa-baseline.md` (canonical PWA pattern across m's web surfaces)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Paliad's current navigation is **desktop-first**: a 64px collapsed / 240px
|
||||
expanded left **Sidebar** (with hover/pin) on `≥1024px`, a slide-out
|
||||
drawer with a top-left hamburger on `<1024px`. This works on a laptop. On
|
||||
a phone it does not — the hamburger is a long thumb-stretch and every
|
||||
common action (open Agenda, create a Frist) is two taps deep behind it.
|
||||
|
||||
This design adds a **bottom navigation bar** for phones (`<768px`) per
|
||||
the m-stack PWA baseline:
|
||||
|
||||
- 5-slot fixed bottom bar with thumb-reach icons.
|
||||
- Center slot opens a **slide-up Quick-Add sheet** (Frist / Termin / Projekt).
|
||||
- Right-most slot opens the existing **mobile sidebar drawer** (no new drawer — we reuse what already works).
|
||||
- Auto-hides when the on-screen keyboard opens (`visualViewport` watcher).
|
||||
- Honors `safe-area-inset-bottom` so iOS home-indicator doesn't sit on top of the buttons.
|
||||
|
||||
The desktop Sidebar (≥1024px) is unchanged. Tablets (768-1023px) keep
|
||||
the current hamburger-drawer pattern. Only phones gain BottomNav.
|
||||
|
||||
PWA shell items split into "do now" (cheap, required) and "defer to a
|
||||
follow-up task":
|
||||
|
||||
- **Now:** `viewport-fit=cover`, `theme-color`, `apple-mobile-web-app-*`
|
||||
meta tags so iOS draws under the notch and `safe-area-inset` actually
|
||||
has values.
|
||||
- **Later (separate task):** `manifest.json` + icon assets, service worker,
|
||||
add-to-home-screen prompt UI.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why These Choices (HLC Patent Lawyer Perspective)
|
||||
|
||||
The user is a litigator-in-the-hallway — between meetings, on the train,
|
||||
in court anteroom. The phone use-case is overwhelmingly **read** rather
|
||||
than create:
|
||||
|
||||
1. *"What's coming up this week?"* → Agenda, Dashboard
|
||||
2. *"What's the status on this matter?"* → Projekte detail
|
||||
3. *"Quickly capture a Frist I just got told about"* → create Frist
|
||||
4. *"What's the Frist for replying to office action X?"* → Fristenrechner (rare on phone)
|
||||
5. *"Settings / Glossar / Kostenrechner"* → desk activities, rare on phone
|
||||
|
||||
The bottom slots therefore optimise for read-heavy, with a single
|
||||
prominent capture path in the center. Tools/Wissen/Settings live in the
|
||||
drawer because phone use of those is rare and a one-tap detour is fine.
|
||||
|
||||
---
|
||||
|
||||
## 3. Slot Layout
|
||||
|
||||
**5 slots, decided:**
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ [page content] │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [🏠] [📁] ╔══[+]══╗ [📅] [☰] │
|
||||
│ Start Projekte Anlegen Agenda Menü │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↑ ↑ ↑ ↑ ↑
|
||||
Dash Projekte Quick-Add Agenda Drawer
|
||||
(/dashboard) (/projects) (sheet) (/agenda) (toggle)
|
||||
```
|
||||
|
||||
| Slot | Label DE | Label EN | Target | Icon (reuse from Sidebar.tsx) |
|
||||
|------|----------|----------|--------|-------------------------------|
|
||||
| 1 | Start | Home | `/dashboard` | `ICON_GAUGE` |
|
||||
| 2 | Projekte | Projects | `/projects` | `ICON_FOLDER` |
|
||||
| 3 | Anlegen | New | (opens sheet) | `ICON_PLUS` (new) |
|
||||
| 4 | Agenda | Agenda | `/agenda` | `ICON_AGENDA` |
|
||||
| 5 | Menü | Menu | (opens drawer) | `ICON_MENU` |
|
||||
|
||||
### Why Dashboard + Agenda over Dashboard + Fristen
|
||||
|
||||
Initial brief proposed `[Dashboard / Projekte / + / Fristen / Menu]` or
|
||||
`[... / Agenda / Menu]`. **Agenda wins** because:
|
||||
|
||||
- Agenda merges Fristen *and* Termine into one date-sorted timeline
|
||||
(shipped in t-paliad-030). On a phone you almost never want one but
|
||||
not the other — you want "what's next".
|
||||
- Fristen is reachable from Agenda items (each row deep-links to
|
||||
`/deadlines/{id}`) and from the drawer.
|
||||
- Dashboard already gives the high-level "traffic light" overview
|
||||
(overdue / today / week / later) — Agenda gives the actionable list
|
||||
underneath. Pairing them in the BottomNav covers ~80% of phone reads.
|
||||
|
||||
### Why Projekte not Termine
|
||||
|
||||
Termine alone is too narrow for a top-level slot. A patent lawyer's
|
||||
mental model is "I'm working on matter X" — Projekte is the natural
|
||||
hub. Termine is reachable from a project's detail page or from Agenda.
|
||||
|
||||
### Active-state highlighting
|
||||
|
||||
Same rule the Sidebar already uses (`navItem` active logic): a slot is
|
||||
active when its `href` is a prefix of `currentPath`. So `/projects/abc`
|
||||
keeps the Projekte slot lit, `/deadlines/{id}` lights nothing in
|
||||
BottomNav (deadlines aren't a top-level slot — that's fine, the
|
||||
breadcrumb still works).
|
||||
|
||||
---
|
||||
|
||||
## 4. Center Slot: Quick-Add Sheet
|
||||
|
||||
A **slide-up sheet** (not a navigation) with three options:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ ───── │ ← drag-handle
|
||||
│ │
|
||||
│ 📅 Frist anlegen › │ → /deadlines/new
|
||||
│ 🗓 Termin anlegen › │ → /appointments/new
|
||||
│ 📁 Projekt anlegen › │ → /projects/new
|
||||
│ │
|
||||
│ [Abbrechen] │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Why a sheet, not a deep-link
|
||||
|
||||
| Option | Pros | Cons |
|
||||
|---|---|---|
|
||||
| **Sheet w/ 3 options** ✅ | One predictable place; works on every page; matches "primary capture/add" idiom from baseline doc | One extra tap vs deep-link |
|
||||
| Always `/deadlines/new` | Zero-tap deadline creation | Wrong default ~30% of the time (Termin/Projekt also frequent); no escape if user wanted Termin |
|
||||
| Context-aware (per page) | Smart defaults | Surprising — same button does different things on different pages; harder to learn |
|
||||
|
||||
The sheet is also where new capture types can be added later (Quick
|
||||
Note, Voice memo) without redesign. Cheap to extend.
|
||||
|
||||
### Sheet mechanics (mvp — does *not* fully copy otto-pwa)
|
||||
|
||||
- Native `<dialog>` element via `dialog.showModal()`.
|
||||
- Slide-up via CSS `transform: translateY(100%) → translateY(0)`,
|
||||
`transition: transform 220ms ease-out`.
|
||||
- Backdrop tap dismisses (`dialog::backdrop` click handler).
|
||||
- ESC closes (native `<dialog>` behavior).
|
||||
- **Drag-to-dismiss is NOT in v1.** The full otto-pwa pointer-event
|
||||
pattern (handle hit-area + pointermove transform + 120px threshold)
|
||||
is great but adds ~80 lines for a phone-only flourish. Ship without
|
||||
it; if m wants it, a follow-up task adds it copying otto-pwa
|
||||
`voice-modal` verbatim.
|
||||
- `max-height: 60vh` (we have only 3 rows; 92vh from the baseline doc is
|
||||
for sheets that contain scrollable lists).
|
||||
|
||||
### Tapping a sheet row
|
||||
|
||||
Just navigates: `window.location.href = "/deadlines/new"` etc. The
|
||||
existing `/deadlines/new`, `/appointments/new`, `/projects/new` pages
|
||||
already work on mobile (form layout is single-column). No new endpoints.
|
||||
|
||||
Note: `/projects/new` requires admin in current implementation — for a
|
||||
non-admin user, that row should be hidden (read `window.__PALIAD_ME__`
|
||||
or whatever the page exposes; if not exposed, just always show and let
|
||||
the destination page error gracefully — m's call).
|
||||
|
||||
---
|
||||
|
||||
## 5. Drawer: Reuse What's There
|
||||
|
||||
**No new drawer.** The existing `Sidebar.tsx` already renders into a
|
||||
fixed-left aside that, at `<1024px`, is `transform: translateX(-100%)`
|
||||
by default and slides to `translateX(0)` when class `mobile-open` is
|
||||
toggled. The hamburger button + `.sidebar-overlay` already do the open
|
||||
mechanics.
|
||||
|
||||
The BottomNav `[Menü]` slot wires into the same toggle that the
|
||||
hamburger uses — they call the same `toggleMobileSidebar()`.
|
||||
|
||||
### Hamburger fate
|
||||
|
||||
At `<768px` (BottomNav visible): the existing top-left hamburger is
|
||||
**hidden** (the BottomNav menu slot does the same job, in a thumb-reach
|
||||
spot). At `768-1023px`: hamburger stays visible, BottomNav stays
|
||||
hidden — current behavior preserved.
|
||||
|
||||
### What's in the drawer
|
||||
|
||||
It's the existing Sidebar — Dashboard, Übersicht (Dashboard, Agenda,
|
||||
Team), Arbeit (Projekte, Fristen, Termine), Werkzeuge, Wissen,
|
||||
Ressourcen, Einstellungen, plus the bottom block (Neuigkeiten, invite,
|
||||
DE/EN, Logout). Nothing duplicated, nothing pruned. Items already in
|
||||
the BottomNav (Dashboard, Projekte, Agenda) also still appear in the
|
||||
drawer — that's intentional, the drawer is the canonical map.
|
||||
|
||||
### Drawer trigger options considered
|
||||
|
||||
| Trigger | Verdict |
|
||||
|---|---|
|
||||
| BottomNav `[Menü]` slot ✅ | Standard, discoverable, thumb-reach |
|
||||
| Top-left hamburger (legacy) | Hidden on phones; lives on for tablets |
|
||||
| Edge-swipe from left | **No** — conflicts with project-detail tabs that already overflow-scroll horizontally on mobile |
|
||||
| ESC closes | Already implemented via `closeMobile()` |
|
||||
|
||||
Matches mBrian/otto pattern: button-triggered, no swipe.
|
||||
|
||||
---
|
||||
|
||||
## 6. Breakpoints
|
||||
|
||||
```
|
||||
≥1024px : Desktop sidebar (hover-expand, pin)
|
||||
768-1023px : Slide-out drawer + top-left hamburger (current behavior)
|
||||
<768px : Slide-out drawer + BottomNav (hamburger hidden)
|
||||
```
|
||||
|
||||
Two distinct thresholds because they answer different questions:
|
||||
|
||||
- **1024px** = "is there room for a persistent collapsed sidebar?"
|
||||
- **768px** = "is this a phone — do we need a thumb-reach bar?"
|
||||
|
||||
The existing `1023px` breakpoint stays. We add a new `767px` breakpoint
|
||||
specifically for showing/hiding BottomNav and hiding the legacy
|
||||
hamburger.
|
||||
|
||||
The pwa-baseline doc says 768px throughout — that's the BottomNav
|
||||
breakpoint. The doc doesn't mandate the 1024 sidebar threshold; that's
|
||||
a paliad-specific affordance worth preserving.
|
||||
|
||||
---
|
||||
|
||||
## 7. Visual Spec
|
||||
|
||||
### Bar dimensions
|
||||
|
||||
- Height: `56px` (`--bottom-nav-height`, matches baseline doc).
|
||||
- Background: `var(--color-surface)` (`#ffffff`).
|
||||
- Top border: `1px solid var(--color-border)`.
|
||||
- Box-shadow: subtle upward `0 -1px 3px rgba(0,0,0,0.04)` — looks
|
||||
attached to the screen edge, not floating.
|
||||
- Position: `fixed; bottom: 0; left: 0; right: 0;`
|
||||
- Padding-bottom: `env(safe-area-inset-bottom)` — additive to the 56px,
|
||||
so on iPhone X+ the bar effectively grows to account for the home
|
||||
indicator without overlapping it.
|
||||
- Width: 100%, slots `flex: 1`.
|
||||
- Z-index: `30` (above content, below sidebar overlay z=35 so the drawer
|
||||
always covers BottomNav, below modals z=100).
|
||||
|
||||
### Slot
|
||||
|
||||
- 56px tall, full-width slot, vertical icon (~22px) + label (10-11px).
|
||||
- Active slot: lime accent `var(--color-accent)` icon + label, with a
|
||||
thin lime top-bar (3px tall) at the slot top edge.
|
||||
- Inactive slot: `var(--color-text-muted)` icon + label.
|
||||
- Tap target: full slot — no inner padding gymnastics. iOS HIG ≥44pt;
|
||||
56px height + ~70px wide slot easily clears that.
|
||||
|
||||
### Center slot ([+])
|
||||
|
||||
- Visually elevated: a 48px circular lime button raised ~4px above the
|
||||
bar (negative margin-top), white plus-icon, subtle `box-shadow:
|
||||
var(--shadow-md)`.
|
||||
- Same width slot underneath; the circle is decoration, the whole slot
|
||||
is the tap target.
|
||||
- This is the only "loud" pattern; matches the baseline doc's
|
||||
"primary capture/add action" emphasis.
|
||||
|
||||
### Quick-Add sheet
|
||||
|
||||
- Width: 100vw on mobile, max 480px on tablet (the sheet should never
|
||||
appear on desktop because the [+] slot only exists on phones, but
|
||||
cap width as belt-and-braces).
|
||||
- Border-radius: `16px 16px 0 0` (top corners rounded, bottom flush).
|
||||
- Padding-bottom: `env(safe-area-inset-bottom)` so the cancel row sits
|
||||
above the home indicator.
|
||||
- Backdrop: `rgba(0,0,0,0.5)` via `<dialog>::backdrop`.
|
||||
|
||||
### Layout reflow
|
||||
|
||||
Pages with `body.has-sidebar` need extra bottom padding on mobile so
|
||||
the BottomNav doesn't cover the last row of content. New CSS rule:
|
||||
|
||||
```css
|
||||
@media (max-width: 767px) {
|
||||
body.has-sidebar main {
|
||||
padding-bottom: calc(var(--bottom-nav-height) + 1rem
|
||||
+ env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`main` gets the padding rather than `body` so the BottomNav's own
|
||||
fixed-position remains glued to the viewport edge.
|
||||
|
||||
### Keyboard-open hide
|
||||
|
||||
```css
|
||||
body.keyboard-open .bottom-nav {
|
||||
transform: translateY(120%);
|
||||
transition: transform 200ms ease-out;
|
||||
}
|
||||
```
|
||||
|
||||
Toggle from JS via `visualViewport.height` delta > 100px (see §9).
|
||||
|
||||
---
|
||||
|
||||
## 8. Files to Add / Change
|
||||
|
||||
### New files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `frontend/src/components/BottomNav.tsx` | TSX component, exports `BottomNav({currentPath, role?})` |
|
||||
| `frontend/src/client/bottom-nav.ts` | `initBottomNav()` — drawer toggle wiring, sheet open/close, visualViewport keyboard watcher |
|
||||
|
||||
### Modified files
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `frontend/src/components/Sidebar.tsx` | Hamburger button gains a class so CSS can hide it `<768px` (`.sidebar-hamburger.hide-on-phone` or just by media query — no markup change needed). Add `id` on the toggle target so bottom-nav.ts can find/share it. |
|
||||
| `frontend/src/client/sidebar.ts` | Export `toggleMobileSidebar()` so bottom-nav.ts re-uses the exact same open/close/overlay code (don't duplicate). |
|
||||
| `frontend/src/client/index.ts` | Add `import { initBottomNav } from "./bottom-nav"; initBottomNav();` |
|
||||
| `frontend/src/styles/global.css` | Add ~120 lines: `--bottom-nav-height` token, `.bottom-nav` + slot styles, `<768px` media query showing BottomNav and hiding hamburger, keyboard-open transform, `body.has-sidebar main` padding-bottom rule, sheet styles. |
|
||||
| All page `*.tsx` files (~25) | (a) Replace `<meta name="viewport" content="width=device-width, initial-scale=1.0" />` with `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />`. (b) Add `<BottomNav currentPath="..." />` next to existing `<Sidebar currentPath="..." />` in each page. Easiest as a sed for (a); each page is touched once for (b). |
|
||||
| `frontend/build.ts` | Add `bottom-nav.ts` entry... — actually `bottom-nav.ts` is imported by `index.ts` so it gets bundled into `index.js` — no separate entry needed. |
|
||||
|
||||
### Optionally (low-cost, highly recommended)
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| All page `*.tsx` `<head>` | Add `<meta name="theme-color" content="#65a30d" />` (lime, matches accent) so iOS Safari paints the URL bar lime in standalone mode. |
|
||||
| All page `*.tsx` `<head>` | Add `<meta name="apple-mobile-web-app-capable" content="yes" />` and `<meta name="apple-mobile-web-app-status-bar-style" content="default" />`. Cheap, no asset dependency. |
|
||||
|
||||
These three meta-tag rows are a one-time sed across 25 files; adding
|
||||
them now means we don't need a follow-up just to redo the sweep.
|
||||
|
||||
### NOT in this task
|
||||
|
||||
- `manifest.json` and icon assets (192/512 maskable PNGs) → follow-up.
|
||||
- Service worker / `sw.js` / app-shell caching → follow-up.
|
||||
- `beforeinstallprompt` add-to-home-screen UI → follow-up.
|
||||
|
||||
These are tracked under §11 below as proposed `t-paliad-04*` follow-ups.
|
||||
|
||||
---
|
||||
|
||||
## 9. Behavior Spec (`bottom-nav.ts`)
|
||||
|
||||
```ts
|
||||
// Pseudo-shape (real impl will follow paliad style — no narration comments).
|
||||
import { toggleMobileSidebar } from "./sidebar";
|
||||
|
||||
export function initBottomNav() {
|
||||
initDrawerSlot(); // [Menü] tap → toggleMobileSidebar()
|
||||
initQuickAddSheet(); // [+] tap → dialog.showModal(); rows nav
|
||||
initKeyboardWatcher(); // visualViewport resize → body.keyboard-open
|
||||
}
|
||||
```
|
||||
|
||||
### Keyboard watcher (the one tricky bit)
|
||||
|
||||
```ts
|
||||
function initKeyboardWatcher() {
|
||||
if (!window.visualViewport) return; // older browsers: no-op
|
||||
const baseHeight = window.innerHeight;
|
||||
const KEYBOARD_THRESHOLD = 100; // px shrink == keyboard
|
||||
window.visualViewport.addEventListener("resize", () => {
|
||||
const delta = baseHeight - window.visualViewport!.height;
|
||||
document.body.classList.toggle("keyboard-open", delta > KEYBOARD_THRESHOLD);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
`baseHeight` is captured once at init — re-orientation events update it
|
||||
via a `window.orientationchange` handler. Edge case: a user who rotates
|
||||
the phone while the keyboard is open will see the bar reappear briefly
|
||||
until the keyboard re-deploys. Acceptable.
|
||||
|
||||
### Active-tab class on navigation
|
||||
|
||||
The TSX component renders the active class server-side from
|
||||
`currentPath`, identical to Sidebar. No client-side recomputation
|
||||
needed.
|
||||
|
||||
---
|
||||
|
||||
## 10. Z-index Map (post-change)
|
||||
|
||||
| Layer | z-index | Notes |
|
||||
|---|---|---|
|
||||
| Page content | auto | |
|
||||
| Header | 10 | Existing `.header` |
|
||||
| **BottomNav** | **30** | New |
|
||||
| Sidebar overlay (drawer backdrop) | 35 | Existing |
|
||||
| Sidebar drawer | 40 | Existing |
|
||||
| Top-left hamburger (legacy, tablet) | 50 | Existing — hidden <768px |
|
||||
| Quick-Add sheet backdrop | 90 | New (or just rely on `<dialog>::backdrop`) |
|
||||
| Quick-Add sheet card | 100 | New, same tier as `.modal-overlay` |
|
||||
| Existing modal-overlay (invite, etc.) | 100 | Existing |
|
||||
|
||||
When the drawer is open over the BottomNav: the drawer (z=40) is wider
|
||||
than the BottomNav (z=30), and its overlay (z=35) sits above the
|
||||
BottomNav — so the BottomNav is fully covered. ✓
|
||||
|
||||
When the Quick-Add sheet is open over the BottomNav: sheet (z=100)
|
||||
sits above; backdrop dims everything below including BottomNav. ✓
|
||||
|
||||
---
|
||||
|
||||
## 11. Rollout Plan
|
||||
|
||||
**Single PR, single coherent commit per phase per task convention:**
|
||||
|
||||
1. *Phase 1 (this task, after m's go):* land BottomNav + Quick-Add sheet
|
||||
+ drawer wiring + viewport-fit meta + theme-color meta. One commit
|
||||
on this worktree's branch (`mai/cronus/pwa-mobile-bottom-nav`),
|
||||
self-merge to `main` per t-paliad-038/039/040 precedent.
|
||||
2. *Verify on mobile breakpoint:* Playwright (`browser_resize` to
|
||||
375×812 iPhone X) — confirm: BottomNav renders, sheet opens, drawer
|
||||
opens from `[Menü]`, no double-hamburger, content padding leaves the
|
||||
last item visible above the bar. Login as `tester@hlc.de` to test
|
||||
the authenticated paths.
|
||||
3. *Build green:* `bun run build` and `go build ./... && go vet ./... && go test ./...`.
|
||||
|
||||
### Follow-up tasks proposed (NOT in this task)
|
||||
|
||||
- `t-paliad-04X` — `manifest.json` + 192/512 maskable icons + `<link rel="apple-touch-icon">` on every page → installable PWA.
|
||||
- `t-paliad-04Y` — `sw.js` network-first cache app-shell strategy (copy from mBrian; keep tiny — just `/dashboard` and `/assets/global.css`).
|
||||
- `t-paliad-04Z` — `beforeinstallprompt` UI: a one-time toast ("Add Paliad to your Home Screen?") gated by a localStorage `paliad-pwa-prompt-dismissed` flag.
|
||||
- `t-paliad-04W` — Drag-to-dismiss for Quick-Add sheet (otto-pwa pattern verbatim).
|
||||
- `t-paliad-04V` — Project-detail tabs horizontal-overflow polish (already-known tablet/phone problem; surfaced again here but out of scope).
|
||||
|
||||
---
|
||||
|
||||
## 12. Open Questions for m
|
||||
|
||||
1. **Slot 4: Agenda or Fristen?** Design picks Agenda. Brief offered
|
||||
either. If you prefer the more old-school Fristen (deadlines only,
|
||||
no Termine), it's a one-line swap. Recommendation: **Agenda**.
|
||||
2. **Center [+] slot: sheet or deep-link?** Design picks the 3-option
|
||||
slide-up sheet. If you prefer to skip the sheet and have [+] always
|
||||
go to `/deadlines/new` (the most-frequent capture), say so —
|
||||
simpler, no `<dialog>`. Recommendation: **sheet**.
|
||||
3. **PWA shell items:** Add the 3 meta tags now (viewport-fit,
|
||||
theme-color, apple-mobile-web-app-capable) but defer manifest +
|
||||
service worker + install prompt to follow-up tasks?
|
||||
Recommendation: **yes — meta now, manifest/SW/prompt later.**
|
||||
4. **`/projects/new` quick-add row visibility:** non-admins can't create
|
||||
projects. Hide the row for them, or always show and let the page
|
||||
gracefully error? Recommendation: **always show**, defer the
|
||||
permission-aware row to a follow-up — keeps this PR self-contained
|
||||
and matches what the Sidebar already does (`Projekte` is shown to
|
||||
everyone; admin-gating happens on the destination page).
|
||||
5. **Badge counts on BottomNav slots** (e.g. red-dot on Agenda when an
|
||||
overdue Frist is due today)? Nice-to-have, not in v1. Out of scope
|
||||
here. Confirm: **defer to follow-up.**
|
||||
6. **Tablet (768-1023px) behavior:** keep as-is (hamburger drawer, no
|
||||
BottomNav)? The pwa-baseline doc draws the line at 768 — we honor
|
||||
it on the BottomNav side. Confirm: **yes, no BottomNav on tablet.**
|
||||
|
||||
---
|
||||
|
||||
## 13. Acceptance Mapping
|
||||
|
||||
| Requirement | How design satisfies |
|
||||
|---|---|
|
||||
| Design doc at `docs/design-pwa-bottom-nav.md` | This file |
|
||||
| BottomNav renders <768px, hidden ≥768px | §6 + media query in §8 |
|
||||
| Mobile drawer slides out, mirrors desktop Sidebar | §5 — reuses existing Sidebar.tsx + slide-out CSS |
|
||||
| Keyboard-open hides BottomNav | §9 visualViewport watcher + `body.keyboard-open` CSS |
|
||||
| safe-area-inset-bottom padding on iOS | §7 dimensions + §8 viewport-fit=cover meta |
|
||||
| No regression in desktop layout | Desktop ≥1024px untouched; only `<768px` adds BottomNav and hides hamburger; tablet 768-1023px unchanged |
|
||||
| Single commit per phase | §11 rollout |
|
||||
615
docs/design-reminder-redesign-2026-04-28.md
Normal file
@@ -0,0 +1,615 @@
|
||||
# Reminder system redesign — zero-overdue SLO, escalation, per-user bundling
|
||||
|
||||
**Task:** t-paliad-064 (cronus, inventor)
|
||||
**Date:** 2026-04-28
|
||||
**Status:** design — awaiting m's go/no-go before implementation
|
||||
|
||||
## Problem statement (from m)
|
||||
|
||||
> "Our main purpose is to avoid ANY DEADLINE EVER becoming past due. So we
|
||||
> remind one week before and on the same day. And if it is not done by the end
|
||||
> of / late in the day, we need to send another urgent reminder and also
|
||||
> escalate."
|
||||
|
||||
Three things going wrong today:
|
||||
|
||||
1. **Timezone bug.** m set morning=09:00 Berlin and got 4 reminder emails this
|
||||
morning at 11:16 Berlin (= 09:16 UTC). Root cause is below — it is **not**
|
||||
the bug m suspected.
|
||||
2. **"Überfällig" wording is wrong.** A deadline due *today* triggered the
|
||||
`overdue` template, which says *"war heute oder früher fällig"*. "Überfällig"
|
||||
should mean past today, not today.
|
||||
3. **Schedule doesn't match the SLO.** Today's design treats overdue as a
|
||||
normal recurring nudge. m wants overdue to be a system-failure exception:
|
||||
the day-of escalation must be aggressive enough that we engineer overdues
|
||||
away.
|
||||
|
||||
---
|
||||
|
||||
## 1. The actual timezone bug
|
||||
|
||||
### What the spec hints
|
||||
|
||||
> "11:16 Berlin = 09:00 UTC + ~16min ticker phase. Fix: compute
|
||||
> `now.In(user.tz).Hour()` and compare against `user.reminder_morning_time.Hour()`."
|
||||
|
||||
### What the code already does
|
||||
|
||||
[`reminder_service.go:177-198`](../internal/services/reminder_service.go#L177-L198):
|
||||
|
||||
```go
|
||||
func inSlot(now time.Time, tz, morning, evening, slot string) bool {
|
||||
loc, err := time.LoadLocation(tz)
|
||||
if err != nil { loc = time.UTC } // <-- silent fallback
|
||||
local := now.In(loc)
|
||||
...
|
||||
return local.Hour() == hour
|
||||
}
|
||||
```
|
||||
|
||||
The conversion to `local` is already there. So the in-process logic is right.
|
||||
|
||||
### The actual root cause
|
||||
|
||||
[`Dockerfile:13-14`](../Dockerfile#L13-L14) is `alpine:3.21` with only
|
||||
`ca-certificates` installed. **The runtime image has no `tzdata` package**
|
||||
(`/usr/share/zoneinfo` doesn't exist on minimal alpine). Therefore
|
||||
`time.LoadLocation("Europe/Berlin")` returns an error in production, and
|
||||
`inSlot` silently falls back to UTC. With `local := now.In(UTC)`, the gate
|
||||
fires when `now.UTC().Hour() == 9` — which on 2026-04-28 (CEST, UTC+2) is
|
||||
**11:00 Berlin** plus the per-tick ~16min phase. Exactly what m saw.
|
||||
|
||||
This bug is invisible in `go test` on a dev box because Linux/macOS dev
|
||||
machines have system tzdata. It only manifests in the alpine container.
|
||||
|
||||
### Fix
|
||||
|
||||
Two small changes; the first is the actual fix, the second is defense-in-depth:
|
||||
|
||||
1. **Embed Go's tzdata into the binary.** Add one line to `cmd/server/main.go`:
|
||||
|
||||
```go
|
||||
import _ "time/tzdata"
|
||||
```
|
||||
|
||||
This adds ~450 KB to the binary and makes tz lookups work without OS
|
||||
`/usr/share/zoneinfo`. No Dockerfile change needed; the binary becomes
|
||||
self-contained. (Equivalent alternative: `apk add --no-cache tzdata` in
|
||||
the runtime stage — but the embedded approach also covers any future
|
||||
stripped-down container.)
|
||||
|
||||
2. **Stop falling back to UTC silently.** When `time.LoadLocation(tz)` fails,
|
||||
log a `slog.Error` and **skip the user this tick** instead of pretending
|
||||
they live in UTC. Combined with the embedded tzdata, the only way to hit
|
||||
this branch is a corrupt or empty `reminder_timezone` value — which we
|
||||
should fix at write time, not paper over at read time.
|
||||
|
||||
Add validation at the user-update boundary (`UserService.UpdateReminderTimes`
|
||||
/ settings handler / admin-team handler): reject empty or unparseable IANA
|
||||
names with HTTP 400 instead of silently storing them. Existing rows are
|
||||
safe (NOT NULL DEFAULT 'Europe/Berlin' from migration 022).
|
||||
|
||||
### Regression test
|
||||
|
||||
`TestInSlot_TZDataAvailable` — explicit check that
|
||||
`time.LoadLocation("Europe/Berlin")` succeeds in the test binary (with the
|
||||
new `_ "time/tzdata"` import in `main.go`, this is automatic in any test
|
||||
that imports the services package transitively). Plus the existing
|
||||
`TestInSlot` cases against `Europe/Berlin` already cover the conversion
|
||||
path — they pass today only because dev machines have tzdata; with the
|
||||
embed, they pass everywhere.
|
||||
|
||||
Also: a new test that asserts `inSlot` returns **false** (skip) when `tz`
|
||||
is empty or invalid — i.e. we no longer silently fall back to UTC.
|
||||
|
||||
---
|
||||
|
||||
## 2. New deadline categorization
|
||||
|
||||
Replace the current per-kind framing (`overdue`/`tomorrow`/`due_today_evening`/
|
||||
`weekly`) with four mutually exclusive categories, computed in the user's
|
||||
local tz on each tick:
|
||||
|
||||
| Category | Predicate (local date) | Wording (DE) | Wording (EN) | Severity |
|
||||
|----------------|-------------------------------------|----------------------|-----------------|----------|
|
||||
| `overdue` | `due_date < today` | "Überfällig" | "Overdue" | red, system-failure framing |
|
||||
| `due_today` | `due_date == today` | "Heute fällig" | "Due today" | amber |
|
||||
| `due_this_week`| `due_date in [today+1, today+offset]` (default offset=7) | "Diese Woche" | "This week" | informational |
|
||||
| `upcoming` | `due_date > today + offset` | "Kommend" | "Upcoming" | not in reminder emails |
|
||||
|
||||
Key correction: `due_date == today` is **not** overdue. The string *"war heute
|
||||
oder früher fällig"* is retired. Today-due deadlines render under "Heute
|
||||
fällig" in normal slots; under "DRINGEND — heute noch zu erledigen" in the
|
||||
evening escalation slot.
|
||||
|
||||
`reminder_warning_offset_days` (new column, default 7) controls the boundary
|
||||
between `due_this_week` and `upcoming`. Per-user customisation lives on the
|
||||
Settings → Notifications page.
|
||||
|
||||
---
|
||||
|
||||
## 3. New reminder schedule
|
||||
|
||||
Replace today's four send paths (`overdue` / `tomorrow` / `due_today_evening`
|
||||
/ `weekly`) with **two slots × bundled emails**, plus an exception path for
|
||||
overdues:
|
||||
|
||||
| Trigger | When (per user, in user's tz) | Audience | Email subject (DE) | Purpose |
|
||||
|----------------------|----------------------------------------------------------------|--------------------------------------------------------|----------------------------------------------------------|--------------------------|
|
||||
| **Morning digest** | morning slot, ANY of: `due_date == today+offset`, `due_date == today`, `due_date < today` (status=pending) | Created_by ∪ project lead set ∪ (global_admins, only for the overdue section) | `[Paliad] Frist-Erinnerung: N offen` (or `… ÜBERFÄLLIG: N` if any overdue) | Day-of awareness + 1-week heads-up + system-failure flag |
|
||||
| **Evening escalation** | evening slot, ANY of: `due_date == today` (status=pending), `due_date < today` (status=pending) | Created_by ∪ project lead set ∪ global_admins | `[Paliad] DRINGEND — heute noch offen: N` (or `… SYSTEMAUSFALL` if overdue) | Last call before tomorrow's escalation |
|
||||
| **Overdue carry** | morning + evening slots, while `due_date < today` and status=pending | (same as escalation; flagged as system failure) | `[Paliad] ÜBERFÄLLIG (System-Eskalation): N` | Until completed |
|
||||
|
||||
Carry rule: an overdue deadline appears in *every* slot until completed (both
|
||||
morning and evening, because it has already breached the SLO). Today-due
|
||||
deadlines appear in the morning, then in the evening escalation if still
|
||||
pending.
|
||||
|
||||
The current per-kind system is fully replaced. The Monday weekly digest
|
||||
(`weekly`) is dropped — its job (heads-up of upcoming deadlines) is now done
|
||||
per-deadline by the +`offset_days` warning, which fires exactly N days before
|
||||
each deadline rather than lumping them on a Monday.
|
||||
|
||||
### Per-trigger SQL predicate (deadline-side, in the user's tz)
|
||||
|
||||
For a candidate user U with timezone `tz`, on tick `now`:
|
||||
|
||||
```sql
|
||||
WITH local_today AS (
|
||||
SELECT (now AT TIME ZONE :tz)::date AS today
|
||||
)
|
||||
SELECT f.id, f.title, f.due_date,
|
||||
CASE
|
||||
WHEN f.due_date < (SELECT today FROM local_today) THEN 'overdue'
|
||||
WHEN f.due_date = (SELECT today FROM local_today) THEN 'due_today'
|
||||
WHEN f.due_date = (SELECT today FROM local_today) + :offset_days * INTERVAL '1 day' THEN 'due_warning'
|
||||
ELSE NULL
|
||||
END AS category
|
||||
FROM paliad.deadlines f
|
||||
WHERE f.status = 'pending'
|
||||
AND (
|
||||
f.due_date < (SELECT today FROM local_today)
|
||||
OR f.due_date = (SELECT today FROM local_today)
|
||||
OR f.due_date = (SELECT today FROM local_today) + :offset_days
|
||||
)
|
||||
AND <visibility predicate for U>
|
||||
```
|
||||
|
||||
In the evening slot, drop the `due_warning` branch (the +N-days heads-up is a
|
||||
morning-only signal):
|
||||
|
||||
```sql
|
||||
WHERE f.status = 'pending'
|
||||
AND (f.due_date < today_local OR f.due_date = today_local)
|
||||
```
|
||||
|
||||
### Audience computation
|
||||
|
||||
Three audience predicates compose the recipient set:
|
||||
|
||||
```sql
|
||||
-- 1) The deadline's creator
|
||||
created_by = U.id
|
||||
|
||||
-- 2) Project leadership along the project's hierarchy path
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.project_teams pt
|
||||
JOIN paliad.projects pp ON pp.id = ANY(string_to_array(p.path, '.')::uuid[])
|
||||
WHERE pt.user_id = U.id
|
||||
AND pt.project_id = pp.id
|
||||
AND pt.role = 'lead'
|
||||
)
|
||||
|
||||
-- 3) Global admin (system escalation channel)
|
||||
U.global_role = 'global_admin'
|
||||
```
|
||||
|
||||
For a given (slot, deadline, candidate user U), U is a recipient iff:
|
||||
|
||||
- **`due_warning` category:** `created_by(U)` OR `project_lead(U)` — early heads-up for the team that owns it, not for global escalation.
|
||||
- **`due_today` morning:** `created_by(U)` OR `project_lead(U)` — same.
|
||||
- **`due_today` evening (DRINGEND):** `created_by(U)` OR `project_lead(U)` OR `global_admin(U)` — the day is closing, time to escalate.
|
||||
- **`overdue` (any slot, system-failure):** `created_by(U)` OR `global_admin(U)` (+ future per-user `escalation_contact_id`) — owner and the escalation channel; project leads no longer help here, the system failed.
|
||||
|
||||
The wider audience for the urgent and overdue tiers is intentional:
|
||||
*one* person forgetting is the failure mode we want to engineer away, so by
|
||||
the evening of the due day, multiple eyes are on it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bundling: one email per user per slot
|
||||
|
||||
**Today:** N pending deadlines → N reminder emails (m got 4 this morning).
|
||||
|
||||
**New:** 1 email per (user, slot, local date). The email body is grouped by
|
||||
category, in fixed order:
|
||||
|
||||
1. ÜBERFÄLLIG (red banner, system-failure framing) — only if any
|
||||
2. DRINGEND — heute noch offen (amber, evening only) / Heute fällig (amber, morning) — only if any
|
||||
3. In einer Woche fällig (informational, morning only) — only if any
|
||||
|
||||
Each section is a table of (Frist title, Akte reference, due-date,
|
||||
"Open in Paliad" link) — the same row shape as today's `deadline_weekly.html`.
|
||||
|
||||
If all three sections are empty for a user in a given slot, no email is sent.
|
||||
|
||||
### Dedup
|
||||
|
||||
Per `(user_id, slot, local_date)`, not per deadline. A new column on
|
||||
`paliad.reminder_log`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.reminder_log
|
||||
ADD COLUMN IF NOT EXISTS slot text, -- 'morning' | 'evening'
|
||||
ADD COLUMN IF NOT EXISTS slot_date date; -- user-local date
|
||||
|
||||
CREATE UNIQUE INDEX reminder_log_slot_dedup_idx
|
||||
ON paliad.reminder_log (user_id, slot, slot_date)
|
||||
WHERE slot IS NOT NULL;
|
||||
```
|
||||
|
||||
The unique index — partial on `slot IS NOT NULL` — coexists with the legacy
|
||||
`(user_id, reminder_type, deadline_id)` rows still on disk. The CHECK
|
||||
constraint on `reminder_type` widens to allow the new `'morning_digest'` /
|
||||
`'evening_digest'` values:
|
||||
|
||||
```sql
|
||||
ALTER TABLE paliad.reminder_log
|
||||
DROP CONSTRAINT IF EXISTS reminder_log_reminder_type_check;
|
||||
ALTER TABLE paliad.reminder_log
|
||||
ADD CONSTRAINT reminder_log_reminder_type_check
|
||||
CHECK (reminder_type IN ('overdue','tomorrow','weekly','morning_digest','evening_digest'));
|
||||
```
|
||||
|
||||
Ordering in the row when inserted by the new code: `slot` is the canonical
|
||||
field; `reminder_type = slot || '_digest'` is set for backward-compatibility
|
||||
with anything querying by type. `deadline_id` is NULL on digest rows.
|
||||
|
||||
### Local-date math for dedup
|
||||
|
||||
The dedup key uses the user's local date, not server-UTC. So a user in
|
||||
`Pacific/Auckland` whose morning slot fires at 18:00 UTC the previous day
|
||||
gets dedup'd against the local "tomorrow" — a second tick at 19:00 UTC that
|
||||
same evening (= local 09:00 next day) is a new local_date and would fire
|
||||
again only if their morning_time is 09:00 (which it won't be at 19:00 UTC).
|
||||
The (slot, slot_date) tuple resolves the boundary cleanly.
|
||||
|
||||
---
|
||||
|
||||
## 5. Email layout (bundled)
|
||||
|
||||
Single new template `deadline_digest.html` replaces the three current ones
|
||||
(`deadline_reminder.html`, `deadline_due_today.html`, `deadline_weekly.html`).
|
||||
|
||||
Skeleton:
|
||||
|
||||
```
|
||||
{{define "content"}}
|
||||
{{if .HasOverdue}}
|
||||
<h1 style="color:#b91c1c">{{t "ÜBERFÄLLIG" "Overdue"}} ({{.OverdueCount}})</h1>
|
||||
<p>{{t "Folgende Fristen sind nicht rechtzeitig erledigt worden. Diese E-Mail geht an die Eskalationskontakte."
|
||||
"These deadlines were not completed on time. This email goes to the escalation contacts."}}</p>
|
||||
{{template "deadline-table" .OverdueItems}}
|
||||
{{end}}
|
||||
|
||||
{{if .HasDueToday}}
|
||||
<h1 style="color:#b45309">
|
||||
{{if .Slot "evening"}}{{t "DRINGEND — heute noch offen" "URGENT — still open today"}}
|
||||
{{else}} {{t "Heute fällig" "Due today"}}{{end}}
|
||||
({{.DueTodayCount}})
|
||||
</h1>
|
||||
{{template "deadline-table" .DueTodayItems}}
|
||||
{{end}}
|
||||
|
||||
{{if .HasWarning}}
|
||||
<h2>{{t "In einer Woche fällig" "Due in one week"}} ({{.WarningCount}})</h2>
|
||||
{{template "deadline-table" .WarningItems}}
|
||||
{{end}}
|
||||
|
||||
<p style="margin-top:24px;">
|
||||
<a href="{{.DeadlinesURL}}">{{t "Alle Fristen" "All deadlines"}}</a>
|
||||
</p>
|
||||
{{end}}
|
||||
```
|
||||
|
||||
The shared `deadline-table` partial renders one row per deadline, similar to
|
||||
today's `deadline_weekly.html` table, plus an "owner" column when the
|
||||
recipient isn't the deadline's `created_by` (so a project lead seeing a
|
||||
team-mate's deadline can immediately tell whose plate it's on).
|
||||
|
||||
### Subject line
|
||||
|
||||
```
|
||||
DE morning, no overdue: [Paliad] Frist-Erinnerung: 3 offen
|
||||
DE morning, with overdue: [Paliad] ÜBERFÄLLIG: 1 — plus 3 weitere
|
||||
DE evening, no overdue: [Paliad] DRINGEND — 2 heute noch offen
|
||||
DE evening, with overdue: [Paliad] SYSTEMAUSFALL: 1 überfällig — plus 2 heute offen
|
||||
```
|
||||
|
||||
Subjects are deliberately scary when overdue is in the bundle — the SLO
|
||||
*is* "no overdues, ever".
|
||||
|
||||
---
|
||||
|
||||
## 6. Schema changes — migration **025**
|
||||
|
||||
(Note: the task brief says "migration 024", but `024_rename_department_columns.up.sql`
|
||||
already exists. The new migration is 025.)
|
||||
|
||||
```sql
|
||||
-- 025_reminder_redesign.up.sql
|
||||
|
||||
-- 1) Per-user warning offset (default 7).
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN IF NOT EXISTS reminder_warning_offset_days
|
||||
INT NOT NULL DEFAULT 7
|
||||
CHECK (reminder_warning_offset_days BETWEEN 1 AND 30);
|
||||
|
||||
-- 2) Optional escalation contact (deferred wiring; column ships now to
|
||||
-- avoid a follow-up migration if m says yes within a sprint).
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN IF NOT EXISTS escalation_contact_id UUID
|
||||
REFERENCES paliad.users(id) ON DELETE SET NULL;
|
||||
|
||||
-- 3) Slot-based dedup on reminder_log.
|
||||
ALTER TABLE paliad.reminder_log
|
||||
ADD COLUMN IF NOT EXISTS slot TEXT,
|
||||
ADD COLUMN IF NOT EXISTS slot_date DATE;
|
||||
|
||||
ALTER TABLE paliad.reminder_log
|
||||
DROP CONSTRAINT IF EXISTS reminder_log_slot_check;
|
||||
ALTER TABLE paliad.reminder_log
|
||||
ADD CONSTRAINT reminder_log_slot_check
|
||||
CHECK (slot IS NULL OR slot IN ('morning','evening'));
|
||||
|
||||
ALTER TABLE paliad.reminder_log
|
||||
DROP CONSTRAINT IF EXISTS reminder_log_reminder_type_check;
|
||||
ALTER TABLE paliad.reminder_log
|
||||
ADD CONSTRAINT reminder_log_reminder_type_check
|
||||
CHECK (reminder_type IN ('overdue','tomorrow','weekly','morning_digest','evening_digest'));
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS reminder_log_slot_dedup_idx
|
||||
ON paliad.reminder_log (user_id, slot, slot_date)
|
||||
WHERE slot IS NOT NULL;
|
||||
```
|
||||
|
||||
### Backfill
|
||||
|
||||
None needed for `reminder_warning_offset_days` (default 7 picks up existing
|
||||
rows). `escalation_contact_id` is NULL by default → behaves as "use
|
||||
global_admins" in code.
|
||||
|
||||
For `reminder_log`: legacy rows have `slot=NULL` and are ignored by the new
|
||||
dedup index (partial). The new code only queries via the partial-index
|
||||
predicate. Old rows are kept for audit; a follow-up housekeeping migration
|
||||
can prune them after the new path runs for a week.
|
||||
|
||||
### Down migration
|
||||
|
||||
`025_reminder_redesign.down.sql` drops the index, both columns, the new
|
||||
constraint variant, and restores the previous CHECK with only the original
|
||||
three values. Reversible.
|
||||
|
||||
---
|
||||
|
||||
## 7. Settings UI changes
|
||||
|
||||
Settings → Notifications gains one new control. The existing morning/evening
|
||||
times and DE/EN toggles stay.
|
||||
|
||||
```
|
||||
[ ] Master toggle: Erinnerungen aktiv
|
||||
|
||||
Morgen-Slot [09:00]
|
||||
Abend-Slot [16:00]
|
||||
Zeitzone [Europe/Berlin ▼]
|
||||
|
||||
Vorwarnung [7] Tage ← NEW (1–30)
|
||||
"Wir erinnern Sie diese viele Tage vor jeder Frist."
|
||||
```
|
||||
|
||||
Backend: `PATCH /api/me/preferences` already accepts a JSON body for
|
||||
`reminder_morning_time` etc. ([settings.ts:338-340](../frontend/src/client/settings.ts#L338-L340));
|
||||
add `reminder_warning_offset_days: number` to the same payload.
|
||||
|
||||
Validation: integer in [1, 30]; reject anything else with HTTP 400. The
|
||||
`reminder_timezone` field also gains stricter validation (reject empty
|
||||
string, reject anything `time.LoadLocation` can't parse) — the same
|
||||
validator used by the new tz-fix.
|
||||
|
||||
### Escalation contact (deferred)
|
||||
|
||||
A `<select>` populated from team users would expose
|
||||
`escalation_contact_id`. Defer the UI to a follow-up task; the column ships
|
||||
now so wiring it later doesn't need a second migration.
|
||||
|
||||
---
|
||||
|
||||
## 8. ReminderService rewrite shape
|
||||
|
||||
The existing `sendPerFrist` (per-deadline kind scan) and `sendWeekly`
|
||||
(Monday digest) are both retired. Replaced by a single
|
||||
`runSlotForUser(ctx, now, user, slot)` per (user, slot) pair the tick
|
||||
matches.
|
||||
|
||||
Loop shape:
|
||||
|
||||
```go
|
||||
func (s *ReminderService) RunOnce(ctx context.Context) {
|
||||
now := s.clock()
|
||||
users, _ := s.users.ListAll(ctx) // small table, untouched today
|
||||
for _, u := range users {
|
||||
for _, slot := range []string{"morning", "evening"} {
|
||||
if !inSlot(now, u, slot) { continue }
|
||||
if alreadySentToday(ctx, u, slot){ continue }
|
||||
if !s.preferenceAllows(u, slot) { continue }
|
||||
s.runSlotForUser(ctx, now, u, slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`runSlotForUser`:
|
||||
|
||||
1. Compute `today_local` from `now` and `u.reminder_timezone` (errors → log + skip; no UTC fallback).
|
||||
2. Pull `overdue`, `due_today`, `due_warning` deadlines for `u`'s recipient set (one query joining `paliad.deadlines`, `paliad.projects`, audience predicates from §3).
|
||||
3. If the result is empty → skip.
|
||||
4. Render `deadline_digest` with the categorized buckets.
|
||||
5. Send. Insert dedup row (user_id, slot, today_local).
|
||||
|
||||
The recipient query unifies all three audience predicates — the user is a
|
||||
recipient iff *any* of the three matches:
|
||||
|
||||
```sql
|
||||
WHERE
|
||||
-- created_by
|
||||
f.created_by = $1
|
||||
OR
|
||||
-- project lead on path
|
||||
EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $1
|
||||
AND pt.role = 'lead'
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[]))
|
||||
OR
|
||||
-- global admin, but only when category is overdue / urgent
|
||||
( $2 = TRUE -- :is_global_admin
|
||||
AND ( f.due_date < $3 -- overdue
|
||||
OR ($4 = 'evening' AND f.due_date = $3) ) -- urgent today, evening only
|
||||
)
|
||||
```
|
||||
|
||||
Per-category eligibility is computed in Go after the SELECT, so the
|
||||
"global_admin only sees overdues / urgent" rule isn't smuggled into SQL.
|
||||
|
||||
---
|
||||
|
||||
## 9. Test plan
|
||||
|
||||
### Existing tests to preserve
|
||||
|
||||
- `TestReminderEnabled` — JSON preference parsing, unchanged.
|
||||
- `TestSlotForKind` — drop, kinds collapse to two slots; replace with `TestSlotMapping` over `('morning','evening')`.
|
||||
- `TestMatchesLocalDueDate` — replaced by `TestCategorize` over the four categories.
|
||||
|
||||
### New unit tests
|
||||
|
||||
| Test | What it locks down |
|
||||
|------|--------------------|
|
||||
| `TestTZDataEmbedded` | `time.LoadLocation("Europe/Berlin")` succeeds in the test binary — guards against losing the `_ "time/tzdata"` import |
|
||||
| `TestInSlot_InvalidTzSkips` | `inSlot(_, "", _, _, _) == false` and `inSlot(_, "Mars/Olympus", _, _, _) == false` (no UTC fallback) |
|
||||
| `TestCategorize_Boundaries` | due_date exactly today → `due_today` (not `overdue`); exactly today+offset → `due_warning`; today-1 → `overdue` |
|
||||
| `TestBundleEmpty_NoSend` | a user with zero matching deadlines in their slot gets no email and no log row |
|
||||
| `TestBundleMultiCategory` | one user with overdue + due_today + warning → exactly one email, three sections, one log row |
|
||||
| `TestDedupBySlotDate` | a second tick in the same slot+local-date → no second send |
|
||||
| `TestDedupRollsOverAtMidnight` | freezing the clock to advance the user's local date past midnight → next slot fires again |
|
||||
| `TestRecipientSet_OwnerOnly` | non-admin, non-lead user gets only their own deadlines |
|
||||
| `TestRecipientSet_ProjectLead` | a project lead sees a team-mate's deadline alongside their own in the same email |
|
||||
| `TestRecipientSet_GlobalAdmin` | a global_admin sees the overdue section but not the warning section |
|
||||
| `TestEscalationContactFallback` | when `escalation_contact_id` is NULL, global_admins fill the role; when set, the chosen user receives instead |
|
||||
| `TestSubjectLine_OverdueFraming` | overdue presence flips the subject from "Frist-Erinnerung" to "ÜBERFÄLLIG"/"SYSTEMAUSFALL" |
|
||||
|
||||
### TZ-fix regression test (the headline acceptance)
|
||||
|
||||
`TestInSlot_BerlinAt0900_NotAt1100` — set `now = 2026-04-28 07:00:00 UTC`,
|
||||
`tz = "Europe/Berlin"`, `morning = "09:00"`. Asserts `inSlot(...) == true`
|
||||
(09:00 Berlin). Same now with `morning = "11:00"` → false. Same now without
|
||||
the `_ "time/tzdata"` import would (today) fail; with the import it passes.
|
||||
|
||||
Plus an *integration* test against the email-send path: with `mailSvc`
|
||||
disabled (Enabled()=false), `RunOnce` at 07:00 UTC writes a dedup row for
|
||||
user m at slot=morning, slot_date=2026-04-28 — but does **not** write one
|
||||
when `now = 09:16 UTC` (= 11:16 Berlin), the bug's signature.
|
||||
|
||||
### Smoke (manual)
|
||||
|
||||
1. Set `tester@hlc.de`'s morning_time to 09:00, tz=Europe/Berlin.
|
||||
2. Create three deadlines: due 2026-04-21 (overdue), 2026-04-28 (today), 2026-05-05 (today+7).
|
||||
3. Trigger `RunOnce` at simulated `now = 2026-04-28 07:05 UTC` (= 09:05 Berlin).
|
||||
4. Verify: one email to tester@hlc.de with three sections; subject contains
|
||||
"ÜBERFÄLLIG"; one row in `paliad.reminder_log` with slot=morning,
|
||||
slot_date=2026-04-28.
|
||||
5. Trigger again at `08:05 UTC` (= 10:05 Berlin) → no second email
|
||||
(out-of-slot).
|
||||
6. Trigger at `14:05 UTC` (= 16:05 Berlin) → evening email arrives,
|
||||
"DRINGEND" wording on the today-due section, overdue section repeated.
|
||||
7. Mark the today-due deadline as completed; trigger at next morning's
|
||||
slot → only the overdue remains (system-failure framing); deadlines
|
||||
completed in the meantime are gone.
|
||||
|
||||
---
|
||||
|
||||
## 10. Migration plan
|
||||
|
||||
1. **PR 1 (this design + tz fix only):** add `_ "time/tzdata"` to `cmd/server/main.go`; tighten `inSlot` to skip on bad tz; add tz validation on user save. **Ships fast** — fixes m's prod 11:16 surprise without waiting for the full redesign. Existing schedule remains functional.
|
||||
2. **PR 2 (schema):** migration 025 (warning_offset_days, escalation_contact_id, reminder_log slot/slot_date). Idempotent, additive only. Deployed via Dokploy auto-deploy on merge to main.
|
||||
3. **PR 3 (service rewrite):** new `runSlotForUser`, new `deadline_digest` template, retire `sendPerFrist`/`sendWeekly` and the three legacy templates. Backward-compatible during deploy: the new code only writes `slot/slot_date` rows; the old code wrote `(reminder_type, deadline_id)` rows. There's no overlap window (old code is replaced, not run in parallel).
|
||||
4. **PR 4 (settings UI):** expose `warning_offset_days` on Settings → Notifications. Optional: escalation_contact dropdown if scope holds.
|
||||
5. **Cleanup follow-up (separate task):** prune legacy reminder_log rows older than 30 days; remove old templates; remove `sendWeekly` test scaffolding.
|
||||
|
||||
A single PR for #2-4 is also reasonable if the diff stays under ~600 lines.
|
||||
The tz fix in PR 1 should ship first, isolated.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions for m
|
||||
|
||||
1. **What does "project_team admins" mean?** The `paliad.project_teams.role`
|
||||
enum is `lead | associate | pa | of_counsel | local_counsel | expert |
|
||||
observer` — there is no `admin` role. My proposal: notify `role = 'lead'`
|
||||
only. Alternative: notify `role IN ('lead','associate')`. Or: notify
|
||||
everyone on the team's path (closest to existing visibility semantics,
|
||||
but spammy for observers).
|
||||
|
||||
2. **Drop the Monday weekly digest?** The new per-deadline +7-day warning
|
||||
covers the same job (heads-up of upcoming deadlines), more precisely
|
||||
(each deadline gets its warning on its own +7 day, not lumped on Mondays).
|
||||
Proposal: drop the Monday digest. If you'd like to keep it as an
|
||||
additional weekly summary (different content from per-deadline warnings,
|
||||
e.g. "everything in your next 30 days"), say so.
|
||||
|
||||
3. **Escalation contact UI in this scope or deferred?** Column ships in
|
||||
migration 025 either way. UI dropdown pulls in user-search — could fit
|
||||
in PR 4 or be its own follow-up.
|
||||
|
||||
4. **`due_warning` recipients — owner only, or owner + leads?** Spec says
|
||||
"created_by + project_team admins". I've matched that (owner ∪ leads).
|
||||
Confirm that's wider than you intended, or accept.
|
||||
|
||||
5. **Calendar-aware skipping (weekends, holidays).** The spec asks me to
|
||||
note this as a known gap — it's not in scope. The `paliad.holidays`
|
||||
table already exists (used by Fristenrechner). A future enhancement
|
||||
could shift the +7 warning earlier when day 7 falls on a weekend, and
|
||||
skip the morning slot on weekends/holidays for low-severity categories.
|
||||
Overdue and DRINGEND should still fire — those are the SLO-critical
|
||||
ones.
|
||||
|
||||
6. **Per-deadline custom reminder offset (defer).** Today the offset is
|
||||
per-user. m might eventually want per-deadline override (e.g., "this
|
||||
filing deadline needs a +14 warning"). Out of scope for this round;
|
||||
noting for the backlog.
|
||||
|
||||
7. **One canonical worry:** the morning email when there's *no* overdue
|
||||
and *no* due-today and *no* +7 warning — i.e. nothing — does **not**
|
||||
send. Confirm that's what you want (no "everything's quiet" ack
|
||||
email). I'm proposing yes-skip; an empty-state daily ack email is
|
||||
noise.
|
||||
|
||||
---
|
||||
|
||||
## 12. Acceptance criteria (mirrored from task brief)
|
||||
|
||||
- `tester@hlc.de` morning=09:00 Berlin → ticker fires at 09:xx Berlin (= 07:xx UTC) and never at 11:xx
|
||||
- A deadline due today + still pending → "Heute fällig" bundled email at 09:00 (one email even with 4 such deadlines), then "DRINGEND" at 16:00 if still pending
|
||||
- A deadline that escapes to tomorrow uncompleted → "ÜBERFÄLLIG (System-Eskalation)" framing, sent to created_by + global_admins
|
||||
- Settings page exposes `morning_time` + `evening_time` + `warning_offset_days`
|
||||
- `go build/vet/test` clean, `bun run build` clean, regression tests for tz + bundle dedup
|
||||
- Self-merge to main authorised on PR-by-PR basis
|
||||
|
||||
---
|
||||
|
||||
## 13. Out of scope (per task brief)
|
||||
|
||||
- WhatsApp / SMS / push escalation channels — defer
|
||||
- Per-deadline custom reminder offset — defer
|
||||
- Calendar-aware skipping (weekends, holidays) — noted as known gap (§11.5)
|
||||
@@ -1,486 +1,216 @@
|
||||
# patholo.de Feature Roadmap
|
||||
# Paliad Feature Roadmap
|
||||
|
||||
**Author:** cronus (inventor) | **Date:** 2026-04-14
|
||||
**Task:** t-patholo-011
|
||||
**Author:** cronus (inventor) | **Original date:** 2026-04-14 | **Rewritten:** 2026-04-17 (Phase J, after KanzlAI integration)
|
||||
**Task:** t-paliad-013 (rewrite); originally t-patholo-011
|
||||
|
||||
---
|
||||
|
||||
## Strategic Position
|
||||
|
||||
patholo.de is a **knowledge platform**, not case management. KanzlAI handles case tracking, deadlines, billing. patholo.de is where HL patent lawyers go to **find tools, templates, guides, and answers** — the internal Wikipedia + toolkit for patent practice.
|
||||
Paliad is the **all-in-one platform for HLC patent practice**: knowledge tools plus Aktenverwaltung, behind one sidebar, one URL, one login.
|
||||
|
||||
The goal: every new associate's first bookmark, every partner's quick-reference, every PA's template source.
|
||||
It grew out of a pure knowledge platform (patholo.de, Q1 2026) and absorbed the KanzlAI case-management prototype on 2026-04-16 after the HL → HLC merger. The goal stays the same: every new associate's first bookmark, every partner's quick-reference, every PA's template source — and now also the place where a lawyer checks their next Frist before looking up the relevant UPC fee.
|
||||
|
||||
### Audience
|
||||
|
||||
- Patent lawyers and PAs across 7 offices (Munich, Dusseldorf, Hamburg, Amsterdam, London, Paris, Milan)
|
||||
- Mix of German and English speakers
|
||||
- Patent lawyers and PAs across 7 offices (Munich, Düsseldorf, Hamburg, Amsterdam, London, Paris, Milan)
|
||||
- Mix of German and English speakers — DE/EN toggle on every page
|
||||
- Range from senior partners to new associates
|
||||
- Daily work: drafting submissions, calculating costs, tracking deadlines, researching case law
|
||||
- Daily work: drafting submissions, calculating costs, tracking deadlines, managing matters, researching case law
|
||||
|
||||
### What We Have (v1)
|
||||
### What We Have (shipped — April 2026)
|
||||
|
||||
| Feature | Status |
|
||||
|---|---|
|
||||
| Supabase auth (@hoganlovells.com) | Live |
|
||||
| Prozesskostenrechner (DE/UPC/EPA) | Live |
|
||||
| Fristenrechner (deadlines with holiday adjustment) | Live |
|
||||
| Downloads (HL Patents Style.dotm) | Live |
|
||||
| Sidebar navigation | Live |
|
||||
| i18n DE/EN | Live |
|
||||
| Lime green branding | Live |
|
||||
| Feature | Phase | Status |
|
||||
|---|---|---|
|
||||
| Supabase auth (@hoganlovells.com gate) | v1 | Live |
|
||||
| Prozesskostenrechner (DE / UPC / EPA) | v1 | Live |
|
||||
| Fristenrechner (deadlines with holiday adjustment) | v1 | Live |
|
||||
| Lime-green branding + DE/EN i18n + sidebar | v1 | Live |
|
||||
| File proxy + Downloads page (HL Patents Style.dotm) | 1.2 | Live |
|
||||
| Link Hub with curated categories + youpc.org case-law link | 1.1 / 2.3 | Live |
|
||||
| Gebührentabellen (GKG / RVG / UPC / EPA / PatKostG) | 1.3 | Live |
|
||||
| Patentglossar (DE/EN, searchable) | 1.4 | Live |
|
||||
| Kostenrechner enhancements (PDF export, URL sharing, scenario comparison) | 1.5 | Live (partial — Prozesskostensicherheit pending) |
|
||||
| Gerichtsverzeichnis (court directory) | 2.2 | Live |
|
||||
| Checklisten (interactive filing checklists) | 2.4 | Live |
|
||||
| **Akten** (matter management, office-scoped visibility, collaborators) | 0.1 (A–D) | Live |
|
||||
| **Fristen** (persistent deadline management, traffic-light cards, calendar) | 0.2 (E) | Live |
|
||||
| **Termine** (appointments, calendar view) + **CalDAV sync** (AES-GCM at rest) | 0.3 (F) | Live |
|
||||
| **Dashboard** (logged-in landing, server-rendered) | 0.4 (G) | Live |
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Content is king** — tools bring people in, content keeps them coming back
|
||||
2. **Self-serve over manual curation** — prefer structured data + search over hand-maintained pages
|
||||
3. **Complement, don't compete** — KanzlAI does case management; patholo does knowledge
|
||||
4. **Ship incrementally** — each feature is independently useful
|
||||
5. **German content quality matters** — proper Umlaute, legal precision, no machine-translation feel
|
||||
1. **Knowledge and practice live together** — tools and content bring people in; Aktenverwaltung keeps them there daily.
|
||||
2. **Office-scoped by default** — an Akte belongs to one office; cross-office access is explicit via collaborator lists or a partner-toggled firm-wide flag. No "everyone sees everything" and no multi-tenancy machinery. See `docs/design-kanzlai-integration.md` §2.
|
||||
3. **Self-serve over manual curation** — prefer structured data + search over hand-maintained pages.
|
||||
4. **Ship incrementally** — each feature is independently useful.
|
||||
5. **German content quality matters** — proper Umlaute, legal precision, no machine-translation feel.
|
||||
6. **HTML-first, JS-enhanced** — server-rendered TSX with per-page client TS bundles. No react-query, no heavy client frameworks.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Aktenverwaltung Foundation (shipped April 2026)
|
||||
|
||||
Ported and rebuilt from the retired KanzlAI prototype. Detailed phase breakdown and acceptance criteria live in `docs/design-kanzlai-integration.md` §8 (Phases A–J); this roadmap is the user-facing summary.
|
||||
|
||||
### 0.1 Akten (Matter Management) — office-scoped ✅ Done (2026-04-16, Phases A–D)
|
||||
|
||||
Persistent Akten (previously "cases" / "Mandate") with Parteien, audit trail (Verlauf), and per-Akte visibility. Every Akte has an `owning_office`, an explicit `collaborators` list, and a partner-togglable `firm_wide_visible` flag. Visibility is enforced both in Supabase RLS (`paliad.can_see_akte(akte_id)`) and at the application layer for defense in depth.
|
||||
|
||||
Shipped in Phases A (schema + RLS), B (services + sqlx pool), C (Fristenrechner → DB), D (Akten CRUD + onboarding + collaborator picker).
|
||||
|
||||
### 0.2 Fristen (Persistent Deadline Management) ✅ Done (2026-04-16, Phase E)
|
||||
|
||||
Persistent Frist list with traffic-light cards (rot / amber / grün / grau), detail page, month calendar, bulk-import from Fristenrechner ("Als Frist(en) speichern"). Visibility inherits from the parent Akte. Every mutation appends an `akten_events` row.
|
||||
|
||||
### 0.3 Termine + CalDAV Sync ✅ Done (2026-04-17, Phase F)
|
||||
|
||||
Termine CRUD (dual-mode: Akte-attached or personal), list/detail/calendar views, per-user CalDAV configuration. Bidirectional sync with a per-user goroutine on a 60s tick. Credentials encrypted at rest with AES-GCM keyed off `CALDAV_ENCRYPTION_KEY` (KanzlAI audit §1.3 fix). Passwords never returned in API responses.
|
||||
|
||||
### 0.4 Dashboard (Logged-in Landing) ✅ Done (2026-04-16, Phase G)
|
||||
|
||||
Server-rendered `/dashboard` for authenticated users: Frist summary (traffic lights), Akten summary, upcoming Fristen and Termine (7d), recent Verlauf. Zero client-side waterfall (audit §2.3 fix).
|
||||
|
||||
### 0.5 AI-assisted Frist-Extraktion — Deferred (Phase H)
|
||||
|
||||
Anthropic-based extraction of Fristen from uploaded court documents. **Not in current scope** — decision by m on 2026-04-16: "We don't want Anthropic API. We put this off for a while." The Dokumente tab on Akten detail stays as a "Kommt bald" placeholder. No `ANTHROPIC_API_KEY` needed on Dokploy today.
|
||||
|
||||
Open when revisiting: document upload + Supabase Storage alone (without AI) may still be worth shipping as a standalone Dokumente feature.
|
||||
|
||||
### 0.6 Notizen (polymorphic) — Pending (Phase I)
|
||||
|
||||
Schema exists (migration 005: `paliad.notizen` with polymorphic FK + CHECK constraint, RLS inherits from parent). Service, handlers, and UI component not yet shipped. Sized at ~4h in the integration design; pick up when cross-cutting notes become the next friction point.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation (Low effort, High impact)
|
||||
|
||||
These features can each be built in a single session. They fill obvious gaps and immediately increase the platform's daily utility.
|
||||
Feature specs from the original roadmap. Items marked **✅ Done** were shipped in the pre-Aktenverwaltung April 2026 content push.
|
||||
|
||||
### 1.1 Link Hub ("Nützliche Links")
|
||||
### 1.1 Link Hub ("Nützliche Links") ✅ Done (2026-04-14)
|
||||
|
||||
**What:** Curated, categorized page of external links relevant to daily patent practice.
|
||||
Curated, categorized page of external links relevant to daily patent practice. Categories cover Gerichte & Ämter, Recherche, UPC, Gesetze, and HL Intern. Lives at `/links`. Includes the youpc.org case-law entry that replaces the dropped §2.3 item. Users can suggest new links via an inline form.
|
||||
|
||||
**Categories:**
|
||||
- **Gerichte & Ämter** — UPC CMS (cms.unifiedpatentcourt.org), UPC Register, EPO (epo.org), DPMA (dpma.de), BPatG
|
||||
- **Recherche** — Espacenet, DPMA-Register, DEPATISnet, Google Patents, Patentscope (WIPO)
|
||||
- **UPC** — Rules of Procedure, Schedule of Fees, Practice Directions, UPC website, Bristows UPC Hub
|
||||
- **Gesetze** — PatG, EPÜ, UPCA, GKG, RVG, ZPO (dejure.org links)
|
||||
- **HL Intern** — SharePoint UPC Vault, UPC Playbook, UPC Knowledge Bank, DraftXPress
|
||||
### 1.2 More Downloads ✅ Done (page shipped — content pending)
|
||||
|
||||
**Why:** Lawyers waste time finding the right URL. A curated link page with one-click access is the simplest high-value feature. Every major law firm knowledge platform starts here.
|
||||
Dedicated `/downloads` page with card-grid layout shipped (2026-04-14, `fd25998`). Current registry still holds only **HL Patents Style.dotm** — adding BuildingBlocks, legal writing templates, and the original Patentprozesskostenrechner.xlsm is a one-line registry edit per file, pending content selection from mWorkRepo.
|
||||
|
||||
**Implementation:**
|
||||
- New page: `/links`
|
||||
- Static data in Go (map of categories -> links), served as JSON
|
||||
- TSX page with category sections, each link as a card with icon + title + short description
|
||||
- Add to sidebar navigation
|
||||
- i18n for category names and descriptions
|
||||
### 1.3 Gebührentabellen (Fee Schedule Reference) ✅ Done (2026-04-14)
|
||||
|
||||
**Effort:** ~2 hours | **Impact:** High (daily use)
|
||||
Interactive, tabbed fee schedule reference at `/tools/gebuehrentabellen`: GKG / RVG / UPC / EPA / PatKostG, with Streitwert quick-lookup and sortable tables per schedule version.
|
||||
|
||||
---
|
||||
### 1.4 Patentglossar (DE/EN) ✅ Done (2026-04-14)
|
||||
|
||||
### 1.2 More Downloads
|
||||
Searchable bilingual glossary at `/glossar` with client-side filter, category tags (prosecution / litigation / UPC / EPA), and a "Begriff vorschlagen" feedback form. Loaded from static JSON at server startup.
|
||||
|
||||
**What:** Expand the downloads page with more templates and documents from mWorkRepo.
|
||||
### 1.5 Kostenrechner Enhancements ✅ Partial (2026-04-14)
|
||||
|
||||
**Files to add (immediate candidates):**
|
||||
- HL Patents Style.dotm (already live)
|
||||
- BuildingBlocks library files (from mWorkRepo/6 - material/Templates/Word/Blocks/)
|
||||
- Legal writing templates (from mWorkRepo/6 - material/Legal Writing/)
|
||||
- Patentprozesskostenrechner.xlsm (the original Excel calculator — some people prefer Excel)
|
||||
|
||||
**Why:** The downloads page exists but has only one file. The file proxy infrastructure is already built and supports multiple files via the registry. Adding files is literally adding map entries.
|
||||
|
||||
**Implementation:**
|
||||
- Add entries to `fileRegistry` in `internal/handlers/files.go`
|
||||
- Update downloads page TSX with cards per file
|
||||
- Group downloads by category (Templates, Rechner, Leitfäden)
|
||||
|
||||
**Effort:** ~1 hour | **Impact:** Medium (removes a SharePoint dependency)
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Gebührentabellen (Fee Schedule Reference)
|
||||
|
||||
**What:** Browsable, interactive fee tables for GKG, RVG, PatKostG, and UPC fee schedules.
|
||||
|
||||
**Content:**
|
||||
- GKG fee brackets (2005, 2013, 2021, 2025, Aktuell) — what's the 1.0 fee for a given Streitwert?
|
||||
- RVG fee brackets (same versions)
|
||||
- UPC fee schedule (pre-2026 vs. 2026+, with SME discount)
|
||||
- EPA fees (opposition, appeal, grant)
|
||||
- Common multipliers reference (3.0x LG, 4.0x OLG, etc.)
|
||||
- PatKostG fixed fees (BPatG, DPMA)
|
||||
|
||||
**Why:** Lawyers regularly need to look up a specific fee without running the full calculator. The Kostenrechner is great for full scenarios, but sometimes you just need "what's the 1.0 RVG fee for Streitwert 2M?" — a reference table answers that in 2 seconds.
|
||||
|
||||
**Implementation:**
|
||||
- New page: `/tools/gebuehrentabellen`
|
||||
- Reuse data from `internal/calc/fee_tables.go` — expose via new API endpoint
|
||||
- TSX page with tabbed view (GKG | RVG | UPC | EPA | PatKostG)
|
||||
- Streitwert input for quick lookup
|
||||
- Sortable/filterable table per schedule version
|
||||
|
||||
**Effort:** ~4 hours | **Impact:** Medium-High (replaces Excel lookup)
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Patentglossar (Patent Glossary DE/EN)
|
||||
|
||||
**What:** Searchable bilingual glossary of patent law terminology.
|
||||
|
||||
**Content (examples):**
|
||||
- Streitwert / Amount in dispute
|
||||
- Nichtigkeitsklage / Nullity action
|
||||
- Patentverletzung / Patent infringement
|
||||
- Unterlassungsanspruch / Injunctive relief
|
||||
- Schadensersatz / Damages
|
||||
- Beschwerde / Appeal
|
||||
- Einspruch / Opposition
|
||||
- Schriftsatz / Written submission
|
||||
- Merkmalsgliederung / Feature breakdown
|
||||
- Vertraulichkeitsklub / Confidentiality club
|
||||
- Einstweilige Verfügung / Preliminary injunction
|
||||
- Gebrauchsmuster / Utility model
|
||||
- ... (50-100 terms initially)
|
||||
|
||||
**Why:** Cross-border teams constantly need precise DE<->EN translations for legal terms. Google Translate is dangerous for legal terminology. A curated glossary prevents mistranslations in submissions.
|
||||
|
||||
**Implementation:**
|
||||
- New page: `/glossar`
|
||||
- Data: JSON file with terms, loaded at startup
|
||||
- Client-side search (instant filter as you type)
|
||||
- Show DE term, EN term, optional short definition
|
||||
- Category tags (prosecution, litigation, UPC, EPA)
|
||||
|
||||
**Effort:** ~3 hours (code) + ~2 hours (content curation) | **Impact:** Medium (frequent use for cross-border work)
|
||||
|
||||
---
|
||||
|
||||
### 1.5 Kostenrechner Enhancements
|
||||
|
||||
**What:** Improve the existing Kostenrechner with frequently-requested features.
|
||||
|
||||
**Enhancements:**
|
||||
- **PDF Export** — generate a branded summary PDF of the calculation result (for client budgets, internal memos)
|
||||
- **Scenario Comparison** — side-by-side comparison of two scenarios (e.g., DE-only vs. UPC, different Streitwerte)
|
||||
- **URL Sharing** — encode calculator state in URL parameters so calculations can be bookmarked/shared
|
||||
- **Prozesskostensicherheit** — add the security-for-costs calculation (currently only in the Excel version, and buggy there)
|
||||
|
||||
**Why:** The Kostenrechner is patholo's flagship feature. Making it PDF-exportable turns it into a client-facing tool. Scenario comparison is the #1 use case for cost calculators (should we litigate at UPC or LG?). The original Excel version's Prozesskostensicherheit section has bugs — patholo can do it right.
|
||||
|
||||
**Implementation:**
|
||||
- PDF: client-side using window.print() with @media print CSS (simple) or server-side PDF generation (more polished)
|
||||
- Scenario comparison: duplicate the calculator form side-by-side, show diff
|
||||
- URL sharing: serialize form state to URL query params
|
||||
- Prozesskostensicherheit: new calc in Go, formula from Kühnen 16th ed. Rn. E.47 ff. (fix the VAT bug from the Excel version)
|
||||
|
||||
**Effort:** ~6 hours total | **Impact:** High (makes the tool client-presentable)
|
||||
- **PDF Export** — shipped (print CSS).
|
||||
- **Scenario Comparison** — shipped (side-by-side diff).
|
||||
- **URL Sharing** — shipped (query-param state).
|
||||
- **Prozesskostensicherheit** — pending. Calculation logic not yet implemented; only the glossary term exists. Kühnen 16th ed. Rn. E.47 ff. formula is still the target reference.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Content Hub (Medium effort, High impact)
|
||||
|
||||
These features require content creation alongside code. They transform patholo from a toolkit into a knowledge platform.
|
||||
### 2.1 Verfahrensleitfäden (Procedure Guides) — Pending
|
||||
|
||||
### 2.1 Verfahrensleitfäden (Procedure Guides)
|
||||
Step-by-step visual guides for UPC Infringement, UPC Revocation, UPC Provisional Measures, German Infringement, German Nullity, EPA Opposition, EPA Appeal. Timeline + step descriptions + cross-links to Fristenrechner pre-filled for the proceeding type. Content exists in mWorkRepo (UPC Know-How, UPC Training); needs structuring.
|
||||
|
||||
**What:** Step-by-step interactive guides for common patent procedures.
|
||||
**Effort:** ~8h code + ~6h content per guide | **Impact:** Very High
|
||||
|
||||
**Initial guides:**
|
||||
- UPC Infringement Action (Statement of Claim -> Defence -> Reply -> Rejoinder -> Hearing -> Decision)
|
||||
- UPC Revocation Action
|
||||
- UPC Provisional Measures
|
||||
- German Infringement (LG -> OLG -> BGH)
|
||||
- German Nullity (BPatG -> BGH)
|
||||
- EPA Opposition
|
||||
- EPA Appeal
|
||||
### 2.2 Gerichtsverzeichnis (Court Directory) ✅ Done (2026-04-16)
|
||||
|
||||
**Per guide:**
|
||||
- Visual timeline/flowchart (like the UPC Course of Proceedings Excalidraw in mWorkRepo)
|
||||
- Step descriptions with party, deadline, rule reference
|
||||
- Tips and practical notes from experienced practitioners
|
||||
- Links to relevant model documents, templates
|
||||
- Cross-links to Fristenrechner (pre-filled for this proceeding type)
|
||||
Reference page at `/gerichte` with entries for every relevant UPC division, German court (LG / OLG / BGH / BPatG), DPMA, EPA, and national courts in NL / UK / FR / IT. Searchable + filterable by type and country.
|
||||
|
||||
**Why:** New associates ask "how does a UPC infringement action work?" constantly. A visual, interactive guide replaces the ad-hoc training that senior lawyers currently provide. The content already exists in mWorkRepo (UPC Know-How, UPC Training sessions) — it just needs to be structured and presented.
|
||||
### 2.3 UPC Rechtsprechungsübersicht — Dropped
|
||||
|
||||
**Implementation:**
|
||||
- New section: `/guides` with sub-pages per guide
|
||||
- Data: structured JSON per guide (steps, durations, parties, rules)
|
||||
- TSX: timeline component with expandable steps
|
||||
- Deep-link to Fristenrechner with pre-selected proceeding type
|
||||
- i18n for all content
|
||||
Explicitly removed 2026-04-16. Rationale: youpc.org already maintains a curated UPC case-law database with 1,600+ decisions. Replaced with a prominent youpc.org entry in the Link Hub under "Recherche" (commit `4526942`). Re-add only if youpc.org shuts down or if HLC needs firm-specific takeaways attached to decisions.
|
||||
|
||||
**Effort:** ~8 hours (code) + ~6 hours (content per guide) | **Impact:** Very High (training + daily reference)
|
||||
### 2.4 Checklisten (Interactive Checklists) ✅ Done (2026-04-16)
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Gerichtsverzeichnis (Court Directory)
|
||||
|
||||
**What:** Reference page with details for every relevant court, division, and office.
|
||||
|
||||
**Content per entry:**
|
||||
- Court name (DE + EN)
|
||||
- Type (UPC Local Division, UPC Central Division, LG, OLG, BGH, BPatG, DPMA, EPA)
|
||||
- Address, phone, fax
|
||||
- Filing details (electronic filing system, accepted formats)
|
||||
- Key judges (for UPC divisions — public info from UPC website)
|
||||
- Link to court website / registry
|
||||
- HL contacts for that jurisdiction
|
||||
- Practical notes (e.g., "Munich LD prefers oral hearings on Wednesdays")
|
||||
|
||||
**Courts to include:**
|
||||
- UPC: Munich CD, Paris CD (Seat), Luxembourg CD, all Local Divisions (Munich, Düsseldorf, Hamburg, Mannheim, The Hague, Paris, Milan, Brussels, Helsinki, etc.), Court of Appeal (Luxembourg)
|
||||
- Germany: LG Munich I, LG Düsseldorf, LG Mannheim, LG Hamburg, OLG Düsseldorf (+ Senat), OLG Munich, BGH (X. Zivilsenat), BPatG
|
||||
- EPO: Boards of Appeal, Opposition Division
|
||||
- National: NL (The Hague), UK (Patents Court, IPEC), FR (TGI Paris), IT (Milan, Turin)
|
||||
|
||||
**Why:** "What's the filing address for the UPC Local Division Hamburg?" — this question gets asked weekly. A central directory with practical filing info saves time and prevents errors.
|
||||
|
||||
**Implementation:**
|
||||
- New page: `/gerichte`
|
||||
- Data: JSON with structured court entries
|
||||
- Client-side search + filter by type/country
|
||||
- Map view (optional, using coordinates)
|
||||
- Print-friendly format for travel
|
||||
|
||||
**Effort:** ~4 hours (code) + ~4 hours (data collection) | **Impact:** Medium-High (weekly use)
|
||||
|
||||
---
|
||||
|
||||
### 2.3 UPC Rechtsprechungsübersicht (UPC Case Law Dashboard)
|
||||
|
||||
**What:** Curated overview of significant UPC decisions with summaries and key takeaways.
|
||||
|
||||
**Features:**
|
||||
- List of decisions, newest first
|
||||
- Per decision: case number, parties, division, date, topic tags, 2-3 sentence summary, key takeaway
|
||||
- Filter by: division, topic (infringement, validity, preliminary injunction, costs, procedure), date range
|
||||
- Search by party name or case number
|
||||
- Link to full decision (UPC register) and to youpc.org if available
|
||||
|
||||
**Content source:** mWorkRepo already has youpc-summaries/ with 8+ recent case summaries. The youpc.org database has 1,600+ decisions. Start with curated highlights, not a full database.
|
||||
|
||||
**Why:** UPC case law is developing rapidly (court opened June 2023). Staying current is critical for practitioners. The UPC website's register is hard to navigate. A curated, searchable overview with HL-relevant takeaways is enormously valuable.
|
||||
|
||||
**Implementation:**
|
||||
- New page: `/upc/rechtsprechung`
|
||||
- Data: JSON file with curated entries (start with 20-30 key decisions, add monthly)
|
||||
- Admin: simple way to add new entries (could be a JSON file in the repo, updated via PR)
|
||||
- TSX: filterable card list with topic tags
|
||||
- Optional: API integration with youpc.org for broader search
|
||||
|
||||
**Effort:** ~6 hours (code) + ~8 hours (initial curation) | **Impact:** Very High (weekly/daily for UPC practitioners)
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Checklisten (Interactive Checklists)
|
||||
|
||||
**What:** Interactive, printable checklists for common patent workflows.
|
||||
|
||||
**Initial checklists:**
|
||||
- UPC Statement of Claim — required elements per RoP
|
||||
- UPC Statement of Defence — required elements per RoP
|
||||
- UPC Confidentiality Application — requirements
|
||||
- UPC Registration as Representative — documents needed
|
||||
- Patent nullity action (BPatG) — filing requirements
|
||||
- EPA Opposition — formal requirements and deadlines
|
||||
- nUPCMS filing — step-by-step for electronic submission
|
||||
|
||||
**Per checklist:**
|
||||
- Checkbox items (persistent in localStorage per user)
|
||||
- Category groupings (formal requirements, content requirements, annexes)
|
||||
- Notes/tips per item
|
||||
- Print-friendly layout
|
||||
- Reset button
|
||||
|
||||
**Why:** Filing requirements are complex and vary by court. Missing a formal requirement means rejection or delay. Checklists prevent errors. The HL Model Documents project in mWorkRepo already has checklist content (CHECKLIST.md with 63 BuildingBlock entries).
|
||||
|
||||
**Implementation:**
|
||||
- New section: `/checklisten`
|
||||
- Data: JSON per checklist
|
||||
- Client-side state (localStorage) for checkbox persistence
|
||||
- Print with checked/unchecked state visible
|
||||
|
||||
**Effort:** ~4 hours (code) + ~3 hours (content per checklist) | **Impact:** High (prevents costly filing errors)
|
||||
Interactive checklists at `/checklisten` for UPC Statement of Claim, Statement of Defence, Confidentiality Application, Representative Registration, BPatG nullity, EPA Opposition, nUPCMS filing. Checkbox state persisted in `localStorage` per user; print-friendly layout; feedback form per list.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Platform Features (Higher effort, Transformative)
|
||||
|
||||
These features require more architecture but move patholo from "useful tool" to "indispensable platform."
|
||||
### 3.1 Suchfunktion (Global Search) — Pending
|
||||
|
||||
### 3.1 Suchfunktion (Global Search)
|
||||
Search across all Paliad content — glossary, Gerichte, Leitfäden, Checklisten, links, and eventually Akten (scoped by visibility). Build the index at startup from JSON sources + DB. Expose `GET /api/search?q=...`.
|
||||
|
||||
**What:** Search across all patholo content — glossary terms, court entries, case law, guides, checklists, links.
|
||||
**Effort:** ~6h | **Impact:** High
|
||||
|
||||
**Why:** Once patholo has significant content, users need to find things fast. A search bar in the sidebar that searches everything is the difference between "I'll check patholo" and "I'll just Google it."
|
||||
### 3.2 Vorlagenbibliothek (Template Library) — Pending
|
||||
|
||||
**Implementation:**
|
||||
- Search index built at startup from all JSON data sources
|
||||
- API endpoint: GET /api/search?q=...
|
||||
- Client: search input in sidebar, results overlay
|
||||
- Highlight matching text in results
|
||||
Evolve `/downloads` from a flat card grid into a proper template library with preview, category filters (Schriftsätze, Vorlagen, Tabellen, Blöcke), and metadata. Distribution channel for the HL Model Documents project and BuildingBlocks.
|
||||
|
||||
**Effort:** ~6 hours | **Impact:** High (scales with content)
|
||||
**Effort:** ~8h | **Impact:** High
|
||||
|
||||
---
|
||||
### 3.3 Schulungsbereich (Training Hub) — Pending
|
||||
|
||||
### 3.2 Vorlagenbibiothek (Template Library)
|
||||
Self-serve onboarding and continuing education at `/schulung`. New-associate guide, UPC training material, video guide links, HL Patents Style tutorial, nUPCMS filing guide, FAQ.
|
||||
|
||||
**What:** Browsable library of document templates with preview and download.
|
||||
**Effort:** ~6h code + ~10h content | **Impact:** Medium-High
|
||||
|
||||
**Content:**
|
||||
- HL Patents Style.dotm (already live)
|
||||
- BuildingBlocks from mWorkRepo (Tables, Headings, Phrases, Elements — 63 blocks)
|
||||
- Model documents (Statement of Claim, Defence, etc. — from HL Model Documents project)
|
||||
- Legal writing templates (research memo, client letter)
|
||||
- Court-specific cover sheets
|
||||
### 3.4 Benachrichtigungen (What's New) — Pending
|
||||
|
||||
**Per template:**
|
||||
- Name, description, category
|
||||
- Preview (rendered HTML or screenshot)
|
||||
- Download (via existing file proxy)
|
||||
- Usage instructions
|
||||
- Version info (last updated)
|
||||
Changelog + "neu seit letztem Besuch" badge in the sidebar. JSON-backed changelog, `localStorage` last-seen timestamp, optional browser push.
|
||||
|
||||
**Why:** The current downloads page is a flat list. As the template library grows, it needs categorization, preview, and proper metadata. This is the distribution channel for the HL Model Documents project.
|
||||
|
||||
**Implementation:**
|
||||
- Expand `/downloads` into a proper library
|
||||
- Template metadata in JSON (or extend fileRegistry)
|
||||
- Preview generation (could be pre-rendered screenshots stored in Gitea)
|
||||
- Category filters (Schriftsätze, Vorlagen, Tabellen, Blöcke)
|
||||
|
||||
**Effort:** ~8 hours | **Impact:** High (central template distribution)
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Schulungsbereich (Training Hub)
|
||||
|
||||
**What:** Onboarding and continuing education section for patent team members.
|
||||
|
||||
**Content:**
|
||||
- "Getting Started" guide for new associates
|
||||
- UPC Training materials (from mWorkRepo UPC Training sessions)
|
||||
- Video guide links (from mWorkRepo UPC Video Guides)
|
||||
- HL Patents Style tutorial (how to use the template)
|
||||
- nUPCMS user guide (filing in the new CMS)
|
||||
- FAQ section
|
||||
|
||||
**Why:** Onboarding a new associate to UPC practice currently requires multiple senior-lawyer meetings. A self-serve training section lets associates learn the basics before those meetings, making the meetings more productive.
|
||||
|
||||
**Implementation:**
|
||||
- New section: `/schulung`
|
||||
- Static content pages with structured lessons
|
||||
- Progress tracking (optional, via localStorage)
|
||||
- Embedded video links
|
||||
- Quiz/self-check elements (optional)
|
||||
|
||||
**Effort:** ~6 hours (code) + ~10 hours (content) | **Impact:** Medium-High (onboarding efficiency)
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Benachrichtigungen (What's New)
|
||||
|
||||
**What:** Simple notification system showing what's been added or updated on patholo.
|
||||
|
||||
**Features:**
|
||||
- "What's New" badge on sidebar when new content exists
|
||||
- Changelog page with dated entries
|
||||
- Optional: browser push notifications for major updates
|
||||
|
||||
**Why:** Content platforms die when users stop checking for new content. A visible "new content" indicator brings people back.
|
||||
|
||||
**Implementation:**
|
||||
- JSON file with changelog entries
|
||||
- Last-seen timestamp in localStorage per user
|
||||
- Badge counter in sidebar
|
||||
- Simple changelog page
|
||||
|
||||
**Effort:** ~3 hours | **Impact:** Medium (retention mechanism)
|
||||
**Effort:** ~3h | **Impact:** Medium (retention)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Advanced (High effort, Long-term)
|
||||
|
||||
These are ambitious features that require significant investment but would make patholo truly indispensable.
|
||||
### 4.1 KI-Recherche (AI-Powered Research) — Pending (AI features deferred alongside Phase H)
|
||||
|
||||
### 4.1 KI-Recherche (AI-Powered Research)
|
||||
Claude-powered chat grounded in Paliad content (glossary, guides, case law, fee tables, and — with visibility enforcement — a user's own Akten/Fristen). Every answer cites sources. Requires guardrails; requires a solid content foundation (Phases 1–3). Currently blocked by the same "no Anthropic API" decision as Phase H; revisit when that decision flips.
|
||||
|
||||
**What:** Claude-powered research assistant for patent law questions.
|
||||
### 4.2 Fristenkalender ✅ Done (Phase F)
|
||||
|
||||
**Features:**
|
||||
- Chat interface for patent law questions
|
||||
- Grounded in patholo content (glossary, guides, case law, fee tables)
|
||||
- Can answer: "What's the deadline for filing a defence in UPC infringement?" or "What are the court fees for Streitwert 5M at LG?"
|
||||
- Sources cited for every answer
|
||||
Originally "export deadlines as .ics / CalDAV sync". Subsumed by Phase 0.3 Termine + CalDAV Sync — bidirectional sync with encrypted credentials at rest. The Fristenrechner's "Als Frist(en) speichern" button is the entry point from quick-calc into persistent Fristen; Fristen themselves appear in the user's CalDAV calendar via Termine linkage.
|
||||
|
||||
**Why:** This is the endgame — a patent law assistant that knows HL's practice. But it requires a solid content foundation (Phases 1-3) to be useful, and careful guardrails to avoid hallucination in a legal context.
|
||||
### 4.3 Collaborative Annotations — Pending (partial via 0.6 Notizen)
|
||||
|
||||
**Effort:** ~20 hours | **Impact:** Transformative (if done right)
|
||||
The polymorphic `paliad.notizen` table already covers per-Akte / per-Frist / per-Termin / per-AkteEvent notes (Phase I). "Annotations on published knowledge content" (e.g., per-glossary-term practitioner tips) is a separate scope and still pending. Requires moderation UI.
|
||||
|
||||
### 4.4 Mandantenkosten-Report (Client Cost Report) — Pending
|
||||
|
||||
Branded PDF cost estimate generated from Kostenrechner data: HL logo, matter reference, date, scenario comparison, editable cover letter. One-click replacement for today's manual Excel-to-memo workflow.
|
||||
|
||||
**Effort:** ~10h | **Impact:** Medium-High (client-facing)
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Fristenkalender (Deadline Calendar)
|
||||
## Prioritized Backlog
|
||||
|
||||
**What:** Integration between the Fristenrechner and external calendar systems.
|
||||
Phase 0 (Aktenverwaltung) items are **Done** as of April 2026. Remaining work ordered by priority.
|
||||
|
||||
**Features:**
|
||||
- Export calculated deadlines as .ics file (for import into Outlook/Google Calendar)
|
||||
- Optional: CalDAV sync (write deadlines directly to a shared calendar)
|
||||
- Reminders with configurable lead time
|
||||
|
||||
**Why:** The Fristenrechner calculates deadlines but doesn't connect to where lawyers actually manage their time. Calendar export closes this gap.
|
||||
|
||||
**Effort:** ~8 hours | **Impact:** High (workflow integration)
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Collaborative Annotations
|
||||
|
||||
**What:** Allow users to add notes, tips, and corrections to patholo content.
|
||||
|
||||
**Features:**
|
||||
- Comment/note button on guides, checklists, case law entries
|
||||
- Notes visible to all patholo users
|
||||
- Upvoting for useful notes
|
||||
- Moderation (flag inappropriate content)
|
||||
|
||||
**Why:** The best knowledge comes from practitioners. Allowing annotations turns patholo from a one-way publication into a living knowledge base. But this requires a database (Supabase) and moderation.
|
||||
|
||||
**Effort:** ~15 hours | **Impact:** High (knowledge capture)
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Mandantenkosten-Report (Client Cost Report)
|
||||
|
||||
**What:** Generate branded PDF cost estimates for clients using Kostenrechner data.
|
||||
|
||||
**Features:**
|
||||
- Kostenrechner -> "Generate Report" button
|
||||
- Branded PDF with HL logo, date, matter reference
|
||||
- Scenario comparison in the report
|
||||
- Customizable cover letter text
|
||||
- Downloadable as PDF
|
||||
|
||||
**Why:** Partners need to send cost estimates to clients. Currently they use the Excel calculator and manually format a memo. A one-click branded PDF saves hours and looks professional.
|
||||
|
||||
**Effort:** ~10 hours | **Impact:** Medium-High (client-facing)
|
||||
|
||||
---
|
||||
|
||||
## Prioritized Backlog (Summary)
|
||||
|
||||
| # | Feature | Phase | Effort | Impact | Priority |
|
||||
|---|---|---|---|---|---|
|
||||
| 1.1 | Link Hub | 1 | 2h | High | **P0** |
|
||||
| 1.2 | More Downloads | 1 | 1h | Medium | **P0** |
|
||||
| 1.3 | Gebührentabellen | 1 | 4h | Med-High | **P0** |
|
||||
| 1.5 | Kostenrechner Enhancements | 1 | 6h | High | **P1** |
|
||||
| 1.4 | Patentglossar | 1 | 5h | Medium | **P1** |
|
||||
| 2.4 | Checklisten | 2 | 7h | High | **P1** |
|
||||
| 2.1 | Verfahrensleitfäden | 2 | 14h | Very High | **P2** |
|
||||
| 2.3 | UPC Rechtsprechung | 2 | 14h | Very High | **P2** |
|
||||
| 2.2 | Gerichtsverzeichnis | 2 | 8h | Med-High | **P2** |
|
||||
| 3.4 | Benachrichtigungen | 3 | 3h | Medium | **P2** |
|
||||
| 3.1 | Suchfunktion | 3 | 6h | High | **P3** |
|
||||
| 3.2 | Vorlagenbibliothek | 3 | 8h | High | **P3** |
|
||||
| 3.3 | Schulungsbereich | 3 | 16h | Med-High | **P3** |
|
||||
| 4.2 | Fristenkalender | 4 | 8h | High | **P3** |
|
||||
| 4.4 | Mandantenkosten-Report | 4 | 10h | Med-High | **P4** |
|
||||
| 4.3 | Collaborative Annotations | 4 | 15h | High | **P4** |
|
||||
| 4.1 | KI-Recherche | 4 | 20h | Transformative | **P5** |
|
||||
| # | Feature | Phase | Effort | Impact | Priority | Status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 0.1 | Akten (matter mgmt) | 0 | — | Foundational | **P0** | ✅ Done |
|
||||
| 0.2 | Fristen (persistent) | 0 | — | Foundational | **P0** | ✅ Done |
|
||||
| 0.3 | Termine + CalDAV | 0 | — | High | **P0** | ✅ Done |
|
||||
| 0.4 | Dashboard | 0 | — | High | **P0** | ✅ Done |
|
||||
| 1.1 | Link Hub | 1 | 2h | High | **P0** | ✅ Done |
|
||||
| 1.3 | Gebührentabellen | 1 | 4h | Med-High | **P0** | ✅ Done |
|
||||
| 1.4 | Patentglossar | 1 | 5h | Medium | **P1** | ✅ Done |
|
||||
| 2.2 | Gerichtsverzeichnis | 2 | 8h | Med-High | **P1** | ✅ Done |
|
||||
| 2.4 | Checklisten | 2 | 7h | High | **P1** | ✅ Done |
|
||||
| 1.5 | Kostenrechner enhancements | 1 | 6h | High | **P1** | ✅ Partial (PDF/URL/compare done; Prozesskostensicherheit pending) |
|
||||
| 1.2 | More Downloads (content) | 1 | 1h/file | Medium | **P1** | ⬜ Page shipped; content pending |
|
||||
| 0.6 | Notizen (service + UI) | 0 | 4h | Medium | **P2** | ⬜ Schema done, service pending |
|
||||
| 2.1 | Verfahrensleitfäden | 2 | 14h | Very High | **P2** | ⬜ Pending |
|
||||
| 3.4 | Benachrichtigungen | 3 | 3h | Medium | **P2** | ⬜ Pending |
|
||||
| 3.1 | Suchfunktion | 3 | 6h | High | **P3** | ⬜ Pending |
|
||||
| 3.2 | Vorlagenbibliothek | 3 | 8h | High | **P3** | ⬜ Pending |
|
||||
| 3.3 | Schulungsbereich | 3 | 16h | Med-High | **P3** | ⬜ Pending |
|
||||
| 4.4 | Mandantenkosten-Report | 4 | 10h | Med-High | **P3** | ⬜ Pending |
|
||||
| 0.5 | AI Frist-Extraktion (Phase H) | 0 | 4h | High | **Deferred** | ⏸ Anthropic API decision pending |
|
||||
| 4.1 | KI-Recherche | 4 | 20h | Transformative | **Deferred** | ⏸ Tied to Phase H decision |
|
||||
| 4.3 | Collaborative Annotations (published content) | 4 | 15h | High | **P4** | ⬜ Pending |
|
||||
|
||||
---
|
||||
|
||||
@@ -488,57 +218,85 @@ These are ambitious features that require significant investment but would make
|
||||
|
||||
### Data Strategy
|
||||
|
||||
Most Phase 1 and 2 features use **static JSON data** loaded at Go server startup. This keeps the stack simple (no new database tables needed) and content is version-controlled in git. Content updates = git commits = automatic deploy.
|
||||
The data model is split:
|
||||
|
||||
When content grows beyond what's practical in JSON files (Phase 3+), migrate to Supabase tables with a simple admin API.
|
||||
- **Phase 0 (Aktenverwaltung)** — Supabase tables in the `paliad` schema with office-scoped RLS (`paliad.can_see_akte(akte_id)`). User-generated data lives here: Akten, Fristen, Termine, Parteien, Dokumente, Notizen, Verlauf, User profiles, CalDAV config. Migrations embedded into the Go binary via `embed.FS` and applied by `golang-migrate` at server startup.
|
||||
- **Knowledge platform (Phases 1–2)** — static JSON data loaded at server startup. Content lives in git; content updates = git commits = automatic deploy.
|
||||
- **Feedback tables** (`link_suggestions`, `checklisten_feedback`, `gerichte_feedback`) — `paliad` schema, firm-wide visibility.
|
||||
|
||||
When static content grows past what's practical in JSON (Phase 3+), migrate specific content types to Supabase tables with a simple admin API. Don't mass-migrate — move what benefits from search/filtering/mutation.
|
||||
|
||||
### Visibility Invariant
|
||||
|
||||
The office-scoped visibility predicate is **defined once** in SQL (`paliad.can_see_akte(akte_id uuid)`) and reused by every RLS policy on every table that carries an `akte_id`. `AkteService.GetByID` mirrors the predicate at the application layer for defense in depth; every child service (`FristService`, `TerminService`, `ParteienService`, …) routes through `AkteService.GetByID` before operating on its own row. **Never duplicate the predicate.** See `docs/design-kanzlai-integration.md` §2 and the Phase E memory episode for the architecture invariant.
|
||||
|
||||
### Content Pipeline
|
||||
|
||||
New content follows this flow:
|
||||
1. Practitioner identifies need (or new case law / template)
|
||||
2. Content written/curated (by knowledge lawyer or contributor)
|
||||
3. Added to patholo repo as JSON/markdown
|
||||
4. PR reviewed and merged
|
||||
5. Auto-deploy via Dokploy webhook
|
||||
New knowledge content follows this flow:
|
||||
1. Practitioner identifies need (or new case law / template).
|
||||
2. Content written/curated (by knowledge lawyer or contributor).
|
||||
3. Added to Paliad repo as JSON/markdown.
|
||||
4. PR reviewed and merged.
|
||||
5. Auto-deploy via Dokploy webhook (push to `main` → Gitea webhook → Dokploy).
|
||||
|
||||
### Navigation Expansion
|
||||
### Navigation
|
||||
|
||||
The sidebar currently has: Home, Kostenrechner, Fristenrechner, Downloads. With new pages, reorganize into groups:
|
||||
The sidebar has six grouped sections (see `docs/design-kanzlai-integration.md` §6):
|
||||
|
||||
```
|
||||
Werkzeuge
|
||||
— ÜBERSICHT —
|
||||
Dashboard
|
||||
|
||||
— ARBEIT —
|
||||
Akten
|
||||
Fristen
|
||||
Termine
|
||||
|
||||
— WERKZEUGE —
|
||||
Kostenrechner
|
||||
Fristenrechner
|
||||
Fristenrechner (stateless quick calc; distinct from /fristen)
|
||||
Gebührentabellen
|
||||
|
||||
Wissen
|
||||
Verfahrensleitfäden
|
||||
Rechtsprechung
|
||||
— WISSEN —
|
||||
Glossar
|
||||
Checklisten
|
||||
Gerichtsverzeichnis
|
||||
Leitfäden (future — Phase 2.1)
|
||||
|
||||
Ressourcen
|
||||
Downloads / Vorlagen
|
||||
— RESSOURCEN —
|
||||
Downloads
|
||||
Nützliche Links
|
||||
Gerichte
|
||||
Schulung
|
||||
|
||||
— EINSTELLUNGEN —
|
||||
CalDAV
|
||||
```
|
||||
|
||||
### What patholo Is NOT
|
||||
### What Paliad Is
|
||||
|
||||
- **Not a case management system** — that's KanzlAI
|
||||
- **Not a document management system** — that's SharePoint/netDocuments
|
||||
- **Not a billing tool** — that's the firm's practice management system
|
||||
- **Not a CMS** — content lives in git, not a database with a CMS UI
|
||||
Paliad is the all-in-one platform for HLC patent practice:
|
||||
|
||||
patholo is a **knowledge platform and toolkit**: curated content, practical tools, quick reference. Fast, focused, friction-free.
|
||||
- **Knowledge platform** — curated content, practical tools, quick reference (Glossar, Gebührentabellen, Checklisten, Gerichtsverzeichnis, Leitfäden, Links, Downloads).
|
||||
- **Aktenverwaltung** — Akten, Fristen, Termine, Parteien, Dokumente, Notizen, Verlauf (audit trail). Office-scoped visibility with explicit collaborator lists for cross-office teams. Personal calendar sync via CalDAV. AI-assisted Frist extraction is designed but deferred.
|
||||
|
||||
What Paliad is *not*:
|
||||
|
||||
- **Not a billing tool** — HLC has firm-wide billing infrastructure.
|
||||
- **Not a beA gateway** — out of scope; lawyers use existing beA software.
|
||||
- **Not a document management system** — SharePoint / netDocuments stay in their lane.
|
||||
- **Not a CMS** — content lives in git, not a database with a CMS UI.
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
## Longer-Term Open Questions
|
||||
|
||||
I can implement Phase 1 features myself — I have the deepest context from this design work. The Link Hub (1.1) and More Downloads (1.2) are particularly quick wins that could ship today. The Gebührentabellen (1.3) and Glossar (1.4) need some content curation but the code is straightforward.
|
||||
- **Outlook / Exchange sync (Phase K).** CalDAV covers Apple iCloud + `dav.msbls.de`. HLC lives on Outlook + Exchange; Exchange's CalDAV support is limited. A follow-on "Phase K" would add an EWS / Microsoft Graph backend behind the same sync abstraction. Decide based on internal feedback to Phase F.
|
||||
- **Practice-group walls.** Today, `practice_group` is filter-only metadata. If a partner asks for "Patents Litigation can't see Patents Prosecution Akten", the schema is ready to extend the `paliad.can_see_akte` predicate. Don't build until asked.
|
||||
- **External counsel access.** Bringing in an outside boutique on a specific Akte currently means adding them as a user (not possible without the HLC email domain). A future `external_collaborators` table with scoped RLS would cover it.
|
||||
- **Read-only archive post-closure.** Add `is_archived` on `paliad.akten`, deny mutations via RLS. Cheap follow-on.
|
||||
- **AI revisit.** The Phase H / 4.1 pause is a decision, not a technical block. When Anthropic API goes back on the table, both AI extraction (Phase H) and KI-Recherche (4.1) can be unblocked.
|
||||
- **Supabase Auth SMTP routing.** Confirmation / password-reset / magic-link mails from `ydb.youpc.org` still go through Supabase's default sender. Routing them through Paliad's SMTP (`mail@paliad.de`) is a one-line GoTrue config change, but youpc's Supabase is shared with youpc.org, so the global SMTP settings can't be flipped without rebranding youpc.org's auth mails too. Resolution paths (lowest-effort first):
|
||||
1. Move Paliad to its own Supabase project and configure SMTP there.
|
||||
2. Wait until the youpc instance exposes per-project SMTP (Supabase Pro / self-hosted upgrade).
|
||||
3. Write a custom GoTrue webhook that Paliad's Go server intercepts and re-sends via `MailService`.
|
||||
|
||||
Phase 2 features need content collaboration (practitioner input for guides, case law curation). The code can be built by a coder worker, but the content requires domain expertise.
|
||||
|
||||
Decision for head: assign implementation to me (with coder role) or hand off to a separate coder?
|
||||
For now the inbox-facing mails (reminders + invitations) go through Paliad's SMTP; identity-bootstrap mails stay on the default sender — acceptable for the current HLC pilot. Tracked as part of t-paliad-021 completion (2026-04-20).
|
||||
|
||||
721
docs/improvement-audit-2026-04-30.md
Normal file
@@ -0,0 +1,721 @@
|
||||
# Paliad — Architecture Improvement Audit
|
||||
|
||||
Prepared by `ada` (consultant) on 2026-04-30 for task **t-paliad-074**.
|
||||
Scope: read-only architecture audit after the 9-merge push of t-paliad-066..073.
|
||||
This doc supersedes neither `docs/improvement-audit.md` (the original
|
||||
2026-04-18 product audit) nor `docs/audit-polish-2-2026-04-29.md` (UX polish).
|
||||
It complements them: this is the structural/maintainability lens.
|
||||
|
||||
Today is 2026-04-30. The repo is at commit `2c67299` on `main`.
|
||||
|
||||
Finding tags:
|
||||
|
||||
- **Severity** — 🔴 active risk · 🟠 friction now · 🟡 future-proofing
|
||||
- **Effort** — 🟢 ≤30 min · 🟡 1-2 h · 🔴 half-day+
|
||||
|
||||
---
|
||||
|
||||
## Executive summary — if I do nothing else, fix these 3 things
|
||||
|
||||
1. **`AdminDeleteUser` queries dropped tables `paliad.department_members` /
|
||||
`paliad.departments`.** Migration 027 (2026-04-29) renamed those to
|
||||
`partner_unit_members` / `partner_units`. The code at
|
||||
`internal/services/user_service.go:768` and `:773` was missed by the
|
||||
t-paliad-070 rename sweep (last edit 2026-04-27 by `c697fe34`, predates
|
||||
the rename). Any admin who clicks "Delete user" in `/admin/team` today
|
||||
will hit `pq: relation "paliad.department_members" does not exist`.
|
||||
**Live production bug, blast radius: admin-only, but blocks user
|
||||
off-boarding.** See **F-1**.
|
||||
|
||||
2. **Seven live-DB integration tests skip silently when
|
||||
`TEST_DATABASE_URL` is unset, and the repo has no CI.** This is the
|
||||
exact pattern that masked the t-paliad-069 reminder placeholder bug
|
||||
for ~24 h. F-1 above is another bug that a passing
|
||||
`TestAdminDeleteUser` would have caught the moment migration 027
|
||||
landed. Fix the visibility of the test gate: either add a Gitea
|
||||
workflow with an ephemeral Postgres, or convert the silent skips to
|
||||
`t.Fatal` when `CI=true`. See **F-9**.
|
||||
|
||||
3. **Visibility predicate is centralised in
|
||||
`internal/services/visibility.go` but inlined in 10 hot-path SQL
|
||||
sites across `dashboard_service.go` (4×), `agenda_service.go` (2×),
|
||||
`reminder_service.go` (2×), `team_service.go` (1×), and
|
||||
`deadline_service.go` (1×).** This is the same security-critical rule
|
||||
that t-paliad-058 already extracted — duplication crept right back
|
||||
in. Every change to the team-visibility model (Chinese-wall
|
||||
restrictions in design v2 §8) has 11 places to update, and the
|
||||
inlined sites quietly skip the `global_admin` shortcut. See **F-2**.
|
||||
|
||||
The next ~20 findings are a mix of naming drift, dead code, schema
|
||||
documentation gaps, and missing tests. None of them are emergencies.
|
||||
|
||||
---
|
||||
|
||||
## 1 — Findings by lens
|
||||
|
||||
### 1.1 Service boundaries
|
||||
|
||||
#### F-1. `AdminDeleteUser` writes to dropped tables — live bug
|
||||
|
||||
🔴 active risk · 🟢 ≤30 min · stand-alone task
|
||||
|
||||
**Files:** `internal/services/user_service.go:768`, `:773`
|
||||
|
||||
```go
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.department_members WHERE user_id = $1`, id); err != nil {
|
||||
return fmt.Errorf("delete department_members: %w", err)
|
||||
}
|
||||
// A Department this user led keeps existing — the lead seat just goes empty.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.departments SET lead_user_id = NULL WHERE lead_user_id = $1`, id); err != nil {
|
||||
return fmt.Errorf("clear dept leads: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
Migration 027 (2026-04-29) renamed `paliad.departments` → `paliad.partner_units` and
|
||||
`paliad.department_members` → `paliad.partner_unit_members`. `git blame`
|
||||
shows lines 763-775 last touched by `c697fe34` on 2026-04-27 — the
|
||||
t-paliad-070 rename sweep (76785da) missed this site.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```go
|
||||
`DELETE FROM paliad.partner_unit_members WHERE user_id = $1`
|
||||
`UPDATE paliad.partner_units SET lead_user_id = NULL WHERE lead_user_id = $1`
|
||||
```
|
||||
|
||||
Also update the comment on line 725: `project_teams / department_members`
|
||||
→ `project_teams / partner_unit_members`. And the comment on line 771:
|
||||
`A Department this user led` → `A partner unit this user led`.
|
||||
|
||||
**Warrants its own task:** yes — it's a customer-visible production bug
|
||||
even if the surface (admin-only) is small. `t-paliad-NN` titled "fix
|
||||
AdminDeleteUser SQL after partner_units rename".
|
||||
|
||||
---
|
||||
|
||||
#### F-2. Visibility predicate inlined in 10 sites despite central helper
|
||||
|
||||
🔴 active risk · 🟡 1-2 h · stand-alone task
|
||||
|
||||
**Files:**
|
||||
|
||||
- `internal/services/dashboard_service.go:158, 214, 244, 274` (4 sites)
|
||||
- `internal/services/agenda_service.go:138, 204` (2 sites)
|
||||
- `internal/services/reminder_service.go:312, 325` (2 sites)
|
||||
- `internal/services/team_service.go:162` (1 site)
|
||||
- `internal/services/deadline_service.go:422` (1 site)
|
||||
|
||||
All ten sites inline the same path-walk fragment:
|
||||
|
||||
```sql
|
||||
EXISTS (SELECT 1 FROM paliad.project_teams pt
|
||||
WHERE pt.user_id = $X
|
||||
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[]))
|
||||
```
|
||||
|
||||
Worse, the inlined sites do **not** include the `global_admin` shortcut
|
||||
that `visibilityPredicate` / `visibilityPredicatePositional` add. A
|
||||
global_admin without an explicit `project_teams` row is visible to the
|
||||
RLS-style helper but invisible to the inlined sites — unless the
|
||||
surrounding query carries `p.firm_wide_visible OR pt.user_id = ...`
|
||||
elsewhere. Audit each site carefully; some queries might be relying on
|
||||
project-team auto-membership (every project creator gets a team row,
|
||||
per `project_service.go:467`) to mask the gap.
|
||||
|
||||
The agenda-service ship memory (t-paliad-030, 2026-04-22) explicitly
|
||||
called this out as deliberate: "We deliberately did not factor this
|
||||
into a helper because the three call sites have slightly different
|
||||
JOIN shapes." That justification is weaker now that t-paliad-058
|
||||
proved the central helper works. The drift cost is real (Chinese-wall
|
||||
in design v2 §8 will need 11 simultaneous edits).
|
||||
|
||||
**Fix:**
|
||||
|
||||
1. Extend `visibility.go` with a third variant — `visibilityPredicateLateral(alias string, userArg int)` — that fits the dashboard/agenda LATERAL-JOIN shape (predicate appears inside a sub-SELECT, not the outer WHERE).
|
||||
2. Convert all 10 sites in one PR. Add a `_test.go` table-driven test that asserts the four variants produce equivalent EXPLAIN plans against a live DB.
|
||||
3. After the conversion, add `make grep-visibility-inline` to CI that fails on `string_to_array.*\.path` outside `visibility.go`.
|
||||
|
||||
**Warrants its own task:** yes — security-critical; one focused PR.
|
||||
|
||||
---
|
||||
|
||||
#### F-3. `NoteService` is a dependency-shaped diamond
|
||||
|
||||
🟡 future-proofing · 🟡 1-2 h · part of broader naming task
|
||||
|
||||
**File:** `internal/services/note_service.go:23-31`
|
||||
|
||||
```go
|
||||
type NoteService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
appointment *AppointmentService
|
||||
}
|
||||
```
|
||||
|
||||
`NoteService` reaches into `ProjectService.GetByID` for visibility and
|
||||
into `AppointmentService.GetByID` for personal-appointment visibility.
|
||||
That's ~6 cross-service `GetByID` calls in one file. The pattern is fine
|
||||
at this scale, but it's the canary for a "domain service" that should
|
||||
own a `CanSee(ctx, userID, parent)` method on each owning service —
|
||||
right now `NoteService` is hand-rolling the dispatch.
|
||||
|
||||
**Fix:** add `ProjectService.CanSee(ctx, userID, projectID) (bool, error)`
|
||||
and `AppointmentService.CanSee(ctx, userID, appointmentID) (bool, error)`.
|
||||
`NoteService.ListForFrist` becomes a one-liner that asks the right
|
||||
parent service whether the user can see, then `s.list(ctx, ...)`.
|
||||
Reduces the number of full-row reads (`GetByID`) — currently every
|
||||
note-list call also pre-fetches the parent's full row.
|
||||
|
||||
**Batch with F-4 (naming).**
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Naming consistency
|
||||
|
||||
The Department→PartnerUnit rename (t-paliad-070), the Akten→Projects
|
||||
data-model rename (t-paliad-024 / migration 018), and the German→English
|
||||
table rename (migration 020) all left rough edges in identifiers. The
|
||||
schema is mostly clean now (one bug — F-1 above). The Go code is
|
||||
half-renamed.
|
||||
|
||||
#### F-4. Service layer mixes English types with German parameter and helper names
|
||||
|
||||
🟠 friction now · 🔴 half-day+ · single-PR mechanical rename
|
||||
|
||||
**Affected files (8):**
|
||||
|
||||
| File | Legacy identifier | English equivalent |
|
||||
|---|---|---|
|
||||
| `note_service.go` | `CreateNotizInput`, `UpdateNotizInput`, `notizColumns`, `notizSelect`, `ListForProjekt`, `ListForFrist`, `ListForTermin`, `CreateForProjekt`, `CreateForFrist`, `CreateForTermin`, `fristProjectID` | `CreateNoteInput`, …, `noteColumns`, `noteSelect`, `ListForProject`, `ListForDeadline`, `ListForAppointment`, `CreateForProject`, …, `deadlineProjectID` |
|
||||
| `appointment_service.go` | `CreateTerminInput`, `UpdateTerminInput`, `ListForProjekt`, parameter names `terminID`, `projektID` | `CreateAppointmentInput`, `UpdateAppointmentInput`, `ListForProject`, `appointmentID`, `projectID` |
|
||||
| `deadline_service.go` | `CreateFristInput`, `UpdateFristInput`, `ListForProjekt`, `isValidFristStatus`, parameters `fristID`, `projektID` | `CreateDeadlineInput`, `UpdateDeadlineInput`, `ListForProject`, `isValidDeadlineStatus`, `deadlineID`, `projectID` |
|
||||
| `project_service.go` | `CreateProjektInput`, `UpdateProjektInput`, `validateProjektStatus` | `CreateProjectInput`, …, `validateProjectStatus` |
|
||||
| `party_service.go` | `CreateParteiInput`, `ListForProjekt` | `CreatePartyInput`, `ListForProject` |
|
||||
| `caldav_service.go` | `OnTerminCreated`, `OnTerminUpdated`, `OnTerminDeleted` | `OnAppointmentCreated`, … |
|
||||
| `caldav_ical.go` | `formatTermin` | `formatAppointment` |
|
||||
| `checklist_instance_service.go` | `ListForProjekt`, `listWithProjekt` | `ListForProject`, `listWithProject` |
|
||||
|
||||
The mismatch is jarring because the *return types* are already English
|
||||
(`*models.Note`, `*models.Appointment`, `*models.Deadline`,
|
||||
`*models.Project`). Every reader does mental translation between
|
||||
parameter and return.
|
||||
|
||||
This also means `note_service.go:38` reads `n.deadline_id = $1` (English
|
||||
column, correct) bound to a parameter named `fristID` (German).
|
||||
|
||||
**Fix:** one branch via gopls "rename symbol". Order: types first
|
||||
(input structs), then methods, then parameters, then comments. Update
|
||||
the corresponding `internal/handlers/notes.go`, `appointments.go`,
|
||||
`deadlines.go` callers in the same PR — gopls handles cross-file
|
||||
symbol-rename correctly.
|
||||
|
||||
The CLAUDE.md convention is unambiguous: "All code, table names, Go
|
||||
types, service names, URL paths, API endpoints, file names — English."
|
||||
This is just finishing the work.
|
||||
|
||||
**Warrants its own task:** yes — one large-mechanical PR. Ship together
|
||||
with F-3 (the NoteService cleanup) since they touch the same file.
|
||||
|
||||
---
|
||||
|
||||
#### F-5. Stale comment in `models.go` claims escalation contact dropdown is deferred
|
||||
|
||||
🟡 future-proofing · 🟢 ≤30 min · batch with other doc fixes
|
||||
|
||||
**File:** `internal/models/models.go:50-52`
|
||||
|
||||
```go
|
||||
// EscalationContactID is an optional override of the escalation channel
|
||||
// for overdue / DRINGEND mail. NULL means "fall back to global_admins".
|
||||
// The Settings UI dropdown is deferred (see CLAUDE.md); set via SQL today.
|
||||
EscalationContactID *uuid.UUID `db:"escalation_contact_id" ...`
|
||||
```
|
||||
|
||||
The dropdown shipped on 2026-04-29 as t-paliad-066 (commit `bff2ec5`).
|
||||
The comment is now misleading.
|
||||
|
||||
**Fix:** drop the last sentence; reference `Settings → Notifications`
|
||||
instead.
|
||||
|
||||
---
|
||||
|
||||
#### F-6. Go module path is still `mgit.msbls.de/m/patholo`
|
||||
|
||||
🟡 future-proofing · 🔴 half-day+ · defer until next major touch
|
||||
|
||||
**File:** `go.mod:1` plus 67 import statements across `internal/`,
|
||||
`cmd/`, and tests.
|
||||
|
||||
The Gitea repo was renamed to `mAi/paliad` and auto-redirects (per
|
||||
project CLAUDE.md). So `go build` works. But `go.mod`, every `import
|
||||
"mgit.msbls.de/m/patholo/..."`, and the binary's debug info still read
|
||||
as `patholo`.
|
||||
|
||||
**Fix:** wait. The cost is touching every Go file in the repo;
|
||||
t-paliad-018 explicitly punted on this for the same reason. Keep the
|
||||
finding on the books, do it next time someone is doing a global
|
||||
formatting pass.
|
||||
|
||||
---
|
||||
|
||||
#### F-7. CSS class names are still mostly German
|
||||
|
||||
🟡 future-proofing · 🔴 half-day+ · separable from Go rename
|
||||
|
||||
**File:** `frontend/src/styles/global.css` — 226 class definitions
|
||||
prefixed with German nouns: `.akten-detail-header`, `.akten-table`,
|
||||
`.akten-parteien-controls`, `.fristen-wizard`, `.frist-section-heading`,
|
||||
`.akten-events-empty`, etc.
|
||||
|
||||
The TSX usages are even more confusing: `frontend/src/deadlines-detail.tsx:38`
|
||||
uses `className="akten-detail-header"` despite the page being about
|
||||
deadlines, not Akten. The CSS class is a generic "detail page header"
|
||||
shape that got named after the first user.
|
||||
|
||||
**Fix:** rename in two passes (1: CSS only, 2: TSX usage), keep both
|
||||
class names valid (`.akten-detail-header, .detail-page-header`) for one
|
||||
deploy cycle, then drop the legacy. Conservative because there's no
|
||||
type-checker to catch missed renames in HTML.
|
||||
|
||||
**Defer:** value-per-effort is poor — no functional impact, mostly
|
||||
churn. Worth doing next time a designer touches the stylesheet.
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Frontend ↔ backend contract
|
||||
|
||||
#### F-8. `i18n.ts` has 1264 keys typed as raw `string` with silent fallback
|
||||
|
||||
🟠 friction now · 🟡 1-2 h · stand-alone task
|
||||
|
||||
**File:** `frontend/src/client/i18n.ts:2776`
|
||||
|
||||
```ts
|
||||
export function t(key: string): string {
|
||||
return translations[currentLang][key] ?? translations.de[key] ?? key;
|
||||
}
|
||||
```
|
||||
|
||||
Three problems compound:
|
||||
|
||||
1. The key parameter is `string` — TypeScript will not catch typos.
|
||||
2. The third fallback (`?? key`) renders the literal i18n key in the
|
||||
UX when a key is missing, so the bug is silent.
|
||||
3. There are 681 distinct `data-i18n="..."` attributes in TSX files
|
||||
that bypass the `t()` function entirely — they're resolved by a
|
||||
generic DOM walker (`initI18n()`) at page boot. No type-checking
|
||||
path possible without a JSX preprocessor.
|
||||
|
||||
The product audits already caught 4+ leaked keys this month
|
||||
(`fristen.field.project.choose`, `project_type_changed`, audit-polish
|
||||
F-04, etc.) and ship docs for t-paliad-067 mention "the i18n leak class
|
||||
keeps reappearing."
|
||||
|
||||
**Fix (cheap step 1 — type the `t()` call):**
|
||||
|
||||
```ts
|
||||
// Generated at build time from the keys in `translations.de`.
|
||||
export type I18nKey =
|
||||
| "nav.home"
|
||||
| "nav.kostenrechner"
|
||||
| // ... 1264 more
|
||||
| "bottomnav.badge.deadlines";
|
||||
|
||||
export function t(key: I18nKey): string { ... }
|
||||
```
|
||||
|
||||
Add a `frontend/build.ts` step that emits `i18n-keys.ts` from
|
||||
`translations.de`. TypeScript now catches typos in `t()` calls at
|
||||
build-time. ~501 call sites across `frontend/src/client/*.ts` get
|
||||
type-checked for free.
|
||||
|
||||
**Fix (more thorough step 2 — typed `data-i18n`):** add a
|
||||
`bun run check-i18n-keys` CLI that greps every TSX file for
|
||||
`data-i18n="..."` and `data-i18n-placeholder="..."`, asserting the
|
||||
referenced key exists in `translations.de`. Run from the build script;
|
||||
fail the build on unknown keys. This catches the `data-i18n` class of
|
||||
leaks too.
|
||||
|
||||
**Warrants its own task:** yes — 1 PR, two scripts, big returns.
|
||||
|
||||
---
|
||||
|
||||
#### F-9. i18n key namespace is split between German and English prefixes
|
||||
|
||||
🟠 friction now · 🟡 1-2 h · stand-alone task
|
||||
|
||||
**File:** `frontend/src/client/i18n.ts`
|
||||
|
||||
Counts:
|
||||
|
||||
- 444 keys with German prefixes (`fristen.`, `termine.`, `notizen.`)
|
||||
- 200 keys with German prefixes (`akten.`, `dezernat.`, `partei.`)
|
||||
- Newer keys use English prefixes (`deadlines.`, `appointments.`,
|
||||
`notes.`, `parties.`, `partner_units.`)
|
||||
|
||||
200+ TSX `data-i18n=` attributes still reference the German keys.
|
||||
|
||||
A contributor adding a new "deadline edit form label" today would have
|
||||
to know whether the existing keyspace uses `fristen.field.title` or
|
||||
`deadlines.field.title`. Both shapes exist. Future drift is guaranteed.
|
||||
|
||||
**Fix:** bulk rename all German-prefix i18n keys to their English
|
||||
equivalents; update the corresponding TSX `data-i18n=` attributes;
|
||||
verify with the F-8 build-time check. Do it as a single PR — the
|
||||
diff is mechanical and reviewable. Nothing here is user-visible.
|
||||
|
||||
**Warrants its own task:** yes; depends on F-8 being shipped first
|
||||
(so the typed checker can validate the renames).
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Migration management
|
||||
|
||||
#### F-10. Migrations 004 / 005 / 006 are obsoleted by 018
|
||||
|
||||
🟡 future-proofing · 🟢 ≤30 min · doc-only
|
||||
|
||||
**Files:** `internal/db/migrations/004_akten.up.sql`,
|
||||
`005_akten_children.up.sql`, `006_visibility.up.sql` (~210 lines
|
||||
combined) are fully superseded by `018_projects_v2.up.sql`. Migration
|
||||
018 itself does the supersession explicitly:
|
||||
|
||||
```sql
|
||||
-- Replaces paliad.akten with a single self-referential paliad.projects tree
|
||||
ALTER TABLE paliad.akten_events RENAME TO project_events;
|
||||
DROP TABLE paliad.akten;
|
||||
DROP FUNCTION IF EXISTS paliad.can_see_akte(uuid);
|
||||
DROP FUNCTION IF EXISTS paliad.notiz_is_visible(uuid, uuid, uuid, uuid);
|
||||
```
|
||||
|
||||
Reading the migration history requires mentally diffing 004→018.
|
||||
First-time contributors will be confused.
|
||||
|
||||
**Fix:** add `internal/db/migrations/SCHEMA_NOTES.md` with a one-line
|
||||
supersession map:
|
||||
|
||||
```
|
||||
004 (akten table) → 018 (projects table)
|
||||
005 (akten_events table) → 018 (renamed to project_events)
|
||||
005 (notes parent FK) → 018 + 020 (renamed columns)
|
||||
006 (can_see_akte function) → 018 (renamed to can_see_project)
|
||||
015 (users.dezernat column) → 027 (dropped + replaced by partner_unit_members)
|
||||
019 (seed dezernat strings) → 027 (re-applies seed before drop)
|
||||
024 (department column rename) → 027 (further renamed to partner_unit)
|
||||
```
|
||||
|
||||
Don't squash. The live DB has a stale `public.paliad_schema_migrations`
|
||||
artifact (per knuth's t-paliad-071 ship memory) that would block any
|
||||
re-numbering. Documentation > squashing.
|
||||
|
||||
**Batch with F-11 + F-12.**
|
||||
|
||||
---
|
||||
|
||||
#### F-11. Migration 027 down silently drops the `partner_unit_events` audit table
|
||||
|
||||
🟡 future-proofing · 🟢 ≤30 min · doc-only
|
||||
|
||||
**File:** `internal/db/migrations/027_rename_to_partner_units.down.sql:19`
|
||||
|
||||
```sql
|
||||
-- 1. Drop the audit table.
|
||||
DROP TABLE IF EXISTS paliad.partner_unit_events;
|
||||
```
|
||||
|
||||
The header comment honestly admits the `users.dezernat` data loss but
|
||||
buries the audit-table drop without a warning banner. Operators
|
||||
running a rollback in 2027 would lose months of audit history without
|
||||
realising.
|
||||
|
||||
**Fix:** prepend to the down file:
|
||||
|
||||
```sql
|
||||
-- DATA LOSS WARNING: this rollback drops paliad.partner_unit_events with no
|
||||
-- recovery path. All audit log entries (created/updated/deleted/member_added/
|
||||
-- member_removed) are permanently lost. If you need to preserve them, dump
|
||||
-- the table before applying this rollback:
|
||||
-- pg_dump -t paliad.partner_unit_events ... > pue-backup.sql
|
||||
```
|
||||
|
||||
**Batch with F-10 + F-12.**
|
||||
|
||||
---
|
||||
|
||||
#### F-12. `paliad.link_suggestions` and `paliad.link_feedback` exist in schema, are not used
|
||||
|
||||
🟠 friction now · 🟡 1-2 h · stand-alone task
|
||||
|
||||
**Files:**
|
||||
|
||||
- `internal/db/migrations/011_feedback_tables.up.sql:17-38` — creates
|
||||
`paliad.link_suggestions` and `paliad.link_feedback` (and RLS
|
||||
policies, indexes).
|
||||
- `internal/handlers/links.go:247, 281, 290` — still POSTs to
|
||||
`public.patholo_link_suggestions` / `public.patholo_link_feedback`
|
||||
via PostgREST.
|
||||
|
||||
Migration 011 (2026-04-16) flagged this explicitly:
|
||||
|
||||
```sql
|
||||
-- A follow-on phase (P1 handler refactor) will:
|
||||
-- (1) swap handlers from PostgREST to the direct DB connection,
|
||||
-- (2) copy any meaningful rows from the public tables,
|
||||
-- (3) drop the public tables and this duplication.
|
||||
```
|
||||
|
||||
That follow-on never landed. Today the platform:
|
||||
|
||||
- has dead tables in its own schema,
|
||||
- still depends on a PostgREST round-trip + Supabase anon key for two
|
||||
endpoints,
|
||||
- writes to `public.patholo_*` tables that the rest of the platform no
|
||||
longer touches and that are invisible to the audit log
|
||||
(`AuditService.UNION ALL` doesn't see them).
|
||||
|
||||
**Fix:** rewrite `handleLinkSuggest` / `handleLinkFeedback` /
|
||||
`handleLinkSuggestionsPendingCount` to use `dbSvc.db.ExecContext()` /
|
||||
`QueryContext()` against `paliad.link_suggestions` /
|
||||
`paliad.link_feedback`. Drop `supabaseInsert` / `supabaseCount`
|
||||
helpers (they're only used by these three sites). Out-of-band SQL
|
||||
(not a migration) to copy any non-zero row counts from the public
|
||||
tables and drop them.
|
||||
|
||||
After this, the only `mgit.msbls.de/m/patholo`-named or
|
||||
`patholo_*`-named runtime artifacts are: the legacy session cookie
|
||||
fallback (which has a 2026-05-18 sunset, see F-15) and the Go module
|
||||
path (F-6).
|
||||
|
||||
**Warrants its own task:** yes — finishes a P1 from migration 011's
|
||||
own comment.
|
||||
|
||||
---
|
||||
|
||||
### 1.5 Tests
|
||||
|
||||
#### F-13. Live-DB tests skip silently; no CI exists
|
||||
|
||||
🔴 active risk · 🟡 1-2 h · stand-alone task
|
||||
|
||||
**Files:** 7 tests across `user_service_test.go`,
|
||||
`deadline_service_test.go`, `reminder_service_test.go` (×3),
|
||||
`visibility_test.go`, `audit_service_test.go` all use the same idiom:
|
||||
|
||||
```go
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
```
|
||||
|
||||
The repo has no `.github/`, `.gitea/`, or any CI configuration. So
|
||||
in practice **these tests never run unless a developer remembers to
|
||||
set `TEST_DATABASE_URL` locally**. The t-paliad-069 reminder
|
||||
placeholder bug masked itself for ~24 h via this exact path. F-1 above
|
||||
(AdminDeleteUser SQL bug) is another instance — a passing
|
||||
`TestAdminDeleteUser` against the live DB would have failed the moment
|
||||
migration 027 was applied.
|
||||
|
||||
**Fix (cheapest):** add `.gitea/workflows/test.yml` (Gitea Actions
|
||||
runs on this repo's host) that:
|
||||
|
||||
1. Brings up an ephemeral Postgres 16 (`services:` block).
|
||||
2. Applies migrations.
|
||||
3. Sets `TEST_DATABASE_URL` to the ephemeral DB.
|
||||
4. Runs `go test ./...`.
|
||||
|
||||
**Fix (safer):** also flip the skip to `t.Fatal` when `CI=true` is set,
|
||||
so a misconfigured CI workflow doesn't silently skip again.
|
||||
|
||||
**Warrants its own task:** yes — one PR, prevents the recurrence of
|
||||
two known footguns.
|
||||
|
||||
---
|
||||
|
||||
#### F-14. Test coverage gaps in core services
|
||||
|
||||
🟠 friction now · 🔴 half-day+ per service · sequence after F-13
|
||||
|
||||
**Missing tests** (no `_test.go` exists for these services):
|
||||
|
||||
- `caldav_crypto.go` — AES-GCM encrypt/decrypt of CalDAV passwords. Pure-Go, no DB needed. Highest priority because crypto bugs are silent and irreversible.
|
||||
- `caldav_client.go` — iCal parse / WebDAV PROPFIND. Table-driven against canned multi-status XML.
|
||||
- `caldav_ical.go` — `formatTermin`, `formatAppointment`. Pure-Go.
|
||||
- `agenda_service.go` — `annotateAgendaUrgency` is a calc routine, easily testable without DB.
|
||||
- `dashboard_service.go` — same logic class as agenda.
|
||||
- `note_service.go` — polymorphic visibility logic.
|
||||
- `appointment_service.go` — personal-vs-project visibility branching.
|
||||
- `partner_unit_service.go` — audit-emit-in-tx ordering (cronus' t-paliad-070 memory says "emit before delete" — that's an invariant worth testing).
|
||||
- `team_service.go` — team membership query shape.
|
||||
- `project_service.go` — tree-walking, ancestor/descendant logic, ltree path materialisation.
|
||||
- `party_service.go` — visibility delegation.
|
||||
|
||||
**Effort:** non-trivial. Don't try to do all in one PR.
|
||||
|
||||
**Suggested order (value-per-hour):**
|
||||
|
||||
1. **`caldav_crypto_test.go`** — table-driven (plain → ciphertext → plain) with known vectors. ~30 min, irreversibly valuable.
|
||||
2. **`agenda_service_test.go`** — table-driven on `annotateAgendaUrgency`. ~30 min, no DB needed.
|
||||
3. **`partner_unit_service_test.go`** — verifies audit-emit-in-tx + ON DELETE SET NULL invariant. Live-DB test (so do this *after* F-13 to ensure CI runs it).
|
||||
4. **Everything else** — one ticket per service, low priority.
|
||||
|
||||
**Warrants its own task:** one task per service, so head can dispatch
|
||||
in priority order.
|
||||
|
||||
---
|
||||
|
||||
### 1.6 Dead code & doc drift
|
||||
|
||||
#### F-15. Legacy `patholo_session` / `patholo_refresh` cookie fallbacks
|
||||
|
||||
🟡 future-proofing · 🟢 ≤30 min · scheduled cleanup
|
||||
|
||||
**File:** `internal/auth/auth.go:27-28`
|
||||
|
||||
```go
|
||||
LegacySessionCookieName = "patholo_session"
|
||||
LegacyRefreshCookieName = "patholo_refresh"
|
||||
```
|
||||
|
||||
Per t-paliad-018 ship memory: "Remove legacy fallbacks after
|
||||
2026-05-18 (30d cookie max age)." Today is 2026-04-30. **Sunset is in
|
||||
~18 days.** All users who haven't logged out since 2026-04-18 will be
|
||||
forced to re-authenticate.
|
||||
|
||||
**Fix:** add a calendar reminder for 2026-05-19 to delete the legacy
|
||||
constants and the fallback branch in `Middleware`. The associated
|
||||
test (`TestMiddleware_LegacyCookieAccepted`,
|
||||
`TestMiddleware_NewCookiePreferredOverLegacy`) goes away too.
|
||||
|
||||
The middleware code that handles the upgrade path is at lines 240-256.
|
||||
|
||||
**Warrants its own task:** yes — small one. Schedule for 2026-05-19.
|
||||
|
||||
---
|
||||
|
||||
#### F-16. Stale comment in `team_pages.go` references dropped API endpoint
|
||||
|
||||
🟡 future-proofing · 🟢 ≤30 min · doc-only
|
||||
|
||||
**File:** `internal/handlers/team_pages.go:5-7`
|
||||
|
||||
```go
|
||||
// GET /team — directory of all Paliad users grouped by office or department.
|
||||
// Server-rendered shell; the client (assets/team.js) hydrates from /api/users
|
||||
// and /api/departments?include=members.
|
||||
```
|
||||
|
||||
`/api/departments` was renamed to `/api/partner-units` in t-paliad-070.
|
||||
|
||||
**Fix:** update the comment. Same for `internal/handlers/admin_users.go:123`
|
||||
("`project_teams / department_members` cleanup") which post-dates t-paliad-070.
|
||||
|
||||
---
|
||||
|
||||
#### F-17. `internal/db/migrations/_dev/mock_supabase_auth.sql` lives inside the embed root
|
||||
|
||||
🟡 future-proofing · 🟢 ≤30 min · already known
|
||||
|
||||
**File:** `internal/db/migrations/_dev/`
|
||||
|
||||
Already noted in 2026-04-18 audit as **T-4**. Not fixed since. The
|
||||
embed.FS pattern likely filters by `NNN_*.sql` naming so it doesn't
|
||||
break, but it's one regex-loosening from a build bug.
|
||||
|
||||
**Fix:** move to `internal/db/devtools/`.
|
||||
|
||||
---
|
||||
|
||||
#### F-18. Project status doc says "Audit polish-2 ~25 BATCH-level findings not yet shipped"
|
||||
|
||||
🟡 future-proofing · 🟢 ≤30 min · doc-only
|
||||
|
||||
**File:** `docs/project-status.md:20`
|
||||
|
||||
The doc was last updated before today's t-paliad-073 cleanup PR shipped
|
||||
the DEFER list. Open follow-ups should be re-checked.
|
||||
|
||||
---
|
||||
|
||||
### 1.7 Architecture observations (no concrete fix)
|
||||
|
||||
#### O-1. `models.go` is one 358-line file with 14+ types
|
||||
|
||||
Already noted in 2026-04-18 as **T-11**. Not bad enough to warrant a
|
||||
ticket on its own, but next time anyone touches a struct, split by
|
||||
domain. Effort negligible.
|
||||
|
||||
#### O-2. `frontend/build.ts` has 24 hand-maintained render-and-write pairs
|
||||
|
||||
Already noted as **T-13**. Adding a page requires edits in 4 places
|
||||
(build.ts, handlers.go, i18n.ts, global.css). A page-manifest reduces
|
||||
to 1 place. Not urgent but would speed up new-page work.
|
||||
|
||||
#### O-3. `RLS policies exist but never enforced`
|
||||
|
||||
Original audit **T-1**. Status unchanged. The Go backend uses a
|
||||
service-role connection so migration 007's RLS policies never run.
|
||||
Decision still open: drop, document, or switch to per-request JWT
|
||||
connections.
|
||||
|
||||
The 2026-04-18 audit recommended Option A (document + `SET row_security
|
||||
= off`). That's still right; one comment block in `internal/db/db.go`
|
||||
would close this out.
|
||||
|
||||
---
|
||||
|
||||
## 2 — Top-10 ranked by value-per-effort
|
||||
|
||||
| Rank | ID | Description | Severity | Effort |
|
||||
|---|---|---|---|---|
|
||||
| 1 | F-1 | Fix `AdminDeleteUser` SQL — wrong table names | 🔴 | 🟢 |
|
||||
| 2 | F-13 | Add CI for live-DB integration tests | 🔴 | 🟡 |
|
||||
| 3 | F-2 | Centralise visibility predicate (10 sites → 1) | 🔴 | 🟡 |
|
||||
| 4 | F-12 | Migrate link suggestions/feedback to `paliad.link_*` | 🟠 | 🟡 |
|
||||
| 5 | F-8 | Type the i18n key + build-time `data-i18n` check | 🟠 | 🟡 |
|
||||
| 6 | F-14a | `caldav_crypto_test.go` table-driven | 🟠 | 🟢 |
|
||||
| 7 | F-14b | `agenda_service_test.go` annotateAgendaUrgency | 🟠 | 🟢 |
|
||||
| 8 | F-9 | Bulk-rename German-prefix i18n keys to English | 🟠 | 🟡 |
|
||||
| 9 | F-4 | Service layer naming sweep (German→English) | 🟠 | 🔴 |
|
||||
| 10 | F-5 + F-16 + F-18 | Comment / doc cleanup batch | 🟡 | 🟢 |
|
||||
|
||||
Items 11+: F-10, F-11, F-15, F-17, F-3, O-1, O-2, O-3. Effort/value
|
||||
drops sharply.
|
||||
|
||||
---
|
||||
|
||||
## 3 — Coordination notes
|
||||
|
||||
- **F-1 first.** It's a live bug. Ship before anything else.
|
||||
- **F-13 second.** Without CI, we'll keep building features on top of
|
||||
silent test gates. The placeholder bug from t-paliad-069 and F-1
|
||||
here are the second and third instances of "live-DB test would
|
||||
have caught it." Fix the meta-problem.
|
||||
- **F-12 + F-15 are the last two `patholo_*` runtime artifacts** (after
|
||||
the cookie sunset). Closing both makes the rebrand complete.
|
||||
- **F-4 + F-9 are mechanically large but conceptually trivial.** Good
|
||||
AFK candidates — give to a Sonnet coder with explicit "don't change
|
||||
semantics" framing.
|
||||
- **F-2 needs careful eyes.** It's security-adjacent. Pair with a
|
||||
reviewer or run as a dedicated PR with explicit before/after EXPLAIN
|
||||
plans on the affected queries.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — items deliberately out of scope
|
||||
|
||||
- **Performance profiling** — different audit kind. The t-paliad-058
|
||||
`string_to_array(p.path, '.')::uuid[]` pattern is a known wart but
|
||||
fits in F-2's centralisation work.
|
||||
- **Security review** — there's a `/security-review` skill for that.
|
||||
Critical items C-1 through C-5 from the 2026-04-18 audit may have
|
||||
been addressed (JWT verification was added — see `golang-jwt/jwt/v5`
|
||||
in `go.mod`); a fresh security pass is its own task.
|
||||
- **Frontend design / accessibility** — already covered by
|
||||
`audit-polish-*-2026-04-2*` docs.
|
||||
- **Phase H AI extraction** — explicitly deferred per CLAUDE.md.
|
||||
- **Dropping the obsoleted migrations 004-006** — schema-history
|
||||
integrity beats tidiness; doc the supersession instead (F-10).
|
||||
649
docs/improvement-audit.md
Normal file
@@ -0,0 +1,649 @@
|
||||
# Paliad — Product Audit & Improvement Roadmap
|
||||
|
||||
Prepared by `cronus` (inventor) on 2026-04-18 for task **t-paliad-015**.
|
||||
Scope: complete code, UX, content, architecture, and ops audit after the
|
||||
17 000-line KanzlAI → Paliad integration (Phases A–J, April 2026).
|
||||
|
||||
Audit posture: first-real-user (HLC patent lawyer, Munich) **and**
|
||||
long-term architect. Items are prioritised and actionable — each has a
|
||||
file reference and a suggested fix. No hour estimates; effort is
|
||||
expressed as **S / M / L** (small / medium / large).
|
||||
|
||||
- **S** = <1 day of focused work; single file or localised change
|
||||
- **M** = multi-file change, migration, or non-trivial design
|
||||
- **L** = new subsystem, cross-cutting rework, or external dep
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — the two things to fix today
|
||||
|
||||
1. **The session middleware does not verify JWT signatures** and the Go
|
||||
backend extracts `auth.uid()` from that unverified token to gate every
|
||||
database read and write. A forged cookie with any `sub` and a future
|
||||
`exp` gives an attacker access to any user's Akten, including admin.
|
||||
See **C-1** below. `internal/auth/auth.go:178` +
|
||||
`internal/auth/user.go:60`.
|
||||
2. **The dashboard leaks every user's personal Termine to every other
|
||||
logged-in user** for the next 7 days (title, location, description,
|
||||
start/end). `internal/services/dashboard_service.go:245`. See **C-2**.
|
||||
|
||||
Both are one-file fixes and must ship before this audit reaches a wider
|
||||
pilot group.
|
||||
|
||||
---
|
||||
|
||||
## 1 — Critical (fix immediately)
|
||||
|
||||
### C-1. Session JWTs are not signature-verified
|
||||
|
||||
**File:** `internal/auth/auth.go:178`, `internal/auth/user.go:60`
|
||||
**Severity:** Critical (authZ bypass)
|
||||
**Effort:** M
|
||||
|
||||
`Client.Middleware` accepts the session cookie, parses `exp` via
|
||||
`DecodeJWTExpiry`, and calls `next` if the token is unexpired. The
|
||||
signature is never checked. `WithUserID` then base64-decodes the `sub`
|
||||
claim straight into the request context. `AkteService.GetByID` and every
|
||||
other service trusts that UUID as the authenticated user.
|
||||
|
||||
Exploit: craft any JWT with `exp` in the future and `sub =
|
||||
<admin-user-uuid>`. Set it as the `patholo_session` cookie. The Go
|
||||
backend uses a service-role Postgres connection, so RLS policies do not
|
||||
run and the app-level visibility check sees the forged UUID as a real
|
||||
admin. Effectively: anyone with a Paliad cookie can impersonate anyone.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Fetch the Supabase project's JWT signing key (JWKS endpoint:
|
||||
`${SUPABASE_URL}/auth/v1/.well-known/jwks.json`) or the shared
|
||||
`SUPABASE_JWT_SECRET` and verify the token in `Middleware` before
|
||||
trusting any claim.
|
||||
- Cache the JWKS for 1 hour; rotate on kid mismatch.
|
||||
- `WithUserID` should read the *verified* claims, not re-decode the raw
|
||||
cookie.
|
||||
- Consider `github.com/golang-jwt/jwt/v5` — already idiomatic for Go.
|
||||
|
||||
**Related:** `internal/services/akte_service.go:18-24` explicitly
|
||||
documents that RLS "does not kick in because the backend does not
|
||||
provide a JWT-backed auth.uid()" — so the app-layer predicate is the
|
||||
*only* gate. The JWT must therefore be trusted.
|
||||
|
||||
---
|
||||
|
||||
### C-2. Dashboard leaks every user's personal Termine cross-user
|
||||
|
||||
**File:** `internal/services/dashboard_service.go:232-256`
|
||||
**Severity:** Critical (privacy / confidentiality)
|
||||
**Effort:** S
|
||||
|
||||
```sql
|
||||
WHERE t.start_at >= $4
|
||||
AND t.start_at < ($4 + interval '7 days')
|
||||
AND (t.akte_id IS NULL -- ← ANY personal Termin, any user
|
||||
OR a.firm_wide_visible = true
|
||||
OR a.owning_office = $1
|
||||
OR $2::uuid = ANY (a.collaborators)
|
||||
OR $3 = 'admin')
|
||||
```
|
||||
|
||||
Personal Termine (`akte_id IS NULL`) are creator-only by contract
|
||||
(`TerminService.canSee` enforces `created_by = userID`). The dashboard
|
||||
forgets this filter and returns up to 10 such rows for *any* user —
|
||||
title, location, description, times included.
|
||||
|
||||
In practice an associate's "Bewerbungsgespräch bei Kanzlei X" is visible
|
||||
to partners and vice-versa.
|
||||
|
||||
**Fix:** change the personal-Termin branch to
|
||||
`(t.akte_id IS NULL AND t.created_by = $2::uuid)` — mirrors the already-
|
||||
correct rule in `termin_service.go:103`.
|
||||
|
||||
---
|
||||
|
||||
### C-3. Any user with visibility can delete Akte children (Parteien, Termine)
|
||||
|
||||
**File:**
|
||||
- `internal/services/parteien_service.go:93-111` — Delete has no role gate.
|
||||
- `internal/services/termin_service.go:357-398` — Akte-linked Termine: only
|
||||
personal Termine check creator; Akte-linked pass through with just
|
||||
visibility.
|
||||
|
||||
**Severity:** Critical (data loss; inconsistent with Fristen policy)
|
||||
**Effort:** S
|
||||
|
||||
An associate in London, viewing a Munich firm-wide Akte, can
|
||||
`DELETE /api/parteien/{id}` and erase the Klägerin-record, or delete any
|
||||
hearing from the Akte's calendar. `FristService.Delete` already scopes
|
||||
to `partner|admin` only — the other child services should follow suit.
|
||||
|
||||
**Fix:** require `user.Role in {partner, admin}` (or `created_by =
|
||||
userID`) for delete on `ParteienService` and `TerminService`. Apply the
|
||||
same rule to `Update` on both.
|
||||
|
||||
---
|
||||
|
||||
### C-4. Email gate still pins to `@hoganlovells.com`
|
||||
|
||||
**File:** `internal/handlers/auth.go:113-116`
|
||||
**Severity:** Critical (post-merger lockout)
|
||||
**Effort:** S
|
||||
|
||||
HLC email domains (`@hlc.com`, `@hlc.de`) cannot register or log in.
|
||||
Login-form placeholders still say `name@hoganlovells.com`.
|
||||
|
||||
**Fix:**
|
||||
|
||||
- Replace `isHoganLovellsEmail` with an env-configurable whitelist,
|
||||
default `hoganlovells.com,hlc.com,hlc.de` (design §2 already
|
||||
requested this).
|
||||
- Update placeholders in `frontend/src/login.tsx:26,34` + the
|
||||
`login.hint` i18n key (de + en).
|
||||
- Error strings (`"Zugang nur für @hoganlovells.com …"`) should become
|
||||
"… für autorisierte HLC-E-Mail-Adressen."
|
||||
|
||||
---
|
||||
|
||||
### C-5. `CALDAV_ENCRYPTION_KEY` not wired into production compose
|
||||
|
||||
**File:** `docker-compose.yml:4-12`
|
||||
**Severity:** Critical in effect (feature silently disabled)
|
||||
**Effort:** S
|
||||
|
||||
The compose file declares `SUPABASE_URL`, `SUPABASE_ANON_KEY`,
|
||||
`GITEA_TOKEN`, `DATABASE_URL`. It does **not** pass
|
||||
`CALDAV_ENCRYPTION_KEY`. On the Dokploy compose (`Zx147ycurfYagKRl_Zzyo`)
|
||||
this means `cmd/server/main.go:66` logs
|
||||
"CALDAV_ENCRYPTION_KEY not set — CalDAV endpoints will return 501" and
|
||||
the entire Termine-sync feature is dead on paliad.de. Users who save
|
||||
their CalDAV settings see a 501 from `/api/caldav-config` and conclude
|
||||
the product is broken.
|
||||
|
||||
**Fix:** add `- CALDAV_ENCRYPTION_KEY=${CALDAV_ENCRYPTION_KEY}` (and
|
||||
while we're at it, a placeholder `ANTHROPIC_API_KEY` commented out so
|
||||
Phase H reactivation is one uncomment). Set the actual key on Dokploy.
|
||||
|
||||
---
|
||||
|
||||
## 2 — Important (fix this week)
|
||||
|
||||
### I-1. Dokumente tab on Akten detail is a dead placeholder
|
||||
|
||||
**File:** `frontend/src/akten-detail.tsx:215-222`,
|
||||
`frontend/src/client/i18n.ts:468,1241`
|
||||
**Severity:** Important (visible UI dead-end; leaks internal phase names)
|
||||
**Effort:** S
|
||||
|
||||
The tab is rendered, clickable, and shows the text "Dokumenten-Upload
|
||||
folgt in Phase H." — a strong UX signal that the product is unfinished.
|
||||
Phase H is deferred indefinitely.
|
||||
|
||||
**Fix (pick one):**
|
||||
|
||||
- Hide the Dokumente tab entirely until there is real content — drop it
|
||||
from the VALID_TABS list in `akten-detail.ts:62` and the TSX tabs
|
||||
strip.
|
||||
- Or keep it visible but replace the copy with a neutral "Dokumenten-
|
||||
Upload in Planung" (no phase leak) and a discreet CTA to vote/express
|
||||
interest (write to `paliad_feature_interest`).
|
||||
|
||||
I'd hide it — dead tabs are worse than missing features.
|
||||
|
||||
### I-2. Office labels in German not translated to English
|
||||
|
||||
**File:** `frontend/src/index.tsx:122-128`
|
||||
**Severity:** Important (i18n regression visible on landing page)
|
||||
**Effort:** S
|
||||
|
||||
Only `index.munich` has a `data-i18n` key. Düsseldorf, Hamburg,
|
||||
Amsterdam, London, Paris, **Mailand** render raw German in EN mode.
|
||||
"Mailand" → "Milan" is the most obvious miss; Düsseldorf/London/Paris
|
||||
are correct in both languages but the fact that only one is i18n'd is a
|
||||
consistency bug waiting for the next new office.
|
||||
|
||||
**Fix:** add `index.office.munich|duesseldorf|hamburg|amsterdam|london|
|
||||
paris|milan` keys (de: "München / Düsseldorf / Hamburg / Amsterdam /
|
||||
London / Paris / Mailand"; en: "Munich / Düsseldorf / Hamburg /
|
||||
Amsterdam / London / Paris / Milan").
|
||||
|
||||
### I-3. Gerichtsverzeichnis UPC URLs redirect
|
||||
|
||||
**File:** `internal/handlers/gerichte.go` (45 occurrences of
|
||||
`unifiedpatentcourt.org`, no hyphens)
|
||||
**Severity:** Important (one redirect per link click; flagged as wrong by
|
||||
link-checkers)
|
||||
**Effort:** S
|
||||
|
||||
Canonical UPC website is `https://www.unified-patent-court.org` (with
|
||||
hyphens). The no-hyphen form returns HTTP 403 challenge pages from
|
||||
Cloudflare on naive `curl` but does resolve to the same destination.
|
||||
`internal/handlers/links.go` uses the canonical form — the two files
|
||||
are inconsistent. Pick one. Canonical (hyphenated) is safer.
|
||||
|
||||
**Fix:** sed-replace `unifiedpatentcourt.org` → `unified-patent-court.org`
|
||||
across `gerichte.go` (and re-verify the deep paths still resolve —
|
||||
some `/en/court/court-appeal` style URLs may have moved).
|
||||
|
||||
### I-4. `loadRecentActivity` omits personal Termine audit rows (not a bug yet, but will be)
|
||||
|
||||
**File:** `internal/services/dashboard_service.go:259-287`
|
||||
**Severity:** Medium-Important (correctness risk)
|
||||
**Effort:** S
|
||||
|
||||
`loadRecentActivity` joins `akten_events` → `akten`, so only
|
||||
Akte-attached events appear. That is correct today because personal
|
||||
Termine explicitly skip the audit (per Phase F memory). If a future
|
||||
contributor adds a personal-Termin event type without reading the
|
||||
design, this query will silently drop it — add a comment or switch to
|
||||
`LEFT JOIN` + `WHERE a.id IS NULL OR <visibility>`.
|
||||
|
||||
### I-5. `/api/akten/{id}/events` has no pagination or size limit
|
||||
|
||||
**File:** `internal/services/akte_service.go:368-383`
|
||||
**Severity:** Important (scalability; long-running Akten)
|
||||
**Effort:** M
|
||||
|
||||
`ListEvents` returns every row ever written to `akten_events` for an
|
||||
Akte. A three-year litigation with CalDAV sync and daily notizen edits
|
||||
could easily reach 5–10 k rows. The Verlauf tab will then ship 2 MB of
|
||||
JSON on each tab-click.
|
||||
|
||||
**Fix:** add `?before=<uuid>&limit=50` cursor pagination and render
|
||||
"Load more" in the Verlauf tab. Already noted in the Phase E followup
|
||||
list, never actioned.
|
||||
|
||||
### I-6. Glossar is missing FRAND / SEP / related commercial-patent terms
|
||||
|
||||
**File:** `internal/handlers/glossar.go:33-118` (~86 entries)
|
||||
**Severity:** Important (content gap for the target audience)
|
||||
**Effort:** S
|
||||
|
||||
HLC's patent practice is a heavy SEP/FRAND shop; the glossary has zero
|
||||
entries for: FRAND, SEP, Standard-essentielles Patent, Patentpool, Anti-
|
||||
Anti-Suit Injunction, Injunction gap, Orange-Book-Verfahren,
|
||||
Huawei/ZTE-Verhandlungsmuster, RAND, ETSI IPR Policy, Patent-Hold-up,
|
||||
Patent-Hold-out. These are table-stakes for the intended user.
|
||||
|
||||
**Fix:** add ~12 entries under a new `SEP/FRAND` category (or stretch
|
||||
`Litigation`). Pull definitions from the canonical CJEU/BGH case law.
|
||||
|
||||
### I-7. README is out of date
|
||||
|
||||
**File:** `README.md:30-43,107`
|
||||
**Severity:** Important (onboarding drag)
|
||||
**Effort:** S
|
||||
|
||||
- Migration list stops at 013; 014 (`checklist_instances`) is live.
|
||||
- Line 107 says "Phase I (Notizen) pending — service and UI aren't
|
||||
built yet"; it shipped on `mai/knuth/phase-i-notizen` (commit
|
||||
`5a9f8e5`, 2026-04-17) with full service + handlers + UI.
|
||||
|
||||
**Fix:** refresh the migrations block (`014_checklist_instances …`) and
|
||||
the status paragraph. Phase I ✅ Done, Phase J docs-only done, infra
|
||||
retirement pending.
|
||||
|
||||
### I-8. Legacy "patholo_" names everywhere
|
||||
|
||||
**File:** `internal/auth/auth.go:17-18`, `internal/handlers/links.go:315`,
|
||||
`frontend/src/client/i18n.ts:6` (`STORAGE_KEY = "patholo-lang"`),
|
||||
`frontend/src/client/*.ts` (other localStorage keys),
|
||||
`paliad_link_suggestions` / `paliad_link_feedback` table names (compare
|
||||
vs. the older `patholo_*` references in the code).
|
||||
**Severity:** Important (brand inconsistency; minor confusion)
|
||||
**Effort:** M
|
||||
|
||||
Cookie name `patholo_session`, storage key `patholo-lang`, Supabase
|
||||
tables `patholo_link_suggestions` / `patholo_link_feedback`. Memory
|
||||
notes a deliberate decision to *keep* the cookie name so users aren't
|
||||
logged out — that's fine — but storage keys and table names can migrate
|
||||
without user impact.
|
||||
|
||||
**Fix:** plan a single branch that:
|
||||
|
||||
- Renames `STORAGE_KEY` → `paliad-lang` with a one-shot migration from
|
||||
the old key (read old, write new, delete old) in `i18n.ts` init.
|
||||
- Renames tables to `paliad_link_suggestions` / `paliad_link_feedback`
|
||||
(migration + update callers in `handlers/links.go`).
|
||||
- Leaves the cookie name alone or migrates it with a dual-read grace
|
||||
period.
|
||||
|
||||
---
|
||||
|
||||
## 3 — Polish (nice to have)
|
||||
|
||||
### P-1. HL Intern links are stubs
|
||||
|
||||
`internal/handlers/links.go:206-219` — two entries with `URL: "#"`
|
||||
render as clickable cards that go nowhere.
|
||||
|
||||
**Fix:** either remove the entries until real URLs land, or add a
|
||||
"Coming soon — suggest a URL" flag + disabled styling.
|
||||
|
||||
### P-2. Dashboard says "Meine Mandate" but the nav and URL say "Akten"
|
||||
|
||||
`frontend/src/dashboard.tsx:82`, i18n key `dashboard.matters.heading`.
|
||||
The project's naming convention (CLAUDE.md) mandates **Akten**
|
||||
throughout — the term "Mandate" is explicitly historical. The dashboard
|
||||
heading should read "Meine Akten" / "My Matters".
|
||||
|
||||
**Fix:** change i18n text to "Meine Akten" (DE) and leave
|
||||
"My Matters" (EN). Rename i18n key if desired.
|
||||
|
||||
### P-3. Login page placeholder still says `name@hoganlovells.com`
|
||||
|
||||
Covered by **C-4**. Mentioned again here because the hint
|
||||
"Nur für @hoganlovells.com Adressen." is a polish concern even after
|
||||
the whitelist change — update to "Nur für autorisierte HLC-Adressen."
|
||||
|
||||
### P-4. Landing page footer reads "Hogan Lovells Patent Practice"
|
||||
|
||||
`frontend/src/components/Footer.tsx:7`. Brand reads as *old* on every
|
||||
page. Switch to "HLC Patent Practice" post-merger (or drop the firm
|
||||
name entirely since Paliad is supposed to survive renames).
|
||||
|
||||
### P-5. No explicit empty-state copy on Fristen-Kalender / Termine-Kalender
|
||||
|
||||
Check `frontend/src/fristen-kalender.tsx`, `termine-kalender.tsx`.
|
||||
Calendars with no events render an empty grid — add a subtle "Keine
|
||||
Fristen / Termine im ausgewählten Zeitraum." string.
|
||||
|
||||
### P-6. Office names in `AkteService.isValidOffice` diverge from UI labels
|
||||
|
||||
`akte_service.go:403-408` accepts keys `munich, duesseldorf, hamburg,
|
||||
amsterdam, london, paris, milan`. The `models.go` user has the same
|
||||
list. But the UI (landing page) writes "München", "Düsseldorf",
|
||||
"Mailand". Currently fine because admins edit office via internal role
|
||||
only, but any future user-facing office selector must map label →
|
||||
key — add a single source of truth (`internal/calc/offices.go` or
|
||||
similar: `{key: "munich", labelDE: "München", labelEN: "Munich"}`).
|
||||
|
||||
### P-7. `Glossar` CSVs hard-coded in one file (230 lines)
|
||||
|
||||
Not a bug, but as the list grows toward 150+ terms it wants to move out
|
||||
of Go source into `internal/handlers/glossar_data.go` or a JSON blob in
|
||||
`internal/data/glossar.json`. Easier for non-devs to edit.
|
||||
|
||||
### P-8. Dockerfile lacks `HEALTHCHECK` and runs as root
|
||||
|
||||
`Dockerfile:13-19`. No `HEALTHCHECK`, no `USER nonroot`. Both are easy
|
||||
wins for Dokploy's health surface and container hardening.
|
||||
|
||||
```dockerfile
|
||||
# before the CMD
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:8080/ || exit 1
|
||||
RUN addgroup -S paliad && adduser -S -G paliad paliad
|
||||
USER paliad
|
||||
```
|
||||
|
||||
Add a `GET /healthz` route that returns 200 without auth; current `/`
|
||||
redirects and wgets a 200 anyway, but an explicit probe is cleaner.
|
||||
|
||||
### P-9. Landing-page copy still frames Paliad as "Paliad — Patentwissen für Hogan Lovells"
|
||||
|
||||
`frontend/src/client/i18n.ts:38-48`. With Phase 0 shipped, Paliad is
|
||||
more than a knowledge hub — it's the Aktenverwaltung. The hero section
|
||||
should call that out. Proposed: "Paliad — alles für den Patentalltag:
|
||||
Akten, Fristen, Termine und Wissen."
|
||||
|
||||
### P-10. Kostenrechner PDF and Checklisten print — not verified by audit
|
||||
|
||||
The design says PDF export works. I did not test it; please verify
|
||||
after any CSS change that affects `.tool-page` / `.print\:hide`.
|
||||
Consider adding a visual-regression snapshot to the build.
|
||||
|
||||
---
|
||||
|
||||
## 4 — Features (prioritised backlog)
|
||||
|
||||
### F-1. Global search (Ctrl-K / Cmd-K)
|
||||
|
||||
**Impact:** Very High
|
||||
**Effort:** M
|
||||
|
||||
A single keystroke that searches across Akten, Fristen, Termine,
|
||||
Parteien, Glossar, Gerichte, Links. Patent lawyers hop between matters
|
||||
constantly; the current UX requires clicking through 2 sidebar entries
|
||||
to find anything. Index on the backend with `tsvector` + `pg_trgm`, or
|
||||
build a client-side Lunr index for the static content and a single
|
||||
`/api/search?q=…` that fans out for user data. Match by Aktenzeichen,
|
||||
party name, Frist title.
|
||||
|
||||
### F-2. Verfahrensleitfäden (procedure guides)
|
||||
|
||||
**Impact:** Very High (per the original roadmap, never shipped)
|
||||
**Effort:** L
|
||||
|
||||
Step-by-step UPC / BPatG / EPO workflows that stitch together
|
||||
checklists, deadline rules, and recommended templates. This is the
|
||||
biggest original-roadmap item still missing.
|
||||
|
||||
### F-3. Expand the Downloads registry
|
||||
|
||||
**Impact:** High (2/10 on the roadmap, partial)
|
||||
**Effort:** M
|
||||
|
||||
Only `HL Patents Style.dotm` is wired up. mWorkRepo has more
|
||||
templates. Wire `Vollmacht.dotm`, `Unterlassungserklärung.dotm`,
|
||||
`UPC-Klageschrift-Skeleton.dotm` — mirror the existing proxy pattern.
|
||||
|
||||
### F-4. Benachrichtigungen (notifications)
|
||||
|
||||
**Impact:** Medium-High
|
||||
**Effort:** L
|
||||
|
||||
Email digest of today's overdue/due Fristen + tomorrow's Termine, sent
|
||||
at 07:00 CET per user. Supabase pg_cron + Edge Functions or a Go
|
||||
scheduler in the Paliad binary. Opt-in per user.
|
||||
|
||||
### F-5. What's New / Changelog in-app
|
||||
|
||||
**Impact:** Medium
|
||||
**Effort:** S
|
||||
|
||||
`docs/changelog.md` rendered under `/whatsnew` with a red dot on the
|
||||
sidebar when there's an entry newer than the user's `last_seen_changelog`
|
||||
timestamp. Cheap way to communicate feature releases.
|
||||
|
||||
### F-6. Akten-Kurzinfo on hover (pop-over)
|
||||
|
||||
**Impact:** Medium
|
||||
**Effort:** S
|
||||
|
||||
Hovering an Aktenzeichen in any list (Dashboard activity, Fristen,
|
||||
Termine) shows a tooltip with matter title, court, next Frist. Reduces
|
||||
tab-switching.
|
||||
|
||||
### F-7. Parteien mit Kontaktkarten (Update endpoint)
|
||||
|
||||
**Impact:** Medium
|
||||
**Effort:** S
|
||||
|
||||
Currently `ParteienService` only supports Create + Delete. No Update.
|
||||
Typos in a party name force a delete + re-add and lose the ID. Add
|
||||
PATCH `/api/parteien/{id}`.
|
||||
|
||||
### F-8. Fristenrechner — "Als Frist speichern" mit Wiedervorlage
|
||||
|
||||
**Impact:** Medium
|
||||
**Effort:** S
|
||||
|
||||
When creating a Frist with a Rule, also derive a Wiedervorlage-Frist
|
||||
(warning-date = due - 14 d) and offer to create both in one click.
|
||||
|
||||
### F-9. Dark mode
|
||||
|
||||
**Impact:** Low (but free with CSS variables)
|
||||
**Effort:** S
|
||||
|
||||
`global.css` already uses CSS variables heavily. Add a
|
||||
`@media (prefers-color-scheme: dark)` block + a manual toggle in the
|
||||
sidebar.
|
||||
|
||||
### F-10. Document upload without AI (revisit Phase H)
|
||||
|
||||
**Impact:** Medium (still open per memory episode)
|
||||
**Effort:** M
|
||||
|
||||
The Supabase Storage upload path already works; only the AI-extraction
|
||||
part was rejected. A plain "upload PDF to Akte" with no auto-extraction
|
||||
is still useful and unblocks the Dokumente tab.
|
||||
|
||||
### F-11. Akten-Tags / Labels
|
||||
|
||||
**Impact:** Medium
|
||||
**Effort:** M
|
||||
|
||||
Free-form tags on Akten (e.g., `SEP`, `OLG-Düsseldorf`, `Q4-Priority`)
|
||||
with a filter UI. Cheaper than a full custom-fields system and used
|
||||
extensively at comparable firms.
|
||||
|
||||
---
|
||||
|
||||
## 5 — Technical Debt
|
||||
|
||||
### T-1. RLS policies exist but are never enforced
|
||||
|
||||
`internal/db/migrations/007_rls_policies.up.sql` defines full office-
|
||||
scoped RLS keyed off `auth.uid()`. The Go backend uses a service-role
|
||||
connection (`db.OpenPool` with `DATABASE_URL`), so those policies never
|
||||
evaluate — every query bypasses RLS by design.
|
||||
|
||||
This is correct today (service-role means app-level checks must be
|
||||
bulletproof, which they mostly are) but it means the RLS layer is dead
|
||||
weight that increases surface for a migration bug to produce a
|
||||
silently-wrong policy. Decide:
|
||||
|
||||
- **Option A:** accept the "belt-and-braces" position, keep RLS,
|
||||
document loudly that it's not active in prod.
|
||||
- **Option B:** drop the RLS migration and policies — less code, less
|
||||
false sense of security.
|
||||
- **Option C:** switch to PostgREST-style per-request JWT connections so
|
||||
RLS actually runs. This is the most defensive but a substantial
|
||||
rewrite.
|
||||
|
||||
My recommendation: Option A, with an `internal/db/RLS_NOTE.md` and a
|
||||
`SET row_security = off;` (or equivalent) in the service-role
|
||||
connection so the policies don't quietly cost perf.
|
||||
|
||||
### T-2. Go module path still `mgit.msbls.de/m/patholo`
|
||||
|
||||
`go.mod:1` + every `import` statement. Not breaking, but a daily
|
||||
reminder of the old name. Rename on the next big refactor, not now —
|
||||
every file in the repo would churn.
|
||||
|
||||
### T-3. `calDAVClient` hand-rolls iCal + WebDAV
|
||||
|
||||
Documented in the Phase F memory as a deliberate choice. Fine for now,
|
||||
but re-evaluate when any of these lands:
|
||||
- Importing foreign UIDs (currently skipped)
|
||||
- RRULE / VTIMEZONE / DTSTART-with-tzid
|
||||
- Multi-calendar support per user
|
||||
|
||||
At that point switch to `github.com/emersion/go-ical` +
|
||||
`github.com/emersion/go-webdav`.
|
||||
|
||||
### T-4. `_dev/mock_supabase_auth.sql` lives inside `migrations/`
|
||||
|
||||
`internal/db/migrations/_dev/`. Embed.FS includes all files under
|
||||
`migrations/` — the `_dev` directory is probably ignored by
|
||||
`iofs.New` (directories not matching the pattern `NNN_*.sql` are
|
||||
filtered) but it's one hop from a build bug. Move to
|
||||
`internal/db/devtools/` or similar, outside the embed root.
|
||||
|
||||
### T-5. Client-side duplicated helpers
|
||||
|
||||
Memory's Phase E followup notes: `urgency-color` + `date-format` JS
|
||||
helpers are duplicated across `fristen.ts`, `fristen-detail.ts`,
|
||||
`fristen-kalender.ts`, `akten-detail.ts`. Extract to
|
||||
`frontend/src/client/fristen-shared.ts`. Not urgent, but next time
|
||||
anyone touches the Fristen UI: do this first.
|
||||
|
||||
### T-6. `/api/fristen?status=all` returns everything client-side filters
|
||||
|
||||
Per Phase E memory: fine at current volumes, but a partner with 500
|
||||
completed Fristen will see the full 500 on every calendar load. Add
|
||||
server-side date-range filtering before that's a support ticket.
|
||||
|
||||
### T-7. No error monitoring
|
||||
|
||||
The server logs to stdout (which Dokploy captures), but there is no
|
||||
Sentry / log-based alert wiring. Nobody knows when something breaks in
|
||||
prod until a user complains. Options:
|
||||
|
||||
- Wire Supabase log drain (if Dokploy supports it).
|
||||
- Self-host a lightweight OpenTelemetry collector on mlake.
|
||||
- At minimum: `GET /metrics` (Prometheus text) with basic counters, and
|
||||
a cron that posts to Gotify when error-rate crosses a threshold.
|
||||
|
||||
Pick the cheapest now — we can evolve later.
|
||||
|
||||
### T-8. No structured logging
|
||||
|
||||
Mixed `log.Printf` + `slog.Info` across the codebase. Pick one, default
|
||||
to `slog` with a JSON handler in prod. Pre-populate user-id and
|
||||
request-id in the context for correlation.
|
||||
|
||||
### T-9. No backup verification for `paliad` schema
|
||||
|
||||
Supabase's automatic backups cover the whole instance, but
|
||||
`paliad.paliad_schema_migrations` collision history (memory episode
|
||||
"paliad migration bootstrap collision") suggests the shared-Postgres
|
||||
posture is fragile. Add a weekly `pg_dump --schema=paliad` cron on
|
||||
mlake that writes to an offsite bucket and a monthly restore-smoke-test
|
||||
(into an ephemeral Postgres container).
|
||||
|
||||
### T-10. Test coverage gaps
|
||||
|
||||
Only `akte_service_test.go`, `deadline_calculator_test.go`,
|
||||
`holidays_test.go`, `fees_test.go` exist. Missing: `FristService`,
|
||||
`TerminService`, `NotizService`, `CalDAVService` (unit tests for
|
||||
iCal encode / decode, encrypt / decrypt, visibility edge-cases).
|
||||
The CalDAV crypto in particular should have a table-driven test with
|
||||
known vectors.
|
||||
|
||||
### T-11. `internal/models/models.go` is one big file
|
||||
|
||||
Not inherently bad, but it's the dumping ground for 14 types. Split by
|
||||
domain (`user.go`, `akte.go`, `frist.go`, `termin.go`, …) next time it's
|
||||
touched.
|
||||
|
||||
### T-12. Dockerfile build cache hygiene
|
||||
|
||||
Every `COPY . .` invalidates on any source change, forcing a full `go
|
||||
mod download`. Move `go.mod`/`go.sum` copy + `go mod download` *before*
|
||||
`COPY . .` — already done. But also consider `FROM golang:1.24-alpine
|
||||
AS backend` → `golang:1.24` with `CGO_ENABLED=0` so libc-less `alpine`
|
||||
runtime doesn't fight any future pgx upgrade. Minor; today it's fine.
|
||||
|
||||
### T-13. `frontend/build.ts` has 24 hand-maintained render-and-write pairs
|
||||
|
||||
Any new page requires edits in 4 places (build.ts, handlers.go
|
||||
registration, i18n keys, CSS). Consider a page-manifest — an array
|
||||
`[{slug, render, clientEntry}]` — that the build script iterates over.
|
||||
Less ceremony; harder to forget one of the four places.
|
||||
|
||||
### T-14. `Gitea-backed file proxy` has no ETag / If-Modified-Since
|
||||
|
||||
`internal/handlers/files.go` caches bytes and the commit SHA in memory
|
||||
but never sets an `ETag` or `Last-Modified` response header, so every
|
||||
browser re-downloads a 500 KB .dotm on each click. Add:
|
||||
`w.Header().Set("ETag", entry.sha)` + handle `If-None-Match` with a
|
||||
304. Cheap win.
|
||||
|
||||
---
|
||||
|
||||
## Delivery suggestion
|
||||
|
||||
- **Week 1 (critical):** C-1, C-2, C-3, C-4, C-5. All small- to
|
||||
medium-effort and gates production readiness.
|
||||
- **Week 2 (important):** I-1, I-2, I-3, I-7, I-8. Polish the
|
||||
post-merger brand story.
|
||||
- **Week 3 (features, pick 2):** F-1 (global search) gives the biggest
|
||||
daily-use win; F-2 (Verfahrensleitfäden) is the biggest content win;
|
||||
F-3 (Downloads) closes a roadmap item cheaply.
|
||||
- **Always-on (tech debt):** pick one T-item per sprint until they're
|
||||
gone.
|
||||
|
||||
None of the critical items are theoretical — the JWT signature bypass
|
||||
and the dashboard Termine leak are real exploit-paths I could walk
|
||||
through today with a curl command and a Paliad cookie. Fix those two
|
||||
first.
|
||||
1048
docs/plans/unified-fristenrechner-v3.md
Normal file
459
docs/plans/unified-fristenrechner-v4.md
Normal file
@@ -0,0 +1,459 @@
|
||||
# Fristenrechner v4 — RoP-rigorous tree, working filter, card-click computes a deadline
|
||||
|
||||
**Task:** t-paliad-136
|
||||
**Branch:** mai/cronus/inventor-fristenrechner-v4-design
|
||||
**Status:** Inventor design — gated. No code changes in this shift.
|
||||
**Predecessors:** v3 (`unified-fristenrechner-v3.md`, t-paliad-133), B1-result-cards (t-paliad-134), pill ordering + dedup (t-paliad-134 v2)
|
||||
|
||||
m's three concerns from 2026-05-05 11:58–11:59, **in m's priority order**:
|
||||
|
||||
1. **Card-click does nothing.** Should expand into a calculation panel that takes a trigger date (default today), shows the resulting deadline (with t-119 adjustment-reason explainer + t-121 vacation-skip), and exposes an "Add to project" CTA that drops the deadline into an existing Akte. _Most important._
|
||||
2. **Filter narrowing is broken.** Picking "CMS-Eingang → Gegenseite → UPC Verletzung" still surfaces national submissions. Confirmed bug — see §2.
|
||||
3. **Decision tree must follow the RoP rigorously.** The seed (migration 049) was rapid first-pass; concept↔leaf mappings have errors. Audit + correction in §3.
|
||||
|
||||
The work splits into three independent migrations / phases (see §4) so we can ship the bug-fix without waiting on the taxonomy revision.
|
||||
|
||||
---
|
||||
|
||||
## 1 — Card-click → compute deadline → add to project
|
||||
|
||||
### 1.1 Why this is the headline feature
|
||||
|
||||
v3 shipped concept cards that visualise "which deadline applies in which forum" at every B1 leaf — a great discovery surface. But the cards are **terminal**. The user can read the pill ("Klageerwiderung · UPC RoP R.23(1) · 3 Monate"), and then they're stuck. To actually compute a date they have to switch back to Pathway A's Verfahrensablauf, click the matching proceeding button, type the date in step 2, and read the deadline out of the timeline.
|
||||
|
||||
That's the round-trip Pathway B was supposed to eliminate. The v2 calculator (CORE Pathway A) had **trigger-date → computed-deadline** as the only feature; the v3 cards lost that.
|
||||
|
||||
This phase brings it back, **scoped to the single rule the user clicked**.
|
||||
|
||||
### 1.2 UX spec — inline calc panel inside the card
|
||||
|
||||
When the user clicks a result card, the card expands inline (no modal, no page navigation). The expanded card has three logical zones, top-to-bottom:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ ▾ Klageerwiderung [ × schließen ] │
|
||||
│ Statement of Defence │
|
||||
│ │
|
||||
│ ┌──────────────── Pill picker (only if N>1) ──────────────┐ │
|
||||
│ │ ◉ UPC Verletzung · R.23(1) · 3 Mon · Beklagter │ │
|
||||
│ │ ○ DE Verletzung (LG) · §276 ZPO · 6 Wo · Beklagter │ │
|
||||
│ │ ○ EPA Einspruch · R.79(1) · 4 Mon · Inhaber │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────── Trigger + Flags ─────────────────────────┐ │
|
||||
│ │ Datum des auslösenden Ereignisses │ │
|
||||
│ │ [ 2026-05-05 ▼ ] │ │
|
||||
│ │ ☐ mit Nichtigkeitswiderklage (R.49.2.a) │ │
|
||||
│ │ ☐ mit Patentänderungsantrag │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────── Berechnete Frist ────────────────────────┐ │
|
||||
│ │ ► 04.08.2026 (3 Monate ab 05.05.2026) │ │
|
||||
│ │ ⚠ Verschoben vom 03.08.2026 wegen UPC-Sommerferien │ │
|
||||
│ │ (27.7.–28.8.) — fällt auf nächsten Werktag. │ │
|
||||
│ │ │ │
|
||||
│ │ [ 📌 Zu Akte hinzufügen ] │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
State transitions, all client-side:
|
||||
|
||||
| Action | Effect |
|
||||
|---|---|
|
||||
| Click a card row in `#fristen-b1-results` or `#fristen-search-results` | Card expands. Pill picker shows only if >1 pill survived narrowing. First pill auto-selected. Trigger date defaults to today. Calc fires immediately. |
|
||||
| Click another pill in the picker | Re-fire calc with the new pill's rule. Flag checkbox visibility re-derived from the new rule's `condition_flag`. |
|
||||
| Change trigger date | Debounce 200 ms (match B2 search debounce), re-fire calc. |
|
||||
| Toggle a flag checkbox | Re-fire calc immediately (no debounce — discrete event). |
|
||||
| Click "Zu Akte hinzufügen" | Open project picker (modal — reuse existing `frist-save-modal` from `client/fristenrechner.ts:332`). On submit, POST `/api/projects/{id}/deadlines/bulk` with a single payload row. Show inline success message inside the card with a link to `/deadlines?project_id=…`. |
|
||||
| Click "× schließen" or click the card header again | Collapse back to compact view. |
|
||||
|
||||
Only **one card at a time** can be expanded — opening a second card collapses the first. This keeps the page short and avoids confusion about which trigger date applies where.
|
||||
|
||||
### 1.3 Picking the right pill on multi-pill cards
|
||||
|
||||
After §2's narrowing fix lands, most leaves will have 1 pill per card. But some legitimately have several:
|
||||
|
||||
- "Frist verpasst → EPA": `wiedereinsetzung` (Art. 122) AND `weiterbehandlung` (Art. 121) — **different rules entirely.**
|
||||
- "Spätere Schriftsätze → Replik auf Erwiderung zur Nichtigkeitsw.": one pill per proceeding the rule applies in (UPC_INF only after fix).
|
||||
- "CMS-Eingang → Gericht → Hinweisbeschluss": `response-to-preliminary-opinion` in `DE_NULL` (the only correct mapping after audit) — exactly 1 pill.
|
||||
|
||||
Heuristic: if the card has **1 pill** after narrowing, skip the pill picker and use that pill directly. If the card has **2+ pills**, render a radio-chip row preselecting the highest-`proceeding_display_order` pill (most-frequent forum first, t-paliad-134 ordering rule).
|
||||
|
||||
### 1.4 The single-rule calculator endpoint
|
||||
|
||||
We **do not** want to call `POST /api/tools/fristenrechner` (which renders the entire proceeding timeline) when the user clicks a card. That payload is 5–15 kB; we only need one rule.
|
||||
|
||||
**New endpoint:** `POST /api/tools/fristenrechner/calculate-rule`
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{
|
||||
"ruleId": "uuid", // either ruleId, OR
|
||||
"proceedingCode": "UPC_INF", // (proceedingCode + ruleLocalCode)
|
||||
"ruleLocalCode": "inf.sod",
|
||||
"triggerDate": "2026-05-05",
|
||||
"flags": ["with_ccr"] // optional; only flags applicable to the rule
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"rule": {
|
||||
"id": "uuid",
|
||||
"localCode": "inf.sod",
|
||||
"nameDE": "Klageerwiderung",
|
||||
"nameEN": "Statement of Defence",
|
||||
"ruleRef": "RoP.023",
|
||||
"legalSource": "UPC.RoP.23.1",
|
||||
"legalSourceDisplay": "UPC RoP R.23(1)",
|
||||
"durationValue": 3,
|
||||
"durationUnit": "months",
|
||||
"party": "defendant",
|
||||
"isCourtSet": false,
|
||||
"isMandatory": true
|
||||
},
|
||||
"proceeding": {
|
||||
"code": "UPC_INF",
|
||||
"nameDE": "Verletzungsverfahren",
|
||||
"nameEN": "Infringement Action"
|
||||
},
|
||||
"triggerDate": "2026-05-05",
|
||||
"originalDate": "2026-08-05",
|
||||
"dueDate": "2026-08-05",
|
||||
"wasAdjusted": false,
|
||||
"adjustmentReason": null
|
||||
}
|
||||
```
|
||||
|
||||
When the rule has `condition_flag` and the user supplies all of them AND `alt_duration_value` is set, the response uses the alt values (existing flag-swap semantics from `services/fristenrechner.go:368`). When the rule is `is_court_set` (party='court' OR event_type ∈ {hearing, decision, order}), `dueDate` is empty and `isCourtSet=true` — the UI shows "Gericht-bestimmt" instead of a calc panel and disables the "Add to project" CTA.
|
||||
|
||||
**Implementation:** `FristenrechnerService.CalculateRule(ctx, params) (*UIDeadline, error)` reusing the same `addDuration` + `HolidayService.AdjustForNonWorkingDaysWithReason` pipeline as `Calculate()` lines 397–404. Crucially it **does not walk the parent chain** — the trigger date is treated as the immediate parent's effective date, since that matches the user's mental model when clicking "Duplik": "I just received the Replik on date X, when's my Duplik due?"
|
||||
|
||||
For zero-duration rules (`is_court_set` waypoints, root events): respond with `dueDate=triggerDate` and `isCourtSet=true` for the court-set case. The UI handles both: court-set → no add-to-project CTA, "Add manually" hint instead.
|
||||
|
||||
### 1.5 Add-to-project — reuse the existing bulk endpoint
|
||||
|
||||
`POST /api/projects/{id}/deadlines/bulk` already exists and takes:
|
||||
```json
|
||||
{ "deadlines": [{ "title": "...", "rule_code": "...", "due_date": "...", "original_due_date": "...", "source": "fristenrechner", "notes": "..." }] }
|
||||
```
|
||||
|
||||
The card's "Zu Akte hinzufügen" sends a single-element array with the calc result. We extend the `source` enum: add `"fristenrechner_card"` so we can tell card-click adds apart from full-timeline adds in audit logs (one-line addition to whatever validates the source field today).
|
||||
|
||||
### 1.6 Why no "auto-add to project on card click"?
|
||||
|
||||
m's wording: "should allow adding that deadline to an existing proceeding" — adding is the explicit step, not the click itself. The click computes; the user reviews the date and adjustment-reason chip; only then do they decide whether the date actually goes into a real Akte. This matters because:
|
||||
|
||||
- Vacation-skip in either direction can move a date by ~28 days; users want to **see** the skip before committing.
|
||||
- The trigger date may be wrong (user typed it, or the matter has multiple receipt dates and they need to pick the right one).
|
||||
- The flag combinations alter the duration — the user may need to flip a flag once they remember "ah, this is the with_ccr case".
|
||||
|
||||
Computing inline is free (single SQL hit + one in-memory holidays scan). Persisting is consequential. Keep them separate.
|
||||
|
||||
---
|
||||
|
||||
## 2 — Filter narrowing bug — diagnosis & fix
|
||||
|
||||
### 2.1 m's repro
|
||||
|
||||
> "I chose 'CMS receipt' from opposing party UPC infringement and it still shows national submissions."
|
||||
|
||||
URL: `/tools/fristenrechner?path=b&mode=tree&b1=cms-eingang.gegenseite.upc-inf` (or the deeper `…upc-inf.klageerwiderung-mit-ccr` etc.).
|
||||
|
||||
Expected: only UPC_INF proceeding pills in result cards.
|
||||
Actual: cards show pills for DE_INF, DE_NULL, DPMA_OPP, EPA_OPP, UPC_DAMAGES, UPC_DISCOVERY, UPC_PI and UPC_REV alongside UPC_INF — i.e. every proceeding where the underlying concept (e.g. `statement-of-defence`) has a rule.
|
||||
|
||||
### 2.2 Root cause
|
||||
|
||||
The fix-needed code path lives in two places:
|
||||
|
||||
**`internal/services/event_category_service.go:194 ConceptIDsForSlug`** — collapses the `ConceptsForSlug` `(concept_id, proceeding_type_code)` tuple list down to a flat slice of concept IDs by deduplicating and **discarding the proceeding code**:
|
||||
|
||||
```go
|
||||
for _, r := range rows {
|
||||
if seen[r.ConceptID] { continue }
|
||||
seen[r.ConceptID] = true
|
||||
out = append(out, r.ConceptID)
|
||||
}
|
||||
```
|
||||
|
||||
**`internal/services/deadline_search_service.go:382 + 533 + 466`** — both `browseRanks`, `loadPills` and the `rankConcepts` matched-CTE filter the matview by `s.concept_id = ANY($N::uuid[])` only. There is no per-(concept × proc) constraint anywhere downstream of `ConceptIDsForSlug`.
|
||||
|
||||
So when a leaf maps `statement-of-defence | UPC_INF` in the `event_category_concepts` junction, the search service:
|
||||
|
||||
1. Resolves slug → concept_ids: `[id-of-statement-of-defence, id-of-reply-to-defence, …]`. Drops `UPC_INF`.
|
||||
2. Loads from matview every row where `concept_id` matches → all 9 proceedings of `statement-of-defence`, since the matview row exists for every (concept × rule) combo across the corpus (matview 047).
|
||||
3. Renders 9 pills under one card.
|
||||
|
||||
Reproduced live (`cms-eingang.gegenseite.upc-inf` subtree):
|
||||
|
||||
| concept | junction proc | matview procs returned |
|
||||
|---|---|---|
|
||||
| `statement-of-defence` | UPC_INF | `DE_INF, DE_NULL, DPMA_OPP, EPA_OPP, UPC_DAMAGES, UPC_DISCOVERY, UPC_INF, UPC_PI, UPC_REV` |
|
||||
| `rejoinder` | UPC_INF | `DE_INF, DE_NULL, UPC_DAMAGES, UPC_DISCOVERY, UPC_INF, UPC_REV` |
|
||||
| `reply-to-defence` | UPC_INF | `DE_INF, DE_NULL, UPC_DAMAGES, UPC_DISCOVERY, UPC_INF, UPC_REV` |
|
||||
| `notice-of-defence-intention` | UPC_INF | `DE_INF` (the junction maps to a non-existent UPC rule — see §3) |
|
||||
| `defence-to-counterclaim-for-revocation` | UPC_INF | `UPC_INF` (correct by coincidence — concept only exists in UPC_INF) |
|
||||
| `cross-appeal` | UPC_APP | `DE_INF_OLG, UPC_APP, UPC_APP_ORDERS` |
|
||||
| `response-to-appeal` | UPC_APP | `DE_INF_BGH, DE_INF_OLG, DE_NULL_BGH, EPA_APP, UPC_APP` |
|
||||
|
||||
Same mis-narrowing applies to **every leaf with a non-NULL `proceeding_type_code` in the junction** — 49 of them in the current seed. Confirmed by counting how many leaves have a junction-proc-code that the matview returns >1 proceeding for: at least 25 leaves are over-broad today, and several more have the inverse problem (junction maps to a proc that has no rule for that concept — silently dropped to no pill).
|
||||
|
||||
Of m's diagnostic options (a)/(b)/(c)/(d): **(b)** is the bug — the leaf-set is computed but the junction's `proceeding_type_code` constraint is dropped between `ConceptsForSlug` and `loadPills`. The frontend (c) is fine; the recursive CTE (a) is fine; (d) is not the cause.
|
||||
|
||||
### 2.3 Fix shape
|
||||
|
||||
Carry `(concept_id, proceeding_type_code)` as a tuple set, not as two independent lists. Tuple semantics:
|
||||
|
||||
- `(c, NULL)` in junction = "all proceeding contexts of this concept apply at this leaf" (used by cross-cutting concepts like `wiedereinsetzung`, `weiterbehandlung`, `versaeumnisurteil-einspruch` — they aren't tied to a specific proceeding).
|
||||
- `(c, X)` in junction = "ONLY proceeding X applies for concept c at this leaf".
|
||||
- Trigger pills (`kind='trigger'`) bypass the proc constraint by design (cross-cutting).
|
||||
|
||||
The matview filter becomes:
|
||||
|
||||
```sql
|
||||
-- Concept allowed AND (junction had no proc-narrowing for this concept
|
||||
-- OR the matview row's proc matches one of the narrowing tuples for this concept).
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM unnest($pairs) AS p(concept_id uuid, proc_code text)
|
||||
WHERE p.concept_id = s.concept_id
|
||||
AND (p.proc_code IS NULL OR p.proc_code = s.proceeding_code)
|
||||
)
|
||||
OR s.kind = 'trigger'
|
||||
```
|
||||
|
||||
Or equivalently, pre-expand on the Go side: from the junction tuples, build two parallel arrays — `concept_ids_unconstrained text[]` (junction had `(c, NULL)`) and `pairs (concept_id, proc_code) (text, text)[]` (junction had a proc) — then:
|
||||
|
||||
```sql
|
||||
WHERE s.concept_id = ANY($unconstrained_concepts)
|
||||
OR (s.concept_id, s.proceeding_code) IN (SELECT * FROM unnest($pair_cids, $pair_procs))
|
||||
OR s.kind = 'trigger'
|
||||
```
|
||||
|
||||
Either form keeps the existing `WHERE concept_id = ANY` query plan happy and adds one bounded set-membership check per row. With the matview ~1k rows and per-leaf tuple sets ≤ ~30, both are sub-millisecond.
|
||||
|
||||
### 2.4 Where the fix touches code
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `internal/services/event_category_service.go` | Add `ConceptOutcomesForSlug` (or rename `ConceptsForSlug` already returns `[]ConceptOutcome` — actually it does; expose it through a new search-friendly accessor that returns the two parallel arrays). Keep `ConceptIDsForSlug` for legacy callers but stop using it from the search service. |
|
||||
| `internal/services/deadline_search_service.go` | `Search` builds the tuple set from `eventCategory.ConceptOutcomesForSlug(slug)` instead of calling `ConceptIDsForSlug`. Pass tuples down to `browseRanks`, `loadPills`, `rankConcepts`. Update the SQL in all three to filter by tuple, not by concept_id alone. |
|
||||
| `internal/services/deadline_search_service.go` BrowseAll path | Stays as-is — when the user has picked NO leaf, all (concept × proc) combos are valid. Currently goes through `allMappedConceptIDs`; after the fix, change to "select distinct (concept_id, proceeding_type_code) from event_category_concepts" so we still respect any concept-context narrowing that's encoded in the junction even at the root view. |
|
||||
|
||||
The forum filter in `Forums` (`?forum=upc_cfi,upc_coa…`) keeps its current AND semantics — it ANDs against the tuple narrowing, never overrides it. After this fix, picking "UPC Verletzung opposing party" in the tree narrows to UPC_INF; adding a forum chip "EPA Einspruch" produces zero results (the user just contradicted themselves and the empty state is correct).
|
||||
|
||||
### 2.5 No migration needed for the bug fix
|
||||
|
||||
The seed data is fine — the per-leaf `proceeding_type_code` was always there. The Go-side wiring just dropped it. Fix is pure Go + SQL, no migration. Phase A in §4.
|
||||
|
||||
### 2.6 Test plan for the fix
|
||||
|
||||
Browser smoke tests (Phase A's PR should ship with these as Playwright cases or a manual checklist):
|
||||
|
||||
| Path | Expected pills (post-fix) |
|
||||
|---|---|
|
||||
| `b1=cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr` | `defence-to-counterclaim-for-revocation` (UPC_INF), `application-to-amend` (UPC_INF), `reply-to-defence` (UPC_INF) — no DE/EPA/DPMA pills |
|
||||
| `b1=cms-eingang.gegenseite.de-inf.klageerwiderung` | `reply-to-defence` (DE_INF only) — no UPC/EPA |
|
||||
| `b1=cms-eingang.gericht.hinweisbeschluss` | `response-to-preliminary-opinion` (DE_NULL only after §3 audit fix; currently shows DE_NULL because matview only has DE_NULL for that concept) |
|
||||
| `b1=frist-verpasst.epa` | `wiedereinsetzung` (cross-cutting/NULL), `weiterbehandlung` (cross-cutting/NULL) — both no proceeding chip |
|
||||
| `b1=` (root, browse-all) | every concept × proc tuple in the junction, ordered by sort_order |
|
||||
|
||||
DB-level invariant to assert in a unit test of `EventCategoryService`:
|
||||
|
||||
```go
|
||||
for _, leaf := range leaves {
|
||||
outcomes := svc.ConceptOutcomesForSlug(ctx, leaf.Slug)
|
||||
pills := svc.searchPillsForOutcomes(ctx, outcomes)
|
||||
for _, p := range pills {
|
||||
if p.Kind != "rule" { continue }
|
||||
// Check: pill's (concept_id, proc_code) was authorised by an outcome.
|
||||
ok := false
|
||||
for _, o := range outcomes {
|
||||
if o.ConceptID != p.ConceptID { continue }
|
||||
if o.ProceedingTypeCode == nil { ok = true; break }
|
||||
if *o.ProceedingTypeCode == p.ProceedingCode { ok = true; break }
|
||||
}
|
||||
require.True(t, ok, "leaf %s leaked pill (%s, %s)", leaf.Slug, p.ConceptSlug, p.ProceedingCode)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If this had existed in t-paliad-133 the bug would never have shipped. Adding it is part of Phase A.
|
||||
|
||||
---
|
||||
|
||||
## 3 — RoP-rigorous tree audit
|
||||
|
||||
### 3.1 Audit method
|
||||
|
||||
For each leaf in the seed (49 leaves with non-NULL `proceeding_type_code`, plus the cross-cutting NULL-coded ones), we cross-checked:
|
||||
|
||||
1. Does the proceeding the leaf maps to actually have a rule for that concept? (i.e. matview returns a row.)
|
||||
2. Does the cited RoP / PatG / EPÜ rule match what a HLC patent lawyer would expect to file in that situation?
|
||||
3. Are there other concepts that legitimately fire from the same leaf but were missed in the seed?
|
||||
|
||||
The tree shape — six root buckets (CMS-Eingang / Mündliche Verhandlung / Beschluss-Entscheidung / Frist verpasst / Ich möchte einreichen / Sonstiges) — is **kept**. m locked it on 2026-05-05 and the structure is sound; the failures are at the leaf-junction level, not in the categorisation.
|
||||
|
||||
### 3.2 Confirmed errors in the seed
|
||||
|
||||
| Leaf | Junction row (current) | Problem | Fix |
|
||||
|---|---|---|---|
|
||||
| `cms-eingang.gericht.hinweisbeschluss` | `response-to-preliminary-opinion \| DE_INF` | DE_INF (LG infringement) has no Hinweisbeschluss step. The Hinweisbeschluss is a BPatG-only mechanism (PatG §83). The matview confirms: this concept exists only for DE_NULL. | DELETE the DE_INF row. Keep the DE_NULL row. |
|
||||
| `cms-eingang.gegenseite.upc-inf.klageschrift` | `notice-of-defence-intention \| UPC_INF` | UPC has no "notice of intention to defend" rule in the corpus. The closest UPC artefact (R.23 explicit reaction) is captured by `statement-of-defence` directly. The matview has this concept only in DE_INF. | DELETE the UPC_INF row. Add `statement-of-defence \| UPC_INF` (already present at sort 200 — keep). |
|
||||
| `cms-eingang.gericht.kostenfestsetzung` | `notice-of-appeal \| UPC_COST_APPEAL` | Wrong rule. The actual rule for cost-decision appeal is `cost.leave_app` → `application-for-leave-to-appeal` (R.221.1), not `notice-of-appeal`. Matview confirms: `application-for-leave-to-appeal` exists only in UPC_COST_APPEAL; `notice-of-appeal` exists in UPC_APP but not UPC_COST_APPEAL. | UPDATE concept slug to `application-for-leave-to-appeal`. |
|
||||
| `beschluss-entscheidung.kostenfestsetzung` | `notice-of-appeal \| UPC_COST_APPEAL` | Same problem as the row above. | Same fix. |
|
||||
| `ich-moechte-einreichen.berufung.upc-cost` | `notice-of-appeal \| UPC_COST_APPEAL` | Same problem. | Same fix. |
|
||||
| `ich-moechte-einreichen.berufung.upc-coa-orders` | `application-for-leave-to-appeal \| UPC_APP_ORDERS` | The UPC_APP_ORDERS proceeding has `app_ord.discretion` (R.220.3 discretionary review) and `app_ord.with_leave` (R.220.2 appeal with leave) — NOT `application-for-leave-to-appeal` (which is the cost-appeal mechanism). | UPDATE the second row to `request-for-discretionary-review \| UPC_APP_ORDERS`. Keep `appeal-with-leave \| UPC_APP_ORDERS` row. |
|
||||
| `cms-eingang.gericht.anordnung` | `request-for-discretionary-review \| NULL` | Looks correct on its own (R.220.3 review is the response to a court order), but the NULL means it'd surface in every proceeding. The right narrowing is UPC_APP_ORDERS only. | UPDATE proc to `UPC_APP_ORDERS`. |
|
||||
|
||||
### 3.3 Coverage-gate exempt list — drop one entry
|
||||
|
||||
The migration 049 coverage gate exempts 4 concepts from leaf-reachability:
|
||||
|
||||
```
|
||||
'filing', 'request-for-examination', 'approval-and-translation', 'reply-to-cross-appeal'
|
||||
```
|
||||
|
||||
`reply-to-cross-appeal` was added to the exempt list in commit `ff36528` because it's downstream of cross-appeal. But after this audit, **it should be reachable** from at least:
|
||||
|
||||
- `cms-eingang.gegenseite.upc-inf.berufungsschrift` (when the Anschlussberufung filed by the opposing side triggers the user's response — the user IS the appellant who needs to respond to the cross-appeal)
|
||||
- `cms-eingang.gegenseite.upc-rev.berufungsschrift` (same logic for revocation appeals)
|
||||
- `cms-eingang.gegenseite.de-inf.berufungsschrift-olg` (DE OLG flavour — `cross-appeal \| DE_INF_OLG` already mapped, the reply has no DE rule in the corpus today, so this is UPC-only)
|
||||
|
||||
**Add** `reply-to-cross-appeal \| UPC_APP` rows under the two UPC `…berufungsschrift` leaves AND `reply-to-cross-appeal \| UPC_APP_ORDERS` under appropriate UPC_APP_ORDERS appeal leaves. **Drop** from the exempt list.
|
||||
|
||||
The other 3 exempt slugs are correctly cross-cutting (filing / examination / translation are EP_GRANT prosecution steps that don't fit the "what just happened" mental model — leave them exempt).
|
||||
|
||||
### 3.4 Bilateral-side coverage gaps
|
||||
|
||||
The seed correctly captures the receiving side ("CMS-Eingang" → opposing party / court actions) but the proactive side `ich-moechte-einreichen.spaetere-schriftsaetze` is missing some common UPC paths:
|
||||
|
||||
| Missing leaf | Concepts to map |
|
||||
|---|---|
|
||||
| `ich-moechte-einreichen.spaetere-schriftsaetze.anschlussberufung-upc` | `cross-appeal \| UPC_APP` (and `\| UPC_APP_ORDERS` if `app_ord.cross` is the right rule per R.237/238 — verify against the corpus rules `app.cross_a` / `app_ord.cross`) |
|
||||
| `ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben` | `r116-final-submissions \| EPA_OPP` and `\| EPA_APP` (matview confirms these exist; user should be able to reach this proactively before an EPA hearing, not only through the "Mündliche Verhandlung → Geladen" leaf) |
|
||||
| `ich-moechte-einreichen.spaetere-schriftsaetze.kostenantrag-upc` (already exists) | Already mapped to `application-for-cost-decision \| UPC_INF`. Verify whether UPC_REV also has it (matview shows only UPC_INF — likely just UPC_INF is correct since R.151 is referenced from infringement context, but flag for m's confirmation). |
|
||||
|
||||
### 3.5 The `bescheid-mit-frist` orphan
|
||||
|
||||
`cms-eingang.gericht.bescheid-mit-frist` ("Order with court-set deadline") has **no junction rows**. Currently a dead-end leaf. The right mapping is the cross-cutting `schriftsatznachreichung` trigger event (the generic "submit something within the court-set period" event).
|
||||
|
||||
Add `(cms-eingang.gericht.bescheid-mit-frist, schriftsatznachreichung, NULL, 100)`.
|
||||
|
||||
### 3.6 What we are NOT changing
|
||||
|
||||
- **Tree shape** — the six root buckets stay (m's lock).
|
||||
- **Tree depth** — stays unlimited.
|
||||
- **Forum bucket map** in `services/deadline_search_service.go:64` — stays the 10-bucket layout (m's lock §10 Q8).
|
||||
- **`is_bilateral` flag and the perspective selector** — out of scope here. v3 deferred Phase D-2 (party-perspective UI) explicitly; v4 keeps that deferral.
|
||||
- **Trigger event taxonomy** (`paliad.trigger_events`) — out of scope.
|
||||
- **`primary_party` semantics** — `'both'` rules continue to use the perspective-selector resolution (v3 §5.1).
|
||||
|
||||
### 3.7 Required leaf-by-leaf review during implementation
|
||||
|
||||
The audit above is high-confidence on the bugs explicitly listed. But a thorough leaf-by-leaf RoP review would benefit from a fresh pass with the corrected pill set visible — that's a one-hour task for the coder shift, not an inventor task. The deliverable for Phase C is a SQL diff against the current `event_category_concepts` rows, with one comment per row citing the RoP/PatG/EPÜ rule. m can spot-check 5–10 leaves and approve/reject the diff in one review pass.
|
||||
|
||||
---
|
||||
|
||||
## 4 — Migration plan — three independent phases
|
||||
|
||||
The phases are deliberately decoupled so the bug fix (the user-visible regression) can ship first without waiting on taxonomy revision. Each phase is one or more atomic commits with an integration test.
|
||||
|
||||
### Phase A — Filter narrowing fix (no schema change)
|
||||
|
||||
- New `EventCategoryService.ConceptOutcomesForSlug` that returns the tuple set with proceeding context preserved.
|
||||
- `DeadlineSearchService.Search` (and helpers `browseRanks`, `loadPills`, `rankConcepts`) accept and apply the tuple constraint.
|
||||
- Update `allMappedConceptIDs` → `allMappedOutcomes` to return tuples for browse-all mode (so the root view also respects per-leaf narrowing).
|
||||
- Add `internal/services/deadline_search_service_test.go` covering the leaks listed in §2.6.
|
||||
- One commit, one PR.
|
||||
|
||||
No migration. No client-side changes. Pure backend correctness fix. Ships independently of B and C.
|
||||
|
||||
### Phase B — Card-click flow
|
||||
|
||||
- New `FristenrechnerService.CalculateRule(ctx, params)` in `internal/services/fristenrechner.go`.
|
||||
- New handler `handleFristenrechnerCalculateRule` in `internal/handlers/fristenrechner.go`.
|
||||
- New route `POST /api/tools/fristenrechner/calculate-rule` in `handlers.go`.
|
||||
- Frontend additions in `frontend/src/client/fristenrechner.ts`:
|
||||
- `expandCard(card, pill)` builds the inline calc panel.
|
||||
- `runCardCalc(rule, triggerDate, flags)` POSTs to the new endpoint and renders the result.
|
||||
- Card-row click handler (already wired for pill drill-in via `wirePillClicks`) extended to also handle "click on card body, not on a pill".
|
||||
- Reuse `openSaveModal` (`client/fristenrechner.ts:332`) with a single-deadline payload variant.
|
||||
- i18n keys: `deadlines.card_calc.trigger_date`, `…flag.<flag_name>`, `…result.due`, `…result.original`, `…add_to_project`.
|
||||
- CSS: `.fristen-card.is-expanded` + the panel zones in `global.css`.
|
||||
|
||||
No migration, no schema change. Depends on Phase A landing first (otherwise the cards are still showing wrong pills and the calc panel computes correctly but on the wrong rules).
|
||||
|
||||
### Phase C — RoP-rigorous tree taxonomy revision
|
||||
|
||||
- One new migration `052_event_categories_rop_audit.up.sql` (and `.down.sql`).
|
||||
- Pure data migration. No DDL. Updates `event_category_concepts` rows per §3.2–§3.5.
|
||||
- **No `RAISE EXCEPTION` coverage gates** — last night's outage was caused by exactly that pattern. Use `RAISE WARNING` at most. Coverage gates that block server boot are an ops failure mode the migration runner should not have. Validation gates can be a separate read-only check (a Go invariant test that runs in CI but doesn't block migrations).
|
||||
- The migration applies idempotently — every row uses `INSERT … ON CONFLICT DO UPDATE` or `DELETE WHERE …` (idempotent on re-run).
|
||||
- Drop `'reply-to-cross-appeal'` from the exempt list (it's now reachable). Keep the other 3 exempt slugs.
|
||||
|
||||
Ships independently of A and B. Ordering recommendation: A → C → B (because B's UX is best evaluated against a correct tree, but B is not technically blocked on C).
|
||||
|
||||
### What we are deliberately not doing in this round
|
||||
|
||||
- **No party-perspective UI** (v3 Phase D-2 defer holds).
|
||||
- **No AI Frist-Extraktion** (Phase H is deferred per m's 2026-04-16 decision).
|
||||
- **No CalDAV write-back of card-click deadlines** — happens through the existing `POST /api/projects/{id}/deadlines/bulk` which already triggers CalDAV sync via the deadline service.
|
||||
- **No multi-rule cards calc** — if a card has 2+ pills, the calc panel handles ONE pill at a time (the user picks which). Adding "calculate both at once" is feature creep.
|
||||
- **No persistent calc state** — collapsing the card discards the trigger date and flags. Users who want to keep working state should "Add to project" first.
|
||||
|
||||
---
|
||||
|
||||
## 5 — Open questions for m before coder shift
|
||||
|
||||
The hardest decisions here are taxonomy and UX, both of which warrant a confirm-before-build:
|
||||
|
||||
1. **Card-click compute scope.** When the card has 1 pill, the calc panel works on that pill. When the card has 2+ pills (after §2 fix this should be rare — mostly `wiedereinsetzung`/`weiterbehandlung` cards and a few cross-jurisdictional concepts like `notice-of-appeal`), should the user pick ONE pill and compute, or should the calc panel produce a side-by-side comparison ("DE: 1 month → 5 June 2026; UPC: 2 months → 5 July 2026")? The latter is more powerful but doubles the UI surface. **Recommendation: stick with single-pill picker.** Comparison view is a future feature.
|
||||
|
||||
2. **Add-to-project source string.** Use `"fristenrechner"` (existing) or `"fristenrechner_card"` (new tag for card-click adds)? **Recommendation: new tag** so the audit log distinguishes the two flows. One-line addition to whatever validates the source field.
|
||||
|
||||
3. **Default trigger date.** Today (`new Date()`) or the user's most-recent trigger date from any prior calc this session? **Recommendation: today.** Prior-date carry-over is surprising; the user's last action in any calc tool is rarely the same as the next.
|
||||
|
||||
4. **`bescheid-mit-frist` mapping.** §3.5 proposes mapping to `schriftsatznachreichung` (cross-cutting / NULL proceeding). Is there a more specific concept I'm missing for "court-set period to file something" in the German PatG/ZPO corpus? If so, point me at the rule and I'll map it instead.
|
||||
|
||||
5. **`cost-appeal` rule labels.** §3.2 fixes 3 leaves to use `application-for-leave-to-appeal` instead of `notice-of-appeal` for UPC_COST_APPEAL. **Confirmation needed:** under R.221, is `application-for-leave-to-appeal` strictly the *first* step (15 days), with the actual `notice-of-appeal` as a *second* step once leave is granted? If so, should the leaf surface BOTH (sort 100 leave + sort 200 notice, conditional)?
|
||||
|
||||
6. **Phase ordering.** A → C → B (correctness first, then taxonomy, then UX) vs. A → B → C (correctness first, then UX so users see card-click immediately, then taxonomy as a follow-up). **Recommendation: A → C → B.** B is most valuable when the cards show the right pills, and C ships without UI risk.
|
||||
|
||||
7. **Coverage-gate replacement.** Phase C drops the `RAISE EXCEPTION` block. Should we replace it with a Go-side `services_test.go` unit test that asserts every `category='submission'` concept (less the 3-slug exempt list) is reachable from at least one leaf? **Recommendation: yes.** It's the same gate, just at CI time instead of migration time, and it can be made part of the `make test` target so it gates merges without gating server boots.
|
||||
|
||||
8. **Project picker autosuggest.** The existing `frist-save-modal` shows a `<select>` of all the user's visible projects. With 100+ Akten this becomes unwieldy. Worth adding a typeahead? **Defer** — out of scope here, but flag for a future task.
|
||||
|
||||
---
|
||||
|
||||
## 6 — Sequencing summary
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Phase A: Filter fix (Go + SQL only, no migration) │
|
||||
│ → ships independently, fixes m's repro │
|
||||
│ │
|
||||
│ Phase C: Tree taxonomy revision (migration 052) │
|
||||
│ → ships independently, fixes m's "RoP-rigorous" concern │
|
||||
│ → no RAISE EXCEPTION │
|
||||
│ │
|
||||
│ Phase B: Card-click → calculate → add-to-project │
|
||||
│ → new endpoint + frontend panel + reuse save modal │
|
||||
│ → most valuable after A + C land │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
m's open questions in §5 should be resolved before Phase B begins (UX choices) and Phase C migration is written (taxonomy choices). Phase A can start immediately on m's go-ahead; it has no open questions.
|
||||
|
||||
---
|
||||
|
||||
## 7 — Changelog vs v3
|
||||
|
||||
What this design changes about v3:
|
||||
|
||||
- **Card-click is no longer a dead-end.** v3 ended at the result card; v4 makes the card the *entry* to a single-rule calculator + add-to-project flow.
|
||||
- **Per-leaf proceeding narrowing actually narrows.** v3 had the data right but dropped it in `ConceptIDsForSlug`; v4 carries the tuple end-to-end.
|
||||
- **One concrete RoP-mapping bug class fixed**: 6 leaves had wrong concept↔proc rows; the bug was masked because the broken filter showed all proceedings anyway. Once §2's fix lands, these leaves would have produced empty cards instead of overbroad cards — surfacing the seed errors. Phase C corrects them.
|
||||
- **No new schema columns.** Same tables (`event_categories`, `event_category_concepts`, `deadline_rules.is_bilateral`); just data corrections + Go logic.
|
||||
930
docs/plans/unified-fristenrechner.md
Normal file
@@ -0,0 +1,930 @@
|
||||
# Unified Fristenrechner — Search-by-Concept + Complete Proceeding-Type Coverage
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-04 (revised after m's go-direction at 23:10)
|
||||
**Task:** t-paliad-131
|
||||
**Mode:** design only — no code, no schema migrations applied
|
||||
**Branch:** `mai/cronus/unified-fristenrechner-design` (worktree)
|
||||
**Status:** v2 — m's answers to v1 + v2 open questions both incorporated. Awaiting final go for coder shift.
|
||||
|
||||
> *m, 2026-05-04 22:55:* "I want additional possibilities to select / filter the trigger. By Rule, by Proceeding Type, by Party etc... we need to classify each of our deadlines again. In particular we do not have all german patg and zpo procedural deadlines, yet. Let us hire an inventor — and allow research on this — for a unified deadline calculator that is ridiculously easy to use."
|
||||
|
||||
---
|
||||
|
||||
## 0. m's go-direction (v2 anchor)
|
||||
|
||||
m's 10 answers (relayed via head 2026-05-04 23:10) reshape the design materially. They are the binding spec for v2:
|
||||
|
||||
1. **Augment, not replace.** Search bar at top **plus** the existing chunky proceeding-type tiles below as browse fallback. The two existing tabs (Verfahrensablauf / Was kommt nach…) **stay**. No subsumption.
|
||||
2. **Aliases hard-coded** in seed migrations (curated, not user-editable in v1).
|
||||
3. **Unifier shape (a) — "shared rule with applicable_in"**: one canonical concept ('Klageerwiderung') that adapts duration / legal_source / notes per context. Pick the cleaner of (jsonb on rule) vs (separate context-overrides table).
|
||||
4. **Counterclaim flag pattern stays**, just add the missing deadlines (R.25, R.30, R.50).
|
||||
5. **"Full Appeal Chain" checkbox** — default: per-instance pick. Toggle on → render LG → OLG → BGH (or BPatG → BGH for nullity) as one tree.
|
||||
6. **One result card per concept with proceeding pills inside.** Search "Klageerwiderung" → one card titled "Klageerwiderung" with pills [LG] [OLG] [UPC] [BPatG] [EPA] [DPMA]. Click a pill = drill into that context's specific deadline.
|
||||
7. **Structured legal_source codes** — `DE.ZPO.282`, `DE.PatG.83`, `UPC.RoP.23.1`, `EU.EPÜ.108`, `DPMA.PatG.59`, etc. Parseable, filterable. Document the canonical format.
|
||||
8. **Forum NOT a filter** — drop. Rules are shared across the court system within a jurisdiction.
|
||||
9. **Sequence preservation in columns-view** — undated court-set events (Counterclaim → Defence → Reply → Decision) currently collapse into one row in the t-paliad-129 columns view. They must order by `sequence_order` even without dates. Flag in this doc; ship as a separate follow-up.
|
||||
10. **Court-set placeholders ARE searchable triggers.** "Verhandlung", "Entscheidung", "Zwischenverfügung" etc. surface in search.
|
||||
|
||||
The remainder of this document implements those ten constraints.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary (v2)
|
||||
|
||||
The Unified Fristenrechner is **search-first with proceeding-type tiles as fallback**, organised around **concepts** ("Klageerwiderung") rather than per-proceeding rule rows. A new `paliad.deadline_concepts` layer sits above the existing `paliad.deadline_rules` and groups context-specific instances of the same legal idea. The tree-shape calculator stays unchanged in math; it just gains a `concept_id` on each rule for grouping.
|
||||
|
||||
**The single search hit is one card per concept**, with proceeding-pills inside. Type "Klageerwiderung" → one card; click [LG] for ZPO §276, [BPatG] for PatG §82(1), [UPC] for RoP R.23, etc.
|
||||
|
||||
**Coverage gap closure:** UPC counterclaim cross-flows (R.25 / R.30 / R.49(2) / R.50 / R.51 / R.52 / R.55 / R.56), DE OLG + BGH-Revision + BGH-NZB + BPatG Hinweisbeschluss-Cycle, DPMA Einspruch + Beschwerde, EPA R.116 / R.79(2)(3) / R.106 / Wiedereinsetzung. ≈ +85 new deadline rules, organised into ≈ 30 concepts.
|
||||
|
||||
**The Full Appeal Chain toggle** synthesises a multi-instance tree (LG → OLG → BGH for infringement; BPatG → BGH for nullity) when the user wants the whole journey on one timeline.
|
||||
|
||||
**The columns-view sequence-preservation fix** is flagged but **deferred to a separate task** as it's a t-paliad-129 follow-up rather than a unified-Fristenrechner concern.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state — verified live
|
||||
|
||||
### 2.1 Two backends, two shapes
|
||||
|
||||
| | `paliad.deadline_rules` (proceeding-tree) | `paliad.trigger_events` + `paliad.event_deadlines` (event-driven) |
|
||||
|---|---|---|
|
||||
| Shape | 74 rules in 12 trees, parent_id chain, sequence_order | 102 triggers, 70 deadlines (1:N), flat |
|
||||
| PK | uuid | bigint |
|
||||
| Provenance | hand-seeded migrations 012/029/031 (HLC + KanzlAI port) | youpc verbatim port (migration 028, IDs preserved) |
|
||||
| Rule-code coverage | 5 distinct UPC RoP codes + 4 PatG/ZPO + 5 EPÜ Art./R | 64 distinct UPC RoP codes (UPC only) |
|
||||
| Conditional logic | `condition_flag text` + `alt_*` columns | implicit (operator picks the matching trigger event) |
|
||||
| Composite math | none | `combine_op IN ('max','min')` + `alt_duration_*` |
|
||||
| Working-day arithmetic | no | yes (`duration_unit='working_days'`) |
|
||||
| Anchor flexibility | `anchor_alt='priority_date'` (single special case) | trigger date only |
|
||||
| Calculator | `internal/services/fristenrechner.go::Calculate` | `internal/services/event_deadline_service.go::Calculate` |
|
||||
| UI | proceeding-button grid → date/flag inputs → timeline / Spalten | search-by-name input → trigger picked → flat results |
|
||||
|
||||
Both backends stay structurally separate (the math is genuinely different). The Unifier sits *above* them.
|
||||
|
||||
### 2.2 Proceeding types currently shipped
|
||||
|
||||
12 fristenrechner-category types live (sort_order 101–303):
|
||||
- **UPC:** UPC_INF, UPC_REV, UPC_PI, UPC_APP, UPC_DAMAGES, UPC_DISCOVERY, UPC_COST_APPEAL, UPC_APP_ORDERS
|
||||
- **DE:** DE_INF, DE_NULL
|
||||
- **EPA:** EPA_OPP, EPA_APP, EP_GRANT
|
||||
|
||||
(Plus 7 internal/KanzlAI-port types under `category='litigation'` for matter-attached fristen — out of scope for this design.)
|
||||
|
||||
### 2.3 Verified gaps confirmed against m's vision
|
||||
|
||||
m's specific complaint:
|
||||
|
||||
> "the checkbox for 'counterclaim' does not actually add the submissions to the timeline; it only changes the rejoinder deadline. But for the counterclaim for revocation we also need the application to amend the patent — and in the revocation action we are lacking a 'counterclaim for infringement'."
|
||||
|
||||
**Verified:** the current `with_ccr` flag on `UPC_INF` only swaps `inf.reply` (RoP.029.b → RoP.029.a) and `inf.rejoin` (RoP.029.c 1mo → RoP.029.d 2mo) durations. The 5–7 additional submissions (Defence to CCR, Application to amend, Defence to App-to-amend, Reply to Defence to CCR, etc.) are missing from the tree entirely. UPC_REV has the same gap — no Application to amend, no Counterclaim for infringement, no R.55 / R.56 cycles.
|
||||
|
||||
The trigger-event tab carries labels for "Verletzungswiderklage" (id=10), "Antrag auf Patentänderung" (id=38), "Nichtigkeitswiderklage" (id=101), but with `num_deadlines=0` for many of them (labels without downstream cycles). DE/EPA/DPMA triggers absent entirely from the trigger-event corpus.
|
||||
|
||||
---
|
||||
|
||||
## 3. UX — search-first, tiles as fallback
|
||||
|
||||
### 3.1 The search bar augments, doesn't replace
|
||||
|
||||
`/tools/fristenrechner` keeps its current structure:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Fristenrechner │
|
||||
│ Berechnung von Verfahrensfristen — UPC, DE, EPA, DPMA │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ 🔍 Tippe Frist, Rechtsgrundlage oder Verfahren… │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [ Häufig: Klageerwiderung │ Berufung │ Einspruch │ Replik │ … ] │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────── │
|
||||
│ │
|
||||
│ oder Verfahren wählen: │
|
||||
│ │
|
||||
│ UPC │
|
||||
│ [ Verletzungs- ] [ Nichtigkeits- ] [ Einstw. M. ] [ Berufung ] │
|
||||
│ [ Schadensbem. ] [ Bucheinsicht ] [ Berufung-K ] [ Anord. ] │
|
||||
│ │
|
||||
│ Deutsche Gerichte │
|
||||
│ [ Verletz. (LG) ] [ Nichtigk. (BPatG) ] [ Berufung (OLG) ] … │
|
||||
│ │
|
||||
│ EPA / DPMA │
|
||||
│ [ Einspruch EPA ] [ Beschwerde EPA ] [ EP-Erteilung ] [ DPMA ] │
|
||||
│ │
|
||||
│ ☐ Vollständige Instanzenkette anzeigen (LG → OLG → BGH) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Behaviour changes vs today:
|
||||
- Search bar at top is new.
|
||||
- Quick-pick chips are new.
|
||||
- The proceeding-type grid below is the existing "Verfahrensablauf" entrypoint, slightly reorganised (DE gets 5 tiles now, EPA picks up DPMA).
|
||||
- The two existing tabs (Verfahrensablauf / Was kommt nach…) stay reachable — when the user clicks a tile, the Verfahrensablauf flow opens. When the user lands on a search hit that's a court-set placeholder ("Verhandlung", "Entscheidung"), the "Was kommt nach…" flow opens.
|
||||
- "Full Appeal Chain" checkbox at the bottom (3.4).
|
||||
|
||||
### 3.2 Search hit — one card per concept
|
||||
|
||||
A typed query resolves to **concept cards** (not rule rows). Each card represents one legal idea ("Klageerwiderung") with the contexts where it applies as proceeding pills inside.
|
||||
|
||||
```
|
||||
Search: "Klageerwiderung"
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ ⚖ Klageerwiderung · Statement of Defence │
|
||||
│ Erwiderung des Beklagten auf eine Klageschrift, üblicherweise │
|
||||
│ mit Verteidigungsanträgen und Sachvortrag. │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ UPC │ │ LG │ │ BPatG │ │ EPA │ │ DPMA │ │
|
||||
│ │ R.23.1 │ │ §276.1 │ │ §82.1 │ │ R.79.1 │ │ §59.3 │ │
|
||||
│ │ 3 Monate │ │ 6 Wochen │ │ 1 Monat │ │ 4 Monate │ │ 4 Mon. │ │
|
||||
│ │ Beklag. │ │ Beklag. │ │ Beklag. │ │ PatInh. │ │ PatInh. │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ ⚖ Klageerwiderung mit Nichtigkeitswiderklage │
|
||||
│ (UPC-spezifisch — Trigger für mehrere Folgefristen) │
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ UPC │ │ UPC │ │
|
||||
│ │ trigger │ │ R.23.1 │ ← der Trigger versus die Frist │
|
||||
│ │ (Was nach│ │ + R.25.1 │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Top line:** concept name (active locale) + EN translation in muted text.
|
||||
- **Description line:** 1–2-sentence concept description.
|
||||
- **Pills row:** one pill per context. Each pill shows: forum chip · structured legal_source · duration · party (compact). Click → drill into that proceeding's calculator at the matching rule.
|
||||
- **Multiple cards:** when the search term is genuinely ambiguous (e.g. "Erwiderung" matches Klageerwiderung AND Erwiderung Prüfbescheid AND Erwiderung Patentinhaber Einspruch), each is its own card. Cards are sorted by relevance score.
|
||||
|
||||
When a single concept also exists as a *trigger event* (court-set placeholder, e.g. "Mündliche Verhandlung"), it gets a special pill labelled "Was kommt nach…" that opens the trigger-event calculator. Per Q10, court-set placeholders ARE searchable.
|
||||
|
||||
### 3.3 Drill-in behaviour
|
||||
|
||||
Click `[ UPC R.23.1 ]` pill → `/tools/fristenrechner?proc=UPC_INF&focus=inf.sod` opens the proceeding-tree calculator with UPC_INF preselected, jumping straight to the date input, then renders the timeline with `inf.sod` highlighted.
|
||||
|
||||
Click `[ UPC trigger (Was nach…) ]` pill → `/tools/fristenrechner?trigger=1` opens the event-driven calculator with the trigger preselected.
|
||||
|
||||
### 3.4 Full Appeal Chain checkbox
|
||||
|
||||
A checkbox at the bottom of the proceeding tile grid: "**☐ Vollständige Instanzenkette anzeigen** (LG → OLG → BGH)".
|
||||
|
||||
When **off** (default): user picks a single instance (LG, OLG, or BGH separately).
|
||||
When **on**: the tiles grouped by case-type render as multi-instance tile pairs. E.g. the "Verletzungsklage" tile expands to show the full chain when clicked, rather than just the LG step.
|
||||
|
||||
Implementation: a synthetic "compound proceeding" rendering option in the calculator. The data model stays per-instance; the calculator stitches three trees together at render time when the toggle is on. Anchor for OLG = "Urteil des LG"; anchor for BGH = "Urteil des OLG". The user enters one date (the LG Klageerhebung) and the chain unfolds; OR the user enters individual stage anchors (1, 2, 3) for known dates.
|
||||
|
||||
Mapping for the chain rendering:
|
||||
- **DE_INF chain:** `DE_INF` (LG) → `DE_INF_OLG` (Berufung) → `DE_INF_BGH` (Revision/NZB)
|
||||
- **DE_NULL chain:** `DE_NULL` (BPatG) → `DE_NULL_BGH` (Berufung BGH)
|
||||
- **DPMA chain:** `DPMA_OPP` (DPMA) → `DPMA_BPATG_BESCHWERDE` (BPatG) → `DPMA_BGH_RB` (Rechtsbeschwerde BGH)
|
||||
- **UPC:** UPC_INF (CFI) → UPC_APP (CoA). Already linkable via Decision → Notice of Appeal.
|
||||
- **EPA:** EPA_OPP (Einspruch) → EPA_APP (Beschwerde). Already linkable.
|
||||
|
||||
Out-of-scope for v1: the toggle as a default-on shortcut. It's an option, not a forced view.
|
||||
|
||||
### 3.5 Filters — slimmer than v1 draft
|
||||
|
||||
Per Q8 (forum dropped), filters are now:
|
||||
|
||||
```
|
||||
[ Verfahrensart ▾ ] [ Partei ▾ ] [ Rechtsquelle ▾ ]
|
||||
```
|
||||
|
||||
- **Verfahrensart:** the 12 (then ≈ 18 after coverage migrations) proceeding types.
|
||||
- **Partei:** Kläger / Beklagte / Beide / Gericht.
|
||||
- **Rechtsquelle:** UPC RoP · UPC Statute · EPÜ · ZPO · PatG · DPMAV · others (parses the prefix of `legal_source`).
|
||||
|
||||
Filters appear only when search returns more than 6 cards. Single-select for v1.
|
||||
|
||||
### 3.6 Empty-state and browse
|
||||
|
||||
- **Empty search input:** no hits below the input; the tile grid is the natural fallback.
|
||||
- **No matches for a typed query:** "Keine Treffer für 'xyz' — meintest du …?" with up to 3 trigram-nearest concept suggestions.
|
||||
- **The two existing tabs** (Verfahrensablauf / Was kommt nach…) stay alive as today, reachable from the tile grid (Verfahrensablauf default) and from event-trigger pills on cards (Was kommt nach…).
|
||||
|
||||
### 3.7 Mobile
|
||||
|
||||
Search bar full-width, quick-pick chips wrap, concept cards stack vertically with pills wrapping. Below ≈ 600px, the proceeding tile grid switches from 4-wide to 2-wide. No special handling beyond the existing responsive breakpoints.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data model — the Unifier shape
|
||||
|
||||
### 4.1 Goal
|
||||
|
||||
One concept, many contexts. The user thinks "Klageerwiderung" — the system knows that's UPC R.23, ZPO §276, PatG §82, EPÜ R.79, PatG §59, all at once.
|
||||
|
||||
### 4.2 Two design alternatives (m's Q3)
|
||||
|
||||
m named two shapes:
|
||||
|
||||
**Option (X): `per_context jsonb` on a unified rule.**
|
||||
- Single `paliad.deadline_rules` table; each row is one concept.
|
||||
- New `per_context jsonb` column holds `{"UPC_INF": {duration, unit, legal_source, parent_concept, ...}, "DE_INF": {...}, ...}`.
|
||||
- Pros: one row per concept, easy to query "where does X apply".
|
||||
- Cons: jsonb keys don't index well for joins; the calculator extracts the right key at runtime; tree-chain (parent) lives inside jsonb, hard to traverse with normal SQL; condition_flag arrays in jsonb are awkward.
|
||||
|
||||
**Option (Y): split into `deadline_concepts` + per-context rule rows (essentially today's `deadline_rules`).**
|
||||
- New `paliad.deadline_concepts` (concept_id, slug, name_de, name_en, aliases, party, description).
|
||||
- Existing `paliad.deadline_rules` keeps its shape, gains a `concept_id` FK column.
|
||||
- Each row in `deadline_rules` is one (concept × proceeding_type) instance. Tree-chain (parent_id) stays where it is.
|
||||
- Pros: backward-compatible (existing calculator code unchanged); standard relational joins; sequence_order, parent_id, condition_flag, alt_* all live in normal columns; concept layer is thin.
|
||||
- Cons: two tables; "where does Klageerwiderung apply" needs a 1-row → N-row join (trivial).
|
||||
|
||||
**Pick: Option (Y).** Cleanest, lowest-risk migration, no calculator refactor required, search hits are a single GROUP BY on concept_id.
|
||||
|
||||
### 4.3 Schema additions
|
||||
|
||||
```sql
|
||||
-- New: concept layer (the canonical legal idea)
|
||||
CREATE TABLE paliad.deadline_concepts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL UNIQUE, -- 'klageerwiderung', 'replik', 'berufungsfrist',
|
||||
-- 'berufungsbegruendung', 'einspruch', 'beschwerde',
|
||||
-- 'rejoinder', 'reply-to-defence', 'application-to-amend',
|
||||
-- 'counterclaim-for-revocation', 'counterclaim-for-infringement', ...
|
||||
name_de text NOT NULL,
|
||||
name_en text NOT NULL,
|
||||
description text, -- 1–2 sentences for the card body
|
||||
aliases text[] NOT NULL DEFAULT '{}',
|
||||
party text, -- canonical (most contexts agree); per-context override on rule row
|
||||
category text NOT NULL, -- 'submission' | 'decision' | 'order' | 'hearing' | 'other'
|
||||
sort_order int NOT NULL DEFAULT 100,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX deadline_concepts_trgm_de ON paliad.deadline_concepts USING gin (name_de gin_trgm_ops);
|
||||
CREATE INDEX deadline_concepts_trgm_en ON paliad.deadline_concepts USING gin (name_en gin_trgm_ops);
|
||||
CREATE INDEX deadline_concepts_aliases ON paliad.deadline_concepts USING gin (aliases);
|
||||
|
||||
-- Existing: gain concept linkage + structured legal_source + condition_flag array
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN concept_id uuid REFERENCES paliad.deadline_concepts(id),
|
||||
ADD COLUMN legal_source text, -- structured code, see §4.5
|
||||
ALTER COLUMN condition_flag TYPE text[] -- Q3: scalar → array; semantic: ALL flags must be set
|
||||
USING (CASE WHEN condition_flag IS NULL THEN NULL ELSE ARRAY[condition_flag] END);
|
||||
|
||||
CREATE INDEX deadline_rules_concept_id ON paliad.deadline_rules (concept_id);
|
||||
CREATE INDEX deadline_rules_legal_source ON paliad.deadline_rules (legal_source);
|
||||
CREATE INDEX deadline_rules_legal_src_trgm ON paliad.deadline_rules USING gin (legal_source gin_trgm_ops);
|
||||
|
||||
-- Existing: trigger_events also gain concept linkage (a court-set placeholder concept like 'oral-hearing' applies in many proc types)
|
||||
ALTER TABLE paliad.trigger_events
|
||||
ADD COLUMN concept_id text; -- nullable; by slug for cross-link without uuid FK gymnastics
|
||||
|
||||
-- Existing: event_deadlines gain legal_source (concept lives on the rule_codes via event_deadline_rule_codes)
|
||||
ALTER TABLE paliad.event_deadlines
|
||||
ADD COLUMN legal_source text;
|
||||
```
|
||||
|
||||
**Why no `aliases text[]` on `deadline_rules` directly anymore?** Because aliases are concept-level, not rule-level. "Klageerwiderung" matches the concept regardless of which proceeding the rule lives in. Per-rule context-specific aliases would be redundant — the legal_source ("UPC.RoP.23.1") and the proceeding code ("UPC_INF") already disambiguate. Per Q5, aliases are concept-only, hard-coded.
|
||||
|
||||
**Why text-by-slug on `trigger_events.concept_id` rather than uuid FK?** trigger_events has bigint PK and was imported verbatim from youpc; introducing a uuid FK touches the import-resync invariant. A text slug (e.g. `'oral-hearing'`) is a soft link sufficient for search.
|
||||
|
||||
### 4.4 Concept ↔ rule mapping examples
|
||||
|
||||
**Slug naming rule (Q1, locked):** **EN slug** for concepts native to UPC/EPC AND for **shared concepts** that exist in both DE and UPC/EPC. **DE slug** only for concepts that exist exclusively in German law (no UPC/EPC equivalent). `name_de` and `name_en` carry both labels for the user-facing surface; the slug is internal/maintenance-facing.
|
||||
|
||||
| concept slug | name_de | name_en | maps to deadline_rules in (proceeding_type, code) |
|
||||
|---|---|---|---|
|
||||
| `statement-of-defence` | Klageerwiderung | Statement of Defence | (UPC_INF, inf.sod), (DE_INF, de_inf.erwidg), (DE_NULL, de_null.erwidg), (UPC_REV, rev.defence), (EPA_OPP, epa_opp.erwidg), (DPMA_OPP, dpma_opp.erwidg) — shared, EN |
|
||||
| `reply-to-defence` | Replik | Reply to Defence | (UPC_INF, inf.reply), (DE_INF, de_inf.replik), (UPC_REV, rev.reply), (UPC_DAMAGES, damages.reply), (UPC_DISCOVERY, disc.reply) — shared, EN |
|
||||
| `rejoinder` | Duplik | Rejoinder | (UPC_INF, inf.rejoin), (UPC_REV, rev.rejoin), (DE_INF, de_inf.duplik), (UPC_DAMAGES, damages.rejoin), … — shared, EN |
|
||||
| `notice-of-appeal` | Berufungsschrift | Notice of Appeal | (DE_INF, de_inf.berufung), (DE_NULL, de_null.berufung), (UPC_APP, app.notice), (EPA_APP, epa_app.beschwerde) — shared, EN |
|
||||
| `statement-of-grounds-of-appeal` | Berufungsbegründung | Statement of Grounds of Appeal | (DE_INF, de_inf.beruf_begr), (DE_NULL, de_null.beruf_begr), (UPC_APP, app.grounds), (EPA_APP, epa_app.begr) — shared, EN |
|
||||
| `opposition` | Einspruch / Einspruchsfrist | Opposition | (EPA_OPP, epa_opp.frist), (DPMA_OPP, dpma_opp.frist) — shared, EN |
|
||||
| `re-establishment-of-rights` | Wiedereinsetzung in den vorigen Stand | Re-establishment of Rights | event_trigger only — PatG §123, ZPO §233, EPÜ Art.122, DPMA §123 — shared cross-cutting, EN |
|
||||
| `application-to-amend` | Antrag auf Patentänderung | Application to Amend the Patent | (UPC_INF, inf.app_to_amend), (UPC_REV, rev.app_to_amend) — UPC/EPC-native, EN |
|
||||
| `defence-to-application-to-amend` | Erwiderung auf den Antrag auf Patentänderung | Defence to Application to Amend | (UPC_INF, inf.def_to_amend), (UPC_REV, rev.def_to_amend) — UPC-native, EN |
|
||||
| `counterclaim-for-revocation` | Nichtigkeitswiderklage | Counterclaim for Revocation | (UPC_INF, inf.ccr_filing) — UPC-native, EN |
|
||||
| `counterclaim-for-infringement` | Verletzungswiderklage | Counterclaim for Infringement | (UPC_REV, rev.cc_inf) — UPC-native, EN |
|
||||
| `request-for-discretionary-review` | Antrag auf Ermessensüberprüfung | Request for Discretionary Review | (UPC_APP_ORDERS, app_ord.discretion) — UPC-native, EN |
|
||||
| `oral-hearing` | Mündliche Verhandlung | Oral Hearing | (every UPC tree, oral), (DE_INF, de_inf.termin), (DE_NULL, de_null.termin), (EPA_*, oral) — court-set, shared, EN |
|
||||
| `decision` | Entscheidung | Decision | court-set, shared, EN |
|
||||
| `nichtzulassungsbeschwerde` | Nichtzulassungsbeschwerde | Complaint Against Denial of Leave | (DE_INF_BGH, …) — DE-only, DE slug |
|
||||
| `versaeumnisurteil-einspruch` | Einspruch gegen Versäumnisurteil | Objection to Default Judgment | event_trigger only — ZPO §339 — DE-only, DE slug |
|
||||
| `hinweisbeschluss-stellungnahme` | Stellungnahme zum Hinweisbeschluss | Response to Court's Preliminary Opinion | (DE_NULL, …) — DE-only, DE slug |
|
||||
|
||||
≈ 30 concepts cover the entire seed corpus after Phase B coverage migrations (≈ +85 rules grouped into those 30 concepts).
|
||||
|
||||
### 4.5 Structured legal_source codes (Q7)
|
||||
|
||||
Canonical format: `<JURIS>.<CODE>.<§/Art./R>.<Para>[.<Sub>]`
|
||||
|
||||
| Source | Format | Example |
|
||||
|---|---|---|
|
||||
| UPC Rules of Procedure | `UPC.RoP.<rule>[.<para>][.<sub>]` | `UPC.RoP.23.1`, `UPC.RoP.29.a`, `UPC.RoP.220.1.c` |
|
||||
| UPC Agreement | `UPC.UPCA.<art>[.<para>]` | `UPC.UPCA.49.5` |
|
||||
| UPC Statute | `UPC.Statute.<art>` | `UPC.Statute.21` |
|
||||
| EPÜ (German abbrev.) | `EU.EPÜ.<art>[.<para>]` | `EU.EPÜ.108`, `EU.EPÜ.99.1`, `EU.EPÜ.122` |
|
||||
| EPC implementing rules | `EU.EPC-R.<rule>[.<para>][.<sub>]` | `EU.EPC-R.79.1`, `EU.EPC-R.116.1`, `EU.EPC-R.136` |
|
||||
| German civil procedure | `DE.ZPO.<§>[.<para>]` | `DE.ZPO.276.1`, `DE.ZPO.520.2`, `DE.ZPO.544.1` |
|
||||
| German patent law | `DE.PatG.<§>[.<para>]` | `DE.PatG.59.1`, `DE.PatG.82.1`, `DE.PatG.111.1` |
|
||||
| DPMA Verordnung | `DE.DPMAV.<§>` | `DE.DPMAV.5` |
|
||||
| RPBA Beschwerdeordnung | `EU.RPBA.<art>[.<para>]` | `EU.RPBA.12.1.c`, `EU.RPBA.13` |
|
||||
|
||||
**Why dot-separated rather than the human form `§82(1) PatG`?** Parseable. The first dot-segment is the juris (`UPC` / `EU` / `DE`); the second is the law name (`RoP` / `EPÜ` / `ZPO` / `PatG`). The filter dropdown can do `legal_source LIKE 'DE.PatG.%'` or `legal_source LIKE 'UPC.%'`. The display layer renders `UPC.RoP.23.1` as **"UPC RoP R.23(1)"** (a small format-converter on the frontend, mirroring what mbrian conventions do).
|
||||
|
||||
**The display form on the result-card pill is the human-rendered version.** Internal storage + index is the structured form. One field, two views.
|
||||
|
||||
**Why `EU.EPÜ` and not `DE.EPÜ`?** EPÜ is European, not German law (despite the German abbreviation). `EU` is the closest namespace; alternatives `EP` or `EPO` would also work. Open for m to override.
|
||||
|
||||
**Why `DE.PatG` and not `BPatG.PatG`?** PatG is national German patent law applicable across DPMA, BPatG, BGH. The forum (where the law is applied) varies; the source is the same. So `DE.PatG.110` is the BGH appeal rule, and the `proceeding_type_id` of the rule row tells us which forum.
|
||||
|
||||
### 4.6 The unified search view
|
||||
|
||||
```sql
|
||||
CREATE MATERIALIZED VIEW paliad.deadline_search AS
|
||||
-- One row per concept × context (so a card is a GROUP BY concept_id of these rows)
|
||||
SELECT
|
||||
'rule'::text AS kind,
|
||||
dc.id AS concept_id,
|
||||
dc.slug AS concept_slug,
|
||||
dc.name_de AS concept_name_de,
|
||||
dc.name_en AS concept_name_en,
|
||||
dc.description AS concept_description,
|
||||
dc.aliases AS concept_aliases,
|
||||
dc.party AS concept_party,
|
||||
dc.category AS concept_category,
|
||||
dr.id AS rule_id,
|
||||
pt.code AS proceeding_code,
|
||||
pt.name AS proceeding_name_de,
|
||||
pt.name_en AS proceeding_name_en,
|
||||
pt.jurisdiction AS jurisdiction,
|
||||
dr.code AS rule_local_code,
|
||||
dr.name AS rule_name_de,
|
||||
dr.name_en AS rule_name_en,
|
||||
dr.legal_source AS legal_source,
|
||||
dr.rule_code AS rule_code,
|
||||
dr.duration_value,
|
||||
dr.duration_unit,
|
||||
dr.timing,
|
||||
COALESCE(dr.primary_party, dc.party) AS effective_party
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
|
||||
WHERE dr.is_active AND pt.is_active AND pt.category = 'fristenrechner'
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Trigger events linked to a concept (court-set placeholders, cross-cutting Wiedereinsetzung, etc.)
|
||||
SELECT
|
||||
'trigger',
|
||||
dc.id, dc.slug, dc.name_de, dc.name_en, dc.description, dc.aliases, dc.party, dc.category,
|
||||
NULL::uuid, -- no rule_id (trigger lives in event_deadlines)
|
||||
NULL, -- no proceeding_code (trigger is cross-cutting)
|
||||
NULL, NULL,
|
||||
'cross-cutting', -- jurisdiction
|
||||
te.code, te.name_de, te.name,
|
||||
NULL, -- legal_source (will join from event_deadline_rule_codes when surfaced)
|
||||
NULL, -- rule_code
|
||||
NULL, NULL, NULL,
|
||||
dc.party
|
||||
FROM paliad.trigger_events te
|
||||
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
|
||||
WHERE te.is_active;
|
||||
|
||||
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
|
||||
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
|
||||
CREATE INDEX deadline_search_legal_src ON paliad.deadline_search (legal_source);
|
||||
CREATE INDEX deadline_search_legal_src_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
|
||||
CREATE INDEX deadline_search_aliases ON paliad.deadline_search USING gin (concept_aliases);
|
||||
```
|
||||
|
||||
`pg_trgm` already enabled (verified). Refresh:
|
||||
|
||||
```sql
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY paliad.deadline_search;
|
||||
```
|
||||
|
||||
Triggered by AFTER INSERT/UPDATE/DELETE on `deadline_rules` / `deadline_concepts` / `trigger_events` / `proceeding_types`. Mat-view remains sub-1k rows, so refresh is < 100 ms.
|
||||
|
||||
---
|
||||
|
||||
## 5. Coverage gaps to map (UPC + DE + EPA + DPMA)
|
||||
|
||||
Same legal substance as v1 §5, retargeted to the concept-layer shape. Citations from `data.laws_contents` (youpcdb, UPCRoP) for UPC; from m's authoritative knowledge (spot-checked) for DE / EPA / DPMA.
|
||||
|
||||
### 5.1 UPC counterclaim cross-flows (m's primary complaint)
|
||||
|
||||
#### UPC_INF with Counterclaim for Revocation (CCR)
|
||||
|
||||
R.29 verbatim from `data.laws_contents`:
|
||||
|
||||
| When | Who | What | Source code | Duration | Anchor |
|
||||
|---|---|---|---|---|---|
|
||||
| (with CCR) | Claimant | Defence to CCR + Reply to SoD + (opt) Application to amend | `UPC.RoP.29.a` | 2 months | service of SoD |
|
||||
| | Defendant | Defence to App to amend (when claimant filed amend) | `UPC.RoP.32.1` | 2 months | service of App to amend |
|
||||
| | Defendant | Reply to Defence to CCR + Rejoinder to Reply to SoD + (opt) Defence to App to amend | `UPC.RoP.29.d` | 2 months | service of Defence to CCR |
|
||||
| | Proprietor | Reply to Defence to App to amend | `UPC.RoP.32.3` | 1 month | service of Defence to App to amend |
|
||||
| | Claimant | Rejoinder + (opt) Reply to Defence to amend | `UPC.RoP.29.e` | 1 month | service of Reply to Defence to CCR |
|
||||
| | Defendant | Rejoinder on Reply to amend | `UPC.RoP.32.3` | 1 month | service of Reply to Defence to amend |
|
||||
|
||||
**New rules added to `UPC_INF` tree (new concepts in `deadline_concepts`):**
|
||||
|
||||
```
|
||||
inf.def_to_ccr Defence to Counterclaim for Revocation 2mo UPC.RoP.29.a parent=inf.sod party=claimant condition_flag={with_ccr}
|
||||
inf.app_to_amend Application to amend the patent 2mo UPC.RoP.30.1 parent=inf.sod party=claimant condition_flag={with_ccr,with_amend}
|
||||
inf.def_to_amend Defence to App to amend 2mo UPC.RoP.32.1 parent=inf.app_to_amend party=defendant condition_flag={with_ccr,with_amend}
|
||||
inf.reply_def_ccr Reply to Defence to CCR 2mo UPC.RoP.29.d parent=inf.def_to_ccr party=defendant condition_flag={with_ccr}
|
||||
inf.reply_def_amd Reply to Defence to amend 1mo UPC.RoP.32.3 parent=inf.def_to_amend party=claimant condition_flag={with_ccr,with_amend}
|
||||
inf.rejoin_reply_ccr Rejoinder on Reply to Defence to CCR 1mo UPC.RoP.29.e parent=inf.reply_def_ccr party=claimant condition_flag={with_ccr}
|
||||
inf.rejoin_amd Rejoinder on Reply to amend 1mo UPC.RoP.32.3 parent=inf.reply_def_amd party=defendant condition_flag={with_ccr,with_amend}
|
||||
```
|
||||
|
||||
**Concepts created:**
|
||||
- `defence-to-counterclaim-for-revocation`
|
||||
- `application-to-amend` (also referenced by UPC_REV, see below)
|
||||
- `defence-to-application-to-amend`
|
||||
- `reply-to-defence-to-counterclaim-for-revocation`
|
||||
- `reply-to-defence-to-application-to-amend`
|
||||
- `rejoinder-to-reply-to-defence-to-counterclaim-for-revocation`
|
||||
- `rejoinder-on-reply-to-amend`
|
||||
|
||||
**UI:** the existing single `with_ccr` checkbox keeps its label. A nested checkbox **"☐ Mit Antrag auf Patentänderung"** appears below it (only enabled when `with_ccr` is on, since R.30 application is only available with a CCR). Two checkbox states: ccr-only / ccr-with-amend.
|
||||
|
||||
#### UPC_REV with Application to Amend + Counterclaim for Infringement
|
||||
|
||||
R.49(2) Defence to revocation may include (a) Application to amend (R.55 = R.32 m.m.) and/or (b) Counterclaim for infringement (R.50, R.56 cycle).
|
||||
|
||||
| When | Who | What | Source code | Duration | Anchor |
|
||||
|---|---|---|---|---|---|
|
||||
| (with amend) | Defendant (proprietor) | Application to amend (within Defence) | `UPC.RoP.49.2.a` | 0 (filed with Defence) | service of SoR |
|
||||
| | Claimant | Defence to Application to amend | `UPC.RoP.43.3` (= R.32.1 m.m.) | 2 months | service of Application to amend |
|
||||
| | Defendant | Reply to Defence to amend | `UPC.RoP.32.3` | 1 month | service of Defence to amend |
|
||||
| | Claimant | Rejoinder on Reply to amend | `UPC.RoP.32.3` | 1 month | service of Reply to Defence to amend |
|
||||
| (with CCI) | Defendant (proprietor) | Counterclaim for infringement (within Defence) | `UPC.RoP.49.2.b` | 0 (with Defence) | service of SoR |
|
||||
| | Claimant | Defence to Counterclaim for infringement | `UPC.RoP.56.1` | 2 months | service of CCI |
|
||||
| | Defendant | Reply to Defence to CCI | `UPC.RoP.56.3` | 1 month | service of Defence to CCI |
|
||||
| | Claimant | Rejoinder on Reply on CCI | `UPC.RoP.56.4` | 1 month | service of Reply to Defence to CCI |
|
||||
|
||||
**New rules added to `UPC_REV` tree, with two parallel independent flag chains** (per Q3 / m's go-direction):
|
||||
|
||||
```
|
||||
rev.app_to_amend Application to amend 0 UPC.RoP.49.2.a parent=rev.defence party=defendant condition_flag={with_amend}
|
||||
rev.def_to_amend Defence to Application to amend 2mo UPC.RoP.43.3 parent=rev.app_to_amend party=claimant condition_flag={with_amend}
|
||||
rev.reply_def_amd Reply to Defence to amend 1mo UPC.RoP.32.3 parent=rev.def_to_amend party=defendant condition_flag={with_amend}
|
||||
rev.rejoin_amd Rejoinder on Reply to amend 1mo UPC.RoP.32.3 parent=rev.reply_def_amd party=claimant condition_flag={with_amend}
|
||||
rev.cc_inf Counterclaim for infringement 0 UPC.RoP.49.2.b parent=rev.defence party=defendant condition_flag={with_cci}
|
||||
rev.def_cci Defence to CCI 2mo UPC.RoP.56.1 parent=rev.cc_inf party=claimant condition_flag={with_cci}
|
||||
rev.reply_def_cci Reply to Defence to CCI 1mo UPC.RoP.56.3 parent=rev.def_cci party=defendant condition_flag={with_cci}
|
||||
rev.rejoin_cci Rejoinder on Reply on CCI 1mo UPC.RoP.56.4 parent=rev.reply_def_cci party=claimant condition_flag={with_cci}
|
||||
```
|
||||
|
||||
**UI:** UPC_REV gets two independent flags — **"☐ Mit Antrag auf Patentänderung"** and **"☐ Mit Verletzungswiderklage"**. Both can be on. They render parallel cycles that don't interact (no rule has `condition_flag={with_amend,with_cci}` — verified per R.49 + R.55 + R.56 reading).
|
||||
|
||||
#### Other UPC gaps remaining (lower priority — t-paliad-084 Tier 2/3 follow-ups)
|
||||
|
||||
Already-shipped per `031_tier2_fristenrechner_ports`: R.137.2 / R.139 (damages), R.151 / R.221.1 (cost-decision), R.220.2 / R.220.3 (leave-to-appeal), R.237 / R.238 (cross-appeal), R.142 (lay-open books). Verify nothing slipped before Phase B2 lands.
|
||||
|
||||
Cross-cutting (best as event_trigger only): R.16(3)(a) / R.27(2) / R.89(2) / R.207.6(a) / R.229(2) / R.253(2) (correction of deficiencies × 6), R.262(2) (confidentiality), R.197(3) / R.198 (evidence preservation), R.245(2)(a)/(b) (rehearing — needs compound trigger), R.321(3) (refer central division), R.353 (rectification).
|
||||
|
||||
#### UPC_APP grounds-anchor bug (open)
|
||||
|
||||
`app.grounds.parent_id = app.notice` — wrong per R.224(2)(a). Grounds is 4 months from **service of the decision**, not 2mo + 2mo from notice. Fix as part of Phase B1.
|
||||
|
||||
### 5.2 German national — PatG / ZPO procedural deadlines
|
||||
|
||||
#### 5.2.1 LG (1. Instanz) — `DE_INF`
|
||||
|
||||
| # | Trigger | Source code | Duration | Anchor | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Klageerhebung | (anchor) | 0 | trigger date | EXISTS |
|
||||
| 2 | Anzeige der Verteidigungsbereitschaft | `DE.ZPO.276.1` | 2 weeks | service of Klage | **GAP** |
|
||||
| 3 | Klageerwiderung (schriftliches Vorverfahren) | `DE.ZPO.276.1` | 6 weeks (court-set) | service of Klage | EXISTS (`de_inf.erwidg`) |
|
||||
| 4 | Replik | `DE.ZPO.282` | court-set, ~4 weeks | service of Klageerwiderung | EXISTS (`de_inf.replik`) |
|
||||
| 5 | Duplik | `DE.ZPO.282` | court-set, ~4 weeks | service of Replik | EXISTS (`de_inf.duplik`) |
|
||||
| 6 | Schriftsatznachreichung | `DE.ZPO.296a` | court-set | end of mündl. Verhandlung | **GAP** |
|
||||
| 7 | Haupttermin | (court event) | court-set | — | EXISTS |
|
||||
| 8 | Urteil | (court event) | court-set | — | EXISTS |
|
||||
| 9 | Einspruch gegen Versäumnisurteil | `DE.ZPO.339` | 2 weeks | service of Versäumnisurteil | **GAP** |
|
||||
| 10 | Berufungsfrist | `DE.ZPO.517` | 1 month | service of Urteil | EXISTS |
|
||||
| 11 | Berufungsbegründung | `DE.ZPO.520.2` | 2 months | service of Urteil (NOT from Berufungsschrift) | EXISTS — verify anchor |
|
||||
| 12 | Berufungserwiderung | `DE.ZPO.521.2` | court-set, ~4 weeks | service of Berufungsbegründung | **GAP** |
|
||||
| 13 | Anschlussberufung | `DE.ZPO.524.2` | until expiry of §521 deadline | (event) | **GAP** |
|
||||
|
||||
**New proceeding types needed:** `DE_INF_OLG` (OLG Berufung), `DE_INF_BGH` (BGH NZB / Revision).
|
||||
|
||||
| # | Trigger | Source code | Duration | Anchor |
|
||||
|---|---|---|---|---|
|
||||
| `DE_INF_OLG` | Berufungsschrift | `DE.ZPO.519` | 1 month from Urteil | (entry trigger) |
|
||||
| | Berufungsbegründung | `DE.ZPO.520.2` | 2 months | service of Urteil |
|
||||
| | Berufungserwiderung | `DE.ZPO.521.2` | court-set | service of Begründung |
|
||||
| | Anschlussberufung | `DE.ZPO.524.2` | until §521 expiry | event |
|
||||
| | Mündliche Verhandlung | (court) | — | — |
|
||||
| | Urteil OLG | (court) | — | — |
|
||||
| `DE_INF_BGH` | Nichtzulassungsbeschwerde | `DE.ZPO.544.1` | 1 month | service of OLG-Urteil |
|
||||
| | NZB-Begründung | `DE.ZPO.544.4` | 2 months | service of OLG-Urteil |
|
||||
| | Revisionsfrist | `DE.ZPO.548` | 1 month | service of OLG-Urteil |
|
||||
| | Revisionsbegründung | `DE.ZPO.551.2` | 2 months | service of OLG-Urteil |
|
||||
| | Revisionserwiderung | `DE.ZPO.554` | court-set | service of Revisionsbegründung |
|
||||
|
||||
#### 5.2.2 BPatG (Nichtigkeit) — `DE_NULL`
|
||||
|
||||
| # | Trigger | Source code | Duration | Anchor | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Nichtigkeitsklage | (anchor) | 0 | trigger date | EXISTS |
|
||||
| 2 | Klageerwiderung | `DE.PatG.82.1` | 2 months (= 1mo base + 1mo typische richterliche Verlängerung; user can override exact date inline per Q4) | service of Klage | EXISTS as 2mo — keep |
|
||||
| 3 | Replik | `DE.PatG.83.2` | court-set, ~2 months | service of Erwiderung | **GAP** |
|
||||
| 4 | Hinweisbeschluss | `DE.PatG.83.1` | court-issued (~6 mo before mündl. Verhandlung) | (court event) | **GAP** |
|
||||
| 5 | Stellungnahme zum Hinweis | `DE.PatG.83.2` | court-set, ~3 months | service of Hinweisbeschluss | **GAP** |
|
||||
| 6 | Duplik | `DE.PatG.83.2` | court-set | service of Replik | **GAP** |
|
||||
| 7 | Mündliche Verhandlung | (court) | — | — | EXISTS |
|
||||
| 8 | Urteil | (court) | — | — | EXISTS |
|
||||
|
||||
**New proceeding type:** `DE_NULL_BGH` (Berufung BGH).
|
||||
|
||||
| # | Trigger | Source code | Duration | Anchor | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| 9 | Berufungsfrist | `DE.PatG.110.1` | 1 month | service of Urteil | EXISTS (currently in DE_NULL) |
|
||||
| 10 | Berufungsbegründung | `DE.PatG.111.1` | **3 months** (currently seeded as 1mo — likely bug) | service of Urteil | **EXISTS but wrong** |
|
||||
| 11 | Berufungserwiderung | `DE.PatG.111.3` (verweist auf ZPO §521) | 2 months | service of Begründung | **GAP** |
|
||||
|
||||
**Bug to fix as part of Phase B3:** `de_null.beruf_begr` 1 month → 3 months per current PatG §111(1).
|
||||
|
||||
#### 5.2.3 DPMA — new proceeding types
|
||||
|
||||
`DPMA_OPP` (Einspruch DPMA), `DPMA_BPATG_BESCHWERDE`, `DPMA_BGH_RB`. Currently 0 rules.
|
||||
|
||||
| Trigger | Source code | Duration | Anchor |
|
||||
|---|---|---|---|
|
||||
| Einspruchsfrist DPMA | `DE.PatG.59.1` | 9 months | publication of grant |
|
||||
| Erwiderung Patentinhaber | `DE.PatG.59.3` (court-set) | typical 4 months | service of Einspruchsschriftsatz |
|
||||
| Beschwerde BPatG | `DE.PatG.73.2` | 1 month | service of DPMA-Entscheidung |
|
||||
| Beschwerdebegründung BPatG | `DE.PatG.75.1` | 1 month (often extended +1) | service of DPMA-Entscheidung |
|
||||
| Rechtsbeschwerde BGH | `DE.PatG.100` | 1 month | service of BPatG-Entscheidung |
|
||||
| Begründung Rechtsbeschwerde | `DE.PatG.102.3` (verweist auf ZPO §551) | 1 month | service of BPatG-Entscheidung |
|
||||
|
||||
#### 5.2.4 ZPO cross-cutting deadlines — event-trigger-only
|
||||
|
||||
Per Q8 (forum dropped) and concept-card UX, these become *cross-cutting concepts* (no proceeding-type pill, only a "Was kommt nach…" pill on the card):
|
||||
|
||||
- `wiedereinsetzung` — `DE.PatG.123.2` (2 months from Wegfall, max 12 months from Fristablauf), `DE.ZPO.233` (2 weeks from Wegfall — different!), `EU.EPÜ.122` + `EU.EPC-R.136` (2 months / 12 months), DPMA equivalent.
|
||||
- `versaeumnisurteil-einspruch` — `DE.ZPO.339` (2 weeks).
|
||||
- `schriftsatznachreichung` — `DE.ZPO.296a` (court-set, typical 2-3 weeks).
|
||||
- `mahnverfahren-widerspruch` — `DE.ZPO.345` (2 weeks).
|
||||
|
||||
### 5.3 EPA — EPÜ + RPBA gaps
|
||||
|
||||
**EP_GRANT** — additions:
|
||||
- `weiterbehandlung` — `EU.EPÜ.121` + `EU.EPC-R.135` (2 months from loss-of-rights notice).
|
||||
- `wiedereinsetzung` — `EU.EPÜ.122` + `EU.EPC-R.136` (2 months / max 12 months).
|
||||
- `teilanmeldung` — `EU.EPÜ.76` + `EU.EPC-R.36.1` (until end of pending parent — anchor is grant date - 1).
|
||||
- `pruefbescheid-erwiderung` — court-set, typical 4–6 months.
|
||||
- `validierungsfrist-national` — `EU.EPÜ.65` + national IPÜG (3 months from publication of grant B1).
|
||||
|
||||
**EPA_OPP** — additions:
|
||||
- `einspruch-stellungnahmen-weitere` — `EU.EPC-R.79.2`/`EU.EPC-R.79.3` (court-set).
|
||||
- `mvor-eingaben-r116` — `EU.EPC-R.116.1` (1 month before oral proceedings, court-set).
|
||||
- `wiedereinsetzung` (cross-cutting concept).
|
||||
- `weiterbehandlung` (cross-cutting concept).
|
||||
|
||||
**EPA_APP** — additions:
|
||||
- `beschwerdeerwiderung` — `EU.RPBA.12.1.c` (4 months from service of grounds).
|
||||
- `eingaben-vor-mvh` — `EU.EPC-R.116.1` + `EU.RPBA.13` (1 month before oral, court-set).
|
||||
- `antrag-auf-ueberpruefung` — `EU.EPÜ.112a` (2 months from service of decision).
|
||||
|
||||
### 5.4 Coverage delta after migration
|
||||
|
||||
| Forum | Today | After migration | Net new |
|
||||
|---|---|---|---|
|
||||
| UPC trees | 8 trees / 39 rules | 8 trees / ~62 rules | +23 (counterclaim cross-flows) |
|
||||
| DE trees | 2 trees / 13 rules | 5 trees / ~43 rules | +30 (OLG, BGH-Rev, BGH-NZB, Hinweisbeschluss, DPMA, BPatG-Beschwerde) |
|
||||
| EPA trees | 3 trees / 18 rules | 3 trees / ~35 rules | +17 (R.116, R.79.2/3, R.106, Wiedereinsetzung, Weiterbehandlung) |
|
||||
| Cross-cutting concepts (event-trigger) | 102 triggers / 70 deadlines (UPC only) | +20 triggers (Wiedereinsetzung × 4, Versäumnis, Schriftsatzfristen) | +20 |
|
||||
| Concepts | 0 (none today) | ~30 | +30 (the new layer) |
|
||||
|
||||
≈ **+90 new rules / triggers** + **30 concepts**.
|
||||
|
||||
---
|
||||
|
||||
## 6. Search & filter — backend mechanics
|
||||
|
||||
### 6.1 The single API endpoint
|
||||
|
||||
```
|
||||
GET /api/tools/fristenrechner/search?q=<phrase>
|
||||
&party=<claimant|defendant|both|court>
|
||||
&proc=<proceeding_code>
|
||||
&source=<UPC|EU|DE|DE.PatG|DE.ZPO|UPC.RoP|EU.EPÜ|...>
|
||||
&limit=<int, default 12, max 30>
|
||||
|
||||
Response 200:
|
||||
{
|
||||
"query": "klageerwiderung",
|
||||
"filters": {"party": null, "proc": null, "source": null},
|
||||
"cards": [
|
||||
{
|
||||
"concept": {
|
||||
"id": "<uuid>",
|
||||
"slug": "klageerwiderung",
|
||||
"name_de": "Klageerwiderung",
|
||||
"name_en": "Statement of Defence",
|
||||
"description": "Erwiderung des Beklagten auf eine Klageschrift, üblicherweise mit Verteidigungsanträgen und Sachvortrag.",
|
||||
"party": "defendant",
|
||||
"category": "submission"
|
||||
},
|
||||
"matched_aliases": ["Statement of Defence", "Erwiderung Klage"],
|
||||
"score": 0.96,
|
||||
"pills": [
|
||||
{
|
||||
"kind": "rule",
|
||||
"rule_id": "<uuid>",
|
||||
"proceeding": {"code": "UPC_INF", "name_de": "Verletzungsverfahren", "jurisdiction": "UPC"},
|
||||
"rule_local_code": "inf.sod",
|
||||
"legal_source": "UPC.RoP.23.1",
|
||||
"legal_source_display": "UPC RoP R.23(1)",
|
||||
"duration": {"value": 3, "unit": "months", "alt": null},
|
||||
"party": "defendant",
|
||||
"drill_url": "/tools/fristenrechner?proc=UPC_INF&focus=inf.sod"
|
||||
},
|
||||
{
|
||||
"kind": "rule",
|
||||
"rule_id": "<uuid>",
|
||||
"proceeding": {"code": "DE_INF", "name_de": "Verletzungsklage (LG)", "jurisdiction": "DE"},
|
||||
"rule_local_code": "de_inf.erwidg",
|
||||
"legal_source": "DE.ZPO.276.1",
|
||||
"legal_source_display": "ZPO §276(1)",
|
||||
"duration": {"value": 6, "unit": "weeks"},
|
||||
"party": "defendant",
|
||||
"drill_url": "/tools/fristenrechner?proc=DE_INF&focus=de_inf.erwidg"
|
||||
},
|
||||
// … BPatG, EPA, DPMA pills
|
||||
]
|
||||
},
|
||||
// … other cards (e.g. "Klageerwiderung mit Nichtigkeitswiderklage" trigger event)
|
||||
],
|
||||
"total_cards": 3,
|
||||
"total_pills": 7
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Ranking
|
||||
|
||||
```sql
|
||||
WITH hits AS (
|
||||
SELECT
|
||||
s.concept_id,
|
||||
bool_or( -- alias hit anywhere?
|
||||
s.concept_aliases @> ARRAY[lower($q)]
|
||||
OR EXISTS (SELECT 1 FROM unnest(s.concept_aliases) a WHERE similarity(a, $q) > 0.4)
|
||||
) AS alias_hit,
|
||||
GREATEST(
|
||||
max(similarity(s.concept_name_de, $q)) * 1.0,
|
||||
max(similarity(s.concept_name_en, $q)) * 1.0,
|
||||
max(similarity(s.legal_source, $q)) * 0.9,
|
||||
max(similarity(s.rule_code, $q)) * 0.9,
|
||||
max(similarity(s.rule_name_de, $q)) * 0.7,
|
||||
max(similarity(s.rule_name_en, $q)) * 0.7
|
||||
) AS field_score
|
||||
FROM paliad.deadline_search s
|
||||
WHERE (
|
||||
s.concept_name_de % $q
|
||||
OR s.concept_name_en % $q
|
||||
OR s.rule_name_de % $q
|
||||
OR s.rule_name_en % $q
|
||||
OR s.legal_source % $q
|
||||
OR s.rule_code % $q
|
||||
OR s.concept_aliases @> ARRAY[lower($q)]
|
||||
OR EXISTS (SELECT 1 FROM unnest(s.concept_aliases) a WHERE a % $q)
|
||||
)
|
||||
AND ($party IS NULL OR s.effective_party = $party)
|
||||
AND ($proc IS NULL OR s.proceeding_code = $proc)
|
||||
AND ($source IS NULL OR s.legal_source LIKE $source || '%')
|
||||
GROUP BY s.concept_id
|
||||
)
|
||||
SELECT h.concept_id,
|
||||
(h.field_score + CASE WHEN h.alias_hit THEN 0.2 ELSE 0 END) AS score
|
||||
FROM hits h
|
||||
ORDER BY score DESC
|
||||
LIMIT $limit;
|
||||
```
|
||||
|
||||
Then for each returned `concept_id` the API does a second, scoped query to fetch all pills for that concept (the JSON shape above). Two queries per request (one for ranked concept ids + one for pills), both indexed.
|
||||
|
||||
Tie-break: sort by `concept.sort_order` then alphabetical.
|
||||
|
||||
### 6.3 Why mat-view + pg_trgm rather than ES / Meilisearch / Postgres FTS
|
||||
|
||||
Same answer as v1: corpus < 1k rows, no new infra, `pg_trgm` already enabled, no tokeniser tax for legal text. The ONE nuance for v2: cards are concept-grouped, so the search query has a GROUP BY rather than a flat ranking. Postgres handles this comfortably at this scale.
|
||||
|
||||
### 6.4 i18n of search
|
||||
|
||||
Indexes both `concept_name_de` and `concept_name_en` plus `rule_name_de`/`rule_name_en` — a search hit anywhere routes to the right concept. The result card displays the concept name in active locale. Pills always show the structured `legal_source` (locale-independent code) plus the display form (locale-rendered: `§276(1) ZPO` in DE, `§276(1) ZPO` or `Section 276(1) ZPO` in EN — German law sections stay in German per HLC convention).
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration path — phases A through D
|
||||
|
||||
Per m's "augment, not replace" — Phase E (subsumption) from v1 is dropped.
|
||||
|
||||
### Phase A — Concept layer + structural additions (purely additive)
|
||||
|
||||
- Migration A1: create `paliad.deadline_concepts` + indexes.
|
||||
- Migration A2: add `concept_id` FK on `deadline_rules`, `legal_source` text on `deadline_rules` and `event_deadlines`, `concept_id` slug-text on `trigger_events`.
|
||||
- Migration A3: change `deadline_rules.condition_flag` from `text` to `text[]` (Q3); update existing rows. The `Calculate` function gains a small loop change: instead of `if rule.condition_flag matches one flag in flagSet` it becomes `if rule.condition_flag is empty OR all elements of rule.condition_flag are in flagSet`.
|
||||
- Migration A4: backfill — seed the ~30 concepts; UPDATE `deadline_rules` SET `concept_id = …` per row; backfill `legal_source` from existing rule_code mapping (algorithm: `'RoP 23'` → `'UPC.RoP.23'`, `'§ 276 ZPO'` → `'DE.ZPO.276'`, `'Art. 108 EPÜ'` → `'EU.EPÜ.108'`, etc. — direct seed, no runtime regex).
|
||||
- Code A5: extend `CalcOptions` with `AnchorOverrides map[string]string` (rule_code → YYYY-MM-DD). The tree-walk in `Calculate` checks `AnchorOverrides[parent.code]` before reading the `computed[parent.code]` map; if present, the override anchors the child. No DB schema change — purely calculator-side. Enables the user-set-custom-date capability per Q4 (m's 23:36 simplification) and reuses for any case where the user knows a real date better than the calculator's projection (court extensions, court-set decisions, post-hoc corrections).
|
||||
- Code A6: per-row editable date affordance on the result UI. Each rule row's date display becomes click-to-edit (or has a small ✏ icon); editing fires a re-fetch with the override added to the request. Court-set placeholder rows (`IsCourtSet=true`) get the same treatment — user enters the actual decision date once known, downstream reflows.
|
||||
- No user-visible behaviour change in A1–A5 *for existing rules* — A6's editable affordance is the only UI delta. Existing rules without overrides compute identically.
|
||||
|
||||
### Phase B — Coverage migrations (the bulk of new content)
|
||||
|
||||
- B1: UPC counterclaim cross-flows on `UPC_INF` and `UPC_REV` (§5.1) with the new condition_flag arrays.
|
||||
- B2: any remaining UPC rules from Tier 2/3 of t-paliad-084 audit (most already in 031; verify before B2 lands).
|
||||
- B3: DE_INF/DE_NULL fixes (PatG §111 1mo→3mo) + new proceeding types DE_INF_OLG, DE_INF_BGH, DE_NULL_BGH; add Hinweisbeschluss-Cycle.
|
||||
- B4: DPMA — DPMA_OPP, DPMA_BPATG_BESCHWERDE, DPMA_BGH_RB.
|
||||
- B5: EPA — fill EPA_OPP / EPA_APP / EP_GRANT gaps (R.116, R.79.2/3, R.106, Wiedereinsetzung, Weiterbehandlung, Validierungsfristen).
|
||||
- B6: cross-cutting concept-only rows — Wiedereinsetzung (4 contexts), Versäumnisurteil-Einspruch, Schriftsatznachreichung.
|
||||
|
||||
Each B-migration is independently shippable; B2-B6 have no ordering dependency (different proceeding types).
|
||||
|
||||
### Phase C — Search backend
|
||||
|
||||
- Mat-view + indexes per §4.6.
|
||||
- New `DeadlineSearchService` (or method on existing `FristenrechnerService`).
|
||||
- New handler `GET /api/tools/fristenrechner/search`.
|
||||
- Tests: golden table with ~15 well-known queries → expected ranked concept cards + pill counts.
|
||||
|
||||
### Phase D — Search-bar + concept-card UI
|
||||
|
||||
- Add search input to top of `frontend/src/fristenrechner.tsx`.
|
||||
- New client module `frontend/src/client/fristenrechner-search.ts` — debounce, fetch, render concept cards with pills.
|
||||
- Drill-in: pill click → `?proc=...&focus=...` URL change → calculator opens with proceeding pre-selected, scrolling to focused rule.
|
||||
- Quick-pick chips above the proceeding tile grid.
|
||||
- "Vollständige Instanzenkette anzeigen" checkbox below the tile grid.
|
||||
- URL state for shareable searches.
|
||||
- The proceeding tile grid stays in place below the search bar (per "augment not replace").
|
||||
|
||||
### Out of scope — separate task
|
||||
|
||||
**The columns-view sequence-preservation fix** (Q9): undated court-set events currently collapse into one row in the t-paliad-129 columns view. They have an inherent sequence (Counterclaim → Defence → Reply → Decision per `sequence_order`). This is a t-paliad-129 follow-up — the rule data carries `sequence_order` already, and the columns-view renderer (`frontend/src/client/fristenrechner.ts`) just needs to use it for vertical positioning even when a date is missing. **Recommend: separate task t-paliad-132 (or similar) to file after this design lands.** Out of scope for the unified-Fristenrechner core work.
|
||||
|
||||
### Full Appeal Chain — implementation note (Q5 locked)
|
||||
|
||||
The toggle is a render option; no new "DE_INF_FULL_CHAIN" proceeding type in the data. The frontend has the proceeding-chain mapping baked in:
|
||||
|
||||
```ts
|
||||
const APPEAL_CHAINS: Record<string, string[]> = {
|
||||
DE_INF: ["DE_INF", "DE_INF_OLG", "DE_INF_BGH"],
|
||||
DE_NULL: ["DE_NULL", "DE_NULL_BGH"],
|
||||
DPMA_OPP: ["DPMA_OPP", "DPMA_BPATG_BESCHWERDE", "DPMA_BGH_RB"],
|
||||
EPA_OPP: ["EPA_OPP", "EPA_APP"],
|
||||
UPC_INF: ["UPC_INF", "UPC_APP"],
|
||||
UPC_REV: ["UPC_REV", "UPC_APP"],
|
||||
};
|
||||
```
|
||||
|
||||
**Anchor handoff (Q5):** the calculator does NOT guess inter-stage gaps. Instead, when the toggle is on the UI renders **one date input per stage anchor + one date input per terminal decision in the chain**:
|
||||
|
||||
```
|
||||
Vollständige Instanzenkette: Verletzungsklage (LG → OLG → BGH)
|
||||
|
||||
Stage 1 — LG:
|
||||
Klageerhebung am: [ 2026-05-01 ] (required)
|
||||
Urteil LG am: [ ___________ ] (optional — required for stage 2)
|
||||
|
||||
Stage 2 — OLG (Berufung):
|
||||
Urteil OLG am: [ ___________ ] (optional — required for stage 3)
|
||||
|
||||
Stage 3 — BGH (Revision/NZB):
|
||||
Urteil BGH am: [ ___________ ] (n/a — terminal)
|
||||
|
||||
[ Berechnen ]
|
||||
```
|
||||
|
||||
If Stage 1's Urteil date is missing, all Stage 2 / Stage 3 deadlines render as IsCourtSet placeholders (same semantic as the existing court-determined-rule path). The user fills in Stage 2's anchor when the LG decision lands. This keeps the calculator honest — no fabricated "+18 months" between instances.
|
||||
|
||||
Render concatenates the per-stage timelines with section headers ("LG", "OLG", "BGH"). Each stage's Calculate call is independent.
|
||||
|
||||
---
|
||||
|
||||
## 8. Test plan
|
||||
|
||||
- **Phase A:** schema migrations only; round-trip up/down; verify `condition_flag` text[] cast preserves existing semantics (single-element array still triggers correctly).
|
||||
- **Phase B1 (counterclaim):** unit tests on `FristenrechnerService.Calculate` for every (with_ccr × with_amend × with_cci) combination per UPC_INF and UPC_REV; assert each new rule appears with the exact dates from a hand-computed example.
|
||||
- **Phase B3–B6:** golden-date tables — for each new trigger, hard-coded trigger date + expected computed deadline. Pinned against m's spot-check.
|
||||
- **Phase C:** mat-view query tests — search "Klageerwiderung" returns one concept card with N pills; "RoP 23" returns the UPC card with the R.23 pill; "§ 82" returns the BPatG card; "Wiedereinsetzung" returns one concept with cross-context pills (PatG §123, ZPO §233, EPÜ Art.122, DPMA §123).
|
||||
- **Phase D:** Playwright smoke — type "klageerwiderung", click first pill, verify proceeding-tree calculator opens with right tree + focused rule highlighted; refresh URL `?q=klageerwiderung` restores card list.
|
||||
|
||||
---
|
||||
|
||||
## 9. Risks & mitigations
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|---|---|---|
|
||||
| Backfilling 90+ legal entries — easy to mistype duration / anchor | High | Each B-migration ships one proceeding family; m spot-checks 2-3 rules each; golden-date tests pin every rule |
|
||||
| Concept slugs drift between migrations | Medium | One canonical slug list maintained at top of `internal/seed/concept_slugs.go` (or similar); every migration references it |
|
||||
| Mat-view refresh staleness | Low | AFTER triggers refresh CONCURRENTLY (corpus < 1k rows, < 100 ms) |
|
||||
| Trigram threshold tuning | Medium | Tune via golden-query test set; per-column threshold if needed |
|
||||
| `condition_flag text → text[]` migration breaks calculator | Low (calculator change is small) | Test with a manual round-trip before merging A3 |
|
||||
| Two backends staying separate = double-maintenance for new deadlines | Low | Acceptable; the concept layer is the unifier without forcing schema merge |
|
||||
| `legal_source` format drift between v1 internal `rule_code` and new structured form | Medium | Phase A normalises both: `rule_code` keeps `RoP.029.b` (period-before-letter) shape; `legal_source` uses the new `UPC.RoP.29.b` shape. Map is 1:1 algorithmic. |
|
||||
| Aliases hand-curation misses search terms | Low | Spot-check during seed; firm-vocabulary edit-window during early access |
|
||||
| "Full Appeal Chain" — anchor handoff between trees gets wrong | Medium | Per-tree user override of stage anchors as the safety hatch (user can enter the Urteil OLG date directly even when chain mode is on) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of scope (deliberate)
|
||||
|
||||
- **AI-driven Frist-Extraktion** from court PDFs (Phase H, deferred).
|
||||
- **Per-user / firm-level aliases.** v1 is curated-only.
|
||||
- **Time-versioned `legal_source`.** Single-snapshot of current law.
|
||||
- **Cross-jurisdiction equivalence claims** as data ("§82 PatG ≈ R.23 UPC"). Search returns both; data does not assert equivalence beyond shared `concept_id`.
|
||||
- **Linking out to law text** from `legal_source` (mbrian / youpc.org deeplinks). Future iteration.
|
||||
- **Internal KanzlAI proceeding types** (INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL category='litigation'). Matter-attached; not in this scope.
|
||||
- **Forum filter** (Q8 — m dropped).
|
||||
- **Tab subsumption** (Q1 — m dropped; tabs stay).
|
||||
- **Columns-view sequence preservation** (Q9 — separate task).
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions — m's answers (locked)
|
||||
|
||||
All v2 open questions resolved by m on 2026-05-04 23:29. Recorded here as the binding spec for the coder shift:
|
||||
|
||||
1. **Concept slug naming convention — mixed.** Use **EN slugs** for concepts that exist primarily in UPC / EPC contexts (`application-to-amend`, `request-for-discretionary-review`, `notice-of-appeal-upc`). Use **DE slugs** for concepts that only exist in German law (`nichtzulassungsbeschwerde`, `versaeumnisurteil-einspruch`, `hinweisbeschluss-stellungnahme`). For **shared concepts that exist in both DE and UPC/EPC** (e.g. Klageerwiderung exists in ZPO §276, PatG §82, UPC R.23, EPA R.79): use the **DE slug** because (a) m works primarily in German, (b) the slug is internal/maintenance-facing only, (c) `name_de` and `name_en` columns carry both labels for the user-facing surface, and (d) it sidesteps EN-translation arguments ("Defence" vs "Statement of Defence" vs "Reply to Application"). Resulting slug examples: `klageerwiderung` (shared, DE wins), `replik` (shared, DE wins), `berufungsfrist` (shared, DE wins), `application-to-amend` (UPC/EPC native, EN), `wiedereinsetzung` (shared cross-cutting, DE wins because the German name dominates HLC vocabulary).
|
||||
|
||||
2. **`legal_source` namespace — `EU.` for EPÜ.** Confirmed. Format stays `EU.EPÜ.108`, `EU.EPC-R.79.1`, `EU.RPBA.12.1.c`.
|
||||
|
||||
3. **DE_NULL Berufungsbegründung 1 → 3 months — confirmed.** Ship the fix as part of Phase B3. Test pin: `de_null.beruf_begr` 1mo → 3mo, `legal_source = 'DE.PatG.111.1'`.
|
||||
|
||||
4. **PatG §82(1) — keep simple seed; user overrides the date inline.** m's revised direction (23:36): drop the customizable-extension flag mechanism — *"sounds complicated, I just want to be able to set a custom date and following deadlines calculate from there."*
|
||||
|
||||
Generalised capability instead: **any computed deadline date in the result is user-overridable**, and downstream rules that chain off it re-compute from the override. So PatG §82's "1 month + court extension to 5 weeks" case is handled by the user typing the actual extended date into the result row, and Replik / Duplik re-flow off it.
|
||||
|
||||
PatG §82(1) seed stays at 2 months (the practical typical) with `deadline_notes` "1 Monat Grundfrist + bis +1 Monat richterliche Verlängerung typisch". No `with_extension` flag, no `flag_param` mechanism. Cleaner for everyone.
|
||||
|
||||
**Calculator change for the override capability:** `CalcOptions` gains an `AnchorOverrides map[string]string` field (rule_code → YYYY-MM-DD). The tree-walk loop in `Calculate` checks `AnchorOverrides[parent.code]` before reading `computed[parent.code]` — if present, that override anchors the child rule.
|
||||
|
||||
**UI change for the override capability:** each result row's date display becomes click-to-edit (or has a small ✏ icon). Editing fires a re-fetch with the override added to the request. The court-set placeholder rows (existing `IsCourtSet=true` rendering) get the same treatment — the user can type the actual decision date once it's known, and downstream deadlines reflow.
|
||||
|
||||
Implementation cost: small. CalcOptions field + 5-line lookup in the tree walk + per-row edit affordance in the timeline / columns view.
|
||||
|
||||
5. **Full Appeal Chain — multiple date inputs, decisions need a date.** Confirmed shape (b). When toggle is on, the UI renders **one date input per stage** plus required date inputs for each terminal decision in the chain (LG Urteil, OLG Urteil, BGH Urteil). The intra-stage deadlines compute off the relevant stage anchor; inter-stage handoff is user-entered, never guessed. If the user hasn't yet got a stage's terminal decision date, that stage's downstream deadlines render as IsCourtSet placeholders — same semantic as the existing isCourtDeterminedRule path. Worth noting: this means the Full Appeal Chain isn't a single "calculate-from-one-date" tool; it's a multi-stage timeline view that the user fills in as the case progresses.
|
||||
|
||||
6. **Concept description copy — drafted in seed migration, reviewed by m or colleague.** Confirmed. ~30 concepts × 1-2 sentences each; PR-1 will include them; m or a colleague reviews on the PR.
|
||||
|
||||
7. **Concept-level `party` = dominant case, per-rule overrides.** Confirmed. Pill displays the per-rule value (e.g. "Patentinhaber" on the EPA_OPP pill of the Klageerwiderung concept, even though concept-level party is "Beklagte" because that's the dominant case).
|
||||
|
||||
8. **Quick-pick chip seed — 8 chips approved.** Klageerwiderung · Berufung · Einspruch · Replik · Beschwerde · Statement of Defence · Schadensbemessung · Wiedereinsetzung. Hot-tunable in a follow-up later if telemetry shows different chip preferences.
|
||||
|
||||
**Net effect on coder shift scope:** Phase A4 picks up the `flag_param` calculator extension (small code change) so PatG §82's customizable-extension shape works when Phase B3 lands. Otherwise the coder spec is unchanged from §7 above.
|
||||
|
||||
---
|
||||
|
||||
## 12. Proposed cycle
|
||||
|
||||
- **Inventor (this shift, cronus, branch `mai/cronus/unified-fristenrechner-design`):** v2 design doc + go/no-go gate (this commit).
|
||||
- **Coder (next shift, after m's go-ahead):**
|
||||
- PR-1 = Phase A1+A2+A3+A4 (concept layer + structural additions + backfill). Smallest first; verifies the concept slug list and the `condition_flag` array migration in isolation.
|
||||
- PR-2 = Phase B1 (UPC counterclaim cross-flows). Closes m's primary complaint.
|
||||
- **Coder (third shift):** B3 + B4 + B5 + B6 (DE + DPMA + EPA + cross-cutting). Each as a separate PR for spot-checkability, parallelisable.
|
||||
- **Coder (fourth shift):** Phase C + D (search infra + UI).
|
||||
- **Separately filed:** t-paliad-132 (or similar) — columns-view sequence preservation per Q9.
|
||||
|
||||
I'd recommend **curie** as implementer (port-heavy, careful seed work — same shape as t-paliad-084 / t-paliad-086 audit + import sequence). Alternative for B1 only: **fritz** (bug-fix shape). Head decides.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Files & references
|
||||
|
||||
**Code paths read:**
|
||||
- `internal/services/fristenrechner.go` (proceeding-tree calculator, CalcOptions extension, `isCourtDeterminedRule` helper)
|
||||
- `internal/services/event_deadline_service.go` (trigger-event calculator, composite-rule resolver, working_days unit)
|
||||
- `internal/services/deadline_rule_service.go` (CRUD over `paliad.deadline_rules`)
|
||||
- `internal/services/holidays.go` (DB-driven holidays + UPC vacation gating from t-paliad-121)
|
||||
- `frontend/src/fristenrechner.tsx` (current 252-line UI shell — proceeding tile grid + 2-tab shell)
|
||||
- `frontend/src/client/fristenrechner.ts` (1031-line client, both modes)
|
||||
- migrations 012 / 028 / 029 / 030 / 031 / 033 / 034 / 035 / 036
|
||||
|
||||
**DB read:**
|
||||
- `paliad.proceeding_types` (19 rows, 12 fristenrechner-category)
|
||||
- `paliad.deadline_rules` (74 rules in fristenrechner trees, 96 total including KanzlAI)
|
||||
- `paliad.trigger_events` (102) + `paliad.event_deadlines` (70) + `paliad.event_deadline_rule_codes` (72, 64 distinct UPC RoP codes)
|
||||
- `data.laws_contents` (UPCRoP/UPCA/UPCS texts in EN — used for §5.1 verbatim quotations)
|
||||
|
||||
**Prior work:**
|
||||
- `docs/audit-fristenrechner-completeness-2026-04-30.md` (curie's t-paliad-084 audit — §5 builds on it)
|
||||
- t-paliad-086 (3-PR shipped: trigger-event Fristenrechner + composite rules + Tier 1 fixes)
|
||||
- t-paliad-101 / t-paliad-111 / t-paliad-112 (QA bug bundles)
|
||||
- t-paliad-121 (UPC vacation no-shift)
|
||||
- t-paliad-127 / t-paliad-129 (columns view + polish)
|
||||
- t-paliad-088 (Event Types design — relationship: Event Types is matter-attached deadline tagging; this design's concept layer is the calculator-side equivalent. They could share concept slugs eventually but ship independently.)
|
||||
|
||||
**Memory references:**
|
||||
- `paliad t-paliad-084 Fristenrechner audit`
|
||||
- `paliad t-paliad-086 Fristenrechner youpc-parity`
|
||||
- `paliad t-paliad-111 SHIPPED — bug-bundle correctness`
|
||||
- `paliad t-paliad-110 SHIPPED — Fristen+Termine unification`
|
||||
- `Design: Event Types for deadlines + submissions (t-paliad-088, cronus)`
|
||||
25
docs/project-status.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Paliad — project status
|
||||
|
||||
Living document tracking what's shipped, what's deferred, and historical context. Update when phases land or open follow-ups change. AI instructions live in `.claude/CLAUDE.md`; this file is project state for humans.
|
||||
|
||||
## Phase status
|
||||
|
||||
Phases A–G shipped (April 2026): schema + RLS, services, Fristenrechner→DB, Akten CRUD, Fristen UI, Termine + CalDAV, Dashboard. See `docs/feature-roadmap.md` for the per-phase scope.
|
||||
|
||||
**Phase H (AI Frist-Extraktion) is deferred** — decision by m on 2026-04-16 ("we don't want Anthropic API"). The Dokumente tab on Akten detail stays as a "Kommt bald" placeholder. No `ANTHROPIC_API_KEY` on Dokploy.
|
||||
|
||||
**Phase I (Notizen polymorphic notes) shipped** — `paliad.notizen` table + RLS (migrations 005, 007), `NoteService` (`internal/services/note_service.go`), REST handlers (`internal/handlers/notes.go` — `GET/POST /api/{projects|deadlines|appointments}/{id}/notes`, `PATCH/DELETE /api/notes/{id}`), shared client module `frontend/src/client/notes.ts` (`initNotes`), wired into project / deadline / appointment detail pages. i18n keys under `notizen.*`.
|
||||
|
||||
**Phase J (this doc + roadmap rewrite + KanzlAI doc retirement notes)** completed 2026-04-17 on `mai/ritchie/phase-j-roadmap-rewrite`. Infra retirement (KanzlAI Dokploy shutdown, `kanzlai` schema drop, Gitea archive) still pending m + head coordination.
|
||||
|
||||
**Reminder system redesign (t-paliad-064)** — landed 2026-04-28 across PR-1..PR-4 on `mai/cronus/reminder-system-redesign`. Zero-overdue SLO model: per-user bundled morning/evening digests with category sections (überfällig / heute / diese Woche), DRINGEND escalation in the evening slot, and global-admin escalation framing on overdues. See `docs/design-reminder-redesign-2026-04-28.md`.
|
||||
|
||||
## Open follow-ups
|
||||
|
||||
- **Settings → Notifications: escalation contact dropdown** — migration 025 ships `paliad.users.escalation_contact_id` (FK to `paliad.users`, nullable, ON DELETE SET NULL). NULL means "fall back to global_admins for the escalation channel"; setting it lets a user designate a specific colleague as their escalation contact. UI shipped t-paliad-066 on 2026-04-29.
|
||||
- **Audit polish-2** — shipped 2026-04-30 across t-paliad-067 / t-paliad-068 / t-paliad-073 (BATCH-level findings + DEFER list). Follow-ups from the 2026-04-30 re-audit (`docs/improvement-audit-2026-04-30.md`) are tracked under t-paliad-074 and downstream task IDs.
|
||||
- **KanzlAI infra retirement** — Dokploy shutdown, `kanzlai` schema drop, Gitea archive. Pending m + head coordination.
|
||||
|
||||
## Historical naming
|
||||
|
||||
Previously called *patHoLo* (Patent + Hogan Lovells). Rebranded to Paliad on 2026-04-16 when HL announced the merger into HLC, making "HoLo" obsolete. Paliad — "Patent Litigation Administration" but in UI used as a standalone word evoking *paladin*, the champion. Firm-agnostic so the brand survives any future firm renames (see t-paliad-065 — single `FIRM_NAME` constant, default "HLC"). Lime branding kept throughout.
|
||||
@@ -1,58 +1,281 @@
|
||||
import { mkdir, cp, rm } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { mkdir, cp, rm, readdir } from "fs/promises";
|
||||
import { join, relative } from "path";
|
||||
import { renderIndex } from "./src/index";
|
||||
import { renderLogin } from "./src/login";
|
||||
import { renderKostenrechner } from "./src/kostenrechner";
|
||||
import { renderFristenrechner } from "./src/fristenrechner";
|
||||
import { renderDownloads } from "./src/downloads";
|
||||
import { renderLinks } from "./src/links";
|
||||
import { renderGlossar } from "./src/glossar";
|
||||
import { renderGlossary } from "./src/glossary";
|
||||
import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
|
||||
import { renderChecklisten } from "./src/checklisten";
|
||||
import { renderChecklistenDetail } from "./src/checklisten-detail";
|
||||
import { renderGerichte } from "./src/gerichte";
|
||||
import { renderAkten } from "./src/akten";
|
||||
import { renderAktenNeu } from "./src/akten-neu";
|
||||
import { renderAktenDetail } from "./src/akten-detail";
|
||||
import { renderFristen } from "./src/fristen";
|
||||
import { renderFristenNeu } from "./src/fristen-neu";
|
||||
import { renderFristenDetail } from "./src/fristen-detail";
|
||||
import { renderFristenKalender } from "./src/fristen-kalender";
|
||||
import { renderChecklists } from "./src/checklists";
|
||||
import { renderChecklistsDetail } from "./src/checklists-detail";
|
||||
import { renderChecklistsInstance } from "./src/checklists-instance";
|
||||
import { renderCourts } from "./src/courts";
|
||||
import { renderProjects } from "./src/projects";
|
||||
import { renderProjectsNew } from "./src/projects-new";
|
||||
import { renderProjectsDetail } from "./src/projects-detail";
|
||||
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";
|
||||
import { renderOnboarding } from "./src/onboarding";
|
||||
import { renderChangelog } from "./src/changelog";
|
||||
import { renderTeam } from "./src/team";
|
||||
import { renderAdmin } from "./src/admin";
|
||||
import { renderInbox } from "./src/inbox";
|
||||
import { renderAdminTeam } from "./src/admin-team";
|
||||
import { renderAdminAuditLog } from "./src/admin-audit-log";
|
||||
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
|
||||
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
|
||||
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
|
||||
import { renderAdminEventTypes } from "./src/admin-event-types";
|
||||
import { renderNotFound } from "./src/notfound";
|
||||
|
||||
const DIST = join(import.meta.dir, "dist");
|
||||
|
||||
// Bundle-scope isolation guard (t-paliad-043).
|
||||
//
|
||||
// All client bundles MUST be built with format: "iife" so each bundle's
|
||||
// top-level `var`/`function` declarations are wrapped in their own scope.
|
||||
// Without IIFE wrapping, minified identifiers leak to `window` and clobber
|
||||
// each other across bundles. On Apr 26, app.js's `var d = "patholo-sidebar-pinned"`
|
||||
// overwrote projects.js's `function d()` (applyTranslations), and the entire
|
||||
// authenticated surface crashed in initI18n with "TypeError: d is not a function".
|
||||
//
|
||||
// The constant below is the single source of truth for the bundle format;
|
||||
// the post-build inspection further down verifies that every emitted asset
|
||||
// actually starts with an IIFE prologue, so this guard survives future Bun
|
||||
// versions, refactors that drop the constant, or anyone trying to silence
|
||||
// the type system with `as "esm"`.
|
||||
const BUILD_FORMAT = "iife" as const;
|
||||
|
||||
// Bun emits IIFE bundles as either `(()=>{...})()` (arrow form, what we get
|
||||
// today with minify: true) or `(function(){...})()`. Match either prologue.
|
||||
const IIFE_PROLOGUE = /^(\(\(\)\s*=>\s*\{|\(function\s*\(\s*\)\s*\{)/;
|
||||
|
||||
// Resolve FIRM_NAME once so both the client bundle's `define` substitution
|
||||
// and the server-side TSX render see the same value. Mirrors the server's
|
||||
// internal/branding/firm.go default — the two MUST stay in sync because
|
||||
// users compare a rendered email body against a rendered HTML page and a
|
||||
// drifted default would produce two different firm names per deploy.
|
||||
const FIRM_NAME = (process.env.FIRM_NAME ?? "").trim() || "HLC";
|
||||
|
||||
// i18n-key codegen + data-i18n scan (t-paliad-078).
|
||||
//
|
||||
// `frontend/src/client/i18n.ts` is the single source of truth for translation
|
||||
// keys. The codegen below extracts every key into a TS literal-union type at
|
||||
// `frontend/src/i18n-keys.ts`, which `t()` and `tOrEmpty()` use to flag
|
||||
// literal-string typos at compile time. The scan downstream cross-checks every
|
||||
// `data-i18n*` attribute literal in `src/**/*.{ts,tsx}` against the same set
|
||||
// — that's the path the runtime `applyTranslations` walks, so a typo there is
|
||||
// just as silent as a `t("typo")` (and how F-04 shipped a raw key in prod).
|
||||
//
|
||||
// The regex over `i18n.ts` source matches only `^[ \t]*"key": value` lines —
|
||||
// both the `de` and `en` blocks. The file is a static literal so this is
|
||||
// robust; if the file shape changes (e.g. someone introduces a function-built
|
||||
// translations object), the explicit zero-key guard below catches it.
|
||||
const I18N_SOURCE = join(import.meta.dir, "src/client/i18n.ts");
|
||||
const I18N_KEYS_OUT = join(import.meta.dir, "src/i18n-keys.ts");
|
||||
|
||||
async function generateI18nKeys(): Promise<ReadonlySet<string>> {
|
||||
const src = await Bun.file(I18N_SOURCE).text();
|
||||
const re = /^[ \t]*"([A-Za-z][\w.\-]*)"\s*:/gm;
|
||||
const keys = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(src)) !== null) keys.add(m[1]);
|
||||
|
||||
if (keys.size === 0) {
|
||||
console.error(
|
||||
"i18n codegen: extracted 0 keys from src/client/i18n.ts. " +
|
||||
"Either the file is empty or the regex no longer matches its shape — " +
|
||||
"fix the codegen before continuing.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sorted = [...keys].sort();
|
||||
const lines: string[] = [
|
||||
"// GENERATED FILE — do not edit by hand.",
|
||||
"// Regenerated on every build by frontend/build.ts (generateI18nKeys).",
|
||||
"// Source of truth: frontend/src/client/i18n.ts.",
|
||||
"//",
|
||||
"// `t(key: I18nKey)` accepts this union, so a literal-string typo at a",
|
||||
"// call site fails `tsc --noEmit`. Runtime-composed keys go through",
|
||||
"// `tDyn(key: string)` which deliberately bypasses the type check. The",
|
||||
"// build's `data-i18n` scan uses the same set to verify literal",
|
||||
"// `data-i18n*` attributes in TSX/TS sources.",
|
||||
"",
|
||||
"export type I18nKey =",
|
||||
...sorted.map(
|
||||
(k, i) => ` | ${JSON.stringify(k)}${i === sorted.length - 1 ? ";" : ""}`,
|
||||
),
|
||||
"",
|
||||
];
|
||||
|
||||
const next = lines.join("\n");
|
||||
// Skip writing if unchanged — keeps tsc/editor watchers quiet on no-op
|
||||
// builds and avoids spurious git diffs when the type is already current.
|
||||
const existing = await Bun.file(I18N_KEYS_OUT)
|
||||
.text()
|
||||
.catch(() => "");
|
||||
if (existing !== next) {
|
||||
await Bun.write(I18N_KEYS_OUT, next);
|
||||
console.log(`i18n codegen: ${sorted.length} keys → src/i18n-keys.ts (updated)`);
|
||||
} else {
|
||||
console.log(`i18n codegen: ${sorted.length} keys (unchanged)`);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
// Scan every TSX/TS source file for literal `data-i18n*` attribute values and
|
||||
// verify each one is a known I18nKey. Skips dynamic forms (`={...}`,
|
||||
// `="${...}"`) since those can't be resolved statically. Mirrors the runtime
|
||||
// behaviour in `applyTranslations` — three attributes, all read literally.
|
||||
async function checkDataI18nUsage(keys: ReadonlySet<string>): Promise<void> {
|
||||
const SRC = join(import.meta.dir, "src");
|
||||
const ATTR_RE =
|
||||
/\bdata-i18n(?:-placeholder|-title)?\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
||||
|
||||
type Hit = { file: string; line: number; attr: string; key: string };
|
||||
const unknown: Hit[] = [];
|
||||
|
||||
async function walk(dir: string): Promise<void> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const ent of entries) {
|
||||
const full = join(dir, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
await walk(full);
|
||||
continue;
|
||||
}
|
||||
if (!ent.name.endsWith(".ts") && !ent.name.endsWith(".tsx")) continue;
|
||||
// Skip the generated file itself + the i18n source-of-truth (its
|
||||
// string keys are translation values, not data-i18n attrs).
|
||||
if (full === I18N_KEYS_OUT) continue;
|
||||
if (full === I18N_SOURCE) continue;
|
||||
|
||||
const text = await Bun.file(full).text();
|
||||
const lines = text.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
let m: RegExpExecArray | null;
|
||||
ATTR_RE.lastIndex = 0;
|
||||
while ((m = ATTR_RE.exec(line)) !== null) {
|
||||
const value = m[1] ?? m[2];
|
||||
if (value === undefined) continue;
|
||||
// Skip dynamic interpolations — can't statically resolve.
|
||||
if (value.includes("${")) continue;
|
||||
// The full attribute name is up to the `=` for the report.
|
||||
const attr = m[0].slice(0, m[0].indexOf("="));
|
||||
if (!keys.has(value)) {
|
||||
unknown.push({
|
||||
file: relative(import.meta.dir, full),
|
||||
line: i + 1,
|
||||
attr: attr.trim(),
|
||||
key: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(SRC);
|
||||
|
||||
if (unknown.length > 0) {
|
||||
console.error(
|
||||
`i18n scan: ${unknown.length} unknown ${unknown.length === 1 ? "key" : "keys"} ` +
|
||||
`referenced via data-i18n* attributes — every literal must exist in i18n.ts:`,
|
||||
);
|
||||
for (const h of unknown) {
|
||||
console.error(` ${h.file}:${h.line} ${h.attr}="${h.key}"`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("i18n scan: data-i18n attributes clean");
|
||||
}
|
||||
|
||||
async function build() {
|
||||
// Clean dist/
|
||||
await rm(DIST, { recursive: true, force: true });
|
||||
await mkdir(join(DIST, "assets"), { recursive: true });
|
||||
|
||||
// Regenerate the I18nKey union BEFORE bundling. Bun.build runs the TSX
|
||||
// renderers, which import t() — if a recent commit added a key without
|
||||
// regenerating, the renderer would still pass tsc only because the union
|
||||
// is stale, so we always rewrite first. The data-i18n scan runs next so
|
||||
// any unknown literal aborts the build before any artefact is emitted.
|
||||
const i18nKeys = await generateI18nKeys();
|
||||
await checkDataI18nUsage(i18nKeys);
|
||||
|
||||
console.log(`branding: firm="${FIRM_NAME}" (override with FIRM_NAME env)`);
|
||||
|
||||
// Bundle client-side JS
|
||||
const result = await Bun.build({
|
||||
entrypoints: [
|
||||
// app.ts is loaded on every page (SW registration + bottom-nav init +
|
||||
// install prompt). Keep it ahead of per-page bundles so name collisions
|
||||
// surface fast.
|
||||
join(import.meta.dir, "src/client/app.ts"),
|
||||
join(import.meta.dir, "src/client/index.ts"),
|
||||
join(import.meta.dir, "src/client/login.ts"),
|
||||
join(import.meta.dir, "src/client/kostenrechner.ts"),
|
||||
join(import.meta.dir, "src/client/fristenrechner.ts"),
|
||||
join(import.meta.dir, "src/client/downloads.ts"),
|
||||
join(import.meta.dir, "src/client/links.ts"),
|
||||
join(import.meta.dir, "src/client/glossar.ts"),
|
||||
join(import.meta.dir, "src/client/glossary.ts"),
|
||||
join(import.meta.dir, "src/client/gebuehrentabellen.ts"),
|
||||
join(import.meta.dir, "src/client/checklisten.ts"),
|
||||
join(import.meta.dir, "src/client/checklisten-detail.ts"),
|
||||
join(import.meta.dir, "src/client/gerichte.ts"),
|
||||
join(import.meta.dir, "src/client/akten.ts"),
|
||||
join(import.meta.dir, "src/client/akten-neu.ts"),
|
||||
join(import.meta.dir, "src/client/akten-detail.ts"),
|
||||
join(import.meta.dir, "src/client/fristen.ts"),
|
||||
join(import.meta.dir, "src/client/fristen-neu.ts"),
|
||||
join(import.meta.dir, "src/client/fristen-detail.ts"),
|
||||
join(import.meta.dir, "src/client/fristen-kalender.ts"),
|
||||
join(import.meta.dir, "src/client/checklists.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"),
|
||||
join(import.meta.dir, "src/client/projects.ts"),
|
||||
join(import.meta.dir, "src/client/projects-new.ts"),
|
||||
join(import.meta.dir, "src/client/projects-detail.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"),
|
||||
join(import.meta.dir, "src/client/inbox.ts"),
|
||||
join(import.meta.dir, "src/client/onboarding.ts"),
|
||||
join(import.meta.dir, "src/client/changelog.ts"),
|
||||
join(import.meta.dir, "src/client/team.ts"),
|
||||
join(import.meta.dir, "src/client/admin.ts"),
|
||||
join(import.meta.dir, "src/client/admin-team.ts"),
|
||||
join(import.meta.dir, "src/client/admin-audit-log.ts"),
|
||||
join(import.meta.dir, "src/client/admin-partner-units.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-event-types.ts"),
|
||||
join(import.meta.dir, "src/client/notfound.ts"),
|
||||
],
|
||||
outdir: join(DIST, "assets"),
|
||||
naming: "[name].js",
|
||||
minify: true,
|
||||
// See BUILD_FORMAT comment at top of file — bundle-scope isolation
|
||||
// depends on IIFE wrapping. Reuses the single-source-of-truth constant
|
||||
// so the post-build guard below can detect a format swap.
|
||||
format: BUILD_FORMAT,
|
||||
// Inline the resolved firm name into every browser bundle. branding.ts
|
||||
// reads `process.env.FIRM_NAME`, which Bun's bundler does NOT replace by
|
||||
// default for browser targets — so without `define`, client code would
|
||||
// see undefined and fall back to "HLC" regardless of FIRM_NAME.
|
||||
define: {
|
||||
"process.env.FIRM_NAME": JSON.stringify(FIRM_NAME),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
@@ -63,12 +286,52 @@ async function build() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Bundle-scope isolation guard (t-paliad-043) — verify every emitted JS
|
||||
// bundle starts with an IIFE prologue. This catches the case where
|
||||
// BUILD_FORMAT is changed to "esm", `format` is dropped from the Bun.build
|
||||
// call, or a future Bun version emits a non-IIFE wrapper despite the
|
||||
// option. Without this, top-level identifier collisions between bundles
|
||||
// can take down the whole authenticated surface (see comment at top).
|
||||
const emittedAssets = await readdir(join(DIST, "assets"));
|
||||
for (const f of emittedAssets) {
|
||||
if (!f.endsWith(".js")) continue;
|
||||
const head = (await Bun.file(join(DIST, "assets", f)).text()).slice(0, 64);
|
||||
if (!IIFE_PROLOGUE.test(head)) {
|
||||
console.error(
|
||||
`Build aborted: dist/assets/${f} is not IIFE-wrapped ` +
|
||||
`(starts with ${JSON.stringify(head.slice(0, 32))}). ` +
|
||||
`All client bundles must be built with Bun.build({ format: "iife" }) — ` +
|
||||
`per-page bundles' top-level identifiers leak to window and clobber ` +
|
||||
`each other after minification (see t-paliad-043).`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy CSS
|
||||
await cp(
|
||||
join(import.meta.dir, "src/styles/global.css"),
|
||||
join(DIST, "assets/global.css"),
|
||||
);
|
||||
|
||||
// Copy public/ → dist/ (manifest.json, sw.js, icons/) — served at the
|
||||
// application root so the service worker can claim scope=/ and so the
|
||||
// manifest is reachable at /manifest.json without a sub-path rewrite.
|
||||
await cp(
|
||||
join(import.meta.dir, "public"),
|
||||
DIST,
|
||||
{ recursive: true },
|
||||
);
|
||||
|
||||
// Stamp a unique version into sw.js so each deploy opens a fresh cache.
|
||||
// Activate-time eviction (in sw.js) deletes any prior cache, including
|
||||
// pre-versioning names like paliad-v1-static — that's what stops a stale
|
||||
// /assets/projects.js from a previous deploy lingering on a user's device.
|
||||
const swPath = join(DIST, "sw.js");
|
||||
const swSrc = await Bun.file(swPath).text();
|
||||
const buildVersion = `v${Date.now()}`;
|
||||
await Bun.write(swPath, swSrc.replace("__PALIAD_BUILD_VERSION__", buildVersion));
|
||||
|
||||
// Render HTML pages
|
||||
await Bun.write(join(DIST, "index.html"), renderIndex());
|
||||
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
|
||||
@@ -76,19 +339,59 @@ async function build() {
|
||||
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
|
||||
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
|
||||
await Bun.write(join(DIST, "links.html"), renderLinks());
|
||||
await Bun.write(join(DIST, "glossar.html"), renderGlossar());
|
||||
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
|
||||
await Bun.write(join(DIST, "gebuehrentabellen.html"), renderGebuehrentabellen());
|
||||
await Bun.write(join(DIST, "checklisten.html"), renderChecklisten());
|
||||
await Bun.write(join(DIST, "checklisten-detail.html"), renderChecklistenDetail());
|
||||
await Bun.write(join(DIST, "gerichte.html"), renderGerichte());
|
||||
await Bun.write(join(DIST, "akten.html"), renderAkten());
|
||||
await Bun.write(join(DIST, "akten-neu.html"), renderAktenNeu());
|
||||
await Bun.write(join(DIST, "akten-detail.html"), renderAktenDetail());
|
||||
await Bun.write(join(DIST, "fristen.html"), renderFristen());
|
||||
await Bun.write(join(DIST, "fristen-neu.html"), renderFristenNeu());
|
||||
await Bun.write(join(DIST, "fristen-detail.html"), renderFristenDetail());
|
||||
await Bun.write(join(DIST, "fristen-kalender.html"), renderFristenKalender());
|
||||
await Bun.write(join(DIST, "checklists.html"), renderChecklists());
|
||||
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());
|
||||
await Bun.write(join(DIST, "projects.html"), renderProjects());
|
||||
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
|
||||
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
||||
// 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
|
||||
// the matching one at hydration time based on the active type.
|
||||
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());
|
||||
await Bun.write(join(DIST, "inbox.html"), renderInbox());
|
||||
await Bun.write(join(DIST, "onboarding.html"), renderOnboarding());
|
||||
await Bun.write(join(DIST, "changelog.html"), renderChangelog());
|
||||
await Bun.write(join(DIST, "team.html"), renderTeam());
|
||||
await Bun.write(join(DIST, "admin.html"), renderAdmin());
|
||||
await Bun.write(join(DIST, "admin-team.html"), renderAdminTeam());
|
||||
await Bun.write(join(DIST, "admin-audit-log.html"), renderAdminAuditLog());
|
||||
await Bun.write(join(DIST, "admin-partner-units.html"), renderAdminPartnerUnits());
|
||||
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
|
||||
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
|
||||
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
|
||||
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
|
||||
|
||||
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in
|
||||
// every emitted HTML file. Cache-Control alone isn't enough: a browser that
|
||||
// cached a script in a previous deploy keeps serving it from disk because
|
||||
// the cache entry was stored without the no-cache directive. Versioning the
|
||||
// URL changes the cache key, so the next page load fetches a fresh bundle
|
||||
// unconditionally \u2014 this is what guarantees t-paliad-043's IIFE wrap fix
|
||||
// actually reaches users on their next visit even without a SW.
|
||||
const htmlFiles = (await readdir(DIST)).filter((f) => f.endsWith(".html"));
|
||||
for (const f of htmlFiles) {
|
||||
const path = join(DIST, f);
|
||||
const html = await Bun.file(path).text();
|
||||
const stamped = html.replace(
|
||||
/(\/assets\/[\w-]+\.(?:js|css))/g,
|
||||
`$1?v=${buildVersion}`,
|
||||
);
|
||||
await Bun.write(path, stamped);
|
||||
}
|
||||
|
||||
console.log("Build complete \u2192 dist/");
|
||||
}
|
||||
|
||||
10
frontend/icons-src/icon-maskable.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" fill="#BFF355"/>
|
||||
<text x="256" y="340"
|
||||
font-family="DejaVu Sans Mono, Liberation Mono, Menlo, Consolas, monospace"
|
||||
font-size="288" font-weight="700"
|
||||
fill="#002236"
|
||||
text-anchor="middle"
|
||||
textLength="170" lengthAdjust="spacingAndGlyphs">p</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 452 B |
10
frontend/icons-src/icon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" fill="#BFF355"/>
|
||||
<text x="256" y="376"
|
||||
font-family="DejaVu Sans Mono, Liberation Mono, Menlo, Consolas, monospace"
|
||||
font-size="384" font-weight="700"
|
||||
fill="#002236"
|
||||
text-anchor="middle"
|
||||
textLength="220" lengthAdjust="spacingAndGlyphs">p</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 452 B |
BIN
frontend/public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
frontend/public/icons/favicon-32.png
Normal file
|
After Width: | Height: | Size: 500 B |
BIN
frontend/public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend/public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
frontend/public/icons/icon-maskable-192.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend/public/icons/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
41
frontend/public/manifest.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "Paliad",
|
||||
"short_name": "Paliad",
|
||||
"description": "Patentwissen und Aktenverwaltung für das HLC-Patent-Team.",
|
||||
"start_url": "/dashboard",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"theme_color": "#BFF355",
|
||||
"background_color": "#EEE5E1",
|
||||
"lang": "de",
|
||||
"dir": "ltr",
|
||||
"id": "paliad",
|
||||
"categories": ["productivity", "business"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
82
frontend/public/sw.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// Paliad service worker. Cache strategy:
|
||||
// /assets/* + /icons/* → cache-first (immutable per deploy)
|
||||
// /api/* → network-first (fall back to cached snapshot)
|
||||
// everything else → network passthrough
|
||||
//
|
||||
// CACHE_VERSION is rewritten to "v<build-epoch-ms>" by frontend/build.ts on
|
||||
// every deploy. The activate handler deletes ANY cache whose name doesn't
|
||||
// match — covers both prior versioned caches (v17143…) and any pre-versioning
|
||||
// cache name (paliad-v1-static, t-paliad-043 kill-switch survivors). This is
|
||||
// what guarantees a stale `/assets/projects.js` from a previous deploy gets
|
||||
// purged the moment the new SW activates, instead of lingering until the user
|
||||
// manually clears site data.
|
||||
|
||||
const CACHE_VERSION = "__PALIAD_BUILD_VERSION__";
|
||||
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
// skipWaiting so the new SW takes over the moment install completes,
|
||||
// rather than waiting for every tab to close. Combined with clients.claim
|
||||
// in activate, this means a deploy reaches users on their next navigation.
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys.filter((k) => k !== STATIC_CACHE).map((k) => caches.delete(k)),
|
||||
);
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
if (url.pathname.startsWith("/assets/") || url.pathname.startsWith("/icons/")) {
|
||||
event.respondWith(cacheFirst(req));
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
event.respondWith(networkFirst(req));
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
async function cacheFirst(req) {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
const cached = await cache.match(req);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
// cache: "reload" forces the network leg to BYPASS the browser HTTP
|
||||
// cache. Without this, a stale /assets/projects.js sitting in the
|
||||
// browser's disk cache from a previous deploy would be returned to us,
|
||||
// we'd cache it again, and the user would be stuck on the old bundle
|
||||
// forever — exactly the failure mode that caused t-paliad-043.
|
||||
const res = await fetch(req, { cache: "reload" });
|
||||
if (res && res.ok) cache.put(req, res.clone());
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (cached) return cached;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function networkFirst(req) {
|
||||
try {
|
||||
return await fetch(req);
|
||||
} catch (err) {
|
||||
const cache = await caches.open(STATIC_CACHE);
|
||||
const cached = await cache.match(req);
|
||||
if (cached) return cached;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
123
frontend/src/admin-audit-log.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAdminAuditLog(): 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="admin.audit.title">Audit-Log — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/audit-log" />
|
||||
<BottomNav currentPath="/admin/audit-log" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.audit.heading">Audit-Log</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.audit.subtitle">
|
||||
Globale Zeitleiste über Projekt-, CalDAV- und Reminder-Ereignisse.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-audit-controls">
|
||||
<div className="admin-audit-control-row">
|
||||
<div className="admin-audit-field">
|
||||
<label htmlFor="audit-source" data-i18n="admin.audit.filter.source">Quelle</label>
|
||||
<select id="audit-source" className="admin-audit-input">
|
||||
<option value="" data-i18n="admin.audit.source.all">Alle</option>
|
||||
<option value="project_events" data-i18n="admin.audit.source.project_events">Projekt-Ereignisse</option>
|
||||
<option value="caldav_sync_log" data-i18n="admin.audit.source.caldav_sync_log">CalDAV-Sync</option>
|
||||
<option value="reminder_log" data-i18n="admin.audit.source.reminder_log">Reminder</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="admin-audit-field">
|
||||
<label htmlFor="audit-range" data-i18n="admin.audit.filter.range">Zeitraum</label>
|
||||
<select id="audit-range" className="admin-audit-input">
|
||||
<option value="24h" data-i18n="admin.audit.range.24h">Letzte 24h</option>
|
||||
<option value="7d" selected data-i18n="admin.audit.range.7d">Letzte 7 Tage</option>
|
||||
<option value="30d" data-i18n="admin.audit.range.30d">Letzte 30 Tage</option>
|
||||
<option value="custom" data-i18n="admin.audit.range.custom">Benutzerdefiniert</option>
|
||||
<option value="all" data-i18n="admin.audit.range.all">Alles</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="admin-audit-field admin-audit-custom-range" id="audit-custom-range" style="display:none">
|
||||
<label htmlFor="audit-from" data-i18n="admin.audit.filter.from">Von</label>
|
||||
<input type="date" lang="de" id="audit-from" className="admin-audit-input" />
|
||||
<label htmlFor="audit-to" data-i18n="admin.audit.filter.to">Bis</label>
|
||||
<input type="date" lang="de" id="audit-to" className="admin-audit-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-audit-control-row">
|
||||
<div className="admin-audit-field admin-audit-search-field">
|
||||
<label htmlFor="audit-search" data-i18n="admin.audit.filter.search">Suche</label>
|
||||
<input
|
||||
type="text"
|
||||
id="audit-search"
|
||||
className="admin-audit-input"
|
||||
placeholder="Subjekt, Beschreibung, Ereignistyp ..."
|
||||
data-i18n-placeholder="admin.audit.search.placeholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-audit-field admin-audit-counter-field">
|
||||
<span className="admin-audit-counter" id="audit-count"> </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="audit-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="entity-table-wrap admin-audit-table-wrap">
|
||||
<table className="entity-table entity-table--readonly admin-audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.audit.col.time">Zeit</th>
|
||||
<th data-i18n="admin.audit.col.source">Quelle</th>
|
||||
<th data-i18n="admin.audit.col.event">Ereignis</th>
|
||||
<th data-i18n="admin.audit.col.actor">Akteur</th>
|
||||
<th data-i18n="admin.audit.col.subject">Subjekt</th>
|
||||
<th data-i18n="admin.audit.col.description">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="audit-tbody">
|
||||
<tr><td colspan={6} className="admin-audit-loading" data-i18n="admin.audit.loading">Lade ...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="audit-empty" style="display:none">
|
||||
<p data-i18n="admin.audit.empty">Keine Ereignisse für die gewählten Filter.</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-audit-pagination">
|
||||
<button type="button" id="audit-loadmore" className="btn-secondary" style="display:none" data-i18n="admin.audit.loadmore">
|
||||
Weitere laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/admin-audit-log.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
117
frontend/src/admin-email-templates-edit.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/email-templates/{key}?lang=de — full editor. The shell holds the
|
||||
// chrome and the empty form/preview/variable wells. The client bundle reads
|
||||
// the key from location.pathname and the lang from location.search, fetches
|
||||
// the active row + variables + version log in parallel, and populates.
|
||||
|
||||
export function renderAdminEmailTemplatesEdit(): 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="admin.email_templates.editor.title">Email-Template bearbeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/email-templates" />
|
||||
<BottomNav currentPath="/admin/email-templates" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container admin-et-edit-container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<a href="/admin/email-templates" className="admin-et-back" data-i18n="admin.email_templates.back">
|
||||
← Zurück zur Liste
|
||||
</a>
|
||||
<h1 id="admin-et-title" data-i18n="admin.email_templates.editor.heading">Email-Template bearbeiten</h1>
|
||||
<p id="admin-et-subtitle" className="tool-subtitle" />
|
||||
</div>
|
||||
<div className="admin-et-lang-toggle" id="admin-et-lang-toggle" role="tablist" aria-label="Language">
|
||||
<button type="button" className="admin-et-lang-btn" data-lang="de" aria-pressed="true">DE</button>
|
||||
<button type="button" className="admin-et-lang-btn" data-lang="en" aria-pressed="false">EN</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-et-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="admin-et-editor">
|
||||
<div className="admin-et-editor-form">
|
||||
<div className="form-field" id="admin-et-subject-wrap">
|
||||
<label htmlFor="admin-et-subject" data-i18n="admin.email_templates.editor.subject">Betreff</label>
|
||||
<input type="text" id="admin-et-subject" className="admin-et-subject-input" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-et-body" data-i18n="admin.email_templates.editor.body">HTML-Body</label>
|
||||
<textarea id="admin-et-body" className="admin-et-body-input" rows={24} spellcheck={false} />
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-et-note" data-i18n="admin.email_templates.editor.note_optional">Notiz (optional)</label>
|
||||
<input type="text" id="admin-et-note" className="admin-et-note-input" autocomplete="off"
|
||||
placeholder="z.B. Korrektur nach Anwalts-Feedback"
|
||||
data-i18n-placeholder="admin.email_templates.editor.note_placeholder" />
|
||||
</div>
|
||||
|
||||
<details className="admin-et-variables">
|
||||
<summary data-i18n="admin.email_templates.editor.variables">Verfügbare Variablen</summary>
|
||||
<div id="admin-et-variables-list" className="admin-et-variables-list" />
|
||||
</details>
|
||||
|
||||
<div className="form-actions admin-et-actions">
|
||||
<button type="button" id="admin-et-save" className="btn-primary" disabled
|
||||
data-i18n="admin.email_templates.editor.save">Speichern</button>
|
||||
<button type="button" id="admin-et-reset" className="btn-secondary"
|
||||
data-i18n="admin.email_templates.editor.reset">Auf Standard zurücksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-et-editor-preview">
|
||||
<div className="admin-et-preview-header">
|
||||
<h2 data-i18n="admin.email_templates.editor.preview">Vorschau</h2>
|
||||
<div className="admin-et-preview-actions">
|
||||
<select id="admin-et-slot" className="admin-et-slot-select" style="display:none">
|
||||
<option value="morning" data-i18n="admin.email_templates.editor.slot.morning">Morgen-Slot</option>
|
||||
<option value="evening" data-i18n="admin.email_templates.editor.slot.evening">Abend-Slot</option>
|
||||
</select>
|
||||
<button type="button" id="admin-et-preview-refresh" className="btn-tertiary"
|
||||
data-i18n="admin.email_templates.editor.preview_refresh">Vorschau aktualisieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-et-preview-subject" id="admin-et-preview-subject" />
|
||||
|
||||
<iframe
|
||||
id="admin-et-preview-frame"
|
||||
className="admin-et-preview-frame"
|
||||
sandbox="allow-same-origin"
|
||||
title="Email preview"
|
||||
/>
|
||||
|
||||
<details className="admin-et-versions">
|
||||
<summary data-i18n="admin.email_templates.editor.versions">Versionen</summary>
|
||||
<ul id="admin-et-versions-list" className="admin-et-versions-list" />
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/admin-email-templates-edit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
56
frontend/src/admin-email-templates.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /admin/email-templates — list of canonical templates with per-language
|
||||
// edit links. Cards are populated client-side from
|
||||
// GET /api/admin/email-templates so the static SPA shell stays language-
|
||||
// neutral and the "Standard" / "Zuletzt geändert" status reflects current
|
||||
// DB state, not build time.
|
||||
|
||||
export function renderAdminEmailTemplates(): 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="admin.email_templates.title">Email-Templates — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/email-templates" />
|
||||
<BottomNav currentPath="/admin/email-templates" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.email_templates.heading">Email-Templates</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.email_templates.subtitle">
|
||||
Vorlagen für Einladungen, Erinnerungen und das Layout-Wrapper anpassen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-et-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="grid grid-2" id="admin-et-list">
|
||||
<div className="card admin-et-loading" data-i18n="admin.email_templates.loading">Lade…</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/admin-email-templates.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
156
frontend/src/admin-event-types.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAdminEventTypes(): 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="admin.event_types.title">Event-Typen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/event-types" />
|
||||
<BottomNav currentPath="/admin/event-types" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.event_types.heading">Event-Typen</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.event_types.subtitle">
|
||||
Firmenweite Event-Typen moderieren: archivieren, zusammenführen, private Typen befördern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-team-controls">
|
||||
<div className="glossar-search-wrap">
|
||||
<svg className="glossar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
id="aet-search"
|
||||
className="glossar-search"
|
||||
placeholder="Bezeichnung, Slug oder Author suchen..."
|
||||
data-i18n-placeholder="admin.event_types.search.placeholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span className="glossar-count" id="aet-count" />
|
||||
</div>
|
||||
<label className="admin-team-multi-opt">
|
||||
<input type="checkbox" id="aet-show-archived" />
|
||||
<span data-i18n="admin.event_types.show_archived">Archivierte anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-team-actions" id="aet-bulk-actions" style="display:none">
|
||||
<span id="aet-bulk-count" className="admin-team-muted" />
|
||||
<button className="btn-primary" id="aet-bulk-archive" type="button" data-i18n="admin.event_types.action.archive_selected">
|
||||
Ausgewählte archivieren
|
||||
</button>
|
||||
<button className="btn-primary" id="aet-bulk-merge" type="button" data-i18n="admin.event_types.action.merge_selected">
|
||||
Zusammenführen…
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="aet-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<h3 className="section-heading" data-i18n="admin.event_types.section.firm_wide">Firmenweite Typen</h3>
|
||||
|
||||
<div className="entity-table-wrap admin-team-table-wrap">
|
||||
<table className="entity-table entity-table--readonly admin-team-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="aet-col-check" />
|
||||
<th data-i18n="admin.event_types.col.label">Bezeichnung</th>
|
||||
<th data-i18n="admin.event_types.col.category">Kategorie</th>
|
||||
<th data-i18n="admin.event_types.col.jurisdiction">Jurisdiktion</th>
|
||||
<th data-i18n="admin.event_types.col.author">Author</th>
|
||||
<th data-i18n="admin.event_types.col.created">Erstellt</th>
|
||||
<th data-i18n="admin.event_types.col.usage">Verwendung</th>
|
||||
<th data-i18n="admin.event_types.col.actions">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="aet-tbody">
|
||||
<tr><td colspan={8} className="admin-team-loading" data-i18n="admin.event_types.loading">Lade...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="aet-empty" style="display:none">
|
||||
<p data-i18n="admin.event_types.empty">Keine Treffer.</p>
|
||||
</div>
|
||||
|
||||
<h3 className="section-heading" data-i18n="admin.event_types.section.private_pending">
|
||||
Private Typen (zur Beförderung)
|
||||
</h3>
|
||||
<p className="tool-subtitle" data-i18n="admin.event_types.section.private_pending.hint">
|
||||
Private Typen anderer Kolleg:innen, sortiert nach Häufigkeit. Befördern macht den Typ firmenweit sichtbar.
|
||||
</p>
|
||||
|
||||
<div className="entity-table-wrap admin-team-table-wrap">
|
||||
<table className="entity-table entity-table--readonly admin-team-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.event_types.col.label">Bezeichnung</th>
|
||||
<th data-i18n="admin.event_types.col.category">Kategorie</th>
|
||||
<th data-i18n="admin.event_types.col.jurisdiction">Jurisdiktion</th>
|
||||
<th data-i18n="admin.event_types.col.author">Author</th>
|
||||
<th data-i18n="admin.event_types.col.usage">Verwendung</th>
|
||||
<th data-i18n="admin.event_types.col.actions">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="aet-private-tbody">
|
||||
<tr><td colspan={6} className="admin-team-loading" data-i18n="admin.event_types.loading">Lade...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="aet-private-empty" style="display:none">
|
||||
<p data-i18n="admin.event_types.private.empty">Keine privaten Typen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Merge modal — list of selected types as candidates, admin picks one
|
||||
as winner. Confirms with usage count, then POST /merge atomically
|
||||
redirects junction rows + archives losers. */}
|
||||
<div className="modal-overlay" id="aet-merge-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="admin.event_types.merge.title">Typen zusammenführen</h2>
|
||||
<button className="modal-close" id="aet-merge-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p data-i18n="admin.event_types.merge.body" className="invite-modal-body">
|
||||
Wählen Sie den Gewinner-Typ. Die Junction-Einträge der Verlierer werden auf den Gewinner umgeleitet, anschließend werden die Verlierer archiviert.
|
||||
</p>
|
||||
<form id="aet-merge-form" className="entity-form" autocomplete="off">
|
||||
<div id="aet-merge-options" className="aet-merge-options" />
|
||||
<p className="form-msg" id="aet-merge-msg" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="aet-merge-cancel" data-i18n="common.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary" id="aet-merge-submit" data-i18n="admin.event_types.merge.submit">Zusammenführen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/admin-event-types.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
131
frontend/src/admin-partner-units.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAdminPartnerUnits(): 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="admin.partner_units.title">Partner Units — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/partner-units" />
|
||||
<BottomNav currentPath="/admin/partner-units" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.partner_units.heading">Partner Units</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.partner_units.subtitle">
|
||||
Strukturelle Partnereinheiten verwalten und Mitglieder zuordnen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-team-actions">
|
||||
<button className="btn-primary" id="pu-new-btn" type="button" data-i18n="admin.partner_units.new">
|
||||
Neue Partner Unit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pu-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="entity-table-wrap admin-team-table-wrap">
|
||||
<table className="entity-table entity-table--readonly admin-team-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.partner_units.col.name">Name</th>
|
||||
<th data-i18n="admin.partner_units.col.office">Büro</th>
|
||||
<th data-i18n="admin.partner_units.col.lead">Lead</th>
|
||||
<th data-i18n="admin.partner_units.col.members">Mitglieder</th>
|
||||
<th data-i18n="admin.partner_units.col.actions">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pu-tbody">
|
||||
<tr><td colspan={5} className="admin-team-loading" data-i18n="admin.partner_units.loading">Lade...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="pu-empty" style="display:none">
|
||||
<p data-i18n="admin.partner_units.empty">Noch keine Partner Units angelegt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Create / edit modal — same shape for both, "id" is empty when
|
||||
creating. Office select is populated from /api/offices at init,
|
||||
lead picker from /api/users (filtered to display_name+email). */}
|
||||
<div className="modal-overlay" id="pu-edit-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="pu-edit-title" data-i18n="admin.partner_units.new.heading">Partner Unit anlegen</h2>
|
||||
<button className="modal-close" id="pu-edit-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<form id="pu-edit-form" className="entity-form" autocomplete="off">
|
||||
<input type="hidden" id="pu-edit-id" />
|
||||
<div className="form-field">
|
||||
<label htmlFor="pu-edit-name" data-i18n="admin.partner_units.col.name">Name</label>
|
||||
<input type="text" id="pu-edit-name" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="pu-edit-office" data-i18n="admin.partner_units.col.office">Büro</label>
|
||||
<select id="pu-edit-office" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="pu-edit-lead" data-i18n="admin.partner_units.col.lead">Lead</label>
|
||||
<select id="pu-edit-lead">
|
||||
<option value="">—</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="form-msg" id="pu-edit-msg" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="pu-edit-cancel" data-i18n="admin.partner_units.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="admin.partner_units.create">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Member-management modal — opens from the row's "Verwalten" button. */}
|
||||
<div className="modal-overlay" id="pu-members-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 id="pu-members-title">Mitglieder verwalten</h2>
|
||||
<button className="modal-close" id="pu-members-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div id="pu-members-body">
|
||||
<ul className="partner-unit-member-list" id="pu-members-list" />
|
||||
<form id="pu-add-form" autocomplete="off" className="entity-form">
|
||||
<div className="form-field">
|
||||
<label htmlFor="pu-add-input" data-i18n="admin.partner_units.member.add">Mitglied hinzufügen</label>
|
||||
<input type="text" id="pu-add-input" data-i18n-placeholder="admin.partner_units.member.placeholder" placeholder="Name oder E-Mail" />
|
||||
<input type="hidden" id="pu-add-user-id" />
|
||||
<div className="collab-suggestions" id="pu-add-suggestions" />
|
||||
</div>
|
||||
<p className="form-msg" id="pu-add-msg" />
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn-primary btn-cta-lime btn-small" data-i18n="admin.partner_units.member.add_btn">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/admin-partner-units.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
138
frontend/src/admin-team.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAdminTeam(): 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="admin.team.title">Team-Verwaltung — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/team" />
|
||||
<BottomNav currentPath="/admin/team" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.team.heading">Team-Verwaltung</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.team.subtitle">
|
||||
Alle Paliad-Konten anzeigen, bearbeiten oder hinzufügen.
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-team-actions">
|
||||
<button className="btn-primary" id="admin-team-direct-add" type="button" data-i18n="admin.team.add.direct">
|
||||
Bestehendes Konto onboarden
|
||||
</button>
|
||||
<button className="btn-primary" id="admin-team-invite" type="button" data-i18n="admin.team.add.invite">
|
||||
Neue:n Kolleg:in einladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-team-controls">
|
||||
<div className="glossar-search-wrap">
|
||||
<svg className="glossar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
id="admin-team-search"
|
||||
className="glossar-search"
|
||||
placeholder="Nach Name oder E-Mail suchen..."
|
||||
data-i18n-placeholder="admin.team.search.placeholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span className="glossar-count" id="admin-team-count" />
|
||||
</div>
|
||||
<div className="admin-team-filter-row" id="admin-team-office-filters">
|
||||
<button className="filter-pill active" data-office="all" type="button" data-i18n="team.filter.all">Alle</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="admin-team-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<div className="entity-table-wrap admin-team-table-wrap">
|
||||
<table className="entity-table entity-table--readonly admin-team-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.team.col.name">Name</th>
|
||||
<th data-i18n="admin.team.col.email">E-Mail</th>
|
||||
<th data-i18n="admin.team.col.office">Standort</th>
|
||||
<th data-i18n="admin.team.col.job_title">Berufsbezeichnung</th>
|
||||
<th data-i18n="admin.team.col.permission">Berechtigung</th>
|
||||
<th data-i18n="admin.team.col.additional">Weitere Standorte</th>
|
||||
<th data-i18n="admin.team.col.lang">Sprache</th>
|
||||
<th data-i18n="admin.team.col.created">Angelegt</th>
|
||||
<th data-i18n="admin.team.col.actions">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="admin-team-tbody">
|
||||
<tr><td colspan={9} className="admin-team-loading" data-i18n="admin.team.loading">Lade...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="admin-team-empty" style="display:none">
|
||||
<p data-i18n="admin.team.empty">Keine Treffer.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Direct-add modal: pick from unonboarded auth.users dropdown. */}
|
||||
<div className="modal-overlay" id="admin-direct-add-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="admin.team.direct_add.title">Bestehendes Konto onboarden</h2>
|
||||
<button className="modal-close" id="admin-direct-add-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p data-i18n="admin.team.direct_add.body" className="invite-modal-body">
|
||||
Diese Auswahl zeigt Konten, die sich angemeldet haben, aber noch kein Profil ausgefüllt haben.
|
||||
</p>
|
||||
<form id="admin-direct-add-form" className="entity-form" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-da-email" data-i18n="admin.team.direct_add.email">E-Mail</label>
|
||||
<select id="admin-da-email" name="email" required>
|
||||
<option value="" data-i18n="admin.team.direct_add.email.placeholder">Bitte auswählen...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-da-name" data-i18n="admin.team.direct_add.name">Anzeigename</label>
|
||||
<input type="text" id="admin-da-name" name="display_name" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-da-office" data-i18n="admin.team.direct_add.office">Standort</label>
|
||||
<select id="admin-da-office" name="office" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="admin-da-role" data-i18n="admin.team.direct_add.job_title">Berufsbezeichnung</label>
|
||||
<input type="text" id="admin-da-role" name="job_title" placeholder="Associate" />
|
||||
</div>
|
||||
<div id="admin-da-feedback" className="form-msg" style="display:none" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="admin-da-cancel" data-i18n="admin.team.direct_add.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary" id="admin-da-submit" data-i18n="admin.team.direct_add.submit">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/admin-team.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
108
frontend/src/admin.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
const ICON_USERS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></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>';
|
||||
const ICON_LOG = '<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="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>';
|
||||
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
|
||||
const ICON_FLAG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></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>';
|
||||
|
||||
interface PlannedCard {
|
||||
icon: string;
|
||||
i18nTitle: string;
|
||||
i18nDesc: string;
|
||||
fallbackTitle: string;
|
||||
fallbackDesc: string;
|
||||
}
|
||||
|
||||
const PLANNED: PlannedCard[] = [
|
||||
{
|
||||
icon: ICON_FLAG,
|
||||
i18nTitle: "admin.card.feature_flags.title",
|
||||
i18nDesc: "admin.card.feature_flags.desc",
|
||||
fallbackTitle: "Feature-Flags",
|
||||
fallbackDesc: "Funktionen pro Standort, Partner Unit oder Rolle aktivieren.",
|
||||
},
|
||||
];
|
||||
|
||||
export function renderAdmin(): 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="admin.title">Admin-Bereich — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin" />
|
||||
<BottomNav currentPath="/admin" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="admin.heading">Admin-Bereich</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.subtitle">
|
||||
Werkzeuge zur Verwaltung von Paliad. Nur für Administrator:innen sichtbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3 className="section-heading" data-i18n="admin.section.available">Verfügbar</h3>
|
||||
<div className="grid grid-2">
|
||||
<a href="/admin/team" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_USERS }} />
|
||||
<h2 data-i18n="admin.card.team.title">Team-Verwaltung</h2>
|
||||
<p data-i18n="admin.card.team.desc">Benutzer:innen anlegen, bearbeiten, löschen.</p>
|
||||
</a>
|
||||
<a href="/admin/partner-units" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_BUILDING }} />
|
||||
<h2 data-i18n="admin.card.partner_units.title">Partner Units</h2>
|
||||
<p data-i18n="admin.card.partner_units.desc">Strukturelle Partnereinheiten anlegen und Mitglieder zuordnen.</p>
|
||||
</a>
|
||||
<a href="/admin/audit-log" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_LOG }} />
|
||||
<h2 data-i18n="admin.card.audit.title">Audit-Log</h2>
|
||||
<p data-i18n="admin.card.audit.desc">Wer hat wann was geändert? Nachvollziehbarkeit für sicherheitsrelevante Aktionen.</p>
|
||||
</a>
|
||||
<a href="/admin/email-templates" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_MAIL }} />
|
||||
<h2 data-i18n="admin.card.email_templates.title">Email-Templates</h2>
|
||||
<p data-i18n="admin.card.email_templates.desc">Vorlagen für Einladungen, Erinnerungen und Layout anpassen.</p>
|
||||
</a>
|
||||
<a href="/admin/event-types" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
|
||||
<h2 data-i18n="admin.card.event_types.title">Event-Typen</h2>
|
||||
<p data-i18n="admin.card.event_types.desc">Firmenweite Event-Typen moderieren: archivieren, zusammenführen, befördern.</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>
|
||||
<div className="grid grid-2">
|
||||
{PLANNED.map((c) => (
|
||||
<div className="card admin-card-soon" title="Kommt bald" data-i18n-title="admin.coming_soon">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: c.icon }} />
|
||||
<h2 data-i18n={c.i18nTitle}>{c.fallbackTitle}</h2>
|
||||
<p data-i18n={c.i18nDesc}>{c.fallbackDesc}</p>
|
||||
<span className="admin-soon-badge" data-i18n="admin.coming_soon">Kommt bald</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
98
frontend/src/agenda.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// The /*__PALIAD_AGENDA_DATA__*/ token is replaced at request time by the Go
|
||||
// handler (internal/handlers/agenda_shell.go) with a JSON payload assigned
|
||||
// to window.__PALIAD_AGENDA__. Keep the token intact and exactly once.
|
||||
const HYDRATION_SCRIPT = "/*__PALIAD_AGENDA_DATA__*/";
|
||||
|
||||
export function renderAgenda(): 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="agenda.title">Agenda — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
<script dangerouslySetInnerHTML={{ __html: HYDRATION_SCRIPT }} />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/agenda" />
|
||||
<BottomNav currentPath="/agenda" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div className="entity-header-row">
|
||||
<div>
|
||||
<h1 data-i18n="agenda.heading">Agenda</h1>
|
||||
<p className="tool-subtitle" data-i18n="agenda.subtitle">
|
||||
Kommende Fristen und Termine über alle sichtbaren Akten, nach Tag gruppiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="agenda-unavailable" className="entity-unavailable" style="display:none">
|
||||
<p data-i18n="agenda.unavailable">
|
||||
Agenda zurzeit nicht verfügbar — bitte Administrator kontaktieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="agenda-controls">
|
||||
<div className="agenda-filter-group" role="group" aria-labelledby="agenda-type-heading">
|
||||
<span id="agenda-type-heading" className="agenda-filter-label" data-i18n="agenda.filter.type">Ansicht</span>
|
||||
<div className="agenda-chip-row">
|
||||
<button type="button" className="agenda-chip" data-type="both" data-i18n="agenda.filter.both">Beides</button>
|
||||
<button type="button" className="agenda-chip" data-type="deadlines" data-i18n="agenda.filter.deadlines">Nur Fristen</button>
|
||||
<button type="button" className="agenda-chip" data-type="appointments" data-i18n="agenda.filter.appointments">Nur Termine</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agenda-filter-group" role="group" aria-labelledby="agenda-range-heading">
|
||||
<span id="agenda-range-heading" className="agenda-filter-label" data-i18n="agenda.filter.range">Zeitraum</span>
|
||||
<div className="agenda-chip-row">
|
||||
<button type="button" className="agenda-chip" data-range="7" data-i18n="agenda.range.7">7 Tage</button>
|
||||
<button type="button" className="agenda-chip" data-range="14" data-i18n="agenda.range.14">14 Tage</button>
|
||||
<button type="button" className="agenda-chip" data-range="30" data-i18n="agenda.range.30">30 Tage</button>
|
||||
<button type="button" className="agenda-chip" data-range="90" data-i18n="agenda.range.90">90 Tage</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agenda-filter-group">
|
||||
<label className="agenda-filter-label" htmlFor="agenda-filter-event-type" data-i18n="agenda.filter.event_type">Typ</label>
|
||||
<button type="button" id="agenda-filter-event-type" className="entity-select multi-trigger" aria-haspopup="listbox" />
|
||||
<div id="agenda-filter-event-type-panel" className="multi-panel" hidden />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agenda-loading" id="agenda-loading" style="display:none" data-i18n="agenda.loading">
|
||||
Lädt …
|
||||
</div>
|
||||
|
||||
<div className="agenda-timeline" id="agenda-timeline" />
|
||||
|
||||
<div className="entity-empty" id="agenda-empty" style="display:none">
|
||||
<h2 data-i18n="agenda.empty.title">Keine Einträge im Zeitraum</h2>
|
||||
<p data-i18n="agenda.empty.hint">
|
||||
Nichts Fälliges — erweitern Sie den Zeitraum oder legen Sie neue Fristen oder Termine an.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/agenda.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderAktenDetail(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title data-i18n="akten.detail.title">Akte — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/akten" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<a href="/akten" className="akten-back-link" data-i18n="akten.detail.back">← Zurück zur Übersicht</a>
|
||||
|
||||
<div id="akten-detail-loading" className="akten-loading">
|
||||
<p data-i18n="akten.detail.loading">Lädt…</p>
|
||||
</div>
|
||||
|
||||
<div id="akten-detail-notfound" className="akten-empty" style="display:none">
|
||||
<p data-i18n="akten.detail.notfound">Akte nicht gefunden oder keine Berechtigung.</p>
|
||||
</div>
|
||||
|
||||
<div id="akten-detail-body" style="display:none">
|
||||
<header className="akten-detail-header">
|
||||
<div className="akten-detail-title-row">
|
||||
<div className="akten-detail-title-col">
|
||||
<h1 id="akte-title-display" />
|
||||
<input type="text" id="akte-title-edit" className="akten-title-input" style="display:none" />
|
||||
<div className="akten-detail-meta">
|
||||
<span className="akten-ref" id="akte-ref-display" />
|
||||
<span id="akte-office-chip" className="akten-office-chip" />
|
||||
<span id="akte-status-chip" className="akten-status-chip" />
|
||||
<span id="akte-firmwide-chip" className="akten-firmwide-chip" style="display:none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="akten-detail-actions">
|
||||
<button id="akte-edit-btn" className="btn-icon" type="button" aria-label="Bearbeiten" data-i18n-title="akten.detail.edit" title="Bearbeiten">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button id="akte-save-btn" className="btn-primary btn-cta-lime" type="button" style="display:none" data-i18n="akten.detail.save">Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="akten-tabs" id="akte-tabs">
|
||||
<a className="akten-tab" data-tab="verlauf" href="#" data-i18n="akten.detail.tab.verlauf">Verlauf</a>
|
||||
<a className="akten-tab" data-tab="parteien" href="#" data-i18n="akten.detail.tab.parteien">Parteien</a>
|
||||
<a className="akten-tab" data-tab="fristen" href="#" data-i18n="akten.detail.tab.fristen">Fristen</a>
|
||||
<a className="akten-tab" data-tab="termine" href="#" data-i18n="akten.detail.tab.termine">Termine</a>
|
||||
<a className="akten-tab" data-tab="dokumente" href="#" data-i18n="akten.detail.tab.dokumente">Dokumente</a>
|
||||
<a className="akten-tab" data-tab="notizen" href="#" data-i18n="akten.detail.tab.notizen">Notizen</a>
|
||||
</nav>
|
||||
|
||||
{/* Verlauf (Activity) */}
|
||||
<section className="akten-tab-panel" id="tab-verlauf">
|
||||
<ul className="akten-events" id="akten-events-list" />
|
||||
<p className="akten-events-empty" id="akten-events-empty" style="display:none" data-i18n="akten.detail.verlauf.empty">
|
||||
Noch keine Ereignisse aufgezeichnet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Parteien */}
|
||||
<section className="akten-tab-panel" id="tab-parteien" style="display:none">
|
||||
<div className="akten-parteien-controls">
|
||||
<button id="partei-add-btn" className="btn-primary btn-cta-lime btn-small" type="button" data-i18n="akten.detail.parteien.add">
|
||||
Partei hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="partei-form" className="akten-form akten-partei-form" style="display:none" autocomplete="off">
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="partei-name" data-i18n="akten.detail.parteien.form.name">Name</label>
|
||||
<input type="text" id="partei-name" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="partei-role" data-i18n="akten.detail.parteien.form.role">Rolle</label>
|
||||
<select id="partei-role">
|
||||
<option value="claimant" data-i18n="akten.detail.parteien.role.claimant">Kläger</option>
|
||||
<option value="defendant" data-i18n="akten.detail.parteien.role.defendant">Beklagter</option>
|
||||
<option value="thirdparty" data-i18n="akten.detail.parteien.role.thirdparty">Streitverkündeter / Drittpartei</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="partei-rep" data-i18n="akten.detail.parteien.form.rep">Vertreter (optional)</label>
|
||||
<input type="text" id="partei-rep" />
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="partei-cancel" data-i18n="akten.detail.parteien.form.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="akten.detail.parteien.form.submit">Hinzufügen</button>
|
||||
</div>
|
||||
<p className="form-msg" id="partei-msg" />
|
||||
</form>
|
||||
|
||||
<table className="akten-parteien-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="akten.detail.parteien.col.name">Name</th>
|
||||
<th data-i18n="akten.detail.parteien.col.role">Rolle</th>
|
||||
<th data-i18n="akten.detail.parteien.col.rep">Vertreter</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="parteien-body" />
|
||||
</table>
|
||||
|
||||
<p className="akten-events-empty" id="parteien-empty" style="display:none" data-i18n="akten.detail.parteien.empty">
|
||||
Noch keine Parteien eingetragen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Fristen — Phase E */}
|
||||
<section className="akten-tab-panel" id="tab-fristen" style="display:none">
|
||||
<div className="akten-parteien-controls">
|
||||
<a id="frist-add-link" className="btn-primary btn-cta-lime btn-small" data-i18n="akten.detail.fristen.add" href="#">
|
||||
Frist hinzufügen
|
||||
</a>
|
||||
</div>
|
||||
<div className="akten-table-wrap" id="akte-fristen-tablewrap">
|
||||
<table className="akten-table fristen-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th data-i18n="fristen.col.due">Fällig</th>
|
||||
<th data-i18n="fristen.col.title">Titel</th>
|
||||
<th data-i18n="fristen.col.rule">Regel</th>
|
||||
<th data-i18n="fristen.col.status">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="akte-fristen-body" />
|
||||
</table>
|
||||
</div>
|
||||
<p className="akten-events-empty" id="akte-fristen-empty" style="display:none" data-i18n="akten.detail.fristen.empty">
|
||||
Für diese Akte sind noch keine Fristen erfasst.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Termine — Phase F placeholder */}
|
||||
<section className="akten-tab-panel" id="tab-termine" style="display:none">
|
||||
<div className="akten-soon">
|
||||
<h2 data-i18n="akten.detail.soon">Bald verfügbar</h2>
|
||||
<p data-i18n="akten.detail.soon.termine">
|
||||
Termine & CalDAV-Sync folgen in Phase F.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Dokumente — Phase H (upload + AI extraction) */}
|
||||
<section className="akten-tab-panel" id="tab-dokumente" style="display:none">
|
||||
<div className="dokumente-intro">
|
||||
<h2 className="dokumente-heading" data-i18n="akten.detail.dokumente.heading">Dokumente</h2>
|
||||
<p className="dokumente-subtitle" data-i18n="akten.detail.dokumente.subtitle">
|
||||
Gerichtsdokumente hochladen und per KI automatisch Fristen extrahieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="dokument-upload-wrap">
|
||||
<div id="dokument-upload-disabled" className="dokumente-disabled-notice" style="display:none" data-i18n="akten.detail.dokumente.upload.disabled">
|
||||
Dokumenten-Upload ist auf diesem Server nicht konfiguriert.
|
||||
</div>
|
||||
<label id="dokument-upload-zone" className="dokumente-upload-zone" htmlFor="dokument-file-input">
|
||||
<svg className="dokumente-upload-icon" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" 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="12" y1="18" x2="12" y2="12" />
|
||||
<polyline points="9 15 12 12 15 15" />
|
||||
</svg>
|
||||
<div className="dokumente-upload-text" data-i18n="akten.detail.dokumente.upload.zone">
|
||||
PDF hierher ziehen oder klicken zum Auswählen
|
||||
</div>
|
||||
<div className="dokumente-upload-hint" data-i18n="akten.detail.dokumente.upload.hint">Nur PDF, max. 20 MB</div>
|
||||
<input type="file" id="dokument-file-input" accept="application/pdf" style="display:none" />
|
||||
</label>
|
||||
<div id="dokument-upload-progress" className="dokumente-upload-progress" style="display:none">
|
||||
<div className="dokumente-upload-bar"><div className="dokumente-upload-bar-fill" id="dokument-upload-bar-fill" /></div>
|
||||
<span id="dokument-upload-status" data-i18n="akten.detail.dokumente.upload.progress">Hochladen…</span>
|
||||
</div>
|
||||
<div id="dokument-upload-msg" className="form-msg" />
|
||||
</div>
|
||||
|
||||
<div className="akten-table-wrap" id="dokumente-tablewrap" style="margin-top:1.5rem">
|
||||
<table className="akten-table dokumente-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="akten.detail.dokumente.col.name">Dateiname</th>
|
||||
<th data-i18n="akten.detail.dokumente.col.uploaded">Hochgeladen</th>
|
||||
<th data-i18n="akten.detail.dokumente.col.size">Größe</th>
|
||||
<th data-i18n="akten.detail.dokumente.col.actions">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dokumente-body" />
|
||||
</table>
|
||||
</div>
|
||||
<p className="akten-events-empty" id="dokumente-empty" style="display:none" data-i18n="akten.detail.dokumente.list.empty">
|
||||
Noch keine Dokumente hochgeladen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Phase H — Extraction review modal */}
|
||||
<div className="modal-overlay" id="extraction-modal" style="display:none">
|
||||
<div className="modal-card modal-card-wide">
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 data-i18n="akten.detail.dokumente.extraction.title">Extrahierte Fristen</h2>
|
||||
<p className="modal-subtitle" data-i18n="akten.detail.dokumente.extraction.subtitle">
|
||||
Wählen Sie aus, welche Vorschläge als Fristen an die Akte übernommen werden sollen.
|
||||
</p>
|
||||
</div>
|
||||
<button className="modal-close" id="extraction-modal-close" type="button">×</button>
|
||||
</div>
|
||||
<div className="extraction-body">
|
||||
<p className="extraction-none" id="extraction-none" style="display:none" data-i18n="akten.detail.dokumente.extraction.none">
|
||||
Die KI hat keine Fristen im Dokument gefunden.
|
||||
</p>
|
||||
<table className="akten-table extraction-table" id="extraction-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="akten.detail.dokumente.extraction.col.keep">Übernehmen</th>
|
||||
<th data-i18n="akten.detail.dokumente.extraction.col.title">Titel</th>
|
||||
<th data-i18n="akten.detail.dokumente.extraction.col.due">Fällig</th>
|
||||
<th data-i18n="akten.detail.dokumente.extraction.col.rule">Regel</th>
|
||||
<th data-i18n="akten.detail.dokumente.extraction.col.confidence">Konfidenz</th>
|
||||
<th data-i18n="akten.detail.dokumente.extraction.col.source">Quelle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="extraction-body" />
|
||||
</table>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="extraction-cancel" data-i18n="akten.detail.dokumente.extraction.cancel">Abbrechen</button>
|
||||
<button type="button" className="btn-primary btn-cta-lime" id="extraction-save" data-i18n="akten.detail.dokumente.extraction.save">Als Fristen speichern</button>
|
||||
</div>
|
||||
<p className="form-msg" id="extraction-msg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notizen — Phase I placeholder */}
|
||||
<section className="akten-tab-panel" id="tab-notizen" style="display:none">
|
||||
<div className="akten-soon">
|
||||
<h2 data-i18n="akten.detail.soon">Bald verfügbar</h2>
|
||||
<p data-i18n="akten.detail.soon.notizen">
|
||||
Notizfunktion folgt in Phase I.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="akten-detail-footer" id="akte-delete-wrap" style="display:none">
|
||||
<button id="akte-delete-btn" className="btn-danger" type="button" data-i18n="akten.detail.delete">
|
||||
Akte löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<div className="modal-overlay" id="delete-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="akten.detail.delete.confirm.title">Akte wirklich löschen?</h2>
|
||||
<button className="modal-close" id="delete-modal-close" type="button">×</button>
|
||||
</div>
|
||||
<p data-i18n="akten.detail.delete.confirm.body">
|
||||
Die Akte wird archiviert. Sie kann nicht direkt wiederhergestellt werden.
|
||||
</p>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="delete-modal-cancel" data-i18n="akten.detail.delete.confirm.cancel">Abbrechen</button>
|
||||
<button type="button" className="btn-danger" id="delete-modal-confirm" data-i18n="akten.detail.delete.confirm.ok">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/akten-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderAktenNeu(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title data-i18n="akten.neu.title">Neue Akte — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/akten/neu" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container container-narrow">
|
||||
<div className="tool-header">
|
||||
<a href="/akten" className="akten-back-link" data-i18n="akten.detail.back">← Zurück zur Übersicht</a>
|
||||
<h1 data-i18n="akten.neu.heading">Neue Akte anlegen</h1>
|
||||
<p className="tool-subtitle" data-i18n="akten.neu.subtitle">
|
||||
Anlegen eines neuen Mandats im eigenen Büro. Sichtbarkeit folgt der Büro-Regel;
|
||||
Partner können firmenweite Sichtbarkeit aktivieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form id="akten-neu-form" className="akten-form" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="akte-title" data-i18n="akten.field.title">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
id="akte-title"
|
||||
required
|
||||
placeholder="Kurzbezeichnung des Mandats"
|
||||
data-i18n-placeholder="akten.field.title.placeholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="akte-ref" data-i18n="akten.field.ref">Aktenzeichen</label>
|
||||
<input
|
||||
type="text"
|
||||
id="akte-ref"
|
||||
required
|
||||
placeholder="z.B. HL-2026-0042"
|
||||
data-i18n-placeholder="akten.field.ref.placeholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="akte-office" data-i18n="akten.field.office">Federführendes Büro</label>
|
||||
<select id="akte-office" required>
|
||||
<option value="munich" data-i18n="office.munich">München</option>
|
||||
<option value="duesseldorf" data-i18n="office.duesseldorf">Düsseldorf</option>
|
||||
<option value="hamburg" data-i18n="office.hamburg">Hamburg</option>
|
||||
<option value="amsterdam" data-i18n="office.amsterdam">Amsterdam</option>
|
||||
<option value="london" data-i18n="office.london">London</option>
|
||||
<option value="paris" data-i18n="office.paris">Paris</option>
|
||||
<option value="milan" data-i18n="office.milan">Mailand</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="akte-status" data-i18n="akten.field.status">Status</label>
|
||||
<select id="akte-status">
|
||||
<option value="active" data-i18n="akten.status.active">Aktiv</option>
|
||||
<option value="completed" data-i18n="akten.status.completed">Abgeschlossen</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="akte-court" data-i18n="akten.field.court">Gericht (optional)</label>
|
||||
<input type="text" id="akte-court" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="akte-courtref" data-i18n="akten.field.courtRef">Gerichtsaktenzeichen (optional)</label>
|
||||
<input type="text" id="akte-courtref" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="akte-type" data-i18n="akten.field.akteType">Verfahrensart (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="akte-type"
|
||||
placeholder="UPC Infringement, BPatG Nichtigkeit, EPA Opposition..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field" id="firm-wide-wrap" style="display:none">
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" id="akte-firmwide" />
|
||||
<span data-i18n="akten.field.firmWide">Firmenweit sichtbar</span>
|
||||
</label>
|
||||
<p className="form-hint" data-i18n="akten.field.firmWide.hint">
|
||||
Wenn aktiviert, sehen alle Lawyer diese Akte. Nur für Partner/Admin.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="akte-collab-input" data-i18n="akten.field.collaborators">
|
||||
Weitere Bearbeiter (optional)
|
||||
</label>
|
||||
<div className="akten-collab">
|
||||
<div id="akte-collab-list" className="akten-collab-chips" />
|
||||
<input
|
||||
type="text"
|
||||
id="akte-collab-input"
|
||||
placeholder="Name oder E-Mail tippen..."
|
||||
data-i18n-placeholder="akten.field.collaborators.placeholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div id="akte-collab-suggestions" className="akten-collab-suggestions" />
|
||||
</div>
|
||||
<p className="form-hint" data-i18n="akten.field.collaborators.hint">
|
||||
Personen, die auch Zugriff erhalten sollen (auch büroübergreifend).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="form-msg" id="akten-neu-msg" />
|
||||
|
||||
<div className="form-actions">
|
||||
<a href="/akten" className="btn-cancel" data-i18n="akten.cancel">Abbrechen</a>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="akten.submit">Akte anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/akten-neu.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderAkten(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title data-i18n="akten.title">Akten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/akten" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div className="akten-header-row">
|
||||
<div>
|
||||
<h1 data-i18n="akten.heading">Akten</h1>
|
||||
<p className="tool-subtitle" data-i18n="akten.subtitle">
|
||||
Büro-bezogene Mandate. Verlauf, Parteien und (bald) Fristen & Termine an einem Ort.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/akten/neu" className="btn-primary btn-cta-lime" data-i18n="akten.new">
|
||||
Neue Akte
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="akten-controls">
|
||||
<div className="glossar-search-wrap akten-search-wrap">
|
||||
<svg className="glossar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
id="akten-search"
|
||||
className="glossar-search"
|
||||
placeholder="Titel oder Aktenzeichen suchen..."
|
||||
data-i18n-placeholder="akten.search.placeholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span className="glossar-count" id="akten-count" />
|
||||
</div>
|
||||
|
||||
<div className="akten-filter-row">
|
||||
<label className="akten-filter-label" htmlFor="akten-office" data-i18n="akten.filter.office">Büro</label>
|
||||
<select id="akten-office" className="akten-select">
|
||||
<option value="" data-i18n="akten.filter.office.all">Alle Büros</option>
|
||||
<option value="munich" data-i18n="office.munich">München</option>
|
||||
<option value="duesseldorf" data-i18n="office.duesseldorf">Düsseldorf</option>
|
||||
<option value="hamburg" data-i18n="office.hamburg">Hamburg</option>
|
||||
<option value="amsterdam" data-i18n="office.amsterdam">Amsterdam</option>
|
||||
<option value="london" data-i18n="office.london">London</option>
|
||||
<option value="paris" data-i18n="office.paris">Paris</option>
|
||||
<option value="milan" data-i18n="office.milan">Mailand</option>
|
||||
</select>
|
||||
|
||||
<label className="akten-filter-label" htmlFor="akten-status" data-i18n="akten.filter.status">Status</label>
|
||||
<select id="akten-status" className="akten-select">
|
||||
<option value="" data-i18n="akten.filter.status.all">Alle Status</option>
|
||||
<option value="active" data-i18n="akten.filter.status.active">Aktiv</option>
|
||||
<option value="completed" data-i18n="akten.filter.status.completed">Abgeschlossen</option>
|
||||
<option value="archived" data-i18n="akten.filter.status.archived">Archiviert</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="akten-unavailable" className="akten-unavailable" style="display:none">
|
||||
<p data-i18n="akten.unavailable">
|
||||
Aktenverwaltung zurzeit nicht verfügbar — bitte Administrator kontaktieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="akten-table-wrap">
|
||||
<table className="akten-table" id="akten-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="akten.col.title">Titel</th>
|
||||
<th data-i18n="akten.col.ref">Aktenzeichen</th>
|
||||
<th data-i18n="akten.col.office">Büro</th>
|
||||
<th data-i18n="akten.col.status">Status</th>
|
||||
<th data-i18n="akten.col.updated">Zuletzt geändert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="akten-body" />
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="akten-empty" id="akten-empty" style="display:none">
|
||||
<h2 data-i18n="akten.empty.title">Noch keine Akte angelegt</h2>
|
||||
<p data-i18n="akten.empty.hint">
|
||||
Starten Sie über „Neue Akte“ — Sie sehen hier später Ihre Mandate, nach Büro gefiltert.
|
||||
</p>
|
||||
<a href="/akten/neu" className="btn-primary btn-cta-lime" data-i18n="akten.new">Neue Akte</a>
|
||||
</div>
|
||||
|
||||
<div className="akten-empty akten-empty-filtered" id="akten-empty-filtered" style="display:none">
|
||||
<p data-i18n="akten.empty.filtered">Keine Treffer für diese Filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/akten.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
101
frontend/src/appointments-calendar.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
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 />
|
||||
<script src="/assets/appointments-calendar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
111
frontend/src/appointments-detail.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAppointmentsDetail(): 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.detail.title">Termin — 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 container-narrow">
|
||||
<a href="/events?type=appointment" className="back-link" data-i18n="appointments.detail.back">← Zurück zur Übersicht</a>
|
||||
|
||||
<div id="appointment-loading" className="entity-loading" data-i18n="appointments.detail.loading">Lädt…</div>
|
||||
<div id="appointment-not-found" style="display:none" className="entity-empty">
|
||||
<h2 data-i18n="appointments.detail.notfound">Termin nicht gefunden</h2>
|
||||
<p data-i18n="appointments.detail.notfound.hint">Der Termin existiert nicht oder Sie haben keine Berechtigung.</p>
|
||||
</div>
|
||||
|
||||
<div id="appointment-body" style="display:none">
|
||||
<div className="tool-header">
|
||||
<span className="termin-type-badge" id="appointment-type-badge" />
|
||||
<h1 id="appointment-title-display" />
|
||||
<p className="tool-subtitle" id="appointment-time-display" />
|
||||
</div>
|
||||
|
||||
<div id="appointment-project-row" className="entity-detail-meta-row" style="display:none">
|
||||
<span className="entity-detail-meta-label" data-i18n="appointments.detail.akte">Akte:</span>
|
||||
<a id="appointment-project-link" className="entity-ref-link" />
|
||||
</div>
|
||||
|
||||
<section className="termin-notes-section">
|
||||
<h2 className="frist-section-heading" data-i18n="notes.section.title">Notizen</h2>
|
||||
<div id="notes-container" className="notiz-container" data-parent-type="appointment" />
|
||||
</section>
|
||||
|
||||
<form id="appointment-edit-form" className="entity-form">
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-title-edit" data-i18n="appointments.field.title">Titel</label>
|
||||
<input type="text" id="appointment-title-edit" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-project-edit" data-i18n="appointments.field.akte">Akte (optional)</label>
|
||||
<select id="appointment-project-edit">
|
||||
<option value="" data-i18n="appointments.field.akte.none">Persönlicher Termin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-start-edit" data-i18n="appointments.field.start">Beginn</label>
|
||||
<input type="datetime-local" id="appointment-start-edit" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-end-edit" data-i18n="appointments.field.end">Ende (optional)</label>
|
||||
<input type="datetime-local" id="appointment-end-edit" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-type-edit" data-i18n="appointments.field.type">Typ</label>
|
||||
<select id="appointment-type-edit">
|
||||
<option value="" data-i18n="appointments.field.type.none">Kein Typ</option>
|
||||
<option value="hearing" data-i18n="appointments.type.hearing">Verhandlung</option>
|
||||
<option value="meeting" data-i18n="appointments.type.meeting">Besprechung</option>
|
||||
<option value="consultation" data-i18n="appointments.type.consultation">Beratung</option>
|
||||
<option value="deadline_hearing" data-i18n="appointments.type.deadline_hearing">Fristverhandlung</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-location-edit" data-i18n="appointments.field.location">Ort</label>
|
||||
<input type="text" id="appointment-location-edit" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-description-edit" data-i18n="appointments.field.description">Beschreibung</label>
|
||||
<textarea id="appointment-description-edit" rows={3} />
|
||||
</div>
|
||||
|
||||
<p className="form-msg" id="appointment-edit-msg" />
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" id="appointment-delete-btn" className="btn-danger" data-i18n="appointments.detail.delete">Termin löschen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="appointments.detail.save">Änderungen speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/appointments-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
103
frontend/src/appointments-new.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAppointmentsNew(): 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.neu.title">Neuer Termin — 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 container-narrow">
|
||||
<div className="tool-header">
|
||||
<a href="/events?type=appointment" className="back-link" id="appointment-new-back" data-i18n="appointments.neu.back">← Zurück zur Übersicht</a>
|
||||
<h1 data-i18n="appointments.neu.heading">Neuer Termin</h1>
|
||||
<p className="tool-subtitle" data-i18n="appointments.neu.subtitle">
|
||||
Persönlich oder einer Akte zugeordnet. Bei aktiver CalDAV-Synchronisation erscheint der Termin auch im externen Kalender.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form id="appointment-new-form" className="entity-form" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-title" data-i18n="appointments.field.title">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
id="appointment-title"
|
||||
required
|
||||
placeholder="z.B. Mündliche Verhandlung"
|
||||
data-i18n-placeholder="appointments.field.title.placeholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-start" data-i18n="appointments.field.start">Beginn</label>
|
||||
<input type="datetime-local" id="appointment-start" required />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-end" data-i18n="appointments.field.end">Ende (optional)</label>
|
||||
<input type="datetime-local" id="appointment-end" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-type" data-i18n="appointments.field.type">Typ</label>
|
||||
<select id="appointment-type">
|
||||
<option value="" data-i18n="appointments.field.type.none">Kein Typ</option>
|
||||
<option value="hearing" data-i18n="appointments.type.hearing">Verhandlung</option>
|
||||
<option value="meeting" data-i18n="appointments.type.meeting">Besprechung</option>
|
||||
<option value="consultation" data-i18n="appointments.type.consultation">Beratung</option>
|
||||
<option value="deadline_hearing" data-i18n="appointments.type.deadline_hearing">Fristverhandlung</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-project" data-i18n="appointments.field.akte">Akte (optional)</label>
|
||||
<select id="appointment-project">
|
||||
<option value="" data-i18n="appointments.field.akte.none">Persönlicher Termin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-location" data-i18n="appointments.field.location">Ort (optional)</label>
|
||||
<input type="text" id="appointment-location" placeholder="z.B. UPC LD München" data-i18n-placeholder="appointments.field.location.placeholder" />
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="appointment-description" data-i18n="appointments.field.description">Beschreibung (optional)</label>
|
||||
<textarea id="appointment-description" rows={3} placeholder="Hinweise, Tagesordnung, nächste Schritte…" data-i18n-placeholder="appointments.field.description.placeholder" />
|
||||
</div>
|
||||
|
||||
<p className="form-msg" id="appointment-new-msg" />
|
||||
|
||||
<div className="form-actions">
|
||||
<a href="/events?type=appointment" id="appointment-new-cancel" className="btn-cancel" data-i18n="appointments.neu.cancel">Abbrechen</a>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="appointments.neu.submit">Termin anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/appointments-new.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
31
frontend/src/branding.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// frontend/src/branding.ts — single source of truth for the firm name
|
||||
// Paliad's UI renders. Mirrors internal/branding/firm.go on the server.
|
||||
//
|
||||
// At build time this resolves twice:
|
||||
// 1. In the server-side render path (build.ts → renderXxx() returning HTML)
|
||||
// Bun is running under Node, so process.env.FIRM_NAME is the real env
|
||||
// var the deployer set; this file is loaded as a regular ESM module.
|
||||
// 2. In the bundled client modules (e.g. client/i18n.ts) Bun.build replaces
|
||||
// `process.env.FIRM_NAME` with a string literal via the `define` option
|
||||
// configured in build.ts. Browsers never see process.env — every
|
||||
// reference is statically substituted before the bundle is emitted.
|
||||
//
|
||||
// Both paths default to "HLC" when FIRM_NAME is unset.
|
||||
//
|
||||
// IMPORTANT: do NOT guard the read with `typeof process !== "undefined"` or
|
||||
// any check on `process` itself. The minifier rewrites that guard into a
|
||||
// short-string lexical comparison (`typeof process < "u"`) which evaluates
|
||||
// false in the browser and would short-circuit the value back to "HLC" even
|
||||
// when define has substituted the env var. The bare `process.env.FIRM_NAME`
|
||||
// reference is only safe because build.ts's `define` rewrites it away
|
||||
// completely for browser bundles.
|
||||
//
|
||||
// Why a runtime constant rather than i18n placeholder substitution: every
|
||||
// Paliad surface (HTML title, hero headline, email body, PDF footer) has the
|
||||
// firm name baked in literally; threading {{firm}} placeholders + a
|
||||
// formatter through every t() call would be a far larger churn for the same
|
||||
// firm-agnostic outcome. Re-deploying with FIRM_NAME=Acme rebuilds every
|
||||
// asset with the new name in one step.
|
||||
|
||||
const RAW: string = (process.env.FIRM_NAME ?? "").trim();
|
||||
export const FIRM: string = RAW !== "" ? RAW : "HLC";
|
||||
48
frontend/src/changelog.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderChangelog(): 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="changelog.title">Neuigkeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/changelog" />
|
||||
<BottomNav currentPath="/changelog" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="changelog.heading">Neuigkeiten</h1>
|
||||
<p className="tool-subtitle" data-i18n="changelog.subtitle">
|
||||
Was sich in Paliad in letzter Zeit getan hat.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol className="changelog-list" id="changelog-list" />
|
||||
|
||||
<p className="changelog-empty" id="changelog-empty" style="display:none" data-i18n="changelog.empty">
|
||||
Noch keine Einträge.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/changelog.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderChecklistenDetail(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title data-i18n="checklisten.title">Checkliste — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/checklisten" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<a href="/checklisten" className="checklist-back">
|
||||
<span className="checklist-back-arrow">←</span>
|
||||
<span data-i18n="checklisten.back">Zurück zur Übersicht</span>
|
||||
</a>
|
||||
|
||||
<div className="tool-header checklist-detail-header">
|
||||
<div className="checklist-detail-head-row">
|
||||
<div>
|
||||
<h1 id="checklist-title"> </h1>
|
||||
<p className="tool-subtitle" id="checklist-subtitle"> </p>
|
||||
<dl className="checklist-meta" id="checklist-meta" />
|
||||
</div>
|
||||
<div className="checklist-actions">
|
||||
<button type="button" id="btn-print" className="btn-ghost" data-i18n="checklisten.print">Drucken</button>
|
||||
<button type="button" id="btn-reset" className="btn-ghost" data-i18n="checklisten.reset">Zurücksetzen</button>
|
||||
<button type="button" id="btn-feedback" className="btn-suggest">
|
||||
<span data-i18n="checklisten.feedback.btn">Feedback</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="checklist-progress">
|
||||
<div className="checklist-progress-bar">
|
||||
<div className="checklist-progress-fill" id="progress-fill" />
|
||||
</div>
|
||||
<span className="checklist-progress-label" id="progress-label">0 / 0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="checklist-groups" className="checklist-groups" />
|
||||
|
||||
<div className="checklist-print-footer">
|
||||
<p className="checklist-disclaimer" data-i18n="checklisten.disclaimer">
|
||||
Hinweis: Diese Checklisten dienen als Gedächtnisstütze und ersetzen keine Prüfung im Einzelfall. Maßgeblich sind die jeweils geltenden Verfahrensregeln.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Feedback modal */}
|
||||
<div className="modal-overlay" id="feedback-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="checklisten.feedback.title">Feedback zur Checkliste</h2>
|
||||
<button className="modal-close" id="modal-close" type="button">×</button>
|
||||
</div>
|
||||
<form id="feedback-form">
|
||||
<div className="form-field">
|
||||
<label htmlFor="feedback-type" data-i18n="checklisten.feedback.type">Art</label>
|
||||
<select id="feedback-type" required>
|
||||
<option value="error" data-i18n="checklisten.feedback.error">Fehler gefunden</option>
|
||||
<option value="missing" data-i18n="checklisten.feedback.missing">Fehlender Punkt</option>
|
||||
<option value="suggestion" data-i18n="checklisten.feedback.suggestion">Verbesserungsvorschlag</option>
|
||||
<option value="other" data-i18n="checklisten.feedback.other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="feedback-message" data-i18n="checklisten.feedback.message">Nachricht</label>
|
||||
<textarea id="feedback-message" rows={4} required />
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="modal-cancel" data-i18n="checklisten.feedback.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-submit" data-i18n="checklisten.feedback.submit">Absenden</button>
|
||||
</div>
|
||||
<p className="form-msg" id="feedback-msg" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/checklisten-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
export function renderChecklisten(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title data-i18n="checklisten.title">Checklisten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/checklisten" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="checklisten.heading">Checklisten</h1>
|
||||
<p className="tool-subtitle" data-i18n="checklisten.subtitle">
|
||||
Interaktive Checklisten für typische Verfahrensschritte vor UPC, BPatG und EPA.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="checklist-filters" id="checklist-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>
|
||||
</div>
|
||||
|
||||
<div className="checklist-grid" id="checklist-grid" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/checklisten.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
159
frontend/src/checklists-detail.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Template detail page. Shows template metadata + list of existing
|
||||
// instances + CTA to create a new instance. Clicking an instance takes
|
||||
// the user to /checklisten/instances/{id} where the interactive
|
||||
// checkboxes live.
|
||||
export function renderChecklistsDetail(): 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.title">Checkliste — 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">
|
||||
<a href="/checklists" className="checklist-back">
|
||||
<span className="checklist-back-arrow">←</span>
|
||||
<span data-i18n="checklisten.back">Zurück zur Übersicht</span>
|
||||
</a>
|
||||
|
||||
<div className="tool-header checklist-detail-header">
|
||||
<div className="checklist-detail-head-row">
|
||||
<div>
|
||||
<h1 id="checklist-title"> </h1>
|
||||
<p className="tool-subtitle" id="checklist-subtitle"> </p>
|
||||
<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>
|
||||
<button type="button" id="btn-feedback" className="btn-cta-lime btn-outline">
|
||||
<span data-i18n="checklisten.feedback.btn">Feedback</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="checklist-instances-section">
|
||||
<h2 data-i18n="checklisten.instances.heading">Instanzen</h2>
|
||||
<p className="tool-subtitle" data-i18n="checklisten.instances.sub">
|
||||
Jede Instanz hat ihren eigenen Fortschritt und kann optional an eine Akte gehängt werden.
|
||||
</p>
|
||||
|
||||
<div id="instances-loading" className="entity-loading">
|
||||
<p data-i18n="checklisten.instances.loading">Lädt…</p>
|
||||
</div>
|
||||
|
||||
<div id="instances-empty" className="entity-events-empty" style="display:none">
|
||||
<p data-i18n="checklisten.instances.empty">
|
||||
Noch keine Instanzen. Klicken Sie auf „Neue Instanz“, um zu beginnen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="instances-tablewrap" style="display:none">
|
||||
<table className="entity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="checklisten.instances.col.name">Name</th>
|
||||
<th data-i18n="checklisten.instances.col.progress">Fortschritt</th>
|
||||
<th data-i18n="checklisten.instances.col.akte">Akte</th>
|
||||
<th data-i18n="checklisten.instances.col.created">Angelegt</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="instances-body" />
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="checklist-print-footer">
|
||||
<p className="checklist-disclaimer" data-i18n="checklisten.disclaimer">
|
||||
Hinweis: Diese Checklisten dienen als Gedächtnisstütze und ersetzen keine Prüfung im Einzelfall. Maßgeblich sind die jeweils geltenden Verfahrensregeln.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Neue Instanz modal */}
|
||||
<div className="modal-overlay" id="new-instance-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="checklisten.newInstance.title">Neue Checklisten-Instanz</h2>
|
||||
<button className="modal-close" id="new-instance-close" type="button">×</button>
|
||||
</div>
|
||||
<form id="new-instance-form" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="new-instance-name" data-i18n="checklisten.newInstance.name">Name</label>
|
||||
<input type="text" id="new-instance-name" required maxLength={200} />
|
||||
<p className="form-hint" data-i18n="checklisten.newInstance.name.hint">z.B. „Müller v. Schmidt — SoC“.</p>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="new-instance-project" data-i18n="checklisten.newInstance.akte">Akte (optional)</label>
|
||||
<select id="new-instance-project">
|
||||
<option value="" data-i18n="checklisten.newInstance.akte.none">— keine Akte —</option>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="checklisten.newInstance.akte.hint">Wenn verknüpft, sehen Bürokollegen die Instanz.</p>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="new-instance-cancel" data-i18n="checklisten.newInstance.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="checklisten.newInstance.submit">Anlegen</button>
|
||||
</div>
|
||||
<p className="form-msg" id="new-instance-msg" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback modal */}
|
||||
<div className="modal-overlay" id="feedback-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="checklisten.feedback.title">Feedback zur Checkliste</h2>
|
||||
<button className="modal-close" id="modal-close" type="button">×</button>
|
||||
</div>
|
||||
<form id="feedback-form">
|
||||
<div className="form-field">
|
||||
<label htmlFor="feedback-type" data-i18n="checklisten.feedback.type">Art</label>
|
||||
<select id="feedback-type" required>
|
||||
<option value="error" data-i18n="checklisten.feedback.error">Fehler gefunden</option>
|
||||
<option value="missing" data-i18n="checklisten.feedback.missing">Fehlender Punkt</option>
|
||||
<option value="suggestion" data-i18n="checklisten.feedback.suggestion">Verbesserungsvorschlag</option>
|
||||
<option value="other" data-i18n="checklisten.feedback.other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="feedback-message" data-i18n="checklisten.feedback.message">Nachricht</label>
|
||||
<textarea id="feedback-message" rows={4} required />
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="modal-cancel" data-i18n="checklisten.feedback.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-submit" data-i18n="checklisten.feedback.submit">Absenden</button>
|
||||
</div>
|
||||
<p className="form-msg" id="feedback-msg" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/checklists-detail.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
125
frontend/src/checklists-instance.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Interactive instance page. Loads template + instance JSON, renders
|
||||
// checkboxes, PATCHes /api/checklist-instances/{id} on every toggle.
|
||||
// Reset button POSTs to .../reset.
|
||||
export function renderChecklistsInstance(): 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.instance.title">Checklisten-Instanz — 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">
|
||||
<a href="#" id="instance-back" className="checklist-back">
|
||||
<span className="checklist-back-arrow">←</span>
|
||||
<span data-i18n="checklisten.instance.back">Zurück zur Vorlage</span>
|
||||
</a>
|
||||
|
||||
<div id="instance-loading" className="entity-loading">
|
||||
<p data-i18n="checklisten.instance.loading">Lädt…</p>
|
||||
</div>
|
||||
|
||||
<div id="instance-notfound" className="entity-empty" style="display:none">
|
||||
<p data-i18n="checklisten.instance.notfound">Instanz nicht gefunden oder keine Berechtigung.</p>
|
||||
</div>
|
||||
|
||||
<div id="instance-body" style="display:none">
|
||||
<div className="tool-header checklist-detail-header">
|
||||
<div className="checklist-detail-head-row">
|
||||
<div className="checklist-instance-titles">
|
||||
<div className="checklist-instance-name-row">
|
||||
<h1 id="instance-name-display" />
|
||||
<input type="text" id="instance-name-edit" className="entity-title-input" maxLength={200} style="display:none" />
|
||||
<button id="instance-rename-btn" className="btn-icon" type="button" aria-label="Umbenennen" data-i18n-title="checklisten.instance.rename" title="Umbenennen">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button id="instance-name-save" className="btn-primary btn-cta-lime btn-small" type="button" style="display:none" data-i18n="checklisten.instance.rename.save">Speichern</button>
|
||||
</div>
|
||||
<p className="tool-subtitle" id="instance-template-title"> </p>
|
||||
<dl className="checklist-meta" id="instance-meta" />
|
||||
</div>
|
||||
<div className="checklist-actions">
|
||||
<button type="button" id="btn-print" className="btn-ghost" data-i18n="checklisten.print">Drucken</button>
|
||||
<button type="button" id="btn-reset" className="btn-ghost" data-i18n="checklisten.reset">Zurücksetzen</button>
|
||||
<button type="button" id="btn-feedback" className="btn-suggest">
|
||||
<span data-i18n="checklisten.feedback.btn">Feedback</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="checklist-progress">
|
||||
<div className="checklist-progress-bar">
|
||||
<div className="checklist-progress-fill" id="progress-fill" />
|
||||
</div>
|
||||
<span className="checklist-progress-label" id="progress-label">0 / 0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="checklist-groups" className="checklist-groups" />
|
||||
|
||||
<div className="checklist-print-footer">
|
||||
<p className="checklist-disclaimer" data-i18n="checklisten.disclaimer">
|
||||
Hinweis: Diese Checklisten dienen als Gedächtnisstütze und ersetzen keine Prüfung im Einzelfall. Maßgeblich sind die jeweils geltenden Verfahrensregeln.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Feedback modal */}
|
||||
<div className="modal-overlay" id="feedback-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="checklisten.feedback.title">Feedback zur Checkliste</h2>
|
||||
<button className="modal-close" id="modal-close" type="button">×</button>
|
||||
</div>
|
||||
<form id="feedback-form">
|
||||
<div className="form-field">
|
||||
<label htmlFor="feedback-type" data-i18n="checklisten.feedback.type">Art</label>
|
||||
<select id="feedback-type" required>
|
||||
<option value="error" data-i18n="checklisten.feedback.error">Fehler gefunden</option>
|
||||
<option value="missing" data-i18n="checklisten.feedback.missing">Fehlender Punkt</option>
|
||||
<option value="suggestion" data-i18n="checklisten.feedback.suggestion">Verbesserungsvorschlag</option>
|
||||
<option value="other" data-i18n="checklisten.feedback.other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="feedback-message" data-i18n="checklisten.feedback.message">Nachricht</label>
|
||||
<textarea id="feedback-message" rows={4} required />
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="modal-cancel" data-i18n="checklisten.feedback.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-submit" data-i18n="checklisten.feedback.submit">Absenden</button>
|
||||
</div>
|
||||
<p className="form-msg" id="feedback-msg" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/checklists-instance.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
81
frontend/src/checklists.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderChecklists(): 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.title">Checklisten — 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 data-i18n="checklisten.heading">Checklisten</h1>
|
||||
<p className="tool-subtitle" data-i18n="checklisten.subtitle">
|
||||
Interaktive Checklisten für typische Verfahrensschritte vor UPC, BPatG und EPA.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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="instances" href="/checklists?tab=instances" data-i18n="checklisten.tab.instances">Vorhandene Instanzen</a>
|
||||
</nav>
|
||||
|
||||
{/* Templates tab — pick a template to inspect / instantiate */}
|
||||
<section className="entity-tab-panel" id="tab-templates">
|
||||
<div className="checklist-filters" id="checklist-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>
|
||||
</div>
|
||||
|
||||
<div className="checklist-grid" id="checklist-grid" />
|
||||
</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>
|
||||
<p className="entity-events-empty" id="checklists-instances-empty" style="display:none" data-i18n="checklisten.instances.all.empty">
|
||||
Noch keine Checklisten-Instanzen erfasst. Legen Sie eine über den Vorlagen-Tab an.
|
||||
</p>
|
||||
<div className="entity-table-wrap" id="checklists-instances-tablewrap" style="display:none">
|
||||
<table className="entity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="checklisten.instances.all.col.template">Vorlage</th>
|
||||
<th data-i18n="checklisten.instances.all.col.name">Name</th>
|
||||
<th data-i18n="checklisten.instances.all.col.project">Projekt</th>
|
||||
<th data-i18n="checklisten.instances.all.col.progress">Fortschritt</th>
|
||||
<th data-i18n="checklisten.instances.all.col.created">Angelegt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="checklists-instances-body" />
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/checklists.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
253
frontend/src/client/admin-audit-log.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface AuditEntry {
|
||||
timestamp: string;
|
||||
id: string;
|
||||
source: string;
|
||||
event_type: string;
|
||||
actor: string;
|
||||
subject: string;
|
||||
project_id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Cursor {
|
||||
ts: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface AuditResponse {
|
||||
entries: AuditEntry[];
|
||||
next_cursor: Cursor | null;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
let entries: AuditEntry[] = [];
|
||||
let nextCursor: Cursor | null = null;
|
||||
let searchDebounce: number | undefined;
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s ?? "";
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
return d.toLocaleString(locale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
return tDyn(`admin.audit.source.${source}`) || source;
|
||||
}
|
||||
|
||||
function sourceClass(source: string): string {
|
||||
switch (source) {
|
||||
case "project_events":
|
||||
return "admin-audit-source admin-audit-source-project";
|
||||
case "caldav_sync_log":
|
||||
return "admin-audit-source admin-audit-source-caldav";
|
||||
case "reminder_log":
|
||||
return "admin-audit-source admin-audit-source-reminder";
|
||||
default:
|
||||
return "admin-audit-source";
|
||||
}
|
||||
}
|
||||
|
||||
// eventNarrative produces the localised "what happened" pair (event label,
|
||||
// description body) for a row. project_events delegates to the shared
|
||||
// translateEvent — that's the t-paliad-067 PR-1 logic the audit table is
|
||||
// supposed to reuse so DE/EN narratives stay identical to the dashboard
|
||||
// activity feed. caldav_sync_log and reminder_log have their own per-event
|
||||
// label keys; their stored description is already a flat key=value summary
|
||||
// so we surface it verbatim.
|
||||
function eventNarrative(e: AuditEntry): { label: string; body: string } {
|
||||
if (e.source === "project_events") {
|
||||
const { title, description } = translateEvent(
|
||||
e.event_type,
|
||||
e.title ?? "",
|
||||
e.description ?? "",
|
||||
);
|
||||
return { label: title || e.event_type, body: description };
|
||||
}
|
||||
const labelKey = `admin.audit.event.${e.event_type}`;
|
||||
const translated = tDyn(labelKey);
|
||||
const label = translated && translated !== labelKey ? translated : e.event_type;
|
||||
return { label, body: e.description ?? "" };
|
||||
}
|
||||
|
||||
function rowHTML(e: AuditEntry): string {
|
||||
const { label, body } = eventNarrative(e);
|
||||
const subjectCell = e.project_id
|
||||
? `<a href="/projects/${esc(e.project_id)}">${esc(e.subject)}</a>`
|
||||
: esc(e.subject);
|
||||
return `
|
||||
<tr data-id="${esc(e.id)}">
|
||||
<td class="admin-audit-time">${esc(fmtDateTime(e.timestamp))}</td>
|
||||
<td><span class="${sourceClass(e.source)}">${esc(sourceLabel(e.source))}</span></td>
|
||||
<td><code class="admin-audit-event">${esc(label)}</code></td>
|
||||
<td>${esc(e.actor)}</td>
|
||||
<td>${subjectCell}</td>
|
||||
<td class="admin-audit-desc">${esc(body)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("audit-feedback")!;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
|
||||
el.style.display = "block";
|
||||
if (!isError) {
|
||||
setTimeout(() => { el.style.display = "none"; }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tbody = document.getElementById("audit-tbody")!;
|
||||
const empty = document.getElementById("audit-empty")!;
|
||||
const counter = document.getElementById("audit-count")!;
|
||||
const loadmore = document.getElementById("audit-loadmore") as HTMLButtonElement;
|
||||
|
||||
if (entries.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
counter.textContent = "0";
|
||||
} else {
|
||||
empty.style.display = "none";
|
||||
tbody.innerHTML = entries.map(rowHTML).join("");
|
||||
counter.textContent = String(entries.length);
|
||||
}
|
||||
loadmore.style.display = nextCursor ? "" : "none";
|
||||
}
|
||||
|
||||
function rangePresetToFrom(preset: string): Date | null {
|
||||
const now = new Date();
|
||||
switch (preset) {
|
||||
case "24h":
|
||||
return new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
case "7d":
|
||||
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
case "30d":
|
||||
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildQuery(cursor: Cursor | null): string {
|
||||
const params = new URLSearchParams();
|
||||
const source = (document.getElementById("audit-source") as HTMLSelectElement).value;
|
||||
if (source) params.set("source", source);
|
||||
|
||||
const range = (document.getElementById("audit-range") as HTMLSelectElement).value;
|
||||
if (range === "custom") {
|
||||
const from = (document.getElementById("audit-from") as HTMLInputElement).value;
|
||||
const to = (document.getElementById("audit-to") as HTMLInputElement).value;
|
||||
if (from) params.set("from", from);
|
||||
if (to) params.set("to", to);
|
||||
} else if (range !== "all") {
|
||||
const from = rangePresetToFrom(range);
|
||||
if (from) params.set("from", from.toISOString());
|
||||
}
|
||||
|
||||
const q = (document.getElementById("audit-search") as HTMLInputElement).value.trim();
|
||||
if (q) params.set("q", q);
|
||||
|
||||
params.set("limit", String(PAGE_SIZE));
|
||||
if (cursor) {
|
||||
params.set("before_ts", cursor.ts);
|
||||
params.set("before_id", cursor.id);
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
async function fetchPage(cursor: Cursor | null): Promise<AuditResponse | null> {
|
||||
const url = "/api/audit-log?" + buildQuery(cursor);
|
||||
const resp = await fetch(url);
|
||||
if (resp.status === 403) {
|
||||
showFeedback(t("admin.audit.error.forbidden") || "Nur Admins.", true);
|
||||
return null;
|
||||
}
|
||||
if (resp.status === 503) {
|
||||
showFeedback(t("admin.audit.error.unavailable") || "Audit-Service nicht verfügbar.", true);
|
||||
return null;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || resp.statusText, true);
|
||||
return null;
|
||||
}
|
||||
return (await resp.json()) as AuditResponse;
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
const tbody = document.getElementById("audit-tbody")!;
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="admin-audit-loading">${esc(t("admin.audit.loading") || "Lade ...")}</td></tr>`;
|
||||
const resp = await fetchPage(null);
|
||||
if (!resp) return;
|
||||
entries = resp.entries;
|
||||
nextCursor = resp.next_cursor;
|
||||
render();
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!nextCursor) return;
|
||||
const btn = document.getElementById("audit-loadmore") as HTMLButtonElement;
|
||||
btn.disabled = true;
|
||||
btn.textContent = t("admin.audit.loading") || "Lade ...";
|
||||
const resp = await fetchPage(nextCursor);
|
||||
btn.disabled = false;
|
||||
btn.textContent = t("admin.audit.loadmore") || "Weitere laden";
|
||||
if (!resp) return;
|
||||
entries = entries.concat(resp.entries);
|
||||
nextCursor = resp.next_cursor;
|
||||
render();
|
||||
}
|
||||
|
||||
function bindFilters() {
|
||||
const sourceSel = document.getElementById("audit-source") as HTMLSelectElement;
|
||||
const rangeSel = document.getElementById("audit-range") as HTMLSelectElement;
|
||||
const fromInput = document.getElementById("audit-from") as HTMLInputElement;
|
||||
const toInput = document.getElementById("audit-to") as HTMLInputElement;
|
||||
const searchInput = document.getElementById("audit-search") as HTMLInputElement;
|
||||
const customWrap = document.getElementById("audit-custom-range")!;
|
||||
|
||||
const onChange = () => { void reload(); };
|
||||
|
||||
sourceSel.addEventListener("change", onChange);
|
||||
rangeSel.addEventListener("change", () => {
|
||||
customWrap.style.display = rangeSel.value === "custom" ? "" : "none";
|
||||
onChange();
|
||||
});
|
||||
fromInput.addEventListener("change", onChange);
|
||||
toInput.addEventListener("change", onChange);
|
||||
|
||||
searchInput.addEventListener("input", () => {
|
||||
if (searchDebounce) window.clearTimeout(searchDebounce);
|
||||
searchDebounce = window.setTimeout(reload, 250);
|
||||
});
|
||||
|
||||
document.getElementById("audit-loadmore")!.addEventListener("click", () => {
|
||||
void loadMore();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
bindFilters();
|
||||
onLangChange(render);
|
||||
void reload();
|
||||
});
|
||||
420
frontend/src/client/admin-email-templates-edit.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// /admin/email-templates/{key}?lang=de — editor client.
|
||||
//
|
||||
// Wires the static SPA shell to the API: fetches active row + variables +
|
||||
// version log on load, debounces a preview request on every input change,
|
||||
// posts saves and resets, and offers per-version restore.
|
||||
//
|
||||
// Render-only HTML is built with createElement (not innerHTML on user data)
|
||||
// so a malicious template body can't escape the editor chrome — the
|
||||
// preview iframe sandboxes the rendered HTML separately.
|
||||
|
||||
interface ActiveRow {
|
||||
key: string;
|
||||
lang: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
updated_at?: string | null;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
interface VariableContract {
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
sample_de: string;
|
||||
sample_en: string;
|
||||
}
|
||||
|
||||
interface VersionRow {
|
||||
id: string;
|
||||
saved_at: string;
|
||||
saved_by?: string | null;
|
||||
note: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const PREVIEW_DEBOUNCE_MS = 500;
|
||||
|
||||
let currentKey = "";
|
||||
let currentLang: "de" | "en" = "de";
|
||||
let activeRow: ActiveRow | null = null;
|
||||
let variables: VariableContract[] = [];
|
||||
let dirty = false;
|
||||
let previewTimer: number | null = null;
|
||||
|
||||
function readKeyFromPath(): string {
|
||||
const m = location.pathname.match(/^\/admin\/email-templates\/([^/]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : "";
|
||||
}
|
||||
|
||||
function readLangFromQuery(): "de" | "en" {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const v = params.get("lang");
|
||||
return v === "en" ? "en" : "de";
|
||||
}
|
||||
|
||||
function setLangInURL(lang: "de" | "en") {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set("lang", lang);
|
||||
history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("admin-et-feedback");
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
|
||||
el.style.display = "block";
|
||||
if (!isError) {
|
||||
setTimeout(() => {
|
||||
if (el.textContent === msg) el.style.display = "none";
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function clearFeedback() {
|
||||
const el = document.getElementById("admin-et-feedback");
|
||||
if (el) el.style.display = "none";
|
||||
}
|
||||
|
||||
function setDirty(d: boolean) {
|
||||
dirty = d;
|
||||
const saveBtn = document.getElementById("admin-et-save") as HTMLButtonElement | null;
|
||||
if (saveBtn) saveBtn.disabled = !d;
|
||||
}
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function applyToInputs(row: ActiveRow) {
|
||||
const subj = document.getElementById("admin-et-subject") as HTMLInputElement | null;
|
||||
const body = document.getElementById("admin-et-body") as HTMLTextAreaElement | null;
|
||||
const subjWrap = document.getElementById("admin-et-subject-wrap");
|
||||
if (subj) subj.value = row.subject;
|
||||
if (body) body.value = row.body;
|
||||
if (subjWrap) {
|
||||
// base templates have no subject of their own — hide the field.
|
||||
subjWrap.style.display = row.key === "base" ? "none" : "";
|
||||
}
|
||||
const slot = document.getElementById("admin-et-slot") as HTMLSelectElement | null;
|
||||
if (slot) {
|
||||
slot.style.display = row.key === "deadline_digest" ? "" : "none";
|
||||
}
|
||||
setDirty(false);
|
||||
|
||||
const sub = document.getElementById("admin-et-subtitle");
|
||||
if (sub) {
|
||||
if (row.is_default) {
|
||||
sub.textContent = t("admin.email_templates.editor.is_default") || "Aktuell wird der Standard verwendet.";
|
||||
} else {
|
||||
const tpl = t("admin.email_templates.editor.last_modified") || "Zuletzt geändert: {date}";
|
||||
sub.textContent = tpl.replace("{date}", fmtDate(row.updated_at));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyTitle(key: string, lang: string) {
|
||||
const title = document.getElementById("admin-et-title");
|
||||
if (!title) return;
|
||||
const tpl = t("admin.email_templates.editor.heading_for") ||
|
||||
"{title} — {lang}";
|
||||
const langName = lang === "en"
|
||||
? (t("admin.email_templates.lang.en") || "Englisch")
|
||||
: (t("admin.email_templates.lang.de") || "Deutsch");
|
||||
title.textContent = tpl
|
||||
.replace("{title}", tDyn(`admin.email_templates.card.${key}.title`) || key)
|
||||
.replace("{lang}", langName);
|
||||
}
|
||||
|
||||
function applyLangToggle(lang: "de" | "en") {
|
||||
document.querySelectorAll(".admin-et-lang-btn").forEach((el) => {
|
||||
const btn = el as HTMLButtonElement;
|
||||
const isActive = btn.dataset.lang === lang;
|
||||
btn.setAttribute("aria-pressed", isActive ? "true" : "false");
|
||||
btn.classList.toggle("active", isActive);
|
||||
});
|
||||
}
|
||||
|
||||
function renderVariables() {
|
||||
const list = document.getElementById("admin-et-variables-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
for (const v of variables) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "admin-et-variable-row";
|
||||
const name = document.createElement("code");
|
||||
name.className = "admin-et-variable-name";
|
||||
name.textContent = v.name;
|
||||
const type = document.createElement("span");
|
||||
type.className = "admin-et-variable-type";
|
||||
type.textContent = v.type;
|
||||
const desc = document.createElement("span");
|
||||
desc.className = "admin-et-variable-desc";
|
||||
desc.textContent = v.description;
|
||||
const sample = document.createElement("span");
|
||||
sample.className = "admin-et-variable-sample";
|
||||
sample.textContent = "→ " + (currentLang === "en" ? v.sample_en : v.sample_de);
|
||||
row.appendChild(name);
|
||||
row.appendChild(type);
|
||||
row.appendChild(desc);
|
||||
row.appendChild(sample);
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function renderVersions(rows: VersionRow[]) {
|
||||
const list = document.getElementById("admin-et-versions-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
if (rows.length === 0) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "admin-et-version-empty";
|
||||
empty.textContent = t("admin.email_templates.editor.versions_empty") || "Keine Versionen.";
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const v of rows) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "admin-et-version-row";
|
||||
const date = document.createElement("span");
|
||||
date.className = "admin-et-version-date";
|
||||
date.textContent = fmtDate(v.saved_at);
|
||||
const note = document.createElement("span");
|
||||
note.className = "admin-et-version-note";
|
||||
note.textContent = v.note || "";
|
||||
const restore = document.createElement("button");
|
||||
restore.type = "button";
|
||||
restore.className = "btn-tertiary admin-et-version-restore";
|
||||
restore.textContent = t("admin.email_templates.editor.restore") || "Wiederherstellen";
|
||||
restore.dataset.versionId = v.id;
|
||||
restore.addEventListener("click", () => onRestoreClick(v.id));
|
||||
li.appendChild(date);
|
||||
li.appendChild(note);
|
||||
li.appendChild(restore);
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActive() {
|
||||
applyTitle(currentKey, currentLang);
|
||||
applyLangToggle(currentLang);
|
||||
const resp = await fetch(`/api/admin/email-templates/${currentKey}/${currentLang}`);
|
||||
if (resp.status === 403) {
|
||||
showFeedback(t("admin.team.error.forbidden") || "Nur Admins.", true);
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
showFeedback(t("admin.email_templates.load_error") || "Fehler beim Laden.", true);
|
||||
return;
|
||||
}
|
||||
activeRow = (await resp.json()) as ActiveRow;
|
||||
applyToInputs(activeRow);
|
||||
void schedulePreview();
|
||||
}
|
||||
|
||||
async function loadVariables() {
|
||||
const resp = await fetch(`/api/admin/email-templates/${currentKey}/variables`);
|
||||
if (!resp.ok) {
|
||||
variables = [];
|
||||
renderVariables();
|
||||
return;
|
||||
}
|
||||
variables = (await resp.json()) as VariableContract[];
|
||||
renderVariables();
|
||||
}
|
||||
|
||||
async function loadVersions() {
|
||||
const resp = await fetch(`/api/admin/email-templates/${currentKey}/${currentLang}/versions`);
|
||||
if (!resp.ok) {
|
||||
renderVersions([]);
|
||||
return;
|
||||
}
|
||||
const rows = (await resp.json()) as VersionRow[];
|
||||
renderVersions(rows);
|
||||
}
|
||||
|
||||
async function refreshPreview() {
|
||||
const subj = (document.getElementById("admin-et-subject") as HTMLInputElement | null)?.value || "";
|
||||
const body = (document.getElementById("admin-et-body") as HTMLTextAreaElement | null)?.value || "";
|
||||
const slotEl = document.getElementById("admin-et-slot") as HTMLSelectElement | null;
|
||||
const slot = currentKey === "deadline_digest" && slotEl ? slotEl.value : "";
|
||||
|
||||
const resp = await fetch(
|
||||
`/api/admin/email-templates/${currentKey}/${currentLang}/preview`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ subject: subj, body, slot }),
|
||||
},
|
||||
);
|
||||
|
||||
const subjEl = document.getElementById("admin-et-preview-subject");
|
||||
const frame = document.getElementById("admin-et-preview-frame") as HTMLIFrameElement | null;
|
||||
|
||||
if (resp.status === 422) {
|
||||
const err = (await resp.json().catch(() => ({ error: "" }))) as { error?: string };
|
||||
if (subjEl) subjEl.textContent = "";
|
||||
if (frame) frame.srcdoc = "";
|
||||
showFeedback(
|
||||
(t("admin.email_templates.editor.parse_error") || "Template-Fehler:") + " " + (err.error || ""),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
showFeedback(t("admin.email_templates.editor.preview_error") || "Vorschau fehlgeschlagen.", true);
|
||||
return;
|
||||
}
|
||||
clearFeedback();
|
||||
const data = (await resp.json()) as { subject_rendered: string; html_rendered: string };
|
||||
if (subjEl) subjEl.textContent = data.subject_rendered;
|
||||
if (frame) frame.srcdoc = data.html_rendered;
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
if (previewTimer !== null) {
|
||||
clearTimeout(previewTimer);
|
||||
}
|
||||
previewTimer = window.setTimeout(() => {
|
||||
previewTimer = null;
|
||||
void refreshPreview();
|
||||
}, PREVIEW_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function onSaveClick() {
|
||||
if (!activeRow) return;
|
||||
const subj = (document.getElementById("admin-et-subject") as HTMLInputElement | null)?.value || "";
|
||||
const body = (document.getElementById("admin-et-body") as HTMLTextAreaElement | null)?.value || "";
|
||||
const note = (document.getElementById("admin-et-note") as HTMLInputElement | null)?.value || "";
|
||||
const resp = await fetch(`/api/admin/email-templates/${currentKey}/${currentLang}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ subject: subj, body, note }),
|
||||
});
|
||||
if (resp.status === 422) {
|
||||
const err = (await resp.json().catch(() => ({ error: "" }))) as { error?: string };
|
||||
showFeedback(
|
||||
(t("admin.email_templates.editor.parse_error") || "Template-Fehler:") + " " + (err.error || ""),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const err = (await resp.json().catch(() => ({ error: resp.statusText }))) as { error?: string };
|
||||
showFeedback((t("admin.email_templates.editor.save_error") || "Speichern fehlgeschlagen.") + " " + (err.error || ""), true);
|
||||
return;
|
||||
}
|
||||
showFeedback(t("admin.email_templates.editor.save_ok") || "Gespeichert.", false);
|
||||
const noteEl = document.getElementById("admin-et-note") as HTMLInputElement | null;
|
||||
if (noteEl) noteEl.value = "";
|
||||
void loadActive();
|
||||
void loadVersions();
|
||||
}
|
||||
|
||||
async function onResetClick() {
|
||||
if (!confirm(t("admin.email_templates.editor.reset_confirm") || "Wirklich auf den Standard zurücksetzen?")) {
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(`/api/admin/email-templates/${currentKey}/${currentLang}/reset`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!resp.ok) {
|
||||
showFeedback(t("admin.email_templates.editor.reset_error") || "Zurücksetzen fehlgeschlagen.", true);
|
||||
return;
|
||||
}
|
||||
showFeedback(t("admin.email_templates.editor.reset_ok") || "Auf Standard zurückgesetzt.", false);
|
||||
void loadActive();
|
||||
void loadVersions();
|
||||
}
|
||||
|
||||
async function onRestoreClick(versionID: string) {
|
||||
if (!confirm(t("admin.email_templates.editor.restore_confirm") || "Diese Version wiederherstellen?")) {
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(
|
||||
`/api/admin/email-templates/${currentKey}/${currentLang}/restore/${versionID}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!resp.ok) {
|
||||
showFeedback(t("admin.email_templates.editor.restore_error") || "Wiederherstellen fehlgeschlagen.", true);
|
||||
return;
|
||||
}
|
||||
showFeedback(t("admin.email_templates.editor.restore_ok") || "Version wiederhergestellt.", false);
|
||||
void loadActive();
|
||||
void loadVersions();
|
||||
}
|
||||
|
||||
function onLangButton(lang: "de" | "en") {
|
||||
if (lang === currentLang) return;
|
||||
if (dirty && !confirm(t("admin.email_templates.editor.dirty_warn") || "Ungespeicherte Änderungen verwerfen?")) {
|
||||
return;
|
||||
}
|
||||
currentLang = lang;
|
||||
setLangInURL(lang);
|
||||
applyTitle(currentKey, lang);
|
||||
applyLangToggle(lang);
|
||||
renderVariables();
|
||||
void loadActive();
|
||||
void loadVersions();
|
||||
}
|
||||
|
||||
function wireInputs() {
|
||||
const onAnyChange = () => {
|
||||
setDirty(true);
|
||||
schedulePreview();
|
||||
};
|
||||
document.getElementById("admin-et-subject")?.addEventListener("input", onAnyChange);
|
||||
document.getElementById("admin-et-body")?.addEventListener("input", onAnyChange);
|
||||
document.getElementById("admin-et-slot")?.addEventListener("change", () => {
|
||||
void refreshPreview();
|
||||
});
|
||||
document.getElementById("admin-et-save")?.addEventListener("click", () => {
|
||||
void onSaveClick();
|
||||
});
|
||||
document.getElementById("admin-et-reset")?.addEventListener("click", () => {
|
||||
void onResetClick();
|
||||
});
|
||||
document.getElementById("admin-et-preview-refresh")?.addEventListener("click", () => {
|
||||
void refreshPreview();
|
||||
});
|
||||
document.querySelectorAll(".admin-et-lang-btn").forEach((el) => {
|
||||
const btn = el as HTMLButtonElement;
|
||||
btn.addEventListener("click", () => {
|
||||
const lang = btn.dataset.lang as "de" | "en";
|
||||
onLangButton(lang);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
currentKey = readKeyFromPath();
|
||||
currentLang = readLangFromQuery();
|
||||
if (!currentKey) {
|
||||
showFeedback(t("admin.email_templates.editor.unknown_key") || "Unbekannter Template-Schlüssel.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
wireInputs();
|
||||
applyTitle(currentKey, currentLang);
|
||||
applyLangToggle(currentLang);
|
||||
|
||||
void loadActive();
|
||||
void loadVariables();
|
||||
void loadVersions();
|
||||
|
||||
onLangChange(() => {
|
||||
applyTitle(currentKey, currentLang);
|
||||
renderVariables();
|
||||
});
|
||||
});
|
||||
150
frontend/src/client/admin-email-templates.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// /admin/email-templates list page. Fetches per-(key, lang) summaries and
|
||||
// renders them as cards grouped by key. Each card shows the human title plus
|
||||
// two language buttons that link into the editor.
|
||||
|
||||
interface Summary {
|
||||
key: string;
|
||||
lang: string;
|
||||
is_default: boolean;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
interface CardCopy {
|
||||
title_key: string;
|
||||
desc_key: string;
|
||||
fallback_title: string;
|
||||
fallback_desc: string;
|
||||
}
|
||||
|
||||
const CARDS: Record<string, CardCopy> = {
|
||||
invitation: {
|
||||
title_key: "admin.email_templates.card.invitation.title",
|
||||
desc_key: "admin.email_templates.card.invitation.desc",
|
||||
fallback_title: "Einladung",
|
||||
fallback_desc:
|
||||
"E-Mail an neue Kolleg:innen, ausgelöst über die Sidebar.",
|
||||
},
|
||||
deadline_digest: {
|
||||
title_key: "admin.email_templates.card.deadline_digest.title",
|
||||
desc_key: "admin.email_templates.card.deadline_digest.desc",
|
||||
fallback_title: "Fristen-Sammelmail",
|
||||
fallback_desc:
|
||||
"Tägliche Morgen- und Abend-Mail mit überfälligen, heute fälligen und kommenden Fristen.",
|
||||
},
|
||||
base: {
|
||||
title_key: "admin.email_templates.card.base.title",
|
||||
desc_key: "admin.email_templates.card.base.desc",
|
||||
fallback_title: "Layout-Wrapper",
|
||||
fallback_desc:
|
||||
"Geteilter HTML-Rahmen mit Header und Footer, der alle E-Mails umschliesst.",
|
||||
},
|
||||
};
|
||||
|
||||
const KEY_ORDER = ["invitation", "deadline_digest", "base"];
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function statusLabel(s: Summary): string {
|
||||
if (s.is_default) {
|
||||
return t("admin.email_templates.status.default") || "Standard";
|
||||
}
|
||||
const date = fmtDate(s.updated_at);
|
||||
const tpl = t("admin.email_templates.status.last_modified") || "Zuletzt geändert: {date}";
|
||||
return tpl.replace("{date}", date);
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("admin-et-feedback");
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-success");
|
||||
el.style.display = "block";
|
||||
}
|
||||
|
||||
function render(summaries: Summary[]) {
|
||||
const container = document.getElementById("admin-et-list");
|
||||
if (!container) return;
|
||||
|
||||
// Group by key in canonical order.
|
||||
const byKey: Record<string, Summary[]> = {};
|
||||
for (const s of summaries) {
|
||||
(byKey[s.key] ||= []).push(s);
|
||||
}
|
||||
|
||||
container.innerHTML = "";
|
||||
for (const key of KEY_ORDER) {
|
||||
const meta = CARDS[key];
|
||||
const rows = byKey[key] || [];
|
||||
const card = document.createElement("div");
|
||||
card.className = "card admin-et-card";
|
||||
const title = tDyn(meta.title_key) || meta.fallback_title;
|
||||
const desc = tDyn(meta.desc_key) || meta.fallback_desc;
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "admin-et-card-header";
|
||||
const h2 = document.createElement("h2");
|
||||
h2.textContent = title;
|
||||
const code = document.createElement("code");
|
||||
code.className = "admin-et-card-key";
|
||||
code.textContent = key;
|
||||
header.appendChild(h2);
|
||||
header.appendChild(code);
|
||||
card.appendChild(header);
|
||||
|
||||
const p = document.createElement("p");
|
||||
p.textContent = desc;
|
||||
card.appendChild(p);
|
||||
|
||||
const langs = document.createElement("div");
|
||||
langs.className = "admin-et-card-langs";
|
||||
for (const lang of ["de", "en"]) {
|
||||
const s = rows.find((r) => r.lang === lang);
|
||||
const a = document.createElement("a");
|
||||
a.className = "admin-et-card-lang-btn";
|
||||
a.href = `/admin/email-templates/${key}?lang=${lang}`;
|
||||
const flag = document.createElement("span");
|
||||
flag.className = "admin-et-card-lang-flag";
|
||||
flag.textContent = lang.toUpperCase();
|
||||
const status = document.createElement("span");
|
||||
status.className = "admin-et-card-lang-status";
|
||||
status.textContent = s ? statusLabel(s) : t("admin.email_templates.status.default") || "Standard";
|
||||
a.appendChild(flag);
|
||||
a.appendChild(status);
|
||||
langs.appendChild(a);
|
||||
}
|
||||
card.appendChild(langs);
|
||||
|
||||
container.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRender() {
|
||||
const resp = await fetch("/api/admin/email-templates");
|
||||
if (resp.status === 403) {
|
||||
showFeedback(t("admin.team.error.forbidden") || "Nur Admins.", true);
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
showFeedback(t("admin.email_templates.load_error") || "Fehler beim Laden.", true);
|
||||
return;
|
||||
}
|
||||
const summaries = (await resp.json()) as Summary[];
|
||||
render(summaries);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
void loadAndRender();
|
||||
onLangChange(() => {
|
||||
void loadAndRender();
|
||||
});
|
||||
});
|
||||
418
frontend/src/client/admin-event-types.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-event-types.ts — moderation panel for paliad.event_types
|
||||
// (t-paliad-089). Loads two tables: firm-wide (with archived toggle, bulk
|
||||
// archive, merge) and private-pending-promotion (per-row promote button).
|
||||
|
||||
interface EventTypeRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
category: string;
|
||||
jurisdiction?: string | null;
|
||||
description?: string;
|
||||
is_firm_wide: boolean;
|
||||
archived_at?: string | null;
|
||||
created_by?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
usage_count: number;
|
||||
author_display_name?: string | null;
|
||||
}
|
||||
|
||||
let firmwide: EventTypeRow[] = [];
|
||||
let priv: EventTypeRow[] = [];
|
||||
let selected = new Set<string>();
|
||||
let showArchived = false;
|
||||
let searchQuery = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function categoryLabel(cat: string): string {
|
||||
return tDyn(`event_types.cat.${cat}`) || cat;
|
||||
}
|
||||
|
||||
function labelFor(row: EventTypeRow): string {
|
||||
// Show DE primary, EN as a small secondary line if it differs.
|
||||
return row.label_de;
|
||||
}
|
||||
|
||||
function rowMatchesSearch(row: EventTypeRow): boolean {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
row.label_de.toLowerCase().includes(q) ||
|
||||
row.label_en.toLowerCase().includes(q) ||
|
||||
row.slug.toLowerCase().includes(q) ||
|
||||
(row.author_display_name ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("aet-feedback")!;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
|
||||
el.style.display = "block";
|
||||
if (!isError) {
|
||||
setTimeout(() => { el.style.display = "none"; }, 3500);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFirmwide(): Promise<void> {
|
||||
const url = "/api/admin/event-types" + (showArchived ? "?include_archived=1" : "");
|
||||
const resp = await fetch(url);
|
||||
if (resp.status === 403) {
|
||||
showFeedback(t("admin.event_types.error.forbidden") || "Nur Admins.", true);
|
||||
firmwide = [];
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
firmwide = [];
|
||||
return;
|
||||
}
|
||||
firmwide = (await resp.json()) as EventTypeRow[];
|
||||
}
|
||||
|
||||
async function loadPrivate(): Promise<void> {
|
||||
const resp = await fetch("/api/admin/event-types/private");
|
||||
if (!resp.ok) {
|
||||
priv = [];
|
||||
return;
|
||||
}
|
||||
priv = (await resp.json()) as EventTypeRow[];
|
||||
}
|
||||
|
||||
function jurisdictionLabel(j: string | null | undefined): string {
|
||||
if (!j) return "—";
|
||||
if (j === "any") return tDyn("event_types.add.jurisdiction.any") || j;
|
||||
return j;
|
||||
}
|
||||
|
||||
function renderFirmwideRow(row: EventTypeRow): string {
|
||||
const archived = !!row.archived_at;
|
||||
const checked = selected.has(row.id) ? " checked" : "";
|
||||
const labelEn = row.label_en && row.label_en !== row.label_de
|
||||
? `<div class="admin-team-muted aet-label-en">${esc(row.label_en)}</div>`
|
||||
: "";
|
||||
const archivedBadge = archived
|
||||
? `<span class="aet-archived-badge">${esc(t("admin.event_types.row.archived") || "Archiviert")}</span>`
|
||||
: "";
|
||||
const restoreBtn = archived
|
||||
? `<button type="button" class="btn-link aet-restore" data-id="${esc(row.id)}" data-i18n="admin.event_types.action.restore">Wiederherstellen</button>`
|
||||
: `<button type="button" class="btn-link aet-archive" data-id="${esc(row.id)}" data-i18n="admin.event_types.action.archive">Archivieren</button>`;
|
||||
return `
|
||||
<tr data-row-id="${esc(row.id)}"${archived ? " class=\"aet-row-archived\"" : ""}>
|
||||
<td class="aet-col-check">
|
||||
${archived ? "" : `<input type="checkbox" class="aet-row-check" data-id="${esc(row.id)}"${checked} />`}
|
||||
</td>
|
||||
<td class="entity-col-title">
|
||||
${archivedBadge}
|
||||
<div>${esc(labelFor(row))}</div>
|
||||
${labelEn}
|
||||
<div class="admin-team-muted aet-slug">${esc(row.slug)}</div>
|
||||
</td>
|
||||
<td>${esc(categoryLabel(row.category))}</td>
|
||||
<td>${esc(jurisdictionLabel(row.jurisdiction))}</td>
|
||||
<td>${row.author_display_name ? esc(row.author_display_name) : `<span class="admin-team-muted">${esc(t("admin.event_types.author.system") || "System")}</span>`}</td>
|
||||
<td class="entity-col-updated">${esc(fmtDate(row.created_at))}</td>
|
||||
<td>${row.usage_count}</td>
|
||||
<td class="admin-team-actions-cell">${restoreBtn}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderPrivateRow(row: EventTypeRow): string {
|
||||
return `
|
||||
<tr data-row-id="${esc(row.id)}">
|
||||
<td class="entity-col-title">
|
||||
<div>${esc(row.label_de)}</div>
|
||||
${row.label_en && row.label_en !== row.label_de ? `<div class="admin-team-muted aet-label-en">${esc(row.label_en)}</div>` : ""}
|
||||
<div class="admin-team-muted aet-slug">${esc(row.slug)}</div>
|
||||
</td>
|
||||
<td>${esc(categoryLabel(row.category))}</td>
|
||||
<td>${esc(jurisdictionLabel(row.jurisdiction))}</td>
|
||||
<td>${row.author_display_name ? esc(row.author_display_name) : `<span class="admin-team-muted">${esc(t("admin.event_types.author.unknown") || "Unbekannt")}</span>`}</td>
|
||||
<td>${row.usage_count}</td>
|
||||
<td class="admin-team-actions-cell">
|
||||
<button type="button" class="btn-primary btn-small aet-promote" data-id="${esc(row.id)}" data-i18n="admin.event_types.action.promote">Befördern</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderFirmwide() {
|
||||
const tbody = document.getElementById("aet-tbody")!;
|
||||
const empty = document.getElementById("aet-empty")!;
|
||||
const count = document.getElementById("aet-count")!;
|
||||
|
||||
const filtered = firmwide.filter(rowMatchesSearch);
|
||||
count.textContent = `${filtered.length} / ${firmwide.length}`;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
updateBulkBar();
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
tbody.innerHTML = filtered.map(renderFirmwideRow).join("");
|
||||
attachFirmwideRowListeners();
|
||||
updateBulkBar();
|
||||
}
|
||||
|
||||
function renderPrivate() {
|
||||
const tbody = document.getElementById("aet-private-tbody")!;
|
||||
const empty = document.getElementById("aet-private-empty")!;
|
||||
|
||||
const filtered = priv.filter(rowMatchesSearch);
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
tbody.innerHTML = filtered.map(renderPrivateRow).join("");
|
||||
attachPrivateRowListeners();
|
||||
}
|
||||
|
||||
function attachFirmwideRowListeners() {
|
||||
document.querySelectorAll<HTMLInputElement>(".aet-row-check").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const id = cb.dataset.id!;
|
||||
if (cb.checked) selected.add(id);
|
||||
else selected.delete(id);
|
||||
updateBulkBar();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>(".aet-archive").forEach((b) => {
|
||||
b.addEventListener("click", () => archiveOne(b.dataset.id!));
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>(".aet-restore").forEach((b) => {
|
||||
b.addEventListener("click", () => restoreOne(b.dataset.id!));
|
||||
});
|
||||
}
|
||||
|
||||
function attachPrivateRowListeners() {
|
||||
document.querySelectorAll<HTMLButtonElement>(".aet-promote").forEach((b) => {
|
||||
b.addEventListener("click", () => promoteOne(b.dataset.id!));
|
||||
});
|
||||
}
|
||||
|
||||
function updateBulkBar() {
|
||||
const bar = document.getElementById("aet-bulk-actions")!;
|
||||
const count = document.getElementById("aet-bulk-count")!;
|
||||
const mergeBtn = document.getElementById("aet-bulk-merge") as HTMLButtonElement;
|
||||
// Drop selections that no longer correspond to a visible live row.
|
||||
const liveIDs = new Set(firmwide.filter((r) => !r.archived_at).map((r) => r.id));
|
||||
for (const id of Array.from(selected)) {
|
||||
if (!liveIDs.has(id)) selected.delete(id);
|
||||
}
|
||||
if (selected.size === 0) {
|
||||
bar.style.display = "none";
|
||||
return;
|
||||
}
|
||||
bar.style.display = "flex";
|
||||
const tmpl = t("admin.event_types.bulk.count") || "{n} ausgewählt";
|
||||
count.textContent = tmpl.replace("{n}", String(selected.size));
|
||||
mergeBtn.disabled = selected.size < 2;
|
||||
}
|
||||
|
||||
async function archiveOne(id: string) {
|
||||
const row = firmwide.find((r) => r.id === id);
|
||||
if (!row) return;
|
||||
const confirmMsg = (t("admin.event_types.confirm.archive") || "„{label}\" wirklich archivieren?").replace("{label}", row.label_de);
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
await bulkArchive([id]);
|
||||
}
|
||||
|
||||
async function bulkArchiveSelected() {
|
||||
if (selected.size === 0) return;
|
||||
const tmpl = t("admin.event_types.confirm.bulk_archive") || "{n} Typen wirklich archivieren?";
|
||||
if (!window.confirm(tmpl.replace("{n}", String(selected.size)))) return;
|
||||
await bulkArchive(Array.from(selected));
|
||||
}
|
||||
|
||||
async function bulkArchive(ids: string[]) {
|
||||
const resp = await fetch("/api/admin/event-types/archive", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || t("admin.event_types.feedback.archive_error") || "Archivierung fehlgeschlagen.", true);
|
||||
return;
|
||||
}
|
||||
const body = (await resp.json()) as { archived: number };
|
||||
selected.clear();
|
||||
showFeedback((t("admin.event_types.feedback.archived") || "{n} archiviert.").replace("{n}", String(body.archived)), false);
|
||||
await Promise.all([loadFirmwide(), loadPrivate()]);
|
||||
renderFirmwide();
|
||||
renderPrivate();
|
||||
}
|
||||
|
||||
async function restoreOne(id: string) {
|
||||
const row = firmwide.find((r) => r.id === id);
|
||||
if (!row) return;
|
||||
const resp = await fetch(`/api/admin/event-types/${id}/restore`, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || t("admin.event_types.feedback.restore_error") || "Wiederherstellung fehlgeschlagen.", true);
|
||||
return;
|
||||
}
|
||||
showFeedback(t("admin.event_types.feedback.restored") || "Wiederhergestellt.", false);
|
||||
await loadFirmwide();
|
||||
renderFirmwide();
|
||||
}
|
||||
|
||||
async function promoteOne(id: string) {
|
||||
const row = priv.find((r) => r.id === id);
|
||||
if (!row) return;
|
||||
const confirmMsg = (t("admin.event_types.confirm.promote") || "„{label}\" firmenweit verfügbar machen?").replace("{label}", row.label_de);
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
const resp = await fetch(`/api/admin/event-types/${id}/promote`, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || t("admin.event_types.feedback.promote_error") || "Beförderung fehlgeschlagen.", true);
|
||||
return;
|
||||
}
|
||||
showFeedback(t("admin.event_types.feedback.promoted") || "Befördert.", false);
|
||||
await Promise.all([loadFirmwide(), loadPrivate()]);
|
||||
renderFirmwide();
|
||||
renderPrivate();
|
||||
}
|
||||
|
||||
function openMergeModal() {
|
||||
if (selected.size < 2) return;
|
||||
const candidates = firmwide.filter((r) => selected.has(r.id) && !r.archived_at);
|
||||
if (candidates.length < 2) return;
|
||||
|
||||
// Suggest the highest-usage row as the default winner — preserves the most
|
||||
// junction rows untouched (they don't even need an INSERT, just the others
|
||||
// get redirected onto it).
|
||||
const initialWinner = candidates.slice().sort((a, b) => b.usage_count - a.usage_count)[0]!.id;
|
||||
|
||||
const opts = document.getElementById("aet-merge-options")!;
|
||||
opts.innerHTML = candidates.map((r) => {
|
||||
const checked = r.id === initialWinner ? " checked" : "";
|
||||
return `
|
||||
<label class="aet-merge-option">
|
||||
<input type="radio" name="aet-merge-winner" value="${esc(r.id)}"${checked} />
|
||||
<div class="aet-merge-option-body">
|
||||
<div class="aet-merge-option-label">${esc(r.label_de)}</div>
|
||||
<div class="admin-team-muted aet-merge-option-meta">
|
||||
${esc(categoryLabel(r.category))} · ${esc(jurisdictionLabel(r.jurisdiction))} · ${r.usage_count}×
|
||||
</div>
|
||||
</div>
|
||||
</label>`;
|
||||
}).join("");
|
||||
|
||||
const msg = document.getElementById("aet-merge-msg")!;
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
document.getElementById("aet-merge-modal")!.style.display = "flex";
|
||||
}
|
||||
|
||||
function closeMergeModal() {
|
||||
document.getElementById("aet-merge-modal")!.style.display = "none";
|
||||
}
|
||||
|
||||
async function submitMerge(e: Event) {
|
||||
e.preventDefault();
|
||||
const winnerInput = document.querySelector<HTMLInputElement>('input[name="aet-merge-winner"]:checked');
|
||||
if (!winnerInput) return;
|
||||
const winnerID = winnerInput.value;
|
||||
const losers = Array.from(selected).filter((id) => id !== winnerID);
|
||||
if (losers.length === 0) return;
|
||||
|
||||
const winner = firmwide.find((r) => r.id === winnerID);
|
||||
const totalUsage = firmwide
|
||||
.filter((r) => losers.includes(r.id))
|
||||
.reduce((acc, r) => acc + r.usage_count, 0);
|
||||
const tmpl = t("admin.event_types.confirm.merge")
|
||||
|| "„{winner}\" als Gewinner: {n} Verlierer-Typ(en) werden archiviert, {usage} Junction-Eintrag/-träge umgeleitet. Fortfahren?";
|
||||
const confirmMsg = tmpl
|
||||
.replace("{winner}", winner?.label_de ?? winnerID)
|
||||
.replace("{n}", String(losers.length))
|
||||
.replace("{usage}", String(totalUsage));
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
|
||||
const resp = await fetch("/api/admin/event-types/merge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ winner_id: winnerID, loser_ids: losers }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
const msgEl = document.getElementById("aet-merge-msg")!;
|
||||
msgEl.textContent = body.error || t("admin.event_types.feedback.merge_error") || "Zusammenführung fehlgeschlagen.";
|
||||
msgEl.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
closeMergeModal();
|
||||
selected.clear();
|
||||
showFeedback(t("admin.event_types.feedback.merged") || "Zusammengeführt.", false);
|
||||
await Promise.all([loadFirmwide(), loadPrivate()]);
|
||||
renderFirmwide();
|
||||
renderPrivate();
|
||||
}
|
||||
|
||||
function initSearch() {
|
||||
const input = document.getElementById("aet-search") as HTMLInputElement;
|
||||
input.addEventListener("input", () => {
|
||||
searchQuery = input.value;
|
||||
renderFirmwide();
|
||||
renderPrivate();
|
||||
});
|
||||
}
|
||||
|
||||
function initShowArchivedToggle() {
|
||||
const cb = document.getElementById("aet-show-archived") as HTMLInputElement;
|
||||
cb.addEventListener("change", async () => {
|
||||
showArchived = cb.checked;
|
||||
await loadFirmwide();
|
||||
renderFirmwide();
|
||||
});
|
||||
}
|
||||
|
||||
function initBulkActions() {
|
||||
document.getElementById("aet-bulk-archive")!.addEventListener("click", bulkArchiveSelected);
|
||||
document.getElementById("aet-bulk-merge")!.addEventListener("click", openMergeModal);
|
||||
}
|
||||
|
||||
function initMergeModal() {
|
||||
document.getElementById("aet-merge-close")!.addEventListener("click", closeMergeModal);
|
||||
document.getElementById("aet-merge-cancel")!.addEventListener("click", closeMergeModal);
|
||||
document.getElementById("aet-merge-modal")!.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeMergeModal();
|
||||
});
|
||||
(document.getElementById("aet-merge-form") as HTMLFormElement).addEventListener("submit", submitMerge);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initSearch();
|
||||
initShowArchivedToggle();
|
||||
initBulkActions();
|
||||
initMergeModal();
|
||||
onLangChange(() => {
|
||||
renderFirmwide();
|
||||
renderPrivate();
|
||||
});
|
||||
Promise.all([loadFirmwide(), loadPrivate()]).then(() => {
|
||||
renderFirmwide();
|
||||
renderPrivate();
|
||||
});
|
||||
});
|
||||
403
frontend/src/client/admin-partner-units.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface PartnerUnit {
|
||||
id: string;
|
||||
name: string;
|
||||
lead_user_id?: string | null;
|
||||
office: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Member {
|
||||
user_id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
office: string;
|
||||
job_title: string | null;
|
||||
}
|
||||
|
||||
interface PartnerUnitWithMembers extends PartnerUnit {
|
||||
lead_display_name?: string;
|
||||
lead_email?: string;
|
||||
members: Member[];
|
||||
}
|
||||
|
||||
interface Office {
|
||||
key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
let units: PartnerUnitWithMembers[] = [];
|
||||
let offices: Office[] = [];
|
||||
let userOptions: UserOption[] = [];
|
||||
let activeUnitID: string | null = null;
|
||||
|
||||
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 officeLabel(key: string): string {
|
||||
const o = offices.find((x) => x.key === key);
|
||||
if (!o) return key;
|
||||
return getLang() === "de" ? o.label_de : o.label_en;
|
||||
}
|
||||
|
||||
async function loadAll(): Promise<void> {
|
||||
await Promise.all([loadOffices(), loadUnits(), loadUsers()]);
|
||||
render();
|
||||
}
|
||||
|
||||
async function loadOffices(): Promise<void> {
|
||||
const resp = await fetch("/api/offices");
|
||||
if (resp.ok) offices = (await resp.json()) as Office[];
|
||||
}
|
||||
|
||||
async function loadUnits(): Promise<void> {
|
||||
const resp = await fetch("/api/partner-units?include=members");
|
||||
if (resp.ok) units = (await resp.json()) as PartnerUnitWithMembers[];
|
||||
}
|
||||
|
||||
async function loadUsers(): Promise<void> {
|
||||
const resp = await fetch("/api/users");
|
||||
if (resp.ok) userOptions = (await resp.json()) as UserOption[];
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, isError: boolean): void {
|
||||
const el = document.getElementById("pu-feedback")!;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
|
||||
el.style.display = "block";
|
||||
if (!isError) setTimeout(() => { el.style.display = "none"; }, 3500);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const tbody = document.getElementById("pu-tbody")!;
|
||||
const empty = document.getElementById("pu-empty")!;
|
||||
if (!units.length) {
|
||||
tbody.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
tbody.innerHTML = units
|
||||
.map((u) => {
|
||||
const lead = u.lead_display_name ?? "—";
|
||||
const memberCount = u.members.length;
|
||||
return `<tr data-id="${esc(u.id)}">
|
||||
<td class="entity-col-title">${esc(u.name)}</td>
|
||||
<td><span class="office-chip office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
|
||||
<td>${esc(lead)}</td>
|
||||
<td>${memberCount}</td>
|
||||
<td class="admin-team-actions-cell">
|
||||
<button type="button" class="btn-link pu-members-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.members">Mitglieder</button>
|
||||
<button type="button" class="btn-link pu-edit-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.edit">Bearbeiten</button>
|
||||
<button type="button" class="btn-link pu-delete-btn" data-id="${esc(u.id)}" data-i18n="admin.partner_units.action.delete">Löschen</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
tbody.querySelectorAll<HTMLButtonElement>(".pu-members-btn").forEach((b) =>
|
||||
b.addEventListener("click", () => openMembersModal(b.dataset.id!)),
|
||||
);
|
||||
tbody.querySelectorAll<HTMLButtonElement>(".pu-edit-btn").forEach((b) =>
|
||||
b.addEventListener("click", () => openEditModal(b.dataset.id!)),
|
||||
);
|
||||
tbody.querySelectorAll<HTMLButtonElement>(".pu-delete-btn").forEach((b) =>
|
||||
b.addEventListener("click", () => deleteUnit(b.dataset.id!)),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Edit modal -----------------------------------------------------------
|
||||
|
||||
function openEditModal(id: string | null): void {
|
||||
const modal = document.getElementById("pu-edit-modal")!;
|
||||
const titleEl = document.getElementById("pu-edit-title")!;
|
||||
const idField = document.getElementById("pu-edit-id") as HTMLInputElement;
|
||||
const nameField = document.getElementById("pu-edit-name") as HTMLInputElement;
|
||||
const officeSel = document.getElementById("pu-edit-office") as HTMLSelectElement;
|
||||
const leadSel = document.getElementById("pu-edit-lead") as HTMLSelectElement;
|
||||
const msg = document.getElementById("pu-edit-msg")!;
|
||||
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
|
||||
// Populate office options.
|
||||
officeSel.innerHTML = offices
|
||||
.map((o) => `<option value="${esc(o.key)}">${esc(officeLabel(o.key))}</option>`)
|
||||
.join("");
|
||||
|
||||
// Populate lead options (sorted).
|
||||
const leadEntries = userOptions
|
||||
.slice()
|
||||
.sort((a, b) => (a.display_name || a.email).localeCompare(b.display_name || b.email));
|
||||
leadSel.innerHTML =
|
||||
`<option value="">—</option>` +
|
||||
leadEntries
|
||||
.map((u) => {
|
||||
const label = u.display_name ? `${u.display_name} (${u.email})` : u.email;
|
||||
return `<option value="${esc(u.id)}">${esc(label)}</option>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
if (id) {
|
||||
const u = units.find((x) => x.id === id);
|
||||
if (!u) return;
|
||||
titleEl.setAttribute("data-i18n", "admin.partner_units.edit.heading");
|
||||
titleEl.textContent = t("admin.partner_units.edit.heading") || "Partner Unit bearbeiten";
|
||||
idField.value = u.id;
|
||||
nameField.value = u.name;
|
||||
officeSel.value = u.office;
|
||||
leadSel.value = u.lead_user_id ?? "";
|
||||
} else {
|
||||
titleEl.setAttribute("data-i18n", "admin.partner_units.new.heading");
|
||||
titleEl.textContent = t("admin.partner_units.new.heading") || "Partner Unit anlegen";
|
||||
idField.value = "";
|
||||
nameField.value = "";
|
||||
officeSel.value = offices[0]?.key ?? "munich";
|
||||
leadSel.value = "";
|
||||
}
|
||||
|
||||
modal.style.display = "flex";
|
||||
nameField.focus();
|
||||
}
|
||||
|
||||
function closeEditModal(): void {
|
||||
document.getElementById("pu-edit-modal")!.style.display = "none";
|
||||
}
|
||||
|
||||
async function submitEdit(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
const idField = document.getElementById("pu-edit-id") as HTMLInputElement;
|
||||
const nameField = document.getElementById("pu-edit-name") as HTMLInputElement;
|
||||
const officeSel = document.getElementById("pu-edit-office") as HTMLSelectElement;
|
||||
const leadSel = document.getElementById("pu-edit-lead") as HTMLSelectElement;
|
||||
const msg = document.getElementById("pu-edit-msg")!;
|
||||
|
||||
const name = nameField.value.trim();
|
||||
if (!name) {
|
||||
msg.textContent = t("admin.partner_units.error.name_required") || "Name erforderlich";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
|
||||
const isEdit = !!idField.value;
|
||||
// Server treats missing keys as "no change". For lead clearing we send the
|
||||
// nil UUID — service code interprets that as "explicit clear".
|
||||
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
const payload: Record<string, unknown> = {
|
||||
name,
|
||||
office: officeSel.value,
|
||||
lead_user_id: leadSel.value || NIL_UUID,
|
||||
};
|
||||
const url = isEdit ? `/api/partner-units/${idField.value}` : "/api/partner-units";
|
||||
const method = isEdit ? "PATCH" : "POST";
|
||||
|
||||
const resp = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
msg.textContent = body.error || "Fehler.";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
closeEditModal();
|
||||
await loadUnits();
|
||||
render();
|
||||
showFeedback(
|
||||
isEdit
|
||||
? t("admin.partner_units.feedback.updated") || "Aktualisiert."
|
||||
: t("admin.partner_units.feedback.created") || "Angelegt.",
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteUnit(id: string): Promise<void> {
|
||||
const u = units.find((x) => x.id === id);
|
||||
if (!u) return;
|
||||
const confirmMsg = (t("admin.partner_units.confirm_delete") || "Partner Unit \"{name}\" wirklich löschen?")
|
||||
.replace("{name}", u.name);
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
const resp = await fetch(`/api/partner-units/${id}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || "Löschen fehlgeschlagen.", true);
|
||||
return;
|
||||
}
|
||||
await loadUnits();
|
||||
render();
|
||||
showFeedback(t("admin.partner_units.feedback.deleted") || "Gelöscht.", false);
|
||||
}
|
||||
|
||||
// ---- Members modal --------------------------------------------------------
|
||||
|
||||
function openMembersModal(id: string): void {
|
||||
activeUnitID = id;
|
||||
const u = units.find((x) => x.id === id);
|
||||
if (!u) return;
|
||||
const titleEl = document.getElementById("pu-members-title")!;
|
||||
titleEl.textContent =
|
||||
(t("admin.partner_units.member.heading") || "Mitglieder verwalten") + " — " + u.name;
|
||||
renderMemberList();
|
||||
|
||||
// Reset add form
|
||||
(document.getElementById("pu-add-input") as HTMLInputElement).value = "";
|
||||
(document.getElementById("pu-add-user-id") as HTMLInputElement).value = "";
|
||||
document.getElementById("pu-add-suggestions")!.innerHTML = "";
|
||||
const msg = document.getElementById("pu-add-msg")!;
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
|
||||
document.getElementById("pu-members-modal")!.style.display = "flex";
|
||||
}
|
||||
|
||||
function closeMembersModal(): void {
|
||||
document.getElementById("pu-members-modal")!.style.display = "none";
|
||||
activeUnitID = null;
|
||||
}
|
||||
|
||||
function renderMemberList(): void {
|
||||
if (!activeUnitID) return;
|
||||
const u = units.find((x) => x.id === activeUnitID);
|
||||
if (!u) return;
|
||||
const list = document.getElementById("pu-members-list")!;
|
||||
if (!u.members.length) {
|
||||
list.innerHTML = `<li class="form-hint">${esc(t("admin.partner_units.member.empty") || "Noch keine Mitglieder.")}</li>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = u.members
|
||||
.map(
|
||||
(m) => `<li class="partner-unit-member-item">
|
||||
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
|
||||
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
|
||||
</li>`,
|
||||
)
|
||||
.join("");
|
||||
list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) =>
|
||||
b.addEventListener("click", () => removeMember(b.dataset.user!)),
|
||||
);
|
||||
}
|
||||
|
||||
function wireSuggestions(): void {
|
||||
const input = document.getElementById("pu-add-input") as HTMLInputElement;
|
||||
const hidden = document.getElementById("pu-add-user-id") as HTMLInputElement;
|
||||
const sugs = document.getElementById("pu-add-suggestions")!;
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
hidden.value = "";
|
||||
if (!q) {
|
||||
sugs.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const matches = userOptions
|
||||
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q))
|
||||
.slice(0, 8);
|
||||
sugs.innerHTML = matches
|
||||
.map(
|
||||
(u) => `<div class="collab-suggestion" data-id="${esc(u.id)}" data-label="${escAttr(u.display_name || u.email)}">
|
||||
<strong>${esc(u.display_name || u.email)}</strong>
|
||||
<span class="form-hint">${esc(u.email)}</span>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
sugs.querySelectorAll<HTMLDivElement>(".collab-suggestion").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
hidden.value = el.dataset.id!;
|
||||
input.value = el.dataset.label!;
|
||||
sugs.innerHTML = "";
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function submitAddMember(e: Event): Promise<void> {
|
||||
e.preventDefault();
|
||||
if (!activeUnitID) return;
|
||||
const hidden = document.getElementById("pu-add-user-id") as HTMLInputElement;
|
||||
const msg = document.getElementById("pu-add-msg")!;
|
||||
if (!hidden.value) {
|
||||
msg.textContent = t("admin.partner_units.error.user_required") || "Benutzer auswählen";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(`/api/partner-units/${activeUnitID}/members`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: hidden.value }),
|
||||
});
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
msg.textContent = body.error || "Fehler.";
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
(document.getElementById("pu-add-input") as HTMLInputElement).value = "";
|
||||
hidden.value = "";
|
||||
document.getElementById("pu-add-suggestions")!.innerHTML = "";
|
||||
msg.textContent = "";
|
||||
await loadUnits();
|
||||
renderMemberList();
|
||||
render();
|
||||
}
|
||||
|
||||
async function removeMember(userID: string): Promise<void> {
|
||||
if (!activeUnitID) return;
|
||||
const confirmMsg = t("admin.partner_units.member.confirm_remove") || "Mitglied entfernen?";
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
const resp = await fetch(
|
||||
`/api/partner-units/${activeUnitID}/members/${userID}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || "Entfernen fehlgeschlagen.", true);
|
||||
return;
|
||||
}
|
||||
await loadUnits();
|
||||
renderMemberList();
|
||||
render();
|
||||
}
|
||||
|
||||
// ---- Init -----------------------------------------------------------------
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
document.getElementById("pu-new-btn")!.addEventListener("click", () => openEditModal(null));
|
||||
document.getElementById("pu-edit-close")!.addEventListener("click", closeEditModal);
|
||||
document.getElementById("pu-edit-cancel")!.addEventListener("click", closeEditModal);
|
||||
document.getElementById("pu-edit-form")!.addEventListener("submit", submitEdit);
|
||||
document.getElementById("pu-edit-modal")!.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeEditModal();
|
||||
});
|
||||
|
||||
document.getElementById("pu-members-close")!.addEventListener("click", closeMembersModal);
|
||||
document.getElementById("pu-add-form")!.addEventListener("submit", submitAddMember);
|
||||
document.getElementById("pu-members-modal")!.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeMembersModal();
|
||||
});
|
||||
wireSuggestions();
|
||||
|
||||
onLangChange(() => render());
|
||||
void loadAll();
|
||||
});
|
||||
447
frontend/src/client/admin-team.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
office: string;
|
||||
additional_offices?: string[];
|
||||
job_title: string | null;
|
||||
global_role: string;
|
||||
lang: string;
|
||||
reminder_morning_time?: string;
|
||||
reminder_evening_time?: string;
|
||||
reminder_timezone?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Office {
|
||||
key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
}
|
||||
|
||||
interface Unonboarded {
|
||||
id: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const OFFICE_ORDER = ["munich", "duesseldorf", "hamburg", "amsterdam", "london", "paris", "milan"];
|
||||
const JOB_TITLE_SUGGESTIONS = [
|
||||
"Partner",
|
||||
"Associate",
|
||||
"PA",
|
||||
"Of Counsel",
|
||||
"Counsel",
|
||||
"Counsel Knowledge Lawyer",
|
||||
"Knowledge Lawyer",
|
||||
"Referendar/in",
|
||||
"Trainee",
|
||||
"wiss. Mitarbeiter/in",
|
||||
"Sekretariat",
|
||||
];
|
||||
|
||||
let users: User[] = [];
|
||||
let offices: Office[] = [];
|
||||
let unonboarded: Unonboarded[] = [];
|
||||
let activeOffice = "all";
|
||||
let searchQuery = "";
|
||||
let editingId: string | null = null;
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function officeLabel(key: string): string {
|
||||
const o = offices.find((x) => x.key === key);
|
||||
if (!o) return key;
|
||||
return tDyn("office." + key) || (document.documentElement.lang === "en" ? o.label_en : o.label_de);
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function permissionLabel(globalRole: string): string {
|
||||
if (globalRole === "global_admin") return t("admin.team.permission.global_admin") || "Global Admin";
|
||||
return t("admin.team.permission.standard") || "Standard";
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [usersResp, officesResp] = await Promise.all([
|
||||
fetch("/api/admin/users"),
|
||||
fetch("/api/offices"),
|
||||
]);
|
||||
if (usersResp.status === 403) {
|
||||
showFeedback(t("admin.team.error.forbidden") || "Nur Admins.", true);
|
||||
return;
|
||||
}
|
||||
if (usersResp.ok) users = (await usersResp.json()) as User[];
|
||||
if (officesResp.ok) offices = (await officesResp.json()) as Office[];
|
||||
buildOfficeFilters();
|
||||
render();
|
||||
}
|
||||
|
||||
async function loadUnonboarded() {
|
||||
const resp = await fetch("/api/admin/users/unonboarded");
|
||||
if (!resp.ok) {
|
||||
unonboarded = [];
|
||||
return;
|
||||
}
|
||||
unonboarded = (await resp.json()) as Unonboarded[];
|
||||
}
|
||||
|
||||
function presentOffices(): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const u of users) seen.add(u.office);
|
||||
return OFFICE_ORDER.filter((k) => seen.has(k)).concat(
|
||||
Array.from(seen).filter((k) => !OFFICE_ORDER.includes(k)).sort(),
|
||||
);
|
||||
}
|
||||
|
||||
function buildOfficeFilters() {
|
||||
const container = document.getElementById("admin-team-office-filters")!;
|
||||
const present = presentOffices();
|
||||
const allBtn = `<button class="filter-pill${activeOffice === "all" ? " active" : ""}" data-office="all" type="button">${esc(t("team.filter.all") || "Alle")}</button>`;
|
||||
const pills = present
|
||||
.map((k) => `<button class="filter-pill${activeOffice === k ? " active" : ""}" data-office="${esc(k)}" type="button">${esc(officeLabel(k))}</button>`)
|
||||
.join("");
|
||||
container.innerHTML = allBtn + pills;
|
||||
container.querySelectorAll<HTMLButtonElement>(".filter-pill").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
activeOffice = btn.dataset.office ?? "all";
|
||||
container.querySelectorAll(".filter-pill").forEach((p) => p.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function userMatchesSearch(u: User): boolean {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
u.display_name.toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.job_title ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
function userMatchesOffice(u: User): boolean {
|
||||
if (activeOffice === "all") return true;
|
||||
if (u.office === activeOffice) return true;
|
||||
return (u.additional_offices ?? []).includes(activeOffice);
|
||||
}
|
||||
|
||||
function officeOptions(selected: string): string {
|
||||
return offices
|
||||
.map((o) => `<option value="${esc(o.key)}"${o.key === selected ? " selected" : ""}>${esc(officeLabel(o.key))}</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function additionalOfficesEditor(selected: string[]): string {
|
||||
return offices
|
||||
.map((o) => {
|
||||
const checked = selected.includes(o.key) ? " checked" : "";
|
||||
return `<label class="admin-team-multi-opt"><input type="checkbox" data-additional="${esc(o.key)}"${checked} /> ${esc(officeLabel(o.key))}</label>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function langOptions(selected: string): string {
|
||||
return ["de", "en"]
|
||||
.map((l) => `<option value="${l}"${l === selected ? " selected" : ""}>${l.toUpperCase()}</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function globalAdminCount(): number {
|
||||
return users.reduce((acc, u) => acc + (u.global_role === "global_admin" ? 1 : 0), 0);
|
||||
}
|
||||
|
||||
function permissionCell(u: User): string {
|
||||
const cls = u.global_role === "global_admin" ? "admin-team-perm admin-team-perm-admin" : "admin-team-perm";
|
||||
return `<span class="${cls}">${esc(permissionLabel(u.global_role))}</span>`;
|
||||
}
|
||||
|
||||
function permissionEditor(u: User): string {
|
||||
// Disable demoting the only remaining global_admin.
|
||||
const isLastAdmin = u.global_role === "global_admin" && globalAdminCount() <= 1;
|
||||
const standardOpt = `<option value="standard"${u.global_role === "standard" ? " selected" : ""}>${esc(permissionLabel("standard"))}</option>`;
|
||||
const adminOpt = `<option value="global_admin"${u.global_role === "global_admin" ? " selected" : ""}>${esc(permissionLabel("global_admin"))}</option>`;
|
||||
const disabled = isLastAdmin ? " disabled" : "";
|
||||
const title = isLastAdmin ? ` title="${esc(t("admin.team.permission.last_admin") || "Letzter Admin kann nicht degradiert werden.")}"` : "";
|
||||
return `<select class="admin-team-input" data-field="global_role"${disabled}${title}>${standardOpt}${adminOpt}</select>`;
|
||||
}
|
||||
|
||||
function renderRow(u: User): string {
|
||||
if (editingId === u.id) return renderEditRow(u);
|
||||
const additional = (u.additional_offices ?? []).filter((o) => o !== u.office);
|
||||
const jobTitle = u.job_title ?? "";
|
||||
return `
|
||||
<tr data-user-id="${esc(u.id)}">
|
||||
<td class="entity-col-title">${esc(u.display_name)}</td>
|
||||
<td><a href="mailto:${esc(u.email)}">${esc(u.email)}</a></td>
|
||||
<td><span class="office-chip office-${esc(u.office)}">${esc(officeLabel(u.office))}</span></td>
|
||||
<td>${jobTitle ? esc(jobTitle) : "<span class=\"admin-team-muted\">—</span>"}</td>
|
||||
<td>${permissionCell(u)}</td>
|
||||
<td>${additional.length ? additional.map((o) => esc(officeLabel(o))).join(", ") : "<span class=\"admin-team-muted\">—</span>"}</td>
|
||||
<td>${esc(u.lang.toUpperCase())}</td>
|
||||
<td class="entity-col-updated">${esc(fmtDate(u.created_at))}</td>
|
||||
<td class="admin-team-actions-cell">
|
||||
<button type="button" class="btn-link admin-team-edit" data-id="${esc(u.id)}" data-i18n="admin.team.row.edit">Bearbeiten</button>
|
||||
<button type="button" class="btn-link admin-team-delete" data-id="${esc(u.id)}" data-i18n="admin.team.row.delete">Löschen</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderEditRow(u: User): string {
|
||||
const additional = u.additional_offices ?? [];
|
||||
const jobTitleList = JOB_TITLE_SUGGESTIONS.map((r) => `<option value="${esc(r)}" />`).join("");
|
||||
const jobTitle = u.job_title ?? "";
|
||||
return `
|
||||
<tr data-user-id="${esc(u.id)}" class="admin-team-edit-row">
|
||||
<td><input type="text" class="admin-team-input" data-field="display_name" value="${esc(u.display_name)}" /></td>
|
||||
<td><span class="admin-team-muted" title="E-Mail kann nicht geändert werden">${esc(u.email)}</span></td>
|
||||
<td><select class="admin-team-input" data-field="office">${officeOptions(u.office)}</select></td>
|
||||
<td>
|
||||
<input type="text" class="admin-team-input" data-field="job_title" value="${esc(jobTitle)}" list="admin-team-job-title-suggest-${esc(u.id)}" />
|
||||
<datalist id="admin-team-job-title-suggest-${esc(u.id)}">${jobTitleList}</datalist>
|
||||
</td>
|
||||
<td>${permissionEditor(u)}</td>
|
||||
<td class="admin-team-multi">${additionalOfficesEditor(additional)}</td>
|
||||
<td><select class="admin-team-input" data-field="lang">${langOptions(u.lang)}</select></td>
|
||||
<td class="entity-col-updated">${esc(fmtDate(u.created_at))}</td>
|
||||
<td class="admin-team-actions-cell">
|
||||
<button type="button" class="btn-primary admin-team-save" data-id="${esc(u.id)}" data-i18n="admin.team.row.save">Speichern</button>
|
||||
<button type="button" class="btn-cancel admin-team-cancel" data-id="${esc(u.id)}" data-i18n="admin.team.row.cancel">Abbrechen</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("admin-team-feedback")!;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
|
||||
el.style.display = "block";
|
||||
if (!isError) {
|
||||
setTimeout(() => { el.style.display = "none"; }, 3500);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tbody = document.getElementById("admin-team-tbody")!;
|
||||
const empty = document.getElementById("admin-team-empty")!;
|
||||
const count = document.getElementById("admin-team-count")!;
|
||||
|
||||
const filtered = users.filter((u) => userMatchesOffice(u) && userMatchesSearch(u));
|
||||
count.textContent = `${filtered.length} / ${users.length}`;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
// Stable sort: global admins first, then by display_name.
|
||||
const sorted = filtered.slice().sort((a, b) => {
|
||||
const aAdmin = a.global_role === "global_admin";
|
||||
const bAdmin = b.global_role === "global_admin";
|
||||
if (aAdmin && !bAdmin) return -1;
|
||||
if (bAdmin && !aAdmin) return 1;
|
||||
return a.display_name.localeCompare(b.display_name);
|
||||
});
|
||||
tbody.innerHTML = sorted.map(renderRow).join("");
|
||||
attachRowListeners();
|
||||
}
|
||||
|
||||
function attachRowListeners() {
|
||||
document.querySelectorAll<HTMLButtonElement>(".admin-team-edit").forEach((b) => {
|
||||
b.addEventListener("click", () => {
|
||||
editingId = b.dataset.id ?? null;
|
||||
render();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>(".admin-team-cancel").forEach((b) => {
|
||||
b.addEventListener("click", () => {
|
||||
editingId = null;
|
||||
render();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>(".admin-team-save").forEach((b) => {
|
||||
b.addEventListener("click", () => saveRow(b.dataset.id!));
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>(".admin-team-delete").forEach((b) => {
|
||||
b.addEventListener("click", () => deleteRow(b.dataset.id!));
|
||||
});
|
||||
}
|
||||
|
||||
async function saveRow(id: string) {
|
||||
const tr = document.querySelector<HTMLTableRowElement>(`tr[data-user-id="${id}"]`);
|
||||
if (!tr) return;
|
||||
const payload: Record<string, unknown> = {};
|
||||
tr.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[data-field]").forEach((el) => {
|
||||
payload[el.dataset.field!] = el.value;
|
||||
});
|
||||
const additional: string[] = [];
|
||||
tr.querySelectorAll<HTMLInputElement>("[data-additional]").forEach((cb) => {
|
||||
if (cb.checked) additional.push(cb.dataset.additional!);
|
||||
});
|
||||
payload.additional_offices = additional;
|
||||
|
||||
const resp = await fetch(`/api/admin/users/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || "Fehler beim Speichern.", true);
|
||||
return;
|
||||
}
|
||||
const updated = (await resp.json()) as User;
|
||||
users = users.map((u) => (u.id === id ? updated : u));
|
||||
editingId = null;
|
||||
showFeedback(t("admin.team.feedback.saved") || "Gespeichert.", false);
|
||||
render();
|
||||
}
|
||||
|
||||
async function deleteRow(id: string) {
|
||||
const u = users.find((x) => x.id === id);
|
||||
if (!u) return;
|
||||
const confirmMsg = (t("admin.team.confirm.delete") || "{name} wirklich löschen?").replace("{name}", u.display_name);
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
const resp = await fetch(`/api/admin/users/${id}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || "Löschen fehlgeschlagen.", true);
|
||||
return;
|
||||
}
|
||||
users = users.filter((x) => x.id !== id);
|
||||
showFeedback(t("admin.team.feedback.deleted") || "Gelöscht.", false);
|
||||
render();
|
||||
}
|
||||
|
||||
function initSearch() {
|
||||
const input = document.getElementById("admin-team-search") as HTMLInputElement;
|
||||
input.addEventListener("input", () => {
|
||||
searchQuery = input.value;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function openDirectAddModal() {
|
||||
const modal = document.getElementById("admin-direct-add-modal")!;
|
||||
const select = document.getElementById("admin-da-email") as HTMLSelectElement;
|
||||
const officeSel = document.getElementById("admin-da-office") as HTMLSelectElement;
|
||||
const fb = document.getElementById("admin-da-feedback")!;
|
||||
const nameField = document.getElementById("admin-da-name") as HTMLInputElement;
|
||||
const jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
|
||||
|
||||
fb.style.display = "none";
|
||||
nameField.value = "";
|
||||
jobTitleField.value = "";
|
||||
|
||||
officeSel.innerHTML = officeOptions("munich");
|
||||
|
||||
loadUnonboarded().then(() => {
|
||||
select.innerHTML = `<option value="">${esc(t("admin.team.direct_add.email.placeholder") || "Bitte auswählen...")}</option>` +
|
||||
unonboarded.map((u) => `<option value="${esc(u.email)}">${esc(u.email)}</option>`).join("");
|
||||
if (unonboarded.length === 0) {
|
||||
const noneMsg = t("admin.team.direct_add.empty") || "Keine offenen Konten.";
|
||||
select.innerHTML = `<option value="">${esc(noneMsg)}</option>`;
|
||||
}
|
||||
});
|
||||
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
function closeDirectAddModal() {
|
||||
document.getElementById("admin-direct-add-modal")!.style.display = "none";
|
||||
}
|
||||
|
||||
function initDirectAddModal() {
|
||||
document.getElementById("admin-team-direct-add")!.addEventListener("click", openDirectAddModal);
|
||||
document.getElementById("admin-direct-add-close")!.addEventListener("click", closeDirectAddModal);
|
||||
document.getElementById("admin-da-cancel")!.addEventListener("click", closeDirectAddModal);
|
||||
|
||||
const emailSel = document.getElementById("admin-da-email") as HTMLSelectElement;
|
||||
const nameField = document.getElementById("admin-da-name") as HTMLInputElement;
|
||||
emailSel.addEventListener("change", () => {
|
||||
if (!nameField.value && emailSel.value) {
|
||||
// Pre-fill from email local-part.
|
||||
const local = emailSel.value.split("@")[0] ?? "";
|
||||
nameField.value = local
|
||||
.split(/[._-]/)
|
||||
.map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s))
|
||||
.join(" ")
|
||||
.trim();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("admin-direct-add-modal")!.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeDirectAddModal();
|
||||
});
|
||||
|
||||
const form = document.getElementById("admin-direct-add-form") as HTMLFormElement;
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const fb = document.getElementById("admin-da-feedback")!;
|
||||
fb.style.display = "none";
|
||||
|
||||
const officeSel = document.getElementById("admin-da-office") as HTMLSelectElement;
|
||||
const jobTitleField = document.getElementById("admin-da-role") as HTMLInputElement;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
email: emailSel.value,
|
||||
display_name: nameField.value.trim(),
|
||||
office: officeSel.value,
|
||||
job_title: jobTitleField.value.trim() || "Associate",
|
||||
lang: "de",
|
||||
};
|
||||
|
||||
const resp = await fetch("/api/admin/users", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
fb.textContent = body.error || "Fehler.";
|
||||
fb.className = "form-msg form-msg-error";
|
||||
fb.style.display = "block";
|
||||
return;
|
||||
}
|
||||
const created = (await resp.json()) as User;
|
||||
users = users.concat(created);
|
||||
closeDirectAddModal();
|
||||
showFeedback(t("admin.team.feedback.added") || "Konto onboardet.", false);
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function initInviteButton() {
|
||||
document.getElementById("admin-team-invite")!.addEventListener("click", () => {
|
||||
document.getElementById("sidebar-invite-btn")?.click();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initSearch();
|
||||
initDirectAddModal();
|
||||
initInviteButton();
|
||||
onLangChange(() => {
|
||||
buildOfficeFilters();
|
||||
render();
|
||||
});
|
||||
loadAll();
|
||||
});
|
||||
7
frontend/src/client/admin.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
});
|
||||
414
frontend/src/client/agenda.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { attachEventTypeMultiSelectFilter, type FilterHandle } from "./event-types";
|
||||
|
||||
let eventTypeFilter: FilterHandle | null = null;
|
||||
|
||||
type Urgency = "overdue" | "today" | "tomorrow" | "this_week" | "later";
|
||||
type AgendaType = "deadline" | "appointment";
|
||||
type TypeFilter = "both" | "deadlines" | "appointments";
|
||||
|
||||
interface AgendaItem {
|
||||
id: string;
|
||||
type: AgendaType;
|
||||
title: string;
|
||||
date: string; // ISO 8601
|
||||
end_at?: string | null;
|
||||
due_date?: string | null; // YYYY-MM-DD (deadlines only)
|
||||
status?: string | null; // deadlines: pending/completed/...
|
||||
location?: string | null;
|
||||
appointment_type?: string | null;
|
||||
urgency: Urgency;
|
||||
project_id?: string | null;
|
||||
project_title?: string | null;
|
||||
project_type?: string | null; // client | litigation | patent | case | project
|
||||
project_reference?: string | null;
|
||||
// Approval workflow (t-paliad-138). "pending" → render the warning pill.
|
||||
approval_status?: "approved" | "pending" | "legacy" | null;
|
||||
}
|
||||
|
||||
interface AgendaPayload {
|
||||
items: AgendaItem[];
|
||||
from: string;
|
||||
to: string;
|
||||
types: string[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__PALIAD_AGENDA__?: AgendaPayload | null;
|
||||
}
|
||||
}
|
||||
|
||||
// Range presets match the TSX chips; 30d stays the default (server agrees).
|
||||
const RANGE_DAYS_DEFAULT = 30;
|
||||
const VALID_RANGES = new Set([7, 14, 30, 90]);
|
||||
|
||||
const state = {
|
||||
items: [] as AgendaItem[],
|
||||
type: "both" as TypeFilter,
|
||||
rangeDays: RANGE_DAYS_DEFAULT,
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
readInitialStateFromURL();
|
||||
|
||||
const inlined = window.__PALIAD_AGENDA__;
|
||||
if (inlined !== undefined) {
|
||||
if (inlined === null) {
|
||||
showUnavailable();
|
||||
} else {
|
||||
hydrate(inlined);
|
||||
}
|
||||
} else {
|
||||
void refetch();
|
||||
}
|
||||
|
||||
wireControls();
|
||||
onLangChange(() => render());
|
||||
});
|
||||
|
||||
// Pull initial state from ?types=...&range=... so reloads and bookmarks work.
|
||||
// Any deviation triggers a refetch via wireControls once the UI is ready.
|
||||
function readInitialStateFromURL(): void {
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
const typesRaw = q.get("types");
|
||||
if (typesRaw) {
|
||||
const set = new Set(typesRaw.split(",").map((s) => s.trim()));
|
||||
const hasD = set.has("deadlines");
|
||||
const hasA = set.has("appointments");
|
||||
if (hasD && !hasA) state.type = "deadlines";
|
||||
else if (hasA && !hasD) state.type = "appointments";
|
||||
else state.type = "both";
|
||||
}
|
||||
const rangeRaw = q.get("range");
|
||||
if (rangeRaw) {
|
||||
const n = parseInt(rangeRaw, 10);
|
||||
if (!isNaN(n) && VALID_RANGES.has(n)) state.rangeDays = n;
|
||||
}
|
||||
}
|
||||
|
||||
function hydrate(payload: AgendaPayload): void {
|
||||
state.items = payload.items;
|
||||
// Infer type filter from server payload when the URL didn't pin it.
|
||||
if (!window.location.search.includes("types=")) {
|
||||
const set = new Set(payload.types);
|
||||
if (set.has("deadlines") && !set.has("appointments")) state.type = "deadlines";
|
||||
else if (set.has("appointments") && !set.has("deadlines")) state.type = "appointments";
|
||||
else state.type = "both";
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function wireControls(): void {
|
||||
document.querySelectorAll<HTMLButtonElement>(".agenda-chip[data-type]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const next = (btn.dataset.type || "both") as TypeFilter;
|
||||
if (state.type === next) return;
|
||||
state.type = next;
|
||||
pushURL();
|
||||
void refetch();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>(".agenda-chip[data-range]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const next = parseInt(btn.dataset.range || "30", 10);
|
||||
if (!VALID_RANGES.has(next) || state.rangeDays === next) return;
|
||||
state.rangeDays = next;
|
||||
pushURL();
|
||||
void refetch();
|
||||
});
|
||||
});
|
||||
syncChips();
|
||||
|
||||
const eventTrigger = document.getElementById("agenda-filter-event-type") as HTMLButtonElement | null;
|
||||
const eventPanel = document.getElementById("agenda-filter-event-type-panel") as HTMLElement | null;
|
||||
if (eventTrigger && eventPanel) {
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
const initialEventIDs: string[] = [];
|
||||
let initialIncludeUntyped = false;
|
||||
const raw = q.get("event_type") ?? "";
|
||||
if (raw) {
|
||||
for (const tok of raw.split(",")) {
|
||||
const t = tok.trim();
|
||||
if (!t) continue;
|
||||
if (t === "none") initialIncludeUntyped = true;
|
||||
else initialEventIDs.push(t);
|
||||
}
|
||||
}
|
||||
eventTypeFilter = attachEventTypeMultiSelectFilter(eventTrigger, eventPanel, {
|
||||
initialIDs: initialEventIDs,
|
||||
initialIncludeUntyped,
|
||||
onChange: () => {
|
||||
pushURL();
|
||||
void refetch();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function pushURL(): void {
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
q.set("range", String(state.rangeDays));
|
||||
q.set("types", typesParam(state.type));
|
||||
const eventQuery = eventTypeFilter?.toQueryValue() ?? "";
|
||||
if (eventQuery) q.set("event_type", eventQuery);
|
||||
else q.delete("event_type");
|
||||
history.replaceState(null, "", `${window.location.pathname}?${q.toString()}`);
|
||||
}
|
||||
|
||||
function typesParam(tf: TypeFilter): string {
|
||||
if (tf === "deadlines") return "deadlines";
|
||||
if (tf === "appointments") return "appointments";
|
||||
return "deadlines,appointments";
|
||||
}
|
||||
|
||||
async function refetch(): Promise<void> {
|
||||
const loading = document.getElementById("agenda-loading")!;
|
||||
const timeline = document.getElementById("agenda-timeline")!;
|
||||
const empty = document.getElementById("agenda-empty")!;
|
||||
loading.style.display = "block";
|
||||
timeline.style.display = "none";
|
||||
empty.style.display = "none";
|
||||
syncChips();
|
||||
|
||||
const from = toISODate(startOfToday());
|
||||
const to = toISODate(addDays(startOfToday(), state.rangeDays - 1));
|
||||
const eventQuery = eventTypeFilter?.toQueryValue() ?? "";
|
||||
const eventParam = eventQuery ? `&event_type=${encodeURIComponent(eventQuery)}` : "";
|
||||
const url = `/api/agenda?from=${from}&to=${to}&types=${typesParam(state.type)}${eventParam}`;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (resp.status === 503) {
|
||||
showUnavailable();
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) throw new Error(`status ${resp.status}`);
|
||||
state.items = (await resp.json()) as AgendaItem[];
|
||||
render();
|
||||
} catch {
|
||||
showUnavailable();
|
||||
} finally {
|
||||
loading.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function showUnavailable(): void {
|
||||
document.getElementById("agenda-unavailable")!.style.display = "block";
|
||||
document.getElementById("agenda-timeline")!.style.display = "none";
|
||||
document.getElementById("agenda-empty")!.style.display = "none";
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
syncChips();
|
||||
const timeline = document.getElementById("agenda-timeline")!;
|
||||
const empty = document.getElementById("agenda-empty")!;
|
||||
|
||||
if (!state.items.length) {
|
||||
timeline.innerHTML = "";
|
||||
timeline.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
timeline.style.display = "";
|
||||
|
||||
const buckets = groupByDay(state.items);
|
||||
timeline.innerHTML = buckets.map((b) => renderDay(b)).join("");
|
||||
}
|
||||
|
||||
interface DayBucket {
|
||||
dayKey: string; // YYYY-MM-DD local
|
||||
day: Date;
|
||||
items: AgendaItem[];
|
||||
}
|
||||
|
||||
function groupByDay(items: AgendaItem[]): DayBucket[] {
|
||||
const map = new Map<string, DayBucket>();
|
||||
for (const it of items) {
|
||||
const d = new Date(it.date);
|
||||
if (isNaN(d.getTime())) continue;
|
||||
const key = toLocalDayKey(d);
|
||||
let b = map.get(key);
|
||||
if (!b) {
|
||||
b = { dayKey: key, day: new Date(d.getFullYear(), d.getMonth(), d.getDate()), items: [] };
|
||||
map.set(key, b);
|
||||
}
|
||||
b.items.push(it);
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.day.getTime() - b.day.getTime());
|
||||
}
|
||||
|
||||
function renderDay(bucket: DayBucket): string {
|
||||
const expected = expectedUrgency(bucket.day);
|
||||
return `<section class="agenda-day">
|
||||
<h2 class="agenda-day-heading">
|
||||
<span class="agenda-day-relative">${esc(relativeDayLabel(bucket.day))}</span>
|
||||
<span class="agenda-day-full">${esc(fullDateLabel(bucket.day))}</span>
|
||||
</h2>
|
||||
<ul class="agenda-items">
|
||||
${bucket.items.map((it) => renderItem(it, expected)).join("")}
|
||||
</ul>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
// F-32: an item's urgency tag duplicates the day-bucket heading in the
|
||||
// common case (a "Heute" item under HEUTE, a "Diese Woche" item under "in 3
|
||||
// Tagen"). The tag stays only when it disagrees with the bucket — e.g. an
|
||||
// "Überfällig" deadline that lands in today's bucket because of a filter
|
||||
// quirk. expectedUrgency mirrors the server's bucketing rule against the
|
||||
// bucket's day.
|
||||
function expectedUrgency(day: Date): Urgency {
|
||||
const today = startOfToday();
|
||||
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
|
||||
if (diff < 0) return "overdue";
|
||||
if (diff === 0) return "today";
|
||||
if (diff === 1) return "tomorrow";
|
||||
if (diff <= 6) return "this_week";
|
||||
return "later";
|
||||
}
|
||||
|
||||
function renderItem(it: AgendaItem, bucketUrgency: Urgency): string {
|
||||
const urgencyClass = `agenda-item-${it.urgency}`;
|
||||
const typeClass = `agenda-item-type-${it.type}`;
|
||||
const pendingClass = it.approval_status === "pending" ? " entity-row--pending-update" : "";
|
||||
const iconHTML = it.type === "deadline" ? deadlineIcon() : appointmentIcon();
|
||||
const detailHref = itemDetailHref(it);
|
||||
const project = it.project_id
|
||||
? `<a class="agenda-item-project" href="/projects/${esc(it.project_id)}">${esc(formatProjectLabel(it))}</a>`
|
||||
: "";
|
||||
const pendingPill = it.approval_status === "pending"
|
||||
? `<span class="approval-pill" title="${esc(tDyn("approvals.pending_update.label"))}">${esc(tDyn("approvals.pending_update.label"))}</span>`
|
||||
: "";
|
||||
|
||||
const timePart = it.type === "appointment"
|
||||
? `<span class="agenda-item-time">${esc(formatAppointmentTime(it))}</span>`
|
||||
: "";
|
||||
const urgencyTag = it.urgency !== bucketUrgency
|
||||
? `<span class="agenda-item-urgency">${esc(tDyn(`agenda.urgency.${it.urgency}`))}</span>`
|
||||
: "";
|
||||
const locationPart = it.type === "appointment" && it.location
|
||||
? `<span class="agenda-item-location">${esc(it.location)}</span>`
|
||||
: "";
|
||||
const typeLabelKey = it.type === "deadline"
|
||||
? "agenda.label.deadline"
|
||||
: (it.appointment_type ? `agenda.appointment_type.${it.appointment_type}` : "agenda.label.appointment");
|
||||
const typeLabel = tDyn(typeLabelKey);
|
||||
|
||||
return `<li class="agenda-item ${typeClass} ${urgencyClass}${pendingClass}">
|
||||
<a class="agenda-item-link" href="${esc(detailHref)}">
|
||||
<span class="agenda-item-icon" aria-hidden="true">${iconHTML}</span>
|
||||
<span class="agenda-item-main">
|
||||
<span class="agenda-item-headline">
|
||||
<span class="agenda-item-type-label">${esc(typeLabel)}:</span>
|
||||
<span class="agenda-item-title">${esc(it.title)}</span>
|
||||
${pendingPill}
|
||||
</span>
|
||||
<span class="agenda-item-sub">
|
||||
${project}
|
||||
${timePart}
|
||||
${locationPart}
|
||||
</span>
|
||||
</span>
|
||||
<span class="agenda-item-meta">
|
||||
${urgencyTag}
|
||||
</span>
|
||||
</a>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function itemDetailHref(it: AgendaItem): string {
|
||||
return it.type === "deadline"
|
||||
? `/deadlines/${encodeURIComponent(it.id)}`
|
||||
: `/appointments/${encodeURIComponent(it.id)}`;
|
||||
}
|
||||
|
||||
function formatProjectLabel(it: AgendaItem): string {
|
||||
const ref = it.project_reference ? `${it.project_reference} · ` : "";
|
||||
const title = it.project_title || "";
|
||||
return `${ref}${title}`.trim();
|
||||
}
|
||||
|
||||
function formatAppointmentTime(it: AgendaItem): string {
|
||||
const start = new Date(it.date);
|
||||
if (isNaN(start.getTime())) return "";
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
const startStr = start.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||||
if (!it.end_at) return startStr;
|
||||
const end = new Date(it.end_at);
|
||||
if (isNaN(end.getTime())) return startStr;
|
||||
const endStr = end.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||||
return `${startStr}–${endStr}`;
|
||||
}
|
||||
|
||||
function relativeDayLabel(day: Date): string {
|
||||
const today = startOfToday();
|
||||
const diff = Math.round((day.getTime() - today.getTime()) / 86400000);
|
||||
if (diff < 0) {
|
||||
const n = Math.abs(diff);
|
||||
return getLang() === "de"
|
||||
? (n === 1 ? "Gestern" : `vor ${n} Tagen`)
|
||||
: (n === 1 ? "Yesterday" : `${n} days ago`);
|
||||
}
|
||||
if (diff === 0) return t("agenda.day.today");
|
||||
if (diff === 1) return t("agenda.day.tomorrow");
|
||||
return getLang() === "de" ? `in ${diff} Tagen` : `in ${diff} days`;
|
||||
}
|
||||
|
||||
function fullDateLabel(day: Date): string {
|
||||
const locale = getLang() === "de" ? "de-DE" : "en-GB";
|
||||
return day.toLocaleDateString(locale, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function syncChips(): void {
|
||||
document.querySelectorAll<HTMLButtonElement>(".agenda-chip[data-type]").forEach((btn) => {
|
||||
btn.classList.toggle("agenda-chip-active", btn.dataset.type === state.type);
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>(".agenda-chip[data-range]").forEach((btn) => {
|
||||
btn.classList.toggle("agenda-chip-active", btn.dataset.range === String(state.rangeDays));
|
||||
});
|
||||
}
|
||||
|
||||
function startOfToday(): Date {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function addDays(d: Date, days: number): Date {
|
||||
const r = new Date(d);
|
||||
r.setDate(r.getDate() + days);
|
||||
return r;
|
||||
}
|
||||
|
||||
function toISODate(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 toLocalDayKey(d: Date): string {
|
||||
return toISODate(d);
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s ?? "";
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function deadlineIcon(): string {
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>';
|
||||
}
|
||||
|
||||
function appointmentIcon(): string {
|
||||
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="17" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
|
||||
}
|
||||
@@ -1,971 +0,0 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Akte {
|
||||
id: string;
|
||||
aktenzeichen: string;
|
||||
title: string;
|
||||
status: string;
|
||||
owning_office: string;
|
||||
firm_wide_visible: boolean;
|
||||
collaborators: string[];
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Partei {
|
||||
id: string;
|
||||
akte_id: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
representative?: string;
|
||||
}
|
||||
|
||||
interface AkteEvent {
|
||||
id: string;
|
||||
akte_id: string;
|
||||
event_type?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
interface Frist {
|
||||
id: string;
|
||||
akte_id: string;
|
||||
title: string;
|
||||
due_date: string;
|
||||
status: string;
|
||||
rule_id?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
role: string;
|
||||
office: string;
|
||||
}
|
||||
|
||||
interface Dokument {
|
||||
id: string;
|
||||
akte_id: string;
|
||||
title: string;
|
||||
file_size?: number;
|
||||
mime_type?: string;
|
||||
ai_extraction_count: number;
|
||||
ai_extracted_at?: string;
|
||||
uploaded_by?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ExtractedDeadline {
|
||||
title: string;
|
||||
due_date: string;
|
||||
rule_code: string;
|
||||
confidence: number;
|
||||
source_quote: string;
|
||||
}
|
||||
|
||||
interface FeatureFlags {
|
||||
document_upload: boolean;
|
||||
ai_extraction: boolean;
|
||||
}
|
||||
|
||||
type TabId = "verlauf" | "parteien" | "fristen" | "termine" | "dokumente" | "notizen";
|
||||
|
||||
const VALID_TABS: TabId[] = ["verlauf", "parteien", "fristen", "termine", "dokumente", "notizen"];
|
||||
|
||||
let akte: Akte | null = null;
|
||||
let me: Me | null = null;
|
||||
let parteien: Partei[] = [];
|
||||
let events: AkteEvent[] = [];
|
||||
let fristen: Frist[] = [];
|
||||
let dokumente: Dokument[] = [];
|
||||
let features: FeatureFlags = { document_upload: false, ai_extraction: false };
|
||||
let extractionDoc: Dokument | null = null;
|
||||
let extractionItems: ExtractedDeadline[] = [];
|
||||
|
||||
function parseAkteID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] !== "akten" || !parts[1]) return null;
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
function parseTab(): TabId {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
const candidate = parts[2] as TabId | undefined;
|
||||
if (candidate && VALID_TABS.includes(candidate)) return candidate;
|
||||
return "verlauf";
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (resp.ok) me = await resp.json();
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAkte(id: string): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`/api/akten/${id}`);
|
||||
if (!resp.ok) return false;
|
||||
akte = await resp.json();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadParteien(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/akten/${id}/parteien`);
|
||||
if (resp.ok) parteien = await resp.json();
|
||||
} catch {
|
||||
parteien = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEvents(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/akten/${id}/events`);
|
||||
if (resp.ok) events = await resp.json();
|
||||
} catch {
|
||||
events = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFristen(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/akten/${id}/fristen`);
|
||||
if (resp.ok) fristen = await resp.json();
|
||||
} catch {
|
||||
fristen = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDokumente(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/akten/${id}/dokumente`);
|
||||
if (resp.ok) dokumente = await resp.json();
|
||||
} catch {
|
||||
dokumente = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFeatures() {
|
||||
try {
|
||||
const resp = await fetch("/api/config/features");
|
||||
if (resp.ok) features = await resp.json();
|
||||
} catch {
|
||||
/* optional — default flags stay false */
|
||||
}
|
||||
}
|
||||
|
||||
function fmtFileSize(bytes: number | undefined): string {
|
||||
if (!bytes || bytes <= 0) return "\u2014";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function confidenceClass(c: number): { cls: string; label: string } {
|
||||
if (c >= 0.75) {
|
||||
return { cls: "extraction-confidence-high", label: t("akten.detail.dokumente.extraction.confidence.high") };
|
||||
}
|
||||
if (c >= 0.5) {
|
||||
return { cls: "extraction-confidence-mid", label: t("akten.detail.dokumente.extraction.confidence.mid") };
|
||||
}
|
||||
return { cls: "extraction-confidence-low", label: t("akten.detail.dokumente.extraction.confidence.low") };
|
||||
}
|
||||
|
||||
function renderDokumente() {
|
||||
const body = document.getElementById("dokumente-body") as HTMLTableSectionElement | null;
|
||||
const wrap = document.getElementById("dokumente-tablewrap") as HTMLElement | null;
|
||||
const empty = document.getElementById("dokumente-empty") as HTMLElement | null;
|
||||
const uploadWrap = document.getElementById("dokument-upload-wrap") as HTMLElement | null;
|
||||
const uploadZone = document.getElementById("dokument-upload-zone") as HTMLLabelElement | null;
|
||||
const uploadDisabled = document.getElementById("dokument-upload-disabled") as HTMLElement | null;
|
||||
if (!body || !wrap || !empty || !uploadWrap || !uploadZone || !uploadDisabled) return;
|
||||
|
||||
if (features.document_upload) {
|
||||
uploadZone.style.display = "";
|
||||
uploadDisabled.style.display = "none";
|
||||
} else {
|
||||
uploadZone.style.display = "none";
|
||||
uploadDisabled.style.display = "";
|
||||
}
|
||||
|
||||
if (dokumente.length === 0) {
|
||||
body.innerHTML = "";
|
||||
wrap.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
|
||||
body.innerHTML = dokumente
|
||||
.map((d) => {
|
||||
const extractedBadge =
|
||||
d.ai_extraction_count > 0
|
||||
? `<span class="dokument-extraction-badge">${d.ai_extraction_count}\u00d7</span>`
|
||||
: "";
|
||||
const extractBtn = features.ai_extraction
|
||||
? `<button type="button" class="dokumente-action-btn dokumente-action-extract" data-action="extract" data-id="${esc(d.id)}">${esc(
|
||||
t("akten.detail.dokumente.action.extract"),
|
||||
)}</button>`
|
||||
: "";
|
||||
return `<tr data-id="${esc(d.id)}">
|
||||
<td>
|
||||
<div class="dokument-name-cell">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" 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" />
|
||||
</svg>
|
||||
<span>${esc(d.title)}</span>${extractedBadge}
|
||||
</div>
|
||||
</td>
|
||||
<td>${fmtDateTime(d.created_at)}</td>
|
||||
<td>${fmtFileSize(d.file_size)}</td>
|
||||
<td class="dokumente-col-actions">
|
||||
<a class="dokumente-action-btn" href="/api/dokumente/${esc(d.id)}/download" target="_blank" rel="noopener">${esc(
|
||||
t("akten.detail.dokumente.action.download"),
|
||||
)}</a>
|
||||
${extractBtn}
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>('button[data-action="extract"]').forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const id = btn.dataset.id!;
|
||||
const doc = dokumente.find((d) => d.id === id) || null;
|
||||
if (!doc) return;
|
||||
await runExtraction(btn, doc);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runExtraction(btn: HTMLButtonElement, doc: Dokument) {
|
||||
const msgEl = document.getElementById("dokument-upload-msg") as HTMLElement | null;
|
||||
btn.disabled = true;
|
||||
const originalText = btn.textContent || "";
|
||||
btn.textContent = t("akten.detail.dokumente.extract.running");
|
||||
if (msgEl) {
|
||||
msgEl.textContent = "";
|
||||
msgEl.className = "form-msg";
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/api/dokumente/${doc.id}/extract-deadlines`, { method: "POST" });
|
||||
if (resp.status === 429) {
|
||||
if (msgEl) {
|
||||
msgEl.textContent = t("akten.detail.dokumente.extract.ratelimit");
|
||||
msgEl.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (resp.status === 501) {
|
||||
if (msgEl) {
|
||||
msgEl.textContent = t("akten.detail.dokumente.extract.disabled");
|
||||
msgEl.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
if (msgEl) {
|
||||
msgEl.textContent = t("akten.detail.dokumente.extract.failed");
|
||||
msgEl.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as { deadlines: ExtractedDeadline[] };
|
||||
extractionDoc = doc;
|
||||
extractionItems = data.deadlines || [];
|
||||
openExtractionModal();
|
||||
// Refresh doc list so the extraction badge updates.
|
||||
if (akte) {
|
||||
await loadDokumente(akte.id);
|
||||
renderDokumente();
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
function openExtractionModal() {
|
||||
const modal = document.getElementById("extraction-modal") as HTMLElement | null;
|
||||
const body = document.getElementById("extraction-body") as HTMLTableSectionElement | null;
|
||||
const none = document.getElementById("extraction-none") as HTMLElement | null;
|
||||
const table = document.getElementById("extraction-table") as HTMLElement | null;
|
||||
const msg = document.getElementById("extraction-msg") as HTMLElement | null;
|
||||
if (!modal || !body || !none || !table) return;
|
||||
if (msg) {
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
}
|
||||
|
||||
if (extractionItems.length === 0) {
|
||||
table.style.display = "none";
|
||||
none.style.display = "block";
|
||||
} else {
|
||||
table.style.display = "";
|
||||
none.style.display = "none";
|
||||
body.innerHTML = extractionItems
|
||||
.map((it, idx) => {
|
||||
const confCls = confidenceClass(it.confidence).cls;
|
||||
const confLabel = confidenceClass(it.confidence).label;
|
||||
const confPct = Math.round(it.confidence * 100);
|
||||
const missingDate = !it.due_date
|
||||
? `<div class="extraction-missing-date">${esc(t("akten.detail.dokumente.extraction.missing.date"))}</div>`
|
||||
: "";
|
||||
const quoteId = `extraction-quote-${idx}`;
|
||||
return `<tr data-idx="${idx}">
|
||||
<td><input type="checkbox" class="extraction-keep" ${it.due_date ? "checked" : ""} /></td>
|
||||
<td><input type="text" class="extraction-title-input" value="${esc(it.title)}" /></td>
|
||||
<td>
|
||||
<input type="date" class="extraction-due-input" value="${esc(it.due_date)}" />
|
||||
${missingDate}
|
||||
</td>
|
||||
<td><input type="text" class="extraction-rule-input" value="${esc(it.rule_code)}" placeholder="z.B. Rule 23 RoP" /></td>
|
||||
<td><span class="extraction-confidence ${confCls}">${confPct}% ${esc(confLabel)}</span></td>
|
||||
<td>
|
||||
<button type="button" class="extraction-source-toggle" data-target="${quoteId}">${esc(
|
||||
t("akten.detail.dokumente.extraction.source.show"),
|
||||
)}</button>
|
||||
<div class="extraction-source-quote" id="${quoteId}">\u201E${esc(it.source_quote)}\u201C</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".extraction-source-toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tgt = document.getElementById(btn.dataset.target!) as HTMLElement | null;
|
||||
if (!tgt) return;
|
||||
const open = tgt.classList.toggle("extraction-source-quote-open");
|
||||
btn.textContent = open
|
||||
? t("akten.detail.dokumente.extraction.source.hide")
|
||||
: t("akten.detail.dokumente.extraction.source.show");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
modal.style.display = "flex";
|
||||
}
|
||||
|
||||
function closeExtractionModal() {
|
||||
const modal = document.getElementById("extraction-modal") as HTMLElement | null;
|
||||
if (modal) modal.style.display = "none";
|
||||
extractionDoc = null;
|
||||
extractionItems = [];
|
||||
}
|
||||
|
||||
async function saveExtractedFristen() {
|
||||
if (!akte || !extractionDoc) return;
|
||||
const msg = document.getElementById("extraction-msg") as HTMLElement | null;
|
||||
const saveBtn = document.getElementById("extraction-save") as HTMLButtonElement | null;
|
||||
if (msg) {
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
}
|
||||
|
||||
const rows = Array.from(document.querySelectorAll<HTMLTableRowElement>("#extraction-body tr"));
|
||||
const items: Array<{ title: string; due_date: string; rule_code?: string; source_quote?: string }> = [];
|
||||
for (const row of rows) {
|
||||
const cb = row.querySelector<HTMLInputElement>(".extraction-keep");
|
||||
if (!cb?.checked) continue;
|
||||
const titleInput = row.querySelector<HTMLInputElement>(".extraction-title-input");
|
||||
const dueInput = row.querySelector<HTMLInputElement>(".extraction-due-input");
|
||||
const ruleInput = row.querySelector<HTMLInputElement>(".extraction-rule-input");
|
||||
if (!titleInput || !dueInput) continue;
|
||||
const title = titleInput.value.trim();
|
||||
const due = dueInput.value.trim();
|
||||
const rule = ruleInput?.value.trim() || "";
|
||||
if (!title || !due) continue;
|
||||
const idx = parseInt(row.dataset.idx || "-1", 10);
|
||||
const sourceQuote = idx >= 0 ? extractionItems[idx]?.source_quote : "";
|
||||
items.push({
|
||||
title,
|
||||
due_date: due,
|
||||
rule_code: rule || undefined,
|
||||
source_quote: sourceQuote || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
if (msg) {
|
||||
msg.textContent = t("akten.detail.dokumente.extraction.save.none");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveBtn) saveBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/akten/${akte.id}/fristen/from-extraction`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ document_id: extractionDoc.id, items }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (msg) {
|
||||
msg.textContent = t("akten.detail.dokumente.extraction.save.failed");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
const count = Array.isArray(created) ? created.length : items.length;
|
||||
if (msg) {
|
||||
const template = t("akten.detail.dokumente.extraction.save.success");
|
||||
msg.textContent = template.replace("{n}", String(count));
|
||||
msg.className = "form-msg form-msg-success";
|
||||
}
|
||||
if (akte) {
|
||||
await Promise.all([loadFristen(akte.id), loadEvents(akte.id), loadDokumente(akte.id)]);
|
||||
renderFristen();
|
||||
renderEvents();
|
||||
renderDokumente();
|
||||
}
|
||||
// Auto-close after short delay so user can see the success message.
|
||||
setTimeout(() => closeExtractionModal(), 1200);
|
||||
} finally {
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function initDokumenteUpload() {
|
||||
const input = document.getElementById("dokument-file-input") as HTMLInputElement | null;
|
||||
const zone = document.getElementById("dokument-upload-zone") as HTMLLabelElement | null;
|
||||
const progress = document.getElementById("dokument-upload-progress") as HTMLElement | null;
|
||||
const bar = document.getElementById("dokument-upload-bar-fill") as HTMLElement | null;
|
||||
const status = document.getElementById("dokument-upload-status") as HTMLElement | null;
|
||||
const msg = document.getElementById("dokument-upload-msg") as HTMLElement | null;
|
||||
if (!input || !zone || !progress || !bar || !status || !msg) return;
|
||||
|
||||
const handleFiles = async (files: FileList | null) => {
|
||||
if (!files || files.length === 0 || !akte) return;
|
||||
const file = files[0]!;
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
|
||||
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
|
||||
msg.textContent = t("akten.detail.dokumente.upload.notpdf");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
if (file.size > 20 * 1024 * 1024) {
|
||||
msg.textContent = t("akten.detail.dokumente.upload.toolarge");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
|
||||
progress.style.display = "flex";
|
||||
bar.style.width = "0%";
|
||||
status.textContent = t("akten.detail.dokumente.upload.progress");
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
// Use XHR for real upload-progress reporting (fetch can't stream upload
|
||||
// progress in browsers without the experimental ReadableStream.pipeTo).
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `/api/akten/${akte.id}/dokumente`);
|
||||
xhr.upload.addEventListener("progress", (ev) => {
|
||||
if (ev.lengthComputable) {
|
||||
const pct = Math.round((ev.loaded / ev.total) * 100);
|
||||
bar.style.width = `${pct}%`;
|
||||
}
|
||||
});
|
||||
xhr.onload = async () => {
|
||||
progress.style.display = "none";
|
||||
input.value = "";
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
msg.textContent = t("akten.detail.dokumente.upload.success");
|
||||
msg.className = "form-msg form-msg-success";
|
||||
if (akte) {
|
||||
await loadDokumente(akte.id);
|
||||
renderDokumente();
|
||||
await loadEvents(akte.id);
|
||||
renderEvents();
|
||||
}
|
||||
} else if (xhr.status === 413) {
|
||||
msg.textContent = t("akten.detail.dokumente.upload.toolarge");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
} else {
|
||||
let reason = t("akten.detail.dokumente.upload.failed");
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText) as { error?: string };
|
||||
if (body.error) reason = body.error;
|
||||
} catch {
|
||||
/* ignore parse errors */
|
||||
}
|
||||
msg.textContent = reason;
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
progress.style.display = "none";
|
||||
msg.textContent = t("akten.detail.dokumente.upload.failed");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
};
|
||||
xhr.send(fd);
|
||||
};
|
||||
|
||||
input.addEventListener("change", () => handleFiles(input.files));
|
||||
|
||||
// Drag-drop on the zone.
|
||||
zone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
zone.classList.add("dokumente-upload-zone-drag");
|
||||
});
|
||||
zone.addEventListener("dragleave", () => {
|
||||
zone.classList.remove("dokumente-upload-zone-drag");
|
||||
});
|
||||
zone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
zone.classList.remove("dokumente-upload-zone-drag");
|
||||
handleFiles(e.dataTransfer?.files ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
function initExtractionModal() {
|
||||
const modal = document.getElementById("extraction-modal") as HTMLElement | null;
|
||||
const closeBtn = document.getElementById("extraction-modal-close") as HTMLElement | null;
|
||||
const cancelBtn = document.getElementById("extraction-cancel") as HTMLElement | null;
|
||||
const saveBtn = document.getElementById("extraction-save") as HTMLElement | null;
|
||||
if (!modal || !closeBtn || !cancelBtn || !saveBtn) return;
|
||||
closeBtn.addEventListener("click", closeExtractionModal);
|
||||
cancelBtn.addEventListener("click", closeExtractionModal);
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeExtractionModal();
|
||||
});
|
||||
saveBtn.addEventListener("click", saveExtractedFristen);
|
||||
}
|
||||
|
||||
function fmtDateOnly(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso.slice(0, 10) + "T00:00:00");
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
function renderFristen() {
|
||||
const tbody = document.getElementById("akte-fristen-body");
|
||||
const empty = document.getElementById("akte-fristen-empty");
|
||||
const wrap = document.getElementById("akte-fristen-tablewrap");
|
||||
if (!tbody || !empty || !wrap) return;
|
||||
if (fristen.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
wrap.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
tbody.innerHTML = fristen
|
||||
.map((f) => {
|
||||
const urgency = urgencyClass(f.due_date, f.status);
|
||||
const statusLabel = t(`fristen.status.${f.status}`) || f.status;
|
||||
const checked = f.status === "completed" ? "checked" : "";
|
||||
const disabled = f.status === "completed" ? "disabled" : "";
|
||||
const titleClass = f.status === "completed" ? "frist-title-done" : "";
|
||||
return `<tr class="frist-row" data-id="${esc(f.id)}">
|
||||
<td class="frist-col-check">
|
||||
<input type="checkbox" class="frist-complete-cb" ${checked} ${disabled}
|
||||
aria-label="${esc(t("fristen.complete.action"))}" />
|
||||
</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)}</td>
|
||||
<td class="frist-col-rule">\u2014</td>
|
||||
<td><span class="akten-status-chip akten-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
tbody.querySelectorAll<HTMLTableRowElement>(".frist-row").forEach((row) => {
|
||||
const id = row.dataset.id!;
|
||||
row.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".frist-complete-cb")) return;
|
||||
window.location.href = `/fristen/${id}`;
|
||||
});
|
||||
const cb = row.querySelector<HTMLInputElement>(".frist-complete-cb");
|
||||
if (cb) {
|
||||
cb.addEventListener("change", async () => {
|
||||
if (!cb.checked || !akte) return;
|
||||
cb.disabled = true;
|
||||
const resp = await fetch(`/api/fristen/${id}/complete`, { method: "PATCH" });
|
||||
if (resp.ok) {
|
||||
await loadFristen(akte.id);
|
||||
renderFristen();
|
||||
await loadEvents(akte.id);
|
||||
renderEvents();
|
||||
} else {
|
||||
cb.checked = false;
|
||||
cb.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
if (!akte) return;
|
||||
(document.getElementById("akte-title-display") as HTMLElement).textContent = akte.title;
|
||||
(document.getElementById("akte-title-edit") as HTMLInputElement).value = akte.title;
|
||||
(document.getElementById("akte-ref-display") as HTMLElement).textContent = akte.aktenzeichen;
|
||||
|
||||
const officeChip = document.getElementById("akte-office-chip")!;
|
||||
officeChip.className = `akten-office-chip akten-office-${akte.owning_office}`;
|
||||
officeChip.textContent = t(`office.${akte.owning_office}`) || akte.owning_office;
|
||||
|
||||
const statusChip = document.getElementById("akte-status-chip")!;
|
||||
statusChip.className = `akten-status-chip akten-status-${akte.status}`;
|
||||
statusChip.textContent = t(`akten.status.${akte.status}`) || akte.status;
|
||||
|
||||
const firmWideChip = document.getElementById("akte-firmwide-chip")!;
|
||||
if (akte.firm_wide_visible) {
|
||||
firmWideChip.style.display = "";
|
||||
firmWideChip.textContent = t("akten.detail.firmwide.on");
|
||||
} else {
|
||||
firmWideChip.style.display = "none";
|
||||
}
|
||||
|
||||
// Delete visibility: partner/admin only
|
||||
const deleteWrap = document.getElementById("akte-delete-wrap")!;
|
||||
if (me && (me.role === "partner" || me.role === "admin")) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
const list = document.getElementById("akten-events-list")!;
|
||||
const empty = document.getElementById("akten-events-empty")!;
|
||||
if (events.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = events
|
||||
.map(
|
||||
(e) => `<li class="akten-event">
|
||||
<div class="akten-event-date">${fmtDateTime(e.created_at)}</div>
|
||||
<div class="akten-event-body">
|
||||
<div class="akten-event-title">${esc(e.title)}</div>
|
||||
${e.description ? `<div class="akten-event-desc">${esc(e.description)}</div>` : ""}
|
||||
</div>
|
||||
</li>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderParteien() {
|
||||
const tbody = document.getElementById("parteien-body")!;
|
||||
const empty = document.getElementById("parteien-empty")!;
|
||||
const tableWrap = tbody.closest<HTMLElement>("table")!;
|
||||
if (parteien.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
tableWrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
tbody.innerHTML = parteien
|
||||
.map((p) => {
|
||||
const roleKey = p.role ? `akten.detail.parteien.role.${p.role}` : "";
|
||||
const roleLabel = p.role ? t(roleKey) || p.role : "";
|
||||
return `<tr data-id="${esc(p.id)}">
|
||||
<td>${esc(p.name)}</td>
|
||||
<td>${esc(roleLabel)}</td>
|
||||
<td>${esc(p.representative || "")}</td>
|
||||
<td class="akten-col-actions">
|
||||
<button type="button" class="btn-link-danger partei-remove" data-i18n="akten.detail.parteien.remove">Entfernen</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
tbody.querySelectorAll<HTMLButtonElement>(".partei-remove").forEach((btn) => {
|
||||
btn.textContent = t("akten.detail.parteien.remove");
|
||||
btn.addEventListener("click", async () => {
|
||||
const row = btn.closest<HTMLTableRowElement>("tr")!;
|
||||
const id = row.dataset.id!;
|
||||
if (!confirm(t("akten.detail.parteien.remove.confirm"))) return;
|
||||
const resp = await fetch(`/api/parteien/${id}`, { method: "DELETE" });
|
||||
if (resp.ok && akte) {
|
||||
await loadParteien(akte.id);
|
||||
renderParteien();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showTab(tab: TabId) {
|
||||
document.querySelectorAll<HTMLElement>(".akten-tab").forEach((el) => {
|
||||
el.classList.toggle("active", el.dataset.tab === tab);
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>(".akten-tab-panel").forEach((el) => {
|
||||
el.style.display = el.id === `tab-${tab}` ? "" : "none";
|
||||
});
|
||||
// Deep-link via pushState so sub-routes stay shareable.
|
||||
if (akte) {
|
||||
const newPath = `/akten/${akte.id}/${tab}`;
|
||||
if (window.location.pathname !== newPath) {
|
||||
window.history.replaceState({}, "", newPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initTabs() {
|
||||
document.querySelectorAll<HTMLAnchorElement>(".akten-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
showTab(tab.dataset.tab as TabId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initTitleEdit() {
|
||||
const display = document.getElementById("akte-title-display")!;
|
||||
const editInput = document.getElementById("akte-title-edit") as HTMLInputElement;
|
||||
const editBtn = document.getElementById("akte-edit-btn") as HTMLButtonElement;
|
||||
const saveBtn = document.getElementById("akte-save-btn") as HTMLButtonElement;
|
||||
|
||||
editBtn.addEventListener("click", () => {
|
||||
display.style.display = "none";
|
||||
editInput.style.display = "";
|
||||
saveBtn.style.display = "";
|
||||
editBtn.style.display = "none";
|
||||
editInput.focus();
|
||||
editInput.select();
|
||||
});
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!akte) return;
|
||||
const newTitle = editInput.value.trim();
|
||||
if (!newTitle || newTitle === akte.title) {
|
||||
cancelEdit();
|
||||
return;
|
||||
}
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/akten/${akte.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: newTitle }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
akte = await resp.json();
|
||||
renderHeader();
|
||||
if (akte) await loadEvents(akte.id);
|
||||
renderEvents();
|
||||
}
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
cancelEdit();
|
||||
}
|
||||
});
|
||||
|
||||
function cancelEdit() {
|
||||
display.style.display = "";
|
||||
editInput.style.display = "none";
|
||||
saveBtn.style.display = "none";
|
||||
editBtn.style.display = "";
|
||||
}
|
||||
}
|
||||
|
||||
function initParteienForm() {
|
||||
const addBtn = document.getElementById("partei-add-btn") as HTMLButtonElement;
|
||||
const form = document.getElementById("partei-form") as HTMLFormElement;
|
||||
const cancelBtn = document.getElementById("partei-cancel") as HTMLButtonElement;
|
||||
const msg = document.getElementById("partei-msg")!;
|
||||
|
||||
addBtn.addEventListener("click", () => {
|
||||
form.style.display = "";
|
||||
addBtn.style.display = "none";
|
||||
(document.getElementById("partei-name") as HTMLInputElement).focus();
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
form.reset();
|
||||
form.style.display = "none";
|
||||
addBtn.style.display = "";
|
||||
msg.textContent = "";
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!akte) return;
|
||||
const name = (document.getElementById("partei-name") as HTMLInputElement).value.trim();
|
||||
const role = (document.getElementById("partei-role") as HTMLSelectElement).value;
|
||||
const rep = (document.getElementById("partei-rep") as HTMLInputElement).value.trim();
|
||||
if (!name) return;
|
||||
|
||||
msg.textContent = "";
|
||||
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
|
||||
submitBtn.disabled = true;
|
||||
|
||||
const payload: Record<string, unknown> = { name, role };
|
||||
if (rep) payload.representative = rep;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/akten/${akte.id}/parteien`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (resp.ok) {
|
||||
form.reset();
|
||||
form.style.display = "none";
|
||||
addBtn.style.display = "";
|
||||
await loadParteien(akte.id);
|
||||
renderParteien();
|
||||
await loadEvents(akte.id);
|
||||
renderEvents();
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = data.error || t("akten.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
} catch {
|
||||
msg.textContent = t("akten.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initFristAddLink() {
|
||||
if (!akte) return;
|
||||
const link = document.getElementById("frist-add-link") as HTMLAnchorElement | null;
|
||||
if (link) link.href = `/akten/${akte.id}/fristen/neu`;
|
||||
}
|
||||
|
||||
function initDelete() {
|
||||
const btn = document.getElementById("akte-delete-btn")!;
|
||||
const modal = document.getElementById("delete-modal")!;
|
||||
const close = document.getElementById("delete-modal-close")!;
|
||||
const cancel = document.getElementById("delete-modal-cancel")!;
|
||||
const confirmBtn = document.getElementById("delete-modal-confirm") as HTMLButtonElement;
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
modal.style.display = "flex";
|
||||
});
|
||||
const closeModal = () => {
|
||||
modal.style.display = "none";
|
||||
};
|
||||
close.addEventListener("click", closeModal);
|
||||
cancel.addEventListener("click", closeModal);
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
});
|
||||
confirmBtn.addEventListener("click", async () => {
|
||||
if (!akte) return;
|
||||
confirmBtn.disabled = true;
|
||||
const resp = await fetch(`/api/akten/${akte.id}`, { method: "DELETE" });
|
||||
if (resp.ok) {
|
||||
window.location.href = "/akten";
|
||||
} else {
|
||||
confirmBtn.disabled = false;
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const id = parseAkteID();
|
||||
const loading = document.getElementById("akten-detail-loading")!;
|
||||
const notfound = document.getElementById("akten-detail-notfound")!;
|
||||
const body = document.getElementById("akten-detail-body")!;
|
||||
|
||||
if (!id) {
|
||||
loading.style.display = "none";
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([loadMe(), loadFeatures()]);
|
||||
const ok = await loadAkte(id);
|
||||
if (!ok || !akte) {
|
||||
loading.style.display = "none";
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([loadParteien(id), loadEvents(id), loadFristen(id), loadDokumente(id)]);
|
||||
|
||||
loading.style.display = "none";
|
||||
body.style.display = "";
|
||||
renderHeader();
|
||||
renderParteien();
|
||||
renderEvents();
|
||||
renderFristen();
|
||||
renderDokumente();
|
||||
initFristAddLink();
|
||||
initTabs();
|
||||
initTitleEdit();
|
||||
initParteienForm();
|
||||
initDelete();
|
||||
initDokumenteUpload();
|
||||
initExtractionModal();
|
||||
showTab(parseTab());
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(() => {
|
||||
renderHeader();
|
||||
renderEvents();
|
||||
renderParteien();
|
||||
renderFristen();
|
||||
renderDokumente();
|
||||
});
|
||||
main();
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
office: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
office: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const selectedCollabs = new Map<string, User>();
|
||||
let allUsers: User[] = [];
|
||||
let me: Me | null = null;
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (resp.status === 404) {
|
||||
showError(t("akten.onboarding.required"));
|
||||
disableForm();
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) return;
|
||||
me = await resp.json();
|
||||
if (!me) return;
|
||||
|
||||
const officeSelect = document.getElementById("akte-office") as HTMLSelectElement;
|
||||
officeSelect.value = me.office;
|
||||
if (me.role !== "admin") {
|
||||
officeSelect.disabled = true;
|
||||
}
|
||||
|
||||
if (me.role === "partner" || me.role === "admin") {
|
||||
document.getElementById("firm-wide-wrap")!.style.display = "";
|
||||
}
|
||||
} catch {
|
||||
/* non-fatal — form still works */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
if (!resp.ok) return;
|
||||
allUsers = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal — collaborator picker disabled silently */
|
||||
}
|
||||
}
|
||||
|
||||
function renderCollabChips() {
|
||||
const wrap = document.getElementById("akte-collab-list")!;
|
||||
wrap.innerHTML = Array.from(selectedCollabs.values())
|
||||
.map(
|
||||
(u) =>
|
||||
`<span class="akten-chip" data-id="${esc(u.id)}">${esc(u.display_name || u.email)}<button type="button" class="akten-chip-x" aria-label="remove">\u00d7</button></span>`,
|
||||
)
|
||||
.join("");
|
||||
wrap.querySelectorAll<HTMLButtonElement>(".akten-chip-x").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const chip = btn.closest<HTMLElement>(".akten-chip")!;
|
||||
selectedCollabs.delete(chip.dataset.id!);
|
||||
renderCollabChips();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initCollabPicker() {
|
||||
const input = document.getElementById("akte-collab-input") as HTMLInputElement;
|
||||
const suggestions = document.getElementById("akte-collab-suggestions")!;
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
if (!q) {
|
||||
suggestions.innerHTML = "";
|
||||
suggestions.style.display = "none";
|
||||
return;
|
||||
}
|
||||
const matches = allUsers
|
||||
.filter((u) => !selectedCollabs.has(u.id) && (!me || u.id !== me.id))
|
||||
.filter(
|
||||
(u) =>
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.display_name && u.display_name.toLowerCase().includes(q)),
|
||||
)
|
||||
.slice(0, 8);
|
||||
if (matches.length === 0) {
|
||||
suggestions.innerHTML = "";
|
||||
suggestions.style.display = "none";
|
||||
return;
|
||||
}
|
||||
suggestions.innerHTML = matches
|
||||
.map(
|
||||
(u) =>
|
||||
`<button type="button" class="akten-suggestion" data-id="${esc(u.id)}">${esc(u.display_name || u.email)}<span class="akten-suggestion-meta">${esc(u.email)}</span></button>`,
|
||||
)
|
||||
.join("");
|
||||
suggestions.style.display = "block";
|
||||
suggestions.querySelectorAll<HTMLButtonElement>(".akten-suggestion").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const id = btn.dataset.id!;
|
||||
const user = allUsers.find((u) => u.id === id);
|
||||
if (user) {
|
||||
selectedCollabs.set(id, user);
|
||||
renderCollabChips();
|
||||
}
|
||||
input.value = "";
|
||||
suggestions.innerHTML = "";
|
||||
suggestions.style.display = "none";
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Hide suggestions on outside click
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!(e.target as HTMLElement).closest(".akten-collab")) {
|
||||
suggestions.style.display = "none";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function showError(msg: string) {
|
||||
const el = document.getElementById("akten-neu-msg")!;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg form-msg-error";
|
||||
}
|
||||
|
||||
function disableForm() {
|
||||
const form = document.getElementById("akten-neu-form") as HTMLFormElement;
|
||||
form.querySelectorAll<HTMLInputElement>("input, select, textarea, button[type=submit]").forEach((el) => {
|
||||
el.disabled = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function submitForm(e: Event) {
|
||||
e.preventDefault();
|
||||
const msg = document.getElementById("akten-neu-msg")!;
|
||||
const submitBtn = document.querySelector<HTMLButtonElement>("#akten-neu-form button[type=submit]")!;
|
||||
|
||||
const title = (document.getElementById("akte-title") as HTMLInputElement).value.trim();
|
||||
const ref = (document.getElementById("akte-ref") as HTMLInputElement).value.trim();
|
||||
const office = (document.getElementById("akte-office") as HTMLSelectElement).value;
|
||||
const status = (document.getElementById("akte-status") as HTMLSelectElement).value;
|
||||
const court = (document.getElementById("akte-court") as HTMLInputElement).value.trim();
|
||||
const courtRef = (document.getElementById("akte-courtref") as HTMLInputElement).value.trim();
|
||||
const akteType = (document.getElementById("akte-type") as HTMLInputElement).value.trim();
|
||||
const firmWide =
|
||||
me &&
|
||||
(me.role === "partner" || me.role === "admin") &&
|
||||
(document.getElementById("akte-firmwide") as HTMLInputElement).checked;
|
||||
|
||||
if (!title || !ref) {
|
||||
showError(t("akten.error.required"));
|
||||
return;
|
||||
}
|
||||
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
submitBtn.disabled = true;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title,
|
||||
aktenzeichen: ref,
|
||||
owning_office: office,
|
||||
status,
|
||||
};
|
||||
if (court) payload.court = court;
|
||||
if (courtRef) payload.court_ref = courtRef;
|
||||
if (akteType) payload.akte_type = akteType;
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/akten", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
showError(t("akten.error.forbidden"));
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||||
showError(data.error || t("akten.error.generic"));
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const akte = await resp.json();
|
||||
|
||||
const collabIds = Array.from(selectedCollabs.keys());
|
||||
if (collabIds.length > 0 || firmWide) {
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (collabIds.length > 0) patch.collaborators = collabIds;
|
||||
if (firmWide) patch.firm_wide_visible = true;
|
||||
await fetch(`/api/akten/${akte.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
window.location.href = `/akten/${akte.id}`;
|
||||
} catch {
|
||||
showError(t("akten.error.generic"));
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initCollabPicker();
|
||||
document.getElementById("akten-neu-form")!.addEventListener("submit", submitForm);
|
||||
loadMe();
|
||||
loadUsers();
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Akte {
|
||||
id: string;
|
||||
aktenzeichen: string;
|
||||
title: string;
|
||||
status: string;
|
||||
owning_office: string;
|
||||
firm_wide_visible: boolean;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
let allAkten: Akte[] = [];
|
||||
let officeFilter = "";
|
||||
let statusFilter = "";
|
||||
let searchQuery = "";
|
||||
let loadedOK = false;
|
||||
|
||||
async function loadAkten() {
|
||||
const unavailable = document.getElementById("akten-unavailable")!;
|
||||
const table = document.querySelector<HTMLElement>(".akten-table-wrap")!;
|
||||
try {
|
||||
const resp = await fetch("/api/akten");
|
||||
if (resp.status === 503) {
|
||||
unavailable.style.display = "block";
|
||||
table.style.display = "none";
|
||||
document.getElementById("akten-empty")!.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
unavailable.style.display = "block";
|
||||
table.style.display = "none";
|
||||
return;
|
||||
}
|
||||
allAkten = await resp.json();
|
||||
loadedOK = true;
|
||||
render();
|
||||
} catch {
|
||||
unavailable.style.display = "block";
|
||||
table.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function getFiltered(): Akte[] {
|
||||
let rows = allAkten;
|
||||
if (officeFilter) rows = rows.filter((a) => a.owning_office === officeFilter);
|
||||
if (statusFilter) rows = rows.filter((a) => a.status === statusFilter);
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
rows = rows.filter(
|
||||
(a) =>
|
||||
a.title.toLowerCase().includes(q) ||
|
||||
a.aktenzeichen.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!loadedOK) return;
|
||||
const tbody = document.getElementById("akten-body")!;
|
||||
const empty = document.getElementById("akten-empty")!;
|
||||
const emptyFiltered = document.getElementById("akten-empty-filtered")!;
|
||||
const tableWrap = document.querySelector<HTMLElement>(".akten-table-wrap")!;
|
||||
const count = document.getElementById("akten-count")!;
|
||||
const filtered = getFiltered();
|
||||
|
||||
count.textContent = `${filtered.length} / ${allAkten.length}`;
|
||||
|
||||
if (allAkten.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = "block";
|
||||
emptyFiltered.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
tableWrap.style.display = "none";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
tableWrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "none";
|
||||
|
||||
tbody.innerHTML = filtered
|
||||
.map((a) => {
|
||||
const statusKey = `akten.status.${a.status}`;
|
||||
const statusLabel = t(statusKey);
|
||||
const officeLabel = t(`office.${a.owning_office}`) || a.owning_office;
|
||||
const firmWide = a.firm_wide_visible
|
||||
? `<span class="akten-firmwide-dot" title="${escAttr(t("akten.detail.firmwide.on"))}">\u2737</span>`
|
||||
: "";
|
||||
return `<tr class="akten-row" data-id="${esc(a.id)}">
|
||||
<td class="akten-col-title">${esc(a.title)} ${firmWide}</td>
|
||||
<td class="akten-col-ref">${esc(a.aktenzeichen)}</td>
|
||||
<td><span class="akten-office-chip akten-office-${esc(a.owning_office)}">${esc(officeLabel)}</span></td>
|
||||
<td><span class="akten-status-chip akten-status-${esc(a.status)}">${esc(statusLabel)}</span></td>
|
||||
<td class="akten-col-updated">${fmtDate(a.updated_at)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
tbody.querySelectorAll<HTMLTableRowElement>(".akten-row").forEach((row) => {
|
||||
row.addEventListener("click", () => {
|
||||
const id = row.dataset.id!;
|
||||
window.location.href = `/akten/${id}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
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 initSearch() {
|
||||
const input = document.getElementById("akten-search") as HTMLInputElement;
|
||||
input.addEventListener("input", () => {
|
||||
searchQuery = input.value.trim();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function initFilters() {
|
||||
const office = document.getElementById("akten-office") as HTMLSelectElement;
|
||||
const status = document.getElementById("akten-status") as HTMLSelectElement;
|
||||
office.addEventListener("change", () => {
|
||||
officeFilter = office.value;
|
||||
render();
|
||||
});
|
||||
status.addEventListener("change", () => {
|
||||
statusFilter = status.value;
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initSearch();
|
||||
initFilters();
|
||||
onLangChange(render);
|
||||
loadAkten();
|
||||
});
|
||||
47
frontend/src/client/app.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
// app.ts — universal client bundle injected on every page. Four jobs:
|
||||
// 1. Wire the BottomNav (was previously written but never bundled — m
|
||||
// reproduced the broken [+] and Menü buttons in production).
|
||||
// 2. Register the service worker so the site qualifies for PWA install.
|
||||
// 3. Surface the install prompt (Chromium banner / iOS share-sheet hint).
|
||||
// 4. Init the theme listener so the OS-level prefers-color-scheme change
|
||||
// flips the page when the user's pref is "auto" (m/paliad#2).
|
||||
//
|
||||
// Per-page bundles still register their own behaviour; this script is
|
||||
// orthogonal and only touches DOM nodes it owns.
|
||||
|
||||
import { initBottomNav } from "./bottom-nav";
|
||||
import { initInstallPrompt } from "./pwa-install";
|
||||
import { initTheme } from "./theme";
|
||||
|
||||
function registerServiceWorker(): void {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
// Don't bother in non-secure contexts (localhost is a secure context, so
|
||||
// local dev still registers).
|
||||
if (!window.isSecureContext) return;
|
||||
|
||||
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch((err) => {
|
||||
// Surface registration failures in the console — do not throw, since the
|
||||
// site has to keep working without the SW.
|
||||
console.warn("paliad: service worker registration failed", err);
|
||||
});
|
||||
}
|
||||
|
||||
function boot(): void {
|
||||
initBottomNav();
|
||||
initInstallPrompt();
|
||||
initTheme();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
|
||||
// Register the SW after the page has had a chance to paint so it never
|
||||
// competes with critical resources on first paint.
|
||||
if (document.readyState === "complete") {
|
||||
registerServiceWorker();
|
||||
} else {
|
||||
window.addEventListener("load", registerServiceWorker);
|
||||
}
|
||||
193
frontend/src/client/appointments-calendar.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
title: string;
|
||||
start_at: string;
|
||||
end_at?: string;
|
||||
appointment_type?: string;
|
||||
project_reference?: string;
|
||||
project_title?: string;
|
||||
}
|
||||
|
||||
let allAppointments: Appointment[] = [];
|
||||
let viewYear = 0;
|
||||
let viewMonth = 0;
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtMonth(year: number, month: number): string {
|
||||
return `${tDyn(`cal.month.${month}`)} ${year}`;
|
||||
}
|
||||
|
||||
function isoDate(year: number, month: number, day: number): string {
|
||||
const m = String(month + 1).padStart(2, "0");
|
||||
const d = String(day).padStart(2, "0");
|
||||
return `${year}-${m}-${d}`;
|
||||
}
|
||||
|
||||
async function loadAppointments() {
|
||||
// Pull a wide window (current month plus a little buffer either side).
|
||||
// We could narrow this, but the user typically navigates ±1-2 months
|
||||
// and the dataset is small.
|
||||
try {
|
||||
const resp = await fetch("/api/appointments");
|
||||
if (resp.ok) allAppointments = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function appointmentsForDate(iso: string): Appointment[] {
|
||||
return allAppointments.filter((t) => t.start_at.slice(0, 10) === iso);
|
||||
}
|
||||
|
||||
function typeClass(t?: string): string {
|
||||
return t ? `termin-type-${t}` : "termin-type-default";
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
|
||||
|
||||
const firstDay = new Date(viewYear, viewMonth, 1);
|
||||
const jsWeekday = firstDay.getDay();
|
||||
const offset = (jsWeekday + 6) % 7;
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const today = new Date();
|
||||
const todayISO = isoDate(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
|
||||
const cells: string[] = [];
|
||||
for (let i = 0; i < offset; i++) {
|
||||
cells.push(`<div class="frist-cal-cell frist-cal-cell-empty"></div>`);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const iso = isoDate(viewYear, viewMonth, day);
|
||||
const items = appointmentsForDate(iso);
|
||||
const isToday = iso === todayISO;
|
||||
|
||||
const dots = items
|
||||
.slice(0, 4)
|
||||
.map((tt) => `<span class="termin-dot ${typeClass(tt.appointment_type)}" title="${esc(tt.title)}"></span>`)
|
||||
.join("");
|
||||
const more = items.length > 4 ? `<span class="frist-cal-more">+${items.length - 4}</span>` : "";
|
||||
|
||||
cells.push(
|
||||
`<div class="frist-cal-cell${isToday ? " frist-cal-today" : ""}${items.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
|
||||
<span class="frist-cal-day">${day}</span>
|
||||
<div class="frist-cal-dots">${dots}${more}</div>
|
||||
</div>`,
|
||||
);
|
||||
}
|
||||
|
||||
const grid = document.getElementById("appointment-cal-grid")!;
|
||||
grid.innerHTML = cells.join("");
|
||||
|
||||
grid.querySelectorAll<HTMLElement>(".frist-cal-cell-has").forEach((cell) => {
|
||||
cell.addEventListener("click", () => openPopup(cell.dataset.iso!));
|
||||
});
|
||||
|
||||
const monthStart = isoDate(viewYear, viewMonth, 1);
|
||||
const monthEnd = isoDate(viewYear, viewMonth, daysInMonth);
|
||||
const hasInMonth = allAppointments.some((tt) => {
|
||||
const iso = tt.start_at.slice(0, 10);
|
||||
return iso >= monthStart && iso <= monthEnd;
|
||||
});
|
||||
const empty = document.getElementById("appointment-cal-empty")!;
|
||||
empty.style.display = hasInMonth ? "none" : "";
|
||||
}
|
||||
|
||||
function openPopup(iso: string) {
|
||||
const items = appointmentsForDate(iso);
|
||||
if (items.length === 0) return;
|
||||
const popup = document.getElementById("cal-popup")!;
|
||||
const dateEl = document.getElementById("cal-popup-date")!;
|
||||
const list = document.getElementById("cal-popup-list")!;
|
||||
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
dateEl.textContent = d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
list.innerHTML = items
|
||||
.map((tt) => {
|
||||
const akteRef = tt.project_id
|
||||
? `<a href="/projects/${esc(tt.project_id)}" class="frist-cal-popup-project">${esc(tt.project_reference ?? "")}</a>`
|
||||
: `<span class="termin-personal-tag">${esc(t("appointments.personal"))}</span>`;
|
||||
return `<li class="frist-cal-popup-item">
|
||||
<span class="termin-dot ${typeClass(tt.appointment_type)}"></span>
|
||||
<span class="frist-cal-popup-time">${esc(fmtTime(tt.start_at))}</span>
|
||||
<a href="/appointments/${esc(tt.id)}" class="frist-cal-popup-title">${esc(tt.title)}</a>
|
||||
${akteRef}
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
popup.style.display = "flex";
|
||||
}
|
||||
|
||||
function initPopup() {
|
||||
const popup = document.getElementById("cal-popup")!;
|
||||
const close = document.getElementById("cal-popup-close")!;
|
||||
close.addEventListener("click", () => (popup.style.display = "none"));
|
||||
popup.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) popup.style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function initNav() {
|
||||
document.getElementById("cal-prev")!.addEventListener("click", () => {
|
||||
viewMonth -= 1;
|
||||
if (viewMonth < 0) {
|
||||
viewMonth = 11;
|
||||
viewYear -= 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
document.getElementById("cal-next")!.addEventListener("click", () => {
|
||||
viewMonth += 1;
|
||||
if (viewMonth > 11) {
|
||||
viewMonth = 0;
|
||||
viewYear += 1;
|
||||
}
|
||||
render();
|
||||
});
|
||||
document.getElementById("cal-today")!.addEventListener("click", () => {
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
const now = new Date();
|
||||
viewYear = now.getFullYear();
|
||||
viewMonth = now.getMonth();
|
||||
initNav();
|
||||
initPopup();
|
||||
onLangChange(render);
|
||||
await loadAppointments();
|
||||
render();
|
||||
});
|
||||
274
frontend/src/client/appointments-detail.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initNotes } from "./notes";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
start_at: string;
|
||||
end_at?: string;
|
||||
location?: string;
|
||||
appointment_type?: string;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
let appointment: Appointment | null = null;
|
||||
let project: Project | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
|
||||
function parseAppointmentID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] !== "appointments" || !parts[1]) return null;
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function toLocalInput(iso?: string): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
async function loadAppointment(id: string): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`/api/appointments/${id}`);
|
||||
if (!resp.ok) return false;
|
||||
appointment = await resp.json();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProject(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${id}`);
|
||||
if (resp.ok) project = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllProjects() {
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (resp.ok) allProjects = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function populateProjectPicker() {
|
||||
const sel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const none = sel.querySelector('option[value=""]');
|
||||
sel.innerHTML = "";
|
||||
if (none) sel.appendChild(none);
|
||||
for (const p of allProjects) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.id;
|
||||
opt.textContent = `${projectIndent(p.path)}${p.reference || ""} — ${p.title}`;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (appointment) {
|
||||
sel.value = appointment.project_id ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
if (!appointment) return;
|
||||
document.getElementById("appointment-title-display")!.textContent = appointment.title;
|
||||
|
||||
const time = appointment.end_at
|
||||
? `${fmtDateTime(appointment.start_at)} — ${fmtDateTime(appointment.end_at)}`
|
||||
: fmtDateTime(appointment.start_at);
|
||||
document.getElementById("appointment-time-display")!.textContent = time;
|
||||
|
||||
const badge = document.getElementById("appointment-type-badge")!;
|
||||
if (appointment.appointment_type) {
|
||||
badge.textContent = tDyn(`appointments.type.${appointment.appointment_type}`) || appointment.appointment_type;
|
||||
badge.className = `termin-type-badge termin-type-${appointment.appointment_type}`;
|
||||
badge.style.display = "";
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
|
||||
const projectRow = document.getElementById("appointment-project-row")!;
|
||||
if (appointment.project_id && project) {
|
||||
const link = document.getElementById("appointment-project-link") as HTMLAnchorElement;
|
||||
link.href = `/projects/${project.id}`;
|
||||
link.textContent = `${project.reference || ""} — ${project.title}`;
|
||||
projectRow.style.display = "";
|
||||
} else {
|
||||
projectRow.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function fillEditForm() {
|
||||
if (!appointment) return;
|
||||
(document.getElementById("appointment-title-edit") as HTMLInputElement).value = appointment.title;
|
||||
(document.getElementById("appointment-start-edit") as HTMLInputElement).value = toLocalInput(appointment.start_at);
|
||||
(document.getElementById("appointment-end-edit") as HTMLInputElement).value = toLocalInput(appointment.end_at);
|
||||
(document.getElementById("appointment-type-edit") as HTMLSelectElement).value = appointment.appointment_type ?? "";
|
||||
(document.getElementById("appointment-location-edit") as HTMLInputElement).value = appointment.location ?? "";
|
||||
(document.getElementById("appointment-description-edit") as HTMLTextAreaElement).value = appointment.description ?? "";
|
||||
const projectSel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
|
||||
if (projectSel) projectSel.value = appointment.project_id ?? "";
|
||||
}
|
||||
|
||||
async function saveEdit(ev: Event) {
|
||||
ev.preventDefault();
|
||||
if (!appointment) return;
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
const submitBtn = (ev.target as HTMLFormElement).querySelector<HTMLButtonElement>("button[type=submit]")!;
|
||||
msg.textContent = "";
|
||||
|
||||
const title = (document.getElementById("appointment-title-edit") as HTMLInputElement).value.trim();
|
||||
const startRaw = (document.getElementById("appointment-start-edit") as HTMLInputElement).value;
|
||||
const endRaw = (document.getElementById("appointment-end-edit") as HTMLInputElement).value;
|
||||
const type = (document.getElementById("appointment-type-edit") as HTMLSelectElement).value;
|
||||
const location = (document.getElementById("appointment-location-edit") as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById("appointment-description-edit") as HTMLTextAreaElement).value;
|
||||
const projectSel = document.getElementById("appointment-project-edit") as HTMLSelectElement | null;
|
||||
const newProjectID = projectSel ? projectSel.value : "";
|
||||
const currentProjectID = appointment.project_id ?? "";
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title,
|
||||
start_at: new Date(startRaw).toISOString(),
|
||||
end_at: endRaw ? new Date(endRaw).toISOString() : null,
|
||||
appointment_type: type,
|
||||
location,
|
||||
description,
|
||||
};
|
||||
if (newProjectID !== currentProjectID) {
|
||||
if (newProjectID === "") {
|
||||
payload.clear_project = true;
|
||||
} else {
|
||||
payload.project_id = newProjectID;
|
||||
}
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/appointments/${appointment.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const prevProjectID = appointment.project_id ?? "";
|
||||
appointment = await resp.json();
|
||||
const nextProjectID = appointment?.project_id ?? "";
|
||||
if (nextProjectID !== prevProjectID) {
|
||||
project = null;
|
||||
if (appointment?.project_id) await loadProject(appointment.project_id);
|
||||
}
|
||||
renderHeader();
|
||||
msg.textContent = t("appointments.detail.saved");
|
||||
msg.className = "form-msg form-msg-ok";
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = data.error || t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
} catch {
|
||||
msg.textContent = t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAppointment() {
|
||||
if (!appointment) return;
|
||||
if (!confirm(t("appointments.detail.delete.confirm"))) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/appointments/${appointment.id}`, { method: "DELETE" });
|
||||
if (resp.ok || resp.status === 204) {
|
||||
window.location.href = "/events?type=appointment";
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
msg.textContent = data.error || t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
} catch {
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
msg.textContent = t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const id = parseAppointmentID();
|
||||
const loading = document.getElementById("appointment-loading")!;
|
||||
const body = document.getElementById("appointment-body")!;
|
||||
const notFound = document.getElementById("appointment-not-found")!;
|
||||
if (!id) {
|
||||
loading.style.display = "none";
|
||||
notFound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
const ok = await loadAppointment(id);
|
||||
if (!ok || !appointment) {
|
||||
loading.style.display = "none";
|
||||
notFound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
await Promise.all([
|
||||
appointment.project_id ? loadProject(appointment.project_id) : Promise.resolve(),
|
||||
loadAllProjects(),
|
||||
]);
|
||||
loading.style.display = "none";
|
||||
body.style.display = "";
|
||||
renderHeader();
|
||||
populateProjectPicker();
|
||||
fillEditForm();
|
||||
|
||||
document.getElementById("appointment-edit-form")!.addEventListener("submit", saveEdit);
|
||||
document.getElementById("appointment-delete-btn")!.addEventListener("click", deleteAppointment);
|
||||
|
||||
const notes = document.getElementById("notes-container");
|
||||
if (notes) {
|
||||
notes.setAttribute("data-parent-id", id);
|
||||
void initNotes(notes as HTMLElement, "appointment", id);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
main();
|
||||
});
|
||||
117
frontend/src/client/appointments-new.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
let allProjects: Project[] = [];
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (resp.ok) allProjects = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function populateProjects() {
|
||||
const sel = document.getElementById("appointment-project") as HTMLSelectElement;
|
||||
const opts: string[] = [
|
||||
`<option value="">${esc(t("appointments.field.akte.none"))}</option>`,
|
||||
];
|
||||
for (const a of allProjects) {
|
||||
const indent = projectIndent(a.path);
|
||||
opts.push(
|
||||
`<option value="${esc(a.id)}">${indent}${esc(a.reference || "")} — ${esc(a.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ak = params.get("project_id");
|
||||
if (ak) sel.value = ak;
|
||||
}
|
||||
|
||||
function preFillStart() {
|
||||
const start = document.getElementById("appointment-start") as HTMLInputElement;
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() + (15 - (now.getMinutes() % 15)));
|
||||
now.setSeconds(0);
|
||||
now.setMilliseconds(0);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
start.value = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
||||
}
|
||||
|
||||
async function submitForm(ev: Event) {
|
||||
ev.preventDefault();
|
||||
const msg = document.getElementById("appointment-new-msg")!;
|
||||
const submitBtn = (ev.target as HTMLFormElement).querySelector<HTMLButtonElement>("button[type=submit]")!;
|
||||
msg.textContent = "";
|
||||
|
||||
const title = (document.getElementById("appointment-title") as HTMLInputElement).value.trim();
|
||||
const startRaw = (document.getElementById("appointment-start") as HTMLInputElement).value;
|
||||
const endRaw = (document.getElementById("appointment-end") as HTMLInputElement).value;
|
||||
const type = (document.getElementById("appointment-type") as HTMLSelectElement).value;
|
||||
const projectID = (document.getElementById("appointment-project") as HTMLSelectElement).value;
|
||||
const location = (document.getElementById("appointment-location") as HTMLInputElement).value.trim();
|
||||
const description = (document.getElementById("appointment-description") as HTMLTextAreaElement).value.trim();
|
||||
|
||||
if (!title || !startRaw) {
|
||||
msg.textContent = t("appointments.error.required");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title,
|
||||
start_at: new Date(startRaw).toISOString(),
|
||||
};
|
||||
if (endRaw) payload.end_at = new Date(endRaw).toISOString();
|
||||
if (type) payload.appointment_type = type;
|
||||
if (projectID) payload.project_id = projectID;
|
||||
if (location) payload.location = location;
|
||||
if (description) payload.description = description;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch("/api/appointments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const created = await resp.json();
|
||||
window.location.href = `/appointments/${created.id}`;
|
||||
return;
|
||||
}
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = data.error || t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
} catch {
|
||||
msg.textContent = t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
await loadProjects();
|
||||
populateProjects();
|
||||
preFillStart();
|
||||
document.getElementById("appointment-new-form")!.addEventListener("submit", submitForm);
|
||||
});
|
||||
127
frontend/src/client/bottom-nav.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { toggleMobileSidebar } from "./sidebar";
|
||||
import { t } from "./i18n";
|
||||
|
||||
const KEYBOARD_THRESHOLD_PX = 100;
|
||||
const BADGE_REFRESH_MS = 60_000;
|
||||
|
||||
export function initBottomNav(): void {
|
||||
const nav = document.getElementById("bottom-nav");
|
||||
if (!nav) return;
|
||||
|
||||
initMenuSlot();
|
||||
initQuickAddSheet();
|
||||
initKeyboardWatcher();
|
||||
initAgendaBadge();
|
||||
}
|
||||
|
||||
function initMenuSlot(): void {
|
||||
const btn = document.getElementById("bottom-nav-menu");
|
||||
btn?.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
toggleMobileSidebar();
|
||||
});
|
||||
}
|
||||
|
||||
function initQuickAddSheet(): void {
|
||||
const trigger = document.getElementById("bottom-nav-add") as HTMLButtonElement | null;
|
||||
const dialog = document.getElementById("quick-add-sheet") as HTMLDialogElement | null;
|
||||
const cancel = document.getElementById("quick-add-cancel") as HTMLButtonElement | null;
|
||||
if (!trigger || !dialog) return;
|
||||
|
||||
trigger.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (typeof dialog.showModal === "function") {
|
||||
dialog.showModal();
|
||||
} else {
|
||||
dialog.setAttribute("open", "");
|
||||
}
|
||||
dialog.classList.add("is-open");
|
||||
});
|
||||
|
||||
function close(): void {
|
||||
dialog!.classList.remove("is-open");
|
||||
if (typeof dialog!.close === "function") {
|
||||
dialog!.close();
|
||||
} else {
|
||||
dialog!.removeAttribute("open");
|
||||
}
|
||||
}
|
||||
|
||||
cancel?.addEventListener("click", close);
|
||||
|
||||
dialog.addEventListener("click", (e) => {
|
||||
if (e.target === dialog) close();
|
||||
});
|
||||
|
||||
dialog.addEventListener("close", () => {
|
||||
dialog.classList.remove("is-open");
|
||||
});
|
||||
|
||||
dialog.querySelectorAll<HTMLAnchorElement>(".quick-add-row").forEach((row) => {
|
||||
row.addEventListener("click", () => {
|
||||
// Native <a> navigation handles routing; close sheet first so it
|
||||
// does not flash on next page paint via bfcache.
|
||||
close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initKeyboardWatcher(): void {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
|
||||
let baseHeight = window.innerHeight;
|
||||
window.addEventListener("orientationchange", () => {
|
||||
setTimeout(() => {
|
||||
baseHeight = window.innerHeight;
|
||||
document.body.classList.remove("keyboard-open");
|
||||
}, 250);
|
||||
});
|
||||
|
||||
const handler = () => {
|
||||
const delta = baseHeight - vv.height;
|
||||
document.body.classList.toggle("keyboard-open", delta > KEYBOARD_THRESHOLD_PX);
|
||||
};
|
||||
vv.addEventListener("resize", handler);
|
||||
}
|
||||
|
||||
function initAgendaBadge(): void {
|
||||
const badge = document.getElementById("bottom-nav-agenda-badge");
|
||||
if (!badge) return;
|
||||
|
||||
function refresh(): void {
|
||||
fetch("/api/deadlines/summary", { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data: { overdue?: number; today?: number } | null) => {
|
||||
if (!data) return;
|
||||
const overdue = typeof data.overdue === "number" ? data.overdue : 0;
|
||||
const today = typeof data.today === "number" ? data.today : 0;
|
||||
const total = overdue + today;
|
||||
if (total <= 0) {
|
||||
badge!.style.display = "none";
|
||||
badge!.classList.remove("bottom-nav-badge-overdue");
|
||||
badge!.removeAttribute("title");
|
||||
badge!.removeAttribute("aria-label");
|
||||
return;
|
||||
}
|
||||
badge!.textContent = total > 9 ? "9+" : String(total);
|
||||
badge!.style.display = "";
|
||||
badge!.classList.toggle("bottom-nav-badge-overdue", overdue > 0);
|
||||
// F-38: the badge counts "actionable" items only — overdue + due
|
||||
// today. The accessible label spells that out so the "2" never
|
||||
// reads as ambiguous (e.g. "2 things this week").
|
||||
const label = t("bottomnav.badge.deadlines")
|
||||
.replace("{overdue}", String(overdue))
|
||||
.replace("{today}", String(today));
|
||||
badge!.setAttribute("title", label);
|
||||
badge!.setAttribute("aria-label", label);
|
||||
badge!.setAttribute("aria-hidden", "false");
|
||||
})
|
||||
.catch(() => {
|
||||
// Badge is decorative; never break the page.
|
||||
});
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, BADGE_REFRESH_MS);
|
||||
}
|
||||
15
frontend/src/client/changelog-seen.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Shared localStorage tracker for the "What's New" badge.
|
||||
//
|
||||
// sidebar.ts reads the stamp on every page to ask the backend how many
|
||||
// entries are newer; changelog.ts writes the stamp when the user visits
|
||||
// /changelog so the badge clears on their next page load.
|
||||
|
||||
export const SEEN_KEY = "paliad-changelog-seen";
|
||||
|
||||
export function getChangelogSeen(): string {
|
||||
return localStorage.getItem(SEEN_KEY) ?? "";
|
||||
}
|
||||
|
||||
export function markChangelogSeen(): void {
|
||||
localStorage.setItem(SEEN_KEY, new Date().toISOString());
|
||||
}
|
||||
89
frontend/src/client/changelog.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { getLang, initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { markChangelogSeen } from "./changelog-seen";
|
||||
|
||||
interface Entry {
|
||||
date: string;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
body_de: string;
|
||||
body_en: string;
|
||||
tag: "feature" | "content" | "fix";
|
||||
}
|
||||
|
||||
let entries: Entry[] = [];
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const resp = await fetch("/api/changelog");
|
||||
if (!resp.ok) return;
|
||||
entries = await resp.json();
|
||||
render();
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
// iso = YYYY-MM-DD. Render locale-aware without Intl allocations per row:
|
||||
// "20. April 2026" (DE) or "20 April 2026" (EN). Cheap and deterministic.
|
||||
const [y, m, d] = iso.split("-");
|
||||
if (!y || !m || !d) return iso;
|
||||
const monthsDE = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"];
|
||||
const monthsEN = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
const monthIdx = parseInt(m, 10) - 1;
|
||||
const day = parseInt(d, 10);
|
||||
if (getLang() === "en") {
|
||||
return `${day} ${monthsEN[monthIdx] ?? m} ${y}`;
|
||||
}
|
||||
return `${day}. ${monthsDE[monthIdx] ?? m} ${y}`;
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const list = document.getElementById("changelog-list") as HTMLOListElement | null;
|
||||
const empty = document.getElementById("changelog-empty") as HTMLElement | null;
|
||||
if (!list || !empty) return;
|
||||
|
||||
if (entries.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
const lang = getLang();
|
||||
list.innerHTML = entries.map((e) => {
|
||||
const title = lang === "en" ? e.title_en : e.title_de;
|
||||
const body = lang === "en" ? e.body_en : e.body_de;
|
||||
const tagLabel = tDyn(`changelog.tag.${e.tag}`);
|
||||
return (
|
||||
`<li class="changelog-entry">` +
|
||||
`<div class="changelog-meta">` +
|
||||
`<time class="changelog-date" datetime="${escapeHTML(e.date)}">${escapeHTML(formatDate(e.date))}</time>` +
|
||||
`<span class="changelog-tag changelog-tag-${escapeHTML(e.tag)}">${escapeHTML(tagLabel)}</span>` +
|
||||
`</div>` +
|
||||
`<h2 class="changelog-title">${escapeHTML(title)}</h2>` +
|
||||
`<p class="changelog-body">${escapeHTML(body)}</p>` +
|
||||
`</li>`
|
||||
);
|
||||
}).join("");
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
// Stamp the visit immediately so the sidebar badge clears even if the
|
||||
// user navigates away before /api/changelog returns.
|
||||
markChangelogSeen();
|
||||
// Also clear any locally-rendered badge in the current DOM so it
|
||||
// disappears without waiting for a reload.
|
||||
document.querySelectorAll<HTMLElement>(".sidebar-badge").forEach((el) => {
|
||||
el.remove();
|
||||
});
|
||||
onLangChange(render);
|
||||
load();
|
||||
});
|
||||
@@ -1,272 +0,0 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface ChecklistItem {
|
||||
labelDE: string;
|
||||
labelEN: string;
|
||||
noteDE?: string;
|
||||
noteEN?: string;
|
||||
rule?: string;
|
||||
}
|
||||
|
||||
interface ChecklistGroup {
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
items: ChecklistItem[];
|
||||
}
|
||||
|
||||
interface Checklist {
|
||||
slug: string;
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
descriptionDE: string;
|
||||
descriptionEN: string;
|
||||
regime: string;
|
||||
courtDE: string;
|
||||
courtEN: string;
|
||||
deadlineDE?: string;
|
||||
deadlineEN?: string;
|
||||
referenceDE?: string;
|
||||
referenceEN?: string;
|
||||
groups: ChecklistGroup[];
|
||||
}
|
||||
|
||||
let checklist: Checklist | null = null;
|
||||
let state: Record<string, boolean> = {};
|
||||
|
||||
function storageKey(slug: string): string {
|
||||
return `patholo:checklist:${slug}`;
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function itemKey(groupIdx: number, itemIdx: number): string {
|
||||
return `g${groupIdx}-i${itemIdx}`;
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
if (!checklist) return;
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(checklist.slug));
|
||||
state = raw ? (JSON.parse(raw) as Record<string, boolean>) : {};
|
||||
} catch {
|
||||
state = {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveState() {
|
||||
if (!checklist) return;
|
||||
localStorage.setItem(storageKey(checklist.slug), JSON.stringify(state));
|
||||
}
|
||||
|
||||
function totalItems(): number {
|
||||
if (!checklist) return 0;
|
||||
return checklist.groups.reduce((n, g) => n + g.items.length, 0);
|
||||
}
|
||||
|
||||
function doneItems(): number {
|
||||
return Object.values(state).filter(Boolean).length;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const slug = window.location.pathname.split("/").pop() ?? "";
|
||||
const resp = await fetch(`/api/checklisten/${encodeURIComponent(slug)}`);
|
||||
if (!resp.ok) {
|
||||
document.title = "404 — Paliad";
|
||||
const title = document.getElementById("checklist-title")!;
|
||||
title.textContent = t("checklisten.notfound");
|
||||
return;
|
||||
}
|
||||
checklist = await resp.json();
|
||||
loadState();
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
if (!checklist) return;
|
||||
renderHeader();
|
||||
renderGroups();
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
if (!checklist) return;
|
||||
const isEN = getLang() === "en";
|
||||
const title = isEN ? checklist.titleEN : checklist.titleDE;
|
||||
const desc = isEN ? checklist.descriptionEN : checklist.descriptionDE;
|
||||
const court = isEN ? checklist.courtEN : checklist.courtDE;
|
||||
const deadline = isEN ? checklist.deadlineEN : checklist.deadlineDE;
|
||||
const reference = isEN ? checklist.referenceEN : checklist.referenceDE;
|
||||
|
||||
document.title = `${title} — Paliad`;
|
||||
document.getElementById("checklist-title")!.textContent = title;
|
||||
document.getElementById("checklist-subtitle")!.textContent = desc;
|
||||
|
||||
const courtLabel = isEN ? "Court / Authority" : "Gericht / Behörde";
|
||||
const deadlineLabel = isEN ? "Deadline" : "Frist";
|
||||
const refLabel = isEN ? "Reference" : "Rechtsgrundlage";
|
||||
const regimeLabel = isEN ? "Regime" : "Bereich";
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${regimeLabel}</dt><dd><span class="checklist-regime checklist-regime-${esc(checklist.regime)}">${esc(checklist.regime)}</span></dd></div>`);
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${courtLabel}</dt><dd>${esc(court)}</dd></div>`);
|
||||
if (deadline) {
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${deadlineLabel}</dt><dd>${esc(deadline)}</dd></div>`);
|
||||
}
|
||||
if (reference) {
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${refLabel}</dt><dd>${esc(reference)}</dd></div>`);
|
||||
}
|
||||
document.getElementById("checklist-meta")!.innerHTML = parts.join("");
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
if (!checklist) return;
|
||||
const isEN = getLang() === "en";
|
||||
const container = document.getElementById("checklist-groups")!;
|
||||
|
||||
container.innerHTML = checklist.groups.map((g, gi) => {
|
||||
const groupTitle = isEN ? g.titleEN : g.titleDE;
|
||||
const items = g.items.map((item, ii) => {
|
||||
const key = itemKey(gi, ii);
|
||||
const checked = !!state[key];
|
||||
const label = isEN ? item.labelEN : item.labelDE;
|
||||
const note = isEN ? item.noteEN : item.noteDE;
|
||||
const rule = item.rule;
|
||||
|
||||
const noteHTML = note ? `<p class="checklist-item-note">${esc(note)}</p>` : "";
|
||||
const ruleHTML = rule ? `<span class="checklist-item-rule">${esc(rule)}</span>` : "";
|
||||
|
||||
return `<li class="checklist-item${checked ? " checked" : ""}" data-key="${key}">
|
||||
<label class="checklist-item-label">
|
||||
<input type="checkbox" class="checklist-checkbox" data-key="${key}"${checked ? " checked" : ""} />
|
||||
<span class="checklist-item-body">
|
||||
<span class="checklist-item-row">
|
||||
<span class="checklist-item-text">${esc(label)}</span>
|
||||
${ruleHTML}
|
||||
</span>
|
||||
${noteHTML}
|
||||
</span>
|
||||
</label>
|
||||
</li>`;
|
||||
}).join("");
|
||||
|
||||
return `<section class="checklist-group">
|
||||
<h2 class="checklist-group-title">${esc(groupTitle)}</h2>
|
||||
<ol class="checklist-list">${items}</ol>
|
||||
</section>`;
|
||||
}).join("");
|
||||
|
||||
container.querySelectorAll<HTMLInputElement>(".checklist-checkbox").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const key = cb.dataset.key!;
|
||||
state[key] = cb.checked;
|
||||
saveState();
|
||||
const li = cb.closest(".checklist-item");
|
||||
if (li) li.classList.toggle("checked", cb.checked);
|
||||
updateProgress();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
const total = totalItems();
|
||||
const done = doneItems();
|
||||
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
|
||||
|
||||
const fill = document.getElementById("progress-fill");
|
||||
if (fill) (fill as HTMLElement).style.width = `${pct}%`;
|
||||
|
||||
const label = document.getElementById("progress-label");
|
||||
if (label) {
|
||||
const doneLabel = getLang() === "en" ? "done" : "erledigt";
|
||||
label.textContent = `${done} / ${total} ${doneLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
function initReset() {
|
||||
document.getElementById("btn-reset")!.addEventListener("click", () => {
|
||||
if (!checklist) return;
|
||||
const ok = confirm(t("checklisten.reset.confirm"));
|
||||
if (!ok) return;
|
||||
state = {};
|
||||
saveState();
|
||||
renderGroups();
|
||||
updateProgress();
|
||||
});
|
||||
}
|
||||
|
||||
function initPrint() {
|
||||
document.getElementById("btn-print")!.addEventListener("click", () => {
|
||||
window.print();
|
||||
});
|
||||
}
|
||||
|
||||
function initFeedback() {
|
||||
const modal = document.getElementById("feedback-modal")!;
|
||||
const form = document.getElementById("feedback-form")!;
|
||||
const msg = document.getElementById("feedback-msg")!;
|
||||
|
||||
document.getElementById("btn-feedback")!.addEventListener("click", () => {
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
modal.style.display = "flex";
|
||||
});
|
||||
|
||||
document.getElementById("modal-close")!.addEventListener("click", () => { modal.style.display = "none"; });
|
||||
document.getElementById("modal-cancel")!.addEventListener("click", () => { modal.style.display = "none"; });
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) modal.style.display = "none"; });
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!checklist) return;
|
||||
const submitBtn = form.querySelector(".btn-submit") as HTMLButtonElement;
|
||||
const payload = {
|
||||
feedback_type: (document.getElementById("feedback-type") as HTMLSelectElement).value,
|
||||
checklist: checklist.slug,
|
||||
message: (document.getElementById("feedback-message") as HTMLTextAreaElement).value.trim(),
|
||||
};
|
||||
|
||||
if (!payload.message) {
|
||||
msg.textContent = t("checklisten.feedback.error.required");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch("/api/checklisten/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
msg.textContent = t("checklisten.feedback.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
msg.textContent = t("checklisten.feedback.success");
|
||||
msg.className = "form-msg form-msg-success";
|
||||
(document.getElementById("feedback-message") as HTMLTextAreaElement).value = "";
|
||||
setTimeout(() => { modal.style.display = "none"; }, 1500);
|
||||
} catch {
|
||||
msg.textContent = t("checklisten.feedback.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initReset();
|
||||
initPrint();
|
||||
initFeedback();
|
||||
onLangChange(renderAll);
|
||||
load();
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface ChecklistSummary {
|
||||
slug: string;
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
descriptionDE: string;
|
||||
descriptionEN: string;
|
||||
regime: string;
|
||||
courtDE: string;
|
||||
courtEN: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
let allChecklists: ChecklistSummary[] = [];
|
||||
let activeRegime = "all";
|
||||
|
||||
function storageKey(slug: string): string {
|
||||
return `patholo:checklist:${slug}`;
|
||||
}
|
||||
|
||||
function progressFor(slug: string, total: number): { done: number; total: number } {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(slug));
|
||||
if (!raw) return { done: 0, total };
|
||||
const obj = JSON.parse(raw) as Record<string, boolean>;
|
||||
const done = Object.values(obj).filter(Boolean).length;
|
||||
return { done: Math.min(done, total), total };
|
||||
} catch {
|
||||
return { done: 0, total };
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const resp = await fetch("/api/checklisten");
|
||||
if (!resp.ok) return;
|
||||
allChecklists = await resp.json();
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
const grid = document.getElementById("checklist-grid")!;
|
||||
const isEN = getLang() === "en";
|
||||
|
||||
const filtered = activeRegime === "all"
|
||||
? allChecklists
|
||||
: allChecklists.filter((c) => c.regime === activeRegime);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML = `<p class="checklist-empty" data-i18n="checklisten.empty">${t("checklisten.empty")}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = filtered.map((c) => {
|
||||
const title = isEN ? c.titleEN : c.titleDE;
|
||||
const desc = isEN ? c.descriptionEN : c.descriptionDE;
|
||||
const court = isEN ? c.courtEN : c.courtDE;
|
||||
const { done, total } = progressFor(c.slug, c.itemCount);
|
||||
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
|
||||
const doneLabel = isEN ? "done" : "erledigt";
|
||||
return `<a href="/checklisten/${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">${total} ${isEN ? "items" : "Punkte"}</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>
|
||||
<div class="checklist-card-progress">
|
||||
<div class="checklist-progress-bar">
|
||||
<div class="checklist-progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<span class="checklist-progress-label">${done} / ${total} ${doneLabel}</span>
|
||||
</div>
|
||||
</a>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function initFilters() {
|
||||
const container = document.getElementById("checklist-filters")!;
|
||||
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");
|
||||
activeRegime = btn.dataset.regime ?? "all";
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initFilters();
|
||||
onLangChange(render);
|
||||
load();
|
||||
});
|
||||
383
frontend/src/client/checklists-detail.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
interface ChecklistItem {
|
||||
labelDE: string;
|
||||
labelEN: string;
|
||||
noteDE?: string;
|
||||
noteEN?: string;
|
||||
rule?: string;
|
||||
}
|
||||
|
||||
interface ChecklistGroup {
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
items: ChecklistItem[];
|
||||
}
|
||||
|
||||
interface Checklist {
|
||||
slug: string;
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
descriptionDE: string;
|
||||
descriptionEN: string;
|
||||
regime: string;
|
||||
courtDE: string;
|
||||
courtEN: string;
|
||||
deadlineDE?: string;
|
||||
deadlineEN?: string;
|
||||
referenceDE?: string;
|
||||
referenceEN?: string;
|
||||
groups: ChecklistGroup[];
|
||||
}
|
||||
|
||||
interface ChecklistInstance {
|
||||
id: string;
|
||||
template_slug: string;
|
||||
name: string;
|
||||
project_id?: string | null;
|
||||
state: Record<string, boolean>;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
project_reference?: string | null;
|
||||
project_title?: string | null;
|
||||
}
|
||||
|
||||
interface AkteSummary {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
let template: Checklist | null = null;
|
||||
let instances: ChecklistInstance[] = [];
|
||||
let projects: AkteSummary[] = [];
|
||||
let totalItems = 0;
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function templateSlug(): string {
|
||||
// /checklisten/{slug}
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
return parts[1] ?? "";
|
||||
}
|
||||
|
||||
async function loadTemplate() {
|
||||
const slug = templateSlug();
|
||||
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}`);
|
||||
if (!resp.ok) {
|
||||
document.title = "404 — Paliad";
|
||||
document.getElementById("checklist-title")!.textContent = t("checklisten.notfound");
|
||||
return;
|
||||
}
|
||||
template = await resp.json();
|
||||
if (template) {
|
||||
totalItems = template.groups.reduce((n, g) => n + g.items.length, 0);
|
||||
}
|
||||
renderHeader();
|
||||
}
|
||||
|
||||
async function loadInstances() {
|
||||
const slug = templateSlug();
|
||||
try {
|
||||
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`);
|
||||
if (!resp.ok) {
|
||||
instances = [];
|
||||
} else {
|
||||
instances = await resp.json();
|
||||
}
|
||||
} catch {
|
||||
instances = [];
|
||||
}
|
||||
renderInstances();
|
||||
}
|
||||
|
||||
async function loadAkten() {
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (resp.ok) projects = await resp.json();
|
||||
} catch {
|
||||
projects = [];
|
||||
}
|
||||
renderAkteOptions();
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
if (!template) return;
|
||||
const isEN = getLang() === "en";
|
||||
const title = isEN ? template.titleEN : template.titleDE;
|
||||
const desc = isEN ? template.descriptionEN : template.descriptionDE;
|
||||
const court = isEN ? template.courtEN : template.courtDE;
|
||||
const deadline = isEN ? template.deadlineEN : template.deadlineDE;
|
||||
const reference = isEN ? template.referenceEN : template.referenceDE;
|
||||
|
||||
document.title = `${title} — Paliad`;
|
||||
document.getElementById("checklist-title")!.textContent = title;
|
||||
document.getElementById("checklist-subtitle")!.textContent = desc;
|
||||
|
||||
const courtLabel = isEN ? "Court / Authority" : "Gericht / Behörde";
|
||||
const deadlineLabel = isEN ? "Deadline" : "Deadline";
|
||||
const refLabel = isEN ? "Reference" : "Rechtsgrundlage";
|
||||
const regimeLabel = isEN ? "Regime" : "Bereich";
|
||||
const itemsLabel = isEN ? "Items" : "Punkte";
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${regimeLabel}</dt><dd><span class="checklist-regime checklist-regime-${esc(template.regime)}">${esc(template.regime)}</span></dd></div>`);
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${courtLabel}</dt><dd>${esc(court)}</dd></div>`);
|
||||
if (deadline) {
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${deadlineLabel}</dt><dd>${esc(deadline)}</dd></div>`);
|
||||
}
|
||||
if (reference) {
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${refLabel}</dt><dd>${esc(reference)}</dd></div>`);
|
||||
}
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${itemsLabel}</dt><dd>${totalItems}</dd></div>`);
|
||||
document.getElementById("checklist-meta")!.innerHTML = parts.join("");
|
||||
}
|
||||
|
||||
function progress(inst: ChecklistInstance): { done: number; pct: number } {
|
||||
const done = Object.values(inst.state || {}).filter(Boolean).length;
|
||||
const pct = totalItems === 0 ? 0 : Math.round((done / totalItems) * 100);
|
||||
return { done, pct };
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const lang = getLang();
|
||||
return d.toLocaleDateString(lang === "en" ? "en-GB" : "de-DE", {
|
||||
year: "numeric", month: "2-digit", day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function renderInstances() {
|
||||
const loading = document.getElementById("instances-loading")!;
|
||||
const empty = document.getElementById("instances-empty")!;
|
||||
const wrap = document.getElementById("instances-tablewrap")!;
|
||||
const body = document.getElementById("instances-body")!;
|
||||
|
||||
loading.style.display = "none";
|
||||
|
||||
if (instances.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
|
||||
const isEN = getLang() === "en";
|
||||
const deleteLabel = isEN ? "Delete" : "Löschen";
|
||||
const openLabel = isEN ? "Öffnen" : "Öffnen";
|
||||
const personalLabel = isEN ? "personal" : "persönlich";
|
||||
|
||||
body.innerHTML = instances.map((inst) => {
|
||||
const { done, pct } = progress(inst);
|
||||
const akteCell = inst.project_id && inst.project_reference
|
||||
? `<a href="/projects/${esc(inst.project_id)}" class="checklist-instance-project-link">${esc(inst.project_reference)}</a>`
|
||||
: `<span class="entity-muted">${personalLabel}</span>`;
|
||||
return `<tr data-id="${esc(inst.id)}" class="checklist-instance-row">
|
||||
<td><a href="/checklists/instances/${esc(inst.id)}" class="checklist-instance-name">${esc(inst.name)}</a></td>
|
||||
<td>
|
||||
<div class="checklist-progress-inline">
|
||||
<div class="checklist-progress-bar">
|
||||
<div class="checklist-progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<span class="checklist-progress-label">${done} / ${totalItems}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>${akteCell}</td>
|
||||
<td>${esc(formatDate(inst.created_at))}</td>
|
||||
<td class="checklist-instance-actions">
|
||||
<a class="btn-small btn-ghost" href="/checklists/instances/${esc(inst.id)}">${esc(openLabel)}</a>
|
||||
<button type="button" class="btn-small btn-ghost btn-delete-instance" data-id="${esc(inst.id)}" data-name="${esc(inst.name)}">${esc(deleteLabel)}</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".btn-delete-instance").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = btn.dataset.id!;
|
||||
const name = btn.dataset.name ?? "";
|
||||
const msg = (t("checklisten.instances.delete.confirm") || "").replace("{name}", name);
|
||||
if (!confirm(msg)) return;
|
||||
void deleteInstance(id);
|
||||
});
|
||||
});
|
||||
|
||||
body.querySelectorAll<HTMLTableRowElement>(".checklist-instance-row").forEach((row) => {
|
||||
const id = row.dataset.id!;
|
||||
row.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("a") || target.closest("button")) return;
|
||||
window.location.href = `/checklists/instances/${id}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderAkteOptions() {
|
||||
const sel = document.getElementById("new-instance-project") as HTMLSelectElement;
|
||||
if (!sel) return;
|
||||
const none = sel.querySelector('option[value=""]');
|
||||
sel.innerHTML = "";
|
||||
if (none) sel.appendChild(none);
|
||||
projects.forEach((a) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = a.id;
|
||||
opt.textContent = `${projectIndent(a.path)}${a.reference || ""} — ${a.title}`;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function initNewInstance() {
|
||||
const modal = document.getElementById("new-instance-modal")!;
|
||||
const form = document.getElementById("new-instance-form")! as HTMLFormElement;
|
||||
const msg = document.getElementById("new-instance-msg")!;
|
||||
const nameInput = document.getElementById("new-instance-name") as HTMLInputElement;
|
||||
const akteSel = document.getElementById("new-instance-project") as HTMLSelectElement;
|
||||
|
||||
const open = () => {
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
nameInput.value = "";
|
||||
akteSel.value = "";
|
||||
modal.style.display = "flex";
|
||||
nameInput.focus();
|
||||
};
|
||||
const close = () => { modal.style.display = "none"; };
|
||||
|
||||
document.getElementById("btn-new-instance")!.addEventListener("click", open);
|
||||
document.getElementById("new-instance-close")!.addEventListener("click", close);
|
||||
document.getElementById("new-instance-cancel")!.addEventListener("click", close);
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const name = nameInput.value.trim();
|
||||
if (!name) {
|
||||
msg.textContent = t("checklisten.newInstance.error.name");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const akteID = akteSel.value || null;
|
||||
const payload: { name: string; project_id?: string } = { name };
|
||||
if (akteID) payload.project_id = akteID;
|
||||
|
||||
const slug = templateSlug();
|
||||
const submitBtn = form.querySelector(".btn-primary") as HTMLButtonElement;
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
msg.textContent = t("checklisten.newInstance.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
const created = await resp.json() as ChecklistInstance;
|
||||
window.location.href = `/checklists/instances/${encodeURIComponent(created.id)}`;
|
||||
} catch {
|
||||
msg.textContent = t("checklisten.newInstance.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteInstance(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
alert(t("checklisten.instances.delete.error"));
|
||||
return;
|
||||
}
|
||||
instances = instances.filter((i) => i.id !== id);
|
||||
renderInstances();
|
||||
} catch {
|
||||
alert(t("checklisten.instances.delete.error"));
|
||||
}
|
||||
}
|
||||
|
||||
function initFeedback() {
|
||||
const modal = document.getElementById("feedback-modal")!;
|
||||
const form = document.getElementById("feedback-form")!;
|
||||
const msg = document.getElementById("feedback-msg")!;
|
||||
|
||||
document.getElementById("btn-feedback")!.addEventListener("click", () => {
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
modal.style.display = "flex";
|
||||
});
|
||||
|
||||
document.getElementById("modal-close")!.addEventListener("click", () => { modal.style.display = "none"; });
|
||||
document.getElementById("modal-cancel")!.addEventListener("click", () => { modal.style.display = "none"; });
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) modal.style.display = "none"; });
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!template) return;
|
||||
const submitBtn = form.querySelector(".btn-submit") as HTMLButtonElement;
|
||||
const payload = {
|
||||
feedback_type: (document.getElementById("feedback-type") as HTMLSelectElement).value,
|
||||
checklist: template.slug,
|
||||
message: (document.getElementById("feedback-message") as HTMLTextAreaElement).value.trim(),
|
||||
};
|
||||
|
||||
if (!payload.message) {
|
||||
msg.textContent = t("checklisten.feedback.error.required");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch("/api/checklists/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
msg.textContent = t("checklisten.feedback.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
msg.textContent = t("checklisten.feedback.success");
|
||||
msg.className = "form-msg form-msg-success";
|
||||
(document.getElementById("feedback-message") as HTMLTextAreaElement).value = "";
|
||||
setTimeout(() => { modal.style.display = "none"; }, 1500);
|
||||
} catch {
|
||||
msg.textContent = t("checklisten.feedback.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function rerenderAll() {
|
||||
renderHeader();
|
||||
renderInstances();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initNewInstance();
|
||||
initFeedback();
|
||||
onLangChange(rerenderAll);
|
||||
void loadTemplate();
|
||||
void loadInstances();
|
||||
void loadAkten();
|
||||
});
|
||||
394
frontend/src/client/checklists-instance.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface ChecklistItem {
|
||||
labelDE: string;
|
||||
labelEN: string;
|
||||
noteDE?: string;
|
||||
noteEN?: string;
|
||||
rule?: string;
|
||||
}
|
||||
|
||||
interface ChecklistGroup {
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
items: ChecklistItem[];
|
||||
}
|
||||
|
||||
interface Checklist {
|
||||
slug: string;
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
descriptionDE: string;
|
||||
descriptionEN: string;
|
||||
regime: string;
|
||||
courtDE: string;
|
||||
courtEN: string;
|
||||
deadlineDE?: string;
|
||||
deadlineEN?: string;
|
||||
referenceDE?: string;
|
||||
referenceEN?: string;
|
||||
groups: ChecklistGroup[];
|
||||
}
|
||||
|
||||
interface Instance {
|
||||
id: string;
|
||||
template_slug: string;
|
||||
name: string;
|
||||
project_id?: string | null;
|
||||
state: Record<string, boolean>;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
let template: Checklist | null = null;
|
||||
let instance: Instance | null = null;
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function itemKey(groupIdx: number, itemIdx: number): string {
|
||||
return `g${groupIdx}-i${itemIdx}`;
|
||||
}
|
||||
|
||||
function instanceID(): string {
|
||||
// /checklisten/instances/{id}
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
return parts[2] ?? "";
|
||||
}
|
||||
|
||||
function totalItems(): number {
|
||||
if (!template) return 0;
|
||||
return template.groups.reduce((n, g) => n + g.items.length, 0);
|
||||
}
|
||||
|
||||
function doneItems(): number {
|
||||
if (!instance) return 0;
|
||||
return Object.values(instance.state || {}).filter(Boolean).length;
|
||||
}
|
||||
|
||||
async function loadInstance(): Promise<boolean> {
|
||||
const id = instanceID();
|
||||
if (!id) return false;
|
||||
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(id)}`);
|
||||
if (!resp.ok) return false;
|
||||
instance = await resp.json();
|
||||
if (instance && typeof instance.state !== "object") instance.state = {};
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadTemplate(slug: string): Promise<boolean> {
|
||||
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}`);
|
||||
if (!resp.ok) return false;
|
||||
template = await resp.json();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const loading = document.getElementById("instance-loading")!;
|
||||
const notfound = document.getElementById("instance-notfound")!;
|
||||
const body = document.getElementById("instance-body")!;
|
||||
|
||||
const okInst = await loadInstance();
|
||||
if (!okInst || !instance) {
|
||||
loading.style.display = "none";
|
||||
notfound.style.display = "";
|
||||
document.title = t("checklisten.instance.notfound");
|
||||
return;
|
||||
}
|
||||
const okTpl = await loadTemplate(instance.template_slug);
|
||||
if (!okTpl || !template) {
|
||||
loading.style.display = "none";
|
||||
notfound.style.display = "";
|
||||
return;
|
||||
}
|
||||
loading.style.display = "none";
|
||||
body.style.display = "";
|
||||
|
||||
// Back link goes to the template page.
|
||||
const back = document.getElementById("instance-back") as HTMLAnchorElement;
|
||||
back.href = `/checklists/${encodeURIComponent(instance.template_slug)}`;
|
||||
|
||||
renderAll();
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
if (!template || !instance) return;
|
||||
renderHeader();
|
||||
renderGroups();
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
if (!template || !instance) return;
|
||||
const isEN = getLang() === "en";
|
||||
const tplTitle = isEN ? template.titleEN : template.titleDE;
|
||||
const court = isEN ? template.courtEN : template.courtDE;
|
||||
const deadline = isEN ? template.deadlineEN : template.deadlineDE;
|
||||
const reference = isEN ? template.referenceEN : template.referenceDE;
|
||||
|
||||
document.title = `${instance.name} — Paliad`;
|
||||
(document.getElementById("instance-name-display") as HTMLElement).textContent = instance.name;
|
||||
(document.getElementById("instance-name-edit") as HTMLInputElement).value = instance.name;
|
||||
(document.getElementById("instance-template-title") as HTMLElement).textContent = tplTitle;
|
||||
|
||||
const courtLabel = isEN ? "Court / Authority" : "Gericht / Behörde";
|
||||
const deadlineLabel = isEN ? "Deadline" : "Deadline";
|
||||
const refLabel = isEN ? "Reference" : "Rechtsgrundlage";
|
||||
const regimeLabel = isEN ? "Regime" : "Bereich";
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${regimeLabel}</dt><dd><span class="checklist-regime checklist-regime-${esc(template.regime)}">${esc(template.regime)}</span></dd></div>`);
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${courtLabel}</dt><dd>${esc(court)}</dd></div>`);
|
||||
if (deadline) {
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${deadlineLabel}</dt><dd>${esc(deadline)}</dd></div>`);
|
||||
}
|
||||
if (reference) {
|
||||
parts.push(`<div class="checklist-meta-item"><dt>${refLabel}</dt><dd>${esc(reference)}</dd></div>`);
|
||||
}
|
||||
if (instance.project_id) {
|
||||
const akteLabel = isEN ? "Project" : "Projekt";
|
||||
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("");
|
||||
}
|
||||
|
||||
function renderGroups() {
|
||||
if (!template || !instance) return;
|
||||
const isEN = getLang() === "en";
|
||||
const container = document.getElementById("checklist-groups")!;
|
||||
const state = instance.state || {};
|
||||
|
||||
container.innerHTML = template.groups.map((g, gi) => {
|
||||
const groupTitle = isEN ? g.titleEN : g.titleDE;
|
||||
const items = g.items.map((item, ii) => {
|
||||
const key = itemKey(gi, ii);
|
||||
const checked = !!state[key];
|
||||
const label = isEN ? item.labelEN : item.labelDE;
|
||||
const note = isEN ? item.noteEN : item.noteDE;
|
||||
const rule = item.rule;
|
||||
|
||||
const noteHTML = note ? `<p class="checklist-item-note">${esc(note)}</p>` : "";
|
||||
const ruleHTML = rule ? `<span class="checklist-item-rule">${esc(rule)}</span>` : "";
|
||||
|
||||
return `<li class="checklist-item${checked ? " checked" : ""}" data-key="${key}">
|
||||
<label class="checklist-item-label">
|
||||
<input type="checkbox" class="checklist-checkbox" data-key="${key}"${checked ? " checked" : ""} />
|
||||
<span class="checklist-item-body">
|
||||
<span class="checklist-item-row">
|
||||
<span class="checklist-item-text">${esc(label)}</span>
|
||||
${ruleHTML}
|
||||
</span>
|
||||
${noteHTML}
|
||||
</span>
|
||||
</label>
|
||||
</li>`;
|
||||
}).join("");
|
||||
|
||||
return `<section class="checklist-group">
|
||||
<h2 class="checklist-group-title">${esc(groupTitle)}</h2>
|
||||
<ol class="checklist-list">${items}</ol>
|
||||
</section>`;
|
||||
}).join("");
|
||||
|
||||
container.querySelectorAll<HTMLInputElement>(".checklist-checkbox").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
if (!instance) return;
|
||||
const key = cb.dataset.key!;
|
||||
instance.state[key] = cb.checked;
|
||||
const li = cb.closest(".checklist-item");
|
||||
if (li) li.classList.toggle("checked", cb.checked);
|
||||
updateProgress();
|
||||
void patchState({ [key]: cb.checked });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
const total = totalItems();
|
||||
const done = doneItems();
|
||||
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
|
||||
const fill = document.getElementById("progress-fill");
|
||||
if (fill) (fill as HTMLElement).style.width = `${pct}%`;
|
||||
const label = document.getElementById("progress-label");
|
||||
if (label) {
|
||||
const doneLabel = getLang() === "en" ? "done" : "erledigt";
|
||||
label.textContent = `${done} / ${total} ${doneLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function patchState(patch: Record<string, boolean>) {
|
||||
if (!instance) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(instance.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ state: patch }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn("patchState failed", resp.status);
|
||||
// Revert local state on server failure.
|
||||
for (const k of Object.keys(patch)) instance.state[k] = !patch[k];
|
||||
renderGroups();
|
||||
updateProgress();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("patchState error", e);
|
||||
}
|
||||
}
|
||||
|
||||
function initReset() {
|
||||
const btn = document.getElementById("btn-reset");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!instance) return;
|
||||
const ok = confirm(t("checklisten.reset.confirm"));
|
||||
if (!ok) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(instance.id)}/reset`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert(t("checklisten.reset.error"));
|
||||
return;
|
||||
}
|
||||
const updated = await resp.json() as Instance;
|
||||
instance = updated;
|
||||
if (typeof instance.state !== "object" || instance.state === null) instance.state = {};
|
||||
renderGroups();
|
||||
updateProgress();
|
||||
} catch {
|
||||
alert(t("checklisten.reset.error"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initPrint() {
|
||||
const btn = document.getElementById("btn-print");
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", () => window.print());
|
||||
}
|
||||
|
||||
function initRename() {
|
||||
const display = document.getElementById("instance-name-display") as HTMLElement;
|
||||
const editInput = document.getElementById("instance-name-edit") as HTMLInputElement;
|
||||
const editBtn = document.getElementById("instance-rename-btn") as HTMLButtonElement;
|
||||
const saveBtn = document.getElementById("instance-name-save") as HTMLButtonElement;
|
||||
if (!display || !editInput || !editBtn || !saveBtn) return;
|
||||
|
||||
const enterEdit = () => {
|
||||
if (!instance) return;
|
||||
editInput.value = instance.name;
|
||||
display.style.display = "none";
|
||||
editBtn.style.display = "none";
|
||||
editInput.style.display = "";
|
||||
saveBtn.style.display = "";
|
||||
editInput.focus();
|
||||
editInput.select();
|
||||
};
|
||||
const exitEdit = () => {
|
||||
display.style.display = "";
|
||||
editBtn.style.display = "";
|
||||
editInput.style.display = "none";
|
||||
saveBtn.style.display = "none";
|
||||
};
|
||||
|
||||
editBtn.addEventListener("click", enterEdit);
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!instance) return;
|
||||
const newName = editInput.value.trim();
|
||||
if (!newName || newName === instance.name) {
|
||||
exitEdit();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/api/checklist-instances/${encodeURIComponent(instance.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: newName }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert(t("checklisten.instance.rename.error"));
|
||||
return;
|
||||
}
|
||||
instance = await resp.json();
|
||||
renderHeader();
|
||||
exitEdit();
|
||||
} catch {
|
||||
alert(t("checklisten.instance.rename.error"));
|
||||
}
|
||||
});
|
||||
editInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); saveBtn.click(); }
|
||||
if (e.key === "Escape") { e.preventDefault(); exitEdit(); }
|
||||
});
|
||||
}
|
||||
|
||||
function initFeedback() {
|
||||
const modal = document.getElementById("feedback-modal")!;
|
||||
const form = document.getElementById("feedback-form")!;
|
||||
const msg = document.getElementById("feedback-msg")!;
|
||||
|
||||
document.getElementById("btn-feedback")!.addEventListener("click", () => {
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
modal.style.display = "flex";
|
||||
});
|
||||
|
||||
document.getElementById("modal-close")!.addEventListener("click", () => { modal.style.display = "none"; });
|
||||
document.getElementById("modal-cancel")!.addEventListener("click", () => { modal.style.display = "none"; });
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) modal.style.display = "none"; });
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!template) return;
|
||||
const submitBtn = form.querySelector(".btn-submit") as HTMLButtonElement;
|
||||
const payload = {
|
||||
feedback_type: (document.getElementById("feedback-type") as HTMLSelectElement).value,
|
||||
checklist: template.slug,
|
||||
message: (document.getElementById("feedback-message") as HTMLTextAreaElement).value.trim(),
|
||||
};
|
||||
if (!payload.message) {
|
||||
msg.textContent = t("checklisten.feedback.error.required");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch("/api/checklists/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
msg.textContent = t("checklisten.feedback.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
msg.textContent = t("checklisten.feedback.success");
|
||||
msg.className = "form-msg form-msg-success";
|
||||
(document.getElementById("feedback-message") as HTMLTextAreaElement).value = "";
|
||||
setTimeout(() => { modal.style.display = "none"; }, 1500);
|
||||
} catch {
|
||||
msg.textContent = t("checklisten.feedback.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initReset();
|
||||
initPrint();
|
||||
initRename();
|
||||
initFeedback();
|
||||
onLangChange(renderAll);
|
||||
void bootstrap();
|
||||
});
|
||||
244
frontend/src/client/checklists.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface ChecklistSummary {
|
||||
slug: string;
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
descriptionDE: string;
|
||||
descriptionEN: string;
|
||||
regime: string;
|
||||
courtDE: string;
|
||||
courtEN: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
interface ChecklistInstance {
|
||||
id: string;
|
||||
template_slug: string;
|
||||
name: string;
|
||||
project_id?: string | null;
|
||||
state: Record<string, boolean>;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
project_reference?: string | null;
|
||||
project_title?: string | null;
|
||||
}
|
||||
|
||||
type TabId = "templates" | "instances";
|
||||
|
||||
const VALID_TABS: TabId[] = ["templates", "instances"];
|
||||
|
||||
let allChecklists: ChecklistSummary[] = [];
|
||||
let activeRegime = "all";
|
||||
let allInstances: ChecklistInstance[] = [];
|
||||
let templatesBySlug: Record<string, ChecklistSummary> = {};
|
||||
let instancesLoaded = false;
|
||||
let activeTab: TabId = "templates";
|
||||
|
||||
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 parseTab(): TabId {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const candidate = params.get("tab");
|
||||
if (candidate && (VALID_TABS as string[]).includes(candidate)) {
|
||||
return candidate as TabId;
|
||||
}
|
||||
return "templates";
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
const resp = await fetch("/api/checklists");
|
||||
if (!resp.ok) return;
|
||||
allChecklists = await resp.json();
|
||||
templatesBySlug = {};
|
||||
for (const tpl of allChecklists) templatesBySlug[tpl.slug] = tpl;
|
||||
renderTemplates();
|
||||
}
|
||||
|
||||
function renderTemplates() {
|
||||
const grid = document.getElementById("checklist-grid")!;
|
||||
const isEN = getLang() === "en";
|
||||
|
||||
const filtered = activeRegime === "all"
|
||||
? allChecklists
|
||||
: allChecklists.filter((c) => c.regime === activeRegime);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
grid.innerHTML = `<p class="checklist-empty" data-i18n="checklisten.empty">${esc(t("checklisten.empty"))}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = filtered.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";
|
||||
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>
|
||||
</a>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function initFilters() {
|
||||
const container = document.getElementById("checklist-filters")!;
|
||||
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");
|
||||
activeRegime = btn.dataset.regime ?? "all";
|
||||
renderTemplates();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInstances() {
|
||||
if (instancesLoaded) return;
|
||||
instancesLoaded = true;
|
||||
// Templates may not be loaded yet if the user lands directly on
|
||||
// ?tab=instances — fetch in parallel so the join below has names.
|
||||
const [instResp, tplResp] = await Promise.all([
|
||||
fetch("/api/checklist-instances"),
|
||||
allChecklists.length === 0 ? fetch("/api/checklists") : Promise.resolve(null),
|
||||
]);
|
||||
if (instResp.ok) {
|
||||
allInstances = (await instResp.json()) ?? [];
|
||||
} else {
|
||||
allInstances = [];
|
||||
}
|
||||
if (tplResp && tplResp.ok) {
|
||||
allChecklists = (await tplResp.json()) ?? [];
|
||||
templatesBySlug = {};
|
||||
for (const tpl of allChecklists) templatesBySlug[tpl.slug] = tpl;
|
||||
}
|
||||
renderInstances();
|
||||
}
|
||||
|
||||
function renderInstances() {
|
||||
const loading = document.getElementById("checklists-instances-loading")!;
|
||||
const empty = document.getElementById("checklists-instances-empty")!;
|
||||
const wrap = document.getElementById("checklists-instances-tablewrap")!;
|
||||
const body = document.getElementById("checklists-instances-body")!;
|
||||
|
||||
loading.style.display = "none";
|
||||
|
||||
if (allInstances.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
|
||||
const isEN = getLang() === "en";
|
||||
const fmtDate = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
return d.toLocaleDateString(isEN ? "en-GB" : "de-DE", {
|
||||
year: "numeric", month: "2-digit", day: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
body.innerHTML = allInstances.map((inst) => {
|
||||
const tpl = templatesBySlug[inst.template_slug];
|
||||
const tplName = tpl
|
||||
? (isEN ? tpl.titleEN : tpl.titleDE)
|
||||
: inst.template_slug;
|
||||
const total = tpl ? tpl.itemCount : 0;
|
||||
const done = Object.values(inst.state || {}).filter(Boolean).length;
|
||||
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
|
||||
|
||||
let projectCell: string;
|
||||
if (inst.project_id && inst.project_title) {
|
||||
const ref = inst.project_reference ? esc(inst.project_reference) : "";
|
||||
const title = esc(inst.project_title);
|
||||
const refPart = ref ? `<span class="entity-ref">${ref}</span> ` : "";
|
||||
projectCell = `<a href="/projects/${esc(inst.project_id)}" class="checklist-instance-project">${refPart}${title}</a>`;
|
||||
} else {
|
||||
projectCell = `<span class="form-hint" data-i18n="checklisten.instances.all.personal">Persönlich</span>`;
|
||||
}
|
||||
|
||||
return `<tr class="checklist-instance-row" data-id="${esc(inst.id)}">
|
||||
<td>${esc(tplName)}</td>
|
||||
<td><a href="/checklists/instances/${esc(inst.id)}" class="checklist-instance-name">${esc(inst.name)}</a></td>
|
||||
<td>${projectCell}</td>
|
||||
<td>
|
||||
<div class="checklist-progress-inline">
|
||||
<div class="checklist-progress-bar">
|
||||
<div class="checklist-progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<span class="checklist-progress-label">${done} / ${total}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>${esc(fmtDate(inst.created_at))}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
body.querySelectorAll<HTMLTableRowElement>(".checklist-instance-row").forEach((row) => {
|
||||
const id = row.dataset.id!;
|
||||
row.addEventListener("click", (e) => {
|
||||
// Let inner links (project, instance name) handle their own navigation.
|
||||
if ((e.target as HTMLElement).closest("a")) return;
|
||||
window.location.href = `/checklists/instances/${id}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showTab(tab: TabId, opts: { pushHistory?: boolean } = {}) {
|
||||
activeTab = tab;
|
||||
document.querySelectorAll<HTMLElement>("#checklists-tabs .entity-tab").forEach((el) => {
|
||||
el.classList.toggle("active", el.dataset.tab === tab);
|
||||
});
|
||||
document.querySelectorAll<HTMLElement>(".entity-tab-panel").forEach((el) => {
|
||||
el.style.display = el.id === `tab-${tab}` ? "" : "none";
|
||||
});
|
||||
if (opts.pushHistory ?? true) {
|
||||
const newURL = tab === "instances" ? "/checklists?tab=instances" : "/checklists";
|
||||
if (window.location.pathname + window.location.search !== newURL) {
|
||||
window.history.replaceState({}, "", newURL);
|
||||
}
|
||||
}
|
||||
if (tab === "instances") {
|
||||
void loadInstances();
|
||||
}
|
||||
}
|
||||
|
||||
function initTabs() {
|
||||
document.querySelectorAll<HTMLAnchorElement>("#checklists-tabs .entity-tab").forEach((tab) => {
|
||||
tab.addEventListener("click", (e) => {
|
||||
// Let middle-click / cmd-click open in new tab via the real href.
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
const id = tab.dataset.tab as TabId;
|
||||
if (VALID_TABS.includes(id)) showTab(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initFilters();
|
||||
initTabs();
|
||||
onLangChange(() => {
|
||||
renderTemplates();
|
||||
if (instancesLoaded) renderInstances();
|
||||
});
|
||||
void loadTemplates();
|
||||
showTab(parseTab(), { pushHistory: false });
|
||||
});
|
||||
@@ -109,7 +109,7 @@ function countryName(code: string): string {
|
||||
}
|
||||
|
||||
async function loadCourts() {
|
||||
const resp = await fetch("/api/gerichte");
|
||||
const resp = await fetch("/api/courts");
|
||||
if (!resp.ok) return;
|
||||
const data: ApiResponse = await resp.json();
|
||||
allCourts = data.courts;
|
||||
@@ -291,6 +291,13 @@ function initSearch() {
|
||||
searchQuery = input.value;
|
||||
render();
|
||||
});
|
||||
// Honor `?q=` from the global search-palette deep links. render() runs
|
||||
// after loadCourts() resolves and reads the module-level searchQuery.
|
||||
const q = new URLSearchParams(location.search).get("q");
|
||||
if (q) {
|
||||
input.value = q;
|
||||
searchQuery = q;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Feedback modal ---
|
||||
@@ -335,7 +342,7 @@ async function submitFeedback(e: Event) {
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch("/api/gerichte/feedback", {
|
||||
const resp = await fetch("/api/courts/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface DashboardUser {
|
||||
@@ -11,9 +11,10 @@ interface DashboardUser {
|
||||
|
||||
interface DeadlineSummary {
|
||||
overdue: number;
|
||||
today: number;
|
||||
this_week: number;
|
||||
upcoming: number;
|
||||
completed_this_week: number;
|
||||
next_week: number;
|
||||
later: number;
|
||||
}
|
||||
|
||||
interface MatterSummary {
|
||||
@@ -26,9 +27,9 @@ interface UpcomingDeadline {
|
||||
id: string;
|
||||
title: string;
|
||||
due_date: string;
|
||||
akte_id: string;
|
||||
akte_title: string;
|
||||
akte_ref: string;
|
||||
project_id: string;
|
||||
project_title: string;
|
||||
project_reference: string;
|
||||
urgency: "overdue" | "today" | "urgent" | "soon";
|
||||
}
|
||||
|
||||
@@ -38,21 +39,22 @@ interface UpcomingAppointment {
|
||||
start_at: string;
|
||||
end_at: string | null;
|
||||
type: string | null;
|
||||
akte_id: string | null;
|
||||
akte_title: string | null;
|
||||
akte_ref: string | null;
|
||||
project_id: string | null;
|
||||
project_title: string | null;
|
||||
project_reference: string | null;
|
||||
}
|
||||
|
||||
interface ActivityEntry {
|
||||
timestamp: string;
|
||||
actor_email: string | null;
|
||||
actor_name: string | null;
|
||||
akte_id: string;
|
||||
akte_title: string;
|
||||
akte_ref: string;
|
||||
project_id: string;
|
||||
project_title: string;
|
||||
project_reference: string;
|
||||
action: string | null;
|
||||
details: string;
|
||||
description: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
@@ -110,9 +112,9 @@ function renderGreeting(user: DashboardUser | null): void {
|
||||
|
||||
if (user) {
|
||||
nameEl.textContent = user.display_name ? `, ${user.display_name}` : "";
|
||||
const officeLabel = t(`office.${user.office}`) || user.office;
|
||||
const officeLabel = tDyn(`office.${user.office}`) || user.office;
|
||||
chip.textContent = officeLabel;
|
||||
chip.className = `dashboard-office-chip akten-office-chip akten-office-${user.office}`;
|
||||
chip.className = `dashboard-office-chip office-chip office-${user.office}`;
|
||||
chip.style.display = "inline-block";
|
||||
} else {
|
||||
nameEl.textContent = "";
|
||||
@@ -128,14 +130,18 @@ function renderGreeting(user: DashboardUser | null): void {
|
||||
|
||||
function renderSummary(s: DeadlineSummary): void {
|
||||
setCount("dashboard-count-overdue", s.overdue);
|
||||
setCount("dashboard-count-today", s.today);
|
||||
setCount("dashboard-count-this-week", s.this_week);
|
||||
setCount("dashboard-count-upcoming", s.upcoming);
|
||||
setCount("dashboard-count-completed", s.completed_this_week);
|
||||
setCount("dashboard-count-next-week", s.next_week);
|
||||
setCount("dashboard-count-later", s.later);
|
||||
|
||||
// Tone down the red card when there's nothing overdue — reduces alarm
|
||||
// fatigue when the user has a clean slate.
|
||||
// Überfällig is an emergency category — hide the card entirely on a clean
|
||||
// slate (the .dashboard-summary-grid uses auto-fit so the row re-flows to
|
||||
// 4 cards) and trip the alarm styling when there's anything overdue. See
|
||||
// t-paliad-105 / t-paliad-106 / t-paliad-110.
|
||||
const overdueCard = document.getElementById("dashboard-card-overdue")!;
|
||||
overdueCard.classList.toggle("dashboard-card-quiet", s.overdue === 0);
|
||||
overdueCard.classList.toggle("dashboard-card-overdue-hidden", s.overdue === 0);
|
||||
overdueCard.classList.toggle("dashboard-card-alarm", s.overdue > 0);
|
||||
}
|
||||
|
||||
function renderMatters(s: MatterSummary): void {
|
||||
@@ -158,12 +164,12 @@ function renderDeadlines(items: UpcomingDeadline[]): void {
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = items.map((d) => {
|
||||
const urgencyClass = `dashboard-urgency-${d.urgency}`;
|
||||
const urgencyLabel = t(`dashboard.urgency.${d.urgency}`);
|
||||
const urgencyLabel = tDyn(`dashboard.urgency.${d.urgency}`);
|
||||
return `<li class="dashboard-list-item">
|
||||
<a href="/akten/${esc(d.akte_id)}/fristen" class="dashboard-list-link">
|
||||
<a href="/projects/${esc(d.project_id)}/deadlines" class="dashboard-list-link">
|
||||
<div class="dashboard-list-main">
|
||||
<span class="dashboard-list-title">${esc(d.title)}</span>
|
||||
<span class="dashboard-list-ref">${esc(d.akte_ref)} · ${esc(d.akte_title)}</span>
|
||||
<span class="dashboard-list-ref" title="${escAttr(`${d.project_reference} · ${d.project_title}`)}">${esc(d.project_reference)} · ${esc(d.project_title)}</span>
|
||||
</div>
|
||||
<div class="dashboard-list-meta">
|
||||
<span class="dashboard-urgency-badge ${urgencyClass}" title="${escAttr(urgencyLabel)}">${esc(formatRelative(d.due_date))}</span>
|
||||
@@ -189,16 +195,16 @@ function renderAppointments(items: UpcomingAppointment[]): void {
|
||||
const dot = a.type
|
||||
? `<span class="dashboard-termin-dot dashboard-termin-${esc(a.type)}" aria-hidden="true"></span>`
|
||||
: `<span class="dashboard-termin-dot" aria-hidden="true"></span>`;
|
||||
const href = a.akte_id ? `/akten/${esc(a.akte_id)}/termine` : "#";
|
||||
const tag = a.akte_id ? "a" : "div";
|
||||
const akteLine = a.akte_ref && a.akte_title
|
||||
? `<span class="dashboard-list-ref">${esc(a.akte_ref)} · ${esc(a.akte_title)}</span>`
|
||||
const href = a.project_id ? `/projects/${esc(a.project_id)}/appointments` : "#";
|
||||
const tag = a.project_id ? "a" : "div";
|
||||
const projectLine = a.project_reference && a.project_title
|
||||
? `<span class="dashboard-list-ref" title="${escAttr(`${a.project_reference} · ${a.project_title}`)}">${esc(a.project_reference)} · ${esc(a.project_title)}</span>`
|
||||
: "";
|
||||
return `<li class="dashboard-list-item">
|
||||
<${tag} href="${href}" class="dashboard-list-link">
|
||||
<div class="dashboard-list-main">
|
||||
<span class="dashboard-list-title">${dot}${esc(a.title)}</span>
|
||||
${akteLine}
|
||||
${projectLine}
|
||||
</div>
|
||||
<div class="dashboard-list-meta">
|
||||
<span class="dashboard-appt-time">${esc(formatDateTime(a.start_at))}</span>
|
||||
@@ -222,24 +228,97 @@ function renderActivity(items: ActivityEntry[]): void {
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = items.map((e) => {
|
||||
const actor = e.actor_name || e.actor_email || t("dashboard.activity.system");
|
||||
const actionLabel = e.action
|
||||
? (t(`dashboard.action.${e.action}`) || e.action)
|
||||
: t("dashboard.activity.event");
|
||||
const shortKey = e.action ? `dashboard.action.short.${e.action}` : "";
|
||||
const translated = shortKey ? tDyn(shortKey) : "";
|
||||
const hasI18n = translated !== "" && translated !== shortKey;
|
||||
const shortAction = hasI18n
|
||||
? translated
|
||||
: (e.action || t("dashboard.activity.event"));
|
||||
// Localize the muted detail line so it speaks DE in DE and EN in EN —
|
||||
// historical rows carry English nouns inside DE narrative ("Deadline „ok"
|
||||
// geändert", "Note zu deadline hinzugefügt"); translateEvent parses both
|
||||
// legacy and new (value-only) shapes.
|
||||
const stored = e.description ?? (hasI18n ? "" : e.details);
|
||||
const { description: detail } = translateEvent(e.action, "", stored);
|
||||
// For checklist_* events with a known instance_id, deep-link the project
|
||||
// ref straight to the instance — saves a click vs. landing on the project.
|
||||
// Falls back to /projects/{id} for any other event or when the instance
|
||||
// ID is missing (older rows pre-metadata, or checklist_deleted).
|
||||
const ref = activityHref(e);
|
||||
return `<li class="dashboard-activity-item">
|
||||
<span class="dashboard-activity-time">${esc(formatDateTime(e.timestamp))}</span>
|
||||
<span class="dashboard-activity-body">
|
||||
<span class="dashboard-activity-actor">${esc(actor)}</span>
|
||||
<span class="dashboard-activity-action">${esc(actionLabel)}</span>
|
||||
<a href="/akten/${esc(e.akte_id)}" class="dashboard-activity-akte">${esc(e.akte_ref)}</a>
|
||||
<span class="dashboard-activity-details">${esc(e.details)}</span>
|
||||
</span>
|
||||
<div class="dashboard-activity-body">
|
||||
<p class="dashboard-activity-summary"><strong>${esc(actor)}</strong> ${esc(shortAction)}</p>
|
||||
<p class="dashboard-activity-detail">
|
||||
<a href="${escAttr(ref)}" class="dashboard-activity-project">${esc(e.project_reference)}</a>${detail ? ` <span>${esc(detail)}</span>` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</li>`;
|
||||
}).join("");
|
||||
// Row-level click handler: clicking anywhere on the row navigates to the
|
||||
// same target as the inner .dashboard-activity-project link. Inner <a>/
|
||||
// <button> clicks bubble through unchanged (Cmd-click → new tab still
|
||||
// works) and text remains selectable — same pattern as .entity-table rows
|
||||
// (t-098/099) and the project Verlauf cards (t-paliad-103).
|
||||
list.querySelectorAll<HTMLLIElement>(".dashboard-activity-item").forEach((row) => {
|
||||
const link = row.querySelector<HTMLAnchorElement>(".dashboard-activity-project");
|
||||
if (!link) return;
|
||||
row.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("a") || target.closest("button")) return;
|
||||
window.location.href = link.href;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve an activity row to the most-specific deep-link target. Mirrors the
|
||||
// rules in projects-detail.ts:eventDetailHref so the activity feed and the
|
||||
// project Verlauf agree on where each event family points. Falls back to the
|
||||
// owning project page when no metadata is wired (older rows or _deleted/
|
||||
// deadlines_imported events). Wired families: checklist_*, deadline_*,
|
||||
// appointment_*, note_created — see t-paliad-097/102.
|
||||
function activityHref(e: ActivityEntry): string {
|
||||
const action = e.action ?? "";
|
||||
const meta = (e.metadata ?? null) as Record<string, unknown> | null;
|
||||
if (meta) {
|
||||
if (action.startsWith("checklist_") && action !== "checklist_deleted") {
|
||||
const id = meta["checklist_instance_id"];
|
||||
if (typeof id === "string" && id) return `/checklists/instances/${id}`;
|
||||
}
|
||||
if (
|
||||
action.startsWith("deadline_") &&
|
||||
action !== "deadline_deleted" &&
|
||||
action !== "deadlines_imported"
|
||||
) {
|
||||
const id = meta["deadline_id"];
|
||||
if (typeof id === "string" && id) return `/deadlines/${id}`;
|
||||
}
|
||||
if (action.startsWith("appointment_") && action !== "appointment_deleted") {
|
||||
const id = meta["appointment_id"];
|
||||
if (typeof id === "string" && id) return `/appointments/${id}`;
|
||||
}
|
||||
if (action === "note_created") {
|
||||
const apptID = meta["appointment_id"];
|
||||
if (typeof apptID === "string" && apptID) return `/appointments/${apptID}`;
|
||||
const deadlineID = meta["deadline_id"];
|
||||
if (typeof deadlineID === "string" && deadlineID) return `/deadlines/${deadlineID}`;
|
||||
}
|
||||
}
|
||||
return `/projects/${e.project_id}`;
|
||||
}
|
||||
|
||||
function toggleOnboardingHint(user: DashboardUser | null): void {
|
||||
// Belt-and-braces: the server-side gate (gateOnboarded in handlers.go)
|
||||
// already redirects users without a paliad.users row to /onboarding before
|
||||
// the dashboard HTML is served. If the gate ever misses (e.g. DB lookup
|
||||
// errored and we fell through), push the user to /onboarding here so they
|
||||
// don't get stuck on a blank dashboard.
|
||||
if (!user) {
|
||||
window.location.href = "/onboarding";
|
||||
return;
|
||||
}
|
||||
const onboarding = document.getElementById("dashboard-onboarding")!;
|
||||
onboarding.style.display = user ? "none" : "block";
|
||||
onboarding.style.display = "none";
|
||||
}
|
||||
|
||||
function setCount(id: string, n: number): void {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Frist {
|
||||
interface Deadline {
|
||||
id: string;
|
||||
akte_id: string;
|
||||
project_id: string;
|
||||
title: string;
|
||||
due_date: string;
|
||||
status: string;
|
||||
akte_aktenzeichen: string;
|
||||
akte_title: string;
|
||||
project_reference: string;
|
||||
project_title: string;
|
||||
}
|
||||
|
||||
let allFristen: Frist[] = [];
|
||||
let allDeadlines: Deadline[] = [];
|
||||
let viewYear = 0;
|
||||
let viewMonth = 0; // 0-11
|
||||
|
||||
@@ -22,32 +22,31 @@ function esc(s: string): string {
|
||||
}
|
||||
|
||||
function fmtMonth(year: number, month: number): string {
|
||||
return `${t(`cal.month.${month}`)} ${year}`;
|
||||
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 + "T00:00:00");
|
||||
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 loadFristen() {
|
||||
async function loadDeadlines() {
|
||||
try {
|
||||
// Load all (open + completed) — calendar shows everything for context.
|
||||
const resp = await fetch("/api/fristen?status=all");
|
||||
if (resp.ok) allFristen = await resp.json();
|
||||
const resp = await fetch("/api/deadlines?status=all");
|
||||
if (resp.ok) allDeadlines = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function fristenForDate(iso: string): Frist[] {
|
||||
return allFristen.filter((f) => f.due_date.slice(0, 10) === iso);
|
||||
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 {
|
||||
@@ -59,10 +58,9 @@ function isoDate(year: number, month: number, day: number): string {
|
||||
function render() {
|
||||
document.getElementById("cal-month-label")!.textContent = fmtMonth(viewYear, viewMonth);
|
||||
|
||||
// First weekday of month (Mon=0..Sun=6)
|
||||
const firstDay = new Date(viewYear, viewMonth, 1);
|
||||
const jsWeekday = firstDay.getDay(); // Sun=0..Sat=6
|
||||
const offset = (jsWeekday + 6) % 7; // Mon=0..Sun=6
|
||||
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());
|
||||
@@ -73,34 +71,43 @@ function render() {
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const iso = isoDate(viewYear, viewMonth, day);
|
||||
const fristen = fristenForDate(iso);
|
||||
const items = deadlinesForDate(iso);
|
||||
const isToday = iso === todayISO;
|
||||
|
||||
const dots = fristen
|
||||
const dots = items
|
||||
.slice(0, 4)
|
||||
.map((f) => `<span class="frist-cal-dot ${urgencyClass(f.due_date, f.status)}"></span>`)
|
||||
.join("");
|
||||
const more = fristen.length > 4 ? `<span class="frist-cal-more">+${fristen.length - 4}</span>` : "";
|
||||
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" : ""}${fristen.length > 0 ? " frist-cal-cell-has" : ""}" data-iso="${iso}">
|
||||
`<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("frist-cal-grid")!;
|
||||
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 fristen = fristenForDate(iso);
|
||||
if (fristen.length === 0) return;
|
||||
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")!;
|
||||
@@ -113,13 +120,13 @@ function openPopup(iso: string) {
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
list.innerHTML = fristen
|
||||
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="/fristen/${esc(f.id)}" class="frist-cal-popup-title">${esc(f.title)}</a>
|
||||
<a href="/akten/${esc(f.akte_id)}" class="frist-cal-popup-akte">${esc(f.akte_aktenzeichen)}</a>
|
||||
<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("");
|
||||
@@ -169,6 +176,6 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
initNav();
|
||||
initPopup();
|
||||
onLangChange(render);
|
||||
await loadFristen();
|
||||
await loadDeadlines();
|
||||
render();
|
||||
});
|
||||
502
frontend/src/client/deadlines-detail.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initNotes } from "./notes";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
fetchEventTypes,
|
||||
eventTypeLabel,
|
||||
type EventType,
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
|
||||
interface Deadline {
|
||||
id: string;
|
||||
project_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
due_date: string;
|
||||
status: string;
|
||||
source: string;
|
||||
rule_id?: string;
|
||||
rule_code?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
event_type_ids?: string[];
|
||||
}
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let eventTypeByID: Map<string, EventType> = new Map();
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface DeadlineRule {
|
||||
id: string;
|
||||
code?: string;
|
||||
name: string;
|
||||
rule_code?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
job_title: string | null;
|
||||
global_role: string;
|
||||
}
|
||||
|
||||
let deadline: Deadline | null = null;
|
||||
let project: Project | null = null;
|
||||
let rule: DeadlineRule | null = null;
|
||||
let me: Me | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
|
||||
function parseDeadlineID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] !== "deadlines" || !parts[1]) return null;
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso + (iso.length === 10 ? "T00:00:00" : ""));
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
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 loadDeadline(id: string): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`/api/deadlines/${id}`);
|
||||
if (!resp.ok) return false;
|
||||
deadline = await resp.json();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProject(projectID: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${projectID}`);
|
||||
if (resp.ok) project = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllProjects() {
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (resp.ok) allProjects = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function populateProjectPicker() {
|
||||
const sel = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
|
||||
if (!sel || !deadline) return;
|
||||
const opts: string[] = [];
|
||||
for (const p of allProjects) {
|
||||
const indent = projectIndent(p.path);
|
||||
const ref = p.reference || "";
|
||||
opts.push(
|
||||
`<option value="${esc(p.id)}">${indent}${esc(ref)} — ${esc(p.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
sel.value = deadline.project_id;
|
||||
}
|
||||
|
||||
async function loadRule(ruleID: string) {
|
||||
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;
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (resp.ok) me = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!deadline) return;
|
||||
(document.getElementById("deadline-title-display") as HTMLElement).textContent = deadline.title;
|
||||
(document.getElementById("deadline-title-edit") as HTMLInputElement).value = deadline.title;
|
||||
|
||||
const dueChip = document.getElementById("deadline-due-chip")!;
|
||||
dueChip.className = `frist-due-chip ${urgencyClass(deadline.due_date, deadline.status)}`;
|
||||
dueChip.textContent = fmtDate(deadline.due_date);
|
||||
(document.getElementById("deadline-due-display") as HTMLElement).textContent = fmtDate(deadline.due_date);
|
||||
(document.getElementById("deadline-due-edit") as HTMLInputElement).value = deadline.due_date.slice(0, 10);
|
||||
|
||||
const statusChip = document.getElementById("deadline-status-chip")!;
|
||||
statusChip.className = `entity-status-chip entity-status-${deadline.status}`;
|
||||
statusChip.textContent = tDyn(`deadlines.status.${deadline.status}`) || deadline.status;
|
||||
|
||||
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
|
||||
if (project) {
|
||||
projectLink.href = `/projects/${project.id}`;
|
||||
projectLink.textContent = `${project.reference || ""} — ${project.title}`;
|
||||
} else {
|
||||
projectLink.href = `/projects/${deadline.project_id}`;
|
||||
projectLink.textContent = "—";
|
||||
}
|
||||
|
||||
const ruleEl = document.getElementById("deadline-rule-display")!;
|
||||
if (rule) {
|
||||
const code = rule.rule_code || rule.code || "";
|
||||
ruleEl.textContent = code ? `${code} — ${rule.name}` : rule.name;
|
||||
} 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).
|
||||
ruleEl.textContent = deadline.rule_code;
|
||||
} else {
|
||||
ruleEl.textContent = "—";
|
||||
}
|
||||
|
||||
(document.getElementById("deadline-source-display") as HTMLElement).textContent =
|
||||
tDyn(`deadlines.source.${deadline.source}`) || deadline.source;
|
||||
|
||||
(document.getElementById("deadline-notes-display") as HTMLElement).textContent = deadline.notes || "—";
|
||||
(document.getElementById("deadline-notes-edit") as HTMLTextAreaElement).value = deadline.notes || "";
|
||||
|
||||
// Event-Type display & picker (display always, picker only in edit mode).
|
||||
const etDisplay = document.getElementById("deadline-event-types-display");
|
||||
if (etDisplay) {
|
||||
const ids = deadline.event_type_ids ?? [];
|
||||
if (ids.length === 0) {
|
||||
etDisplay.innerHTML = "—";
|
||||
} else {
|
||||
etDisplay.innerHTML = ids
|
||||
.map((id) => {
|
||||
const et = eventTypeByID.get(id);
|
||||
if (!et) return "";
|
||||
return `<span class="entity-event-type-pill">${esc(eventTypeLabel(et))}</span>`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (etDisplay.innerHTML === "") etDisplay.innerHTML = "—";
|
||||
}
|
||||
}
|
||||
if (eventTypePicker) {
|
||||
eventTypePicker.setIDs(deadline.event_type_ids ?? []);
|
||||
}
|
||||
|
||||
(document.getElementById("deadline-created-display") as HTMLElement).textContent = fmtDateTime(deadline.created_at);
|
||||
|
||||
const completedLabel = document.getElementById("deadline-completed-row-label")!;
|
||||
const completedDD = document.getElementById("deadline-completed-display")!;
|
||||
if (deadline.completed_at) {
|
||||
completedLabel.style.display = "";
|
||||
completedDD.style.display = "";
|
||||
completedDD.textContent = fmtDateTime(deadline.completed_at);
|
||||
} else {
|
||||
completedLabel.style.display = "none";
|
||||
completedDD.style.display = "none";
|
||||
}
|
||||
|
||||
const completeBtn = document.getElementById("deadline-complete-btn") as HTMLButtonElement;
|
||||
const reopenBtn = document.getElementById("deadline-reopen-btn") as HTMLButtonElement;
|
||||
if (deadline.status === "completed") {
|
||||
completeBtn.style.display = "none";
|
||||
// Reopen is admin-gated server-side; the button is shown for global
|
||||
// admins/partners here as a client-side hint. Project leads who lack a
|
||||
// global admin/partner role won't see the inline button — they get a 403
|
||||
// only if they try, but the button itself stays hidden. They can still
|
||||
// PATCH the endpoint directly.
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
reopenBtn.style.display = "";
|
||||
reopenBtn.disabled = false;
|
||||
} else {
|
||||
reopenBtn.style.display = "none";
|
||||
}
|
||||
} else {
|
||||
completeBtn.style.display = "";
|
||||
completeBtn.disabled = false;
|
||||
completeBtn.textContent = t("deadlines.detail.complete");
|
||||
reopenBtn.style.display = "none";
|
||||
}
|
||||
|
||||
const deleteWrap = document.getElementById("deadline-delete-wrap")!;
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function initEdit() {
|
||||
const titleDisplay = document.getElementById("deadline-title-display")!;
|
||||
const titleEdit = document.getElementById("deadline-title-edit") as HTMLInputElement;
|
||||
const dueDisplay = document.getElementById("deadline-due-display")!;
|
||||
const dueEdit = document.getElementById("deadline-due-edit") as HTMLInputElement;
|
||||
const notesDisplay = document.getElementById("deadline-notes-display")!;
|
||||
const notesEdit = document.getElementById("deadline-notes-edit") as HTMLTextAreaElement;
|
||||
const editBtn = document.getElementById("deadline-edit-btn") as HTMLButtonElement;
|
||||
const saveBtn = document.getElementById("deadline-save-btn") as HTMLButtonElement;
|
||||
const etDisplay = document.getElementById("deadline-event-types-display");
|
||||
const etEdit = document.getElementById("deadline-event-types-edit");
|
||||
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
|
||||
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
|
||||
|
||||
function enterEdit() {
|
||||
titleDisplay.style.display = "none";
|
||||
titleEdit.style.display = "";
|
||||
dueDisplay.style.display = "none";
|
||||
dueEdit.style.display = "";
|
||||
notesDisplay.style.display = "none";
|
||||
notesEdit.style.display = "";
|
||||
if (etDisplay) etDisplay.style.display = "none";
|
||||
if (etEdit) etEdit.style.display = "";
|
||||
if (projectEdit && deadline) {
|
||||
projectLink.style.display = "none";
|
||||
projectEdit.style.display = "";
|
||||
projectEdit.value = deadline.project_id;
|
||||
}
|
||||
saveBtn.style.display = "";
|
||||
editBtn.style.display = "none";
|
||||
titleEdit.focus();
|
||||
titleEdit.select();
|
||||
}
|
||||
function exitEdit() {
|
||||
titleDisplay.style.display = "";
|
||||
titleEdit.style.display = "none";
|
||||
dueDisplay.style.display = "";
|
||||
dueEdit.style.display = "none";
|
||||
notesDisplay.style.display = "";
|
||||
notesEdit.style.display = "none";
|
||||
if (etDisplay) etDisplay.style.display = "";
|
||||
if (etEdit) etEdit.style.display = "none";
|
||||
if (projectEdit) {
|
||||
projectEdit.style.display = "none";
|
||||
projectLink.style.display = "";
|
||||
}
|
||||
saveBtn.style.display = "none";
|
||||
editBtn.style.display = "";
|
||||
}
|
||||
|
||||
editBtn.addEventListener("click", enterEdit);
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!deadline) return;
|
||||
const newTitle = titleEdit.value.trim();
|
||||
const newDue = dueEdit.value;
|
||||
const newNotes = notesEdit.value;
|
||||
if (!newTitle || !newDue) return;
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
title: newTitle,
|
||||
due_date: newDue,
|
||||
notes: newNotes,
|
||||
};
|
||||
if (eventTypePicker) {
|
||||
payload.event_type_ids = eventTypePicker.getIDs();
|
||||
}
|
||||
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
|
||||
payload.project_id = projectEdit.value;
|
||||
}
|
||||
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (resp.ok) {
|
||||
const prevProjectID = deadline.project_id;
|
||||
deadline = await resp.json();
|
||||
if (deadline && deadline.project_id !== prevProjectID) {
|
||||
await loadProject(deadline.project_id);
|
||||
}
|
||||
render();
|
||||
}
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
exitEdit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initComplete() {
|
||||
const btn = document.getElementById("deadline-complete-btn") as HTMLButtonElement;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!deadline || deadline.status === "completed") return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/deadlines/${deadline.id}/complete`, { method: "PATCH" });
|
||||
if (resp.ok) {
|
||||
deadline = await resp.json();
|
||||
render();
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initReopen() {
|
||||
const btn = document.getElementById("deadline-reopen-btn") as HTMLButtonElement;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!deadline || deadline.status !== "completed") return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/deadlines/${deadline.id}/reopen`, { method: "PATCH" });
|
||||
if (resp.ok) {
|
||||
deadline = await resp.json();
|
||||
render();
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initDelete() {
|
||||
const btn = document.getElementById("deadline-delete-btn")!;
|
||||
const modal = document.getElementById("deadline-delete-modal")!;
|
||||
const close = document.getElementById("deadline-delete-modal-close")!;
|
||||
const cancel = document.getElementById("deadline-delete-modal-cancel")!;
|
||||
const confirmBtn = document.getElementById("deadline-delete-modal-confirm") as HTMLButtonElement;
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
modal.style.display = "flex";
|
||||
});
|
||||
const closeModal = () => {
|
||||
modal.style.display = "none";
|
||||
};
|
||||
close.addEventListener("click", closeModal);
|
||||
cancel.addEventListener("click", closeModal);
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
});
|
||||
confirmBtn.addEventListener("click", async () => {
|
||||
if (!deadline) return;
|
||||
confirmBtn.disabled = true;
|
||||
const resp = await fetch(`/api/deadlines/${deadline.id}`, { method: "DELETE" });
|
||||
if (resp.ok) {
|
||||
const target = project ? `/projects/${project.id}/deadlines` : "/events?type=deadline";
|
||||
window.location.href = target;
|
||||
} else {
|
||||
confirmBtn.disabled = false;
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const id = parseDeadlineID();
|
||||
const loading = document.getElementById("deadline-loading")!;
|
||||
const notfound = document.getElementById("deadline-notfound")!;
|
||||
const body = document.getElementById("deadline-body")!;
|
||||
if (!id) {
|
||||
loading.style.display = "none";
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
await loadMe();
|
||||
const ok = await loadDeadline(id);
|
||||
if (!ok || !deadline) {
|
||||
loading.style.display = "none";
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
await Promise.all([loadProject(deadline.project_id), loadAllProjects()]);
|
||||
if (deadline.rule_id) await loadRule(deadline.rule_id);
|
||||
|
||||
// Load event types in parallel; render once ready (the picker re-renders
|
||||
// chips off the cached map, and the display element re-renders on the
|
||||
// next render() call after data lands).
|
||||
try {
|
||||
const types = await fetchEventTypes();
|
||||
eventTypeByID = new Map(types.map((et) => [et.id, et]));
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
|
||||
loading.style.display = "none";
|
||||
body.style.display = "";
|
||||
|
||||
// Mount the picker (hidden until enterEdit()).
|
||||
const pickerHost = document.getElementById("deadline-event-types-edit");
|
||||
if (pickerHost) {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
initialIDs: deadline.event_type_ids ?? [],
|
||||
currentUserAdmin: me?.global_role === "global_admin",
|
||||
});
|
||||
}
|
||||
|
||||
populateProjectPicker();
|
||||
render();
|
||||
initEdit();
|
||||
initComplete();
|
||||
initReopen();
|
||||
initDelete();
|
||||
|
||||
const notes = document.getElementById("notes-container");
|
||||
if (notes) {
|
||||
notes.setAttribute("data-parent-id", id);
|
||||
void initNotes(notes as HTMLElement, "deadline", id);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(render);
|
||||
main();
|
||||
});
|
||||
190
frontend/src/client/deadlines-new.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { attachEventTypePicker, type PickerHandle } from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let currentUserAdmin = false;
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
reference?: string | null;
|
||||
title: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface DeadlineRule {
|
||||
id: string;
|
||||
code?: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
rule_code?: string;
|
||||
}
|
||||
|
||||
let preselectedProjectID = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function showError(msg: string) {
|
||||
const el = document.getElementById("deadline-new-msg")!;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg form-msg-error";
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
const sel = document.getElementById("deadline-project") as HTMLSelectElement;
|
||||
const hint = document.getElementById("deadline-project-empty-hint")!;
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (!resp.ok) return;
|
||||
const projects: Project[] = await resp.json();
|
||||
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>`;
|
||||
return;
|
||||
}
|
||||
const options: string[] = [
|
||||
`<option value="" disabled${preselectedProjectID ? "" : " selected"} data-i18n="deadlines.field.akte.choose">${esc(t("deadlines.field.akte.choose"))}</option>`,
|
||||
];
|
||||
for (const p of projects) {
|
||||
const isSelected = preselectedProjectID === p.id ? " selected" : "";
|
||||
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>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = options.join("");
|
||||
} 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();
|
||||
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("");
|
||||
} catch {
|
||||
/* non-fatal — rule select stays at "no rule" */
|
||||
}
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm(e: Event) {
|
||||
e.preventDefault();
|
||||
const submitBtn = document.querySelector<HTMLButtonElement>("#deadline-new-form button[type=submit]")!;
|
||||
const msg = document.getElementById("deadline-new-msg")!;
|
||||
|
||||
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) {
|
||||
showError(t("deadlines.error.required"));
|
||||
return;
|
||||
}
|
||||
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
submitBtn.disabled = true;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title,
|
||||
due_date: due,
|
||||
source: "manual",
|
||||
};
|
||||
if (ruleID) payload.rule_id = ruleID;
|
||||
if (notes) payload.notes = notes;
|
||||
const eventTypeIDs = eventTypePicker?.getIDs() ?? [];
|
||||
if (eventTypeIDs.length > 0) payload.event_type_ids = eventTypeIDs;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||||
showError(data.error || t("deadlines.error.generic"));
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
if (preselectedProjectID) {
|
||||
window.location.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
} else {
|
||||
window.location.href = `/deadlines/${created.id}`;
|
||||
}
|
||||
} catch {
|
||||
showError(t("deadlines.error.generic"));
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function detectPreselect() {
|
||||
// Path /projects/{id}/deadlines/new pre-selects that project.
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] === "projects" && parts[1] && parts[2] === "deadlines" && parts[3] === "new") {
|
||||
preselectedProjectID = parts[1];
|
||||
}
|
||||
// Or ?project_id= query string
|
||||
const qp = new URLSearchParams(window.location.search);
|
||||
const fromQuery = qp.get("project_id");
|
||||
if (fromQuery) preselectedProjectID = fromQuery;
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (!resp.ok) return;
|
||||
const me = await resp.json();
|
||||
currentUserAdmin = me?.global_role === "global_admin";
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
detectPreselect();
|
||||
initBackLinks();
|
||||
document.getElementById("deadline-new-form")!.addEventListener("submit", submitForm);
|
||||
// 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()]);
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
if (pickerHost) {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
currentUserAdmin,
|
||||
});
|
||||
}
|
||||
});
|
||||
831
frontend/src/client/event-types.ts
Normal file
@@ -0,0 +1,831 @@
|
||||
// t-paliad-088: Event Types — shared client module.
|
||||
//
|
||||
// Four surfaces share this module:
|
||||
// 1. EventTypePicker — multi-tag chip cluster on /deadlines/new and the
|
||||
// /deadlines/{id} edit form. Lets the user pick 0..N event types via
|
||||
// search-as-you-type, "Alle anzeigen" (browse-all), or "+ Neuer Typ".
|
||||
// 2. EventTypeMultiSelectFilter — listbox-panel filter on /deadlines and
|
||||
// /agenda. Multi-select with search + "Alle" + "Ohne Typ" specials.
|
||||
// 3. AddEventTypeModal — opened from inside the picker via a
|
||||
// "+ Neuen Typ hinzufügen…" affordance. Any authenticated user may
|
||||
// publish firm-wide types (per m's Q6); admins moderate via archive.
|
||||
// 4. BrowseAllEventTypesModal (t-paliad-107) — opened from the picker via
|
||||
// "Alle anzeigen". Lists every type grouped by category with sticky
|
||||
// search and multi-select checkboxes pre-populated from the picker.
|
||||
//
|
||||
// Backend contract: see internal/handlers/event_types.go and
|
||||
// internal/services/event_type_service.go.
|
||||
|
||||
import { t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
|
||||
export interface EventType {
|
||||
id: string;
|
||||
slug: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
category: string;
|
||||
jurisdiction?: string | null;
|
||||
description: string;
|
||||
trigger_event_id?: number | null;
|
||||
created_by?: string | null;
|
||||
is_firm_wide: boolean;
|
||||
archived_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const CATEGORY_ORDER = [
|
||||
"submission",
|
||||
"decision",
|
||||
"order",
|
||||
"service",
|
||||
"fee",
|
||||
"hearing",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
export type Category = (typeof CATEGORY_ORDER)[number];
|
||||
|
||||
export function eventTypeLabel(et: EventType): string {
|
||||
const lang = getLang();
|
||||
const primary = lang === "en" ? et.label_en : et.label_de;
|
||||
return primary?.trim() || et.label_en || et.label_de || et.slug;
|
||||
}
|
||||
|
||||
export function categoryLabel(category: string): string {
|
||||
return tDyn(`event_types.cat.${category}`) || category;
|
||||
}
|
||||
|
||||
let cache: EventType[] | null = null;
|
||||
let cachePromise: Promise<EventType[]> | null = null;
|
||||
|
||||
export async function fetchEventTypes(force = false): Promise<EventType[]> {
|
||||
if (!force && cache) return cache;
|
||||
if (!force && cachePromise) return cachePromise;
|
||||
cachePromise = (async () => {
|
||||
const resp = await fetch("/api/event-types");
|
||||
if (!resp.ok) {
|
||||
cachePromise = null;
|
||||
return [];
|
||||
}
|
||||
cache = (await resp.json()) as EventType[];
|
||||
cachePromise = null;
|
||||
return cache;
|
||||
})();
|
||||
return cachePromise;
|
||||
}
|
||||
|
||||
export function invalidateEventTypeCache() {
|
||||
cache = null;
|
||||
cachePromise = null;
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function groupByCategory(types: EventType[]): Map<string, EventType[]> {
|
||||
const m = new Map<string, EventType[]>();
|
||||
for (const cat of CATEGORY_ORDER) m.set(cat, []);
|
||||
for (const et of types) {
|
||||
const list = m.get(et.category) ?? [];
|
||||
list.push(et);
|
||||
m.set(et.category, list);
|
||||
}
|
||||
// Sort each bucket by label.
|
||||
for (const [k, v] of m) {
|
||||
v.sort((a, b) => eventTypeLabel(a).localeCompare(eventTypeLabel(b)));
|
||||
m.set(k, v);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Picker (multi-tag chip cluster) — used on deadline create/edit
|
||||
// ============================================================================
|
||||
|
||||
interface PickerOptions {
|
||||
initialIDs?: string[];
|
||||
onChange?: (ids: string[]) => void;
|
||||
currentUserAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface PickerHandle {
|
||||
getIDs(): string[];
|
||||
setIDs(ids: string[]): void;
|
||||
refresh(): Promise<void>;
|
||||
}
|
||||
|
||||
export function attachEventTypePicker(container: HTMLElement, opts: PickerOptions): PickerHandle {
|
||||
let selected = new Set<string>(opts.initialIDs ?? []);
|
||||
let allTypes: EventType[] = [];
|
||||
|
||||
container.classList.add("event-type-picker");
|
||||
container.innerHTML = `
|
||||
<div class="event-type-chips" data-role="chips"></div>
|
||||
<div class="event-type-search-row">
|
||||
<input type="text" class="event-type-search" data-role="search" placeholder="${esc(t("event_types.picker.search"))}" />
|
||||
<button type="button" class="event-type-browse-btn" data-role="browse">${esc(t("event_types.picker.browse_all"))}</button>
|
||||
<button type="button" class="event-type-add-btn" data-role="add">${esc(t("event_types.picker.add"))}</button>
|
||||
</div>
|
||||
<div class="event-type-suggest" data-role="suggest" hidden></div>
|
||||
`;
|
||||
|
||||
const chipsEl = container.querySelector<HTMLElement>("[data-role=chips]")!;
|
||||
const searchEl = container.querySelector<HTMLInputElement>("[data-role=search]")!;
|
||||
const suggestEl = container.querySelector<HTMLElement>("[data-role=suggest]")!;
|
||||
const browseBtn = container.querySelector<HTMLButtonElement>("[data-role=browse]")!;
|
||||
const addBtn = container.querySelector<HTMLButtonElement>("[data-role=add]")!;
|
||||
|
||||
function notify() {
|
||||
opts.onChange?.(Array.from(selected));
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
const byID = new Map(allTypes.map((et) => [et.id, et]));
|
||||
chipsEl.innerHTML = Array.from(selected)
|
||||
.map((id) => {
|
||||
const et = byID.get(id);
|
||||
if (!et) return "";
|
||||
return `<span class="event-type-chip" data-id="${esc(et.id)}">
|
||||
<span class="event-type-chip-label">${esc(eventTypeLabel(et))}</span>
|
||||
<button type="button" class="event-type-chip-remove" aria-label="${esc(t("event_types.picker.remove"))}">×</button>
|
||||
</span>`;
|
||||
})
|
||||
.join("");
|
||||
chipsEl.querySelectorAll<HTMLButtonElement>(".event-type-chip-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const id = btn.parentElement?.dataset.id;
|
||||
if (id) {
|
||||
selected.delete(id);
|
||||
renderChips();
|
||||
notify();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSuggest(query: string) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q.length < 1) {
|
||||
suggestEl.hidden = true;
|
||||
suggestEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const matches = allTypes
|
||||
.filter((et) => !selected.has(et.id))
|
||||
.filter((et) => {
|
||||
const lde = et.label_de.toLowerCase();
|
||||
const len = et.label_en.toLowerCase();
|
||||
return lde.includes(q) || len.includes(q);
|
||||
})
|
||||
.slice(0, 12);
|
||||
if (matches.length === 0) {
|
||||
suggestEl.hidden = false;
|
||||
suggestEl.innerHTML = `<div class="event-type-suggest-empty">${esc(t("event_types.picker.no_match"))}</div>`;
|
||||
return;
|
||||
}
|
||||
suggestEl.hidden = false;
|
||||
suggestEl.innerHTML = matches
|
||||
.map(
|
||||
(et) => `<button type="button" class="event-type-suggest-row" data-id="${esc(et.id)}">
|
||||
<span class="event-type-suggest-label">${esc(eventTypeLabel(et))}</span>
|
||||
<span class="event-type-suggest-cat">${esc(categoryLabel(et.category))}</span>
|
||||
</button>`,
|
||||
)
|
||||
.join("");
|
||||
suggestEl.querySelectorAll<HTMLButtonElement>(".event-type-suggest-row").forEach((row) => {
|
||||
row.addEventListener("click", () => {
|
||||
const id = row.dataset.id!;
|
||||
selected.add(id);
|
||||
searchEl.value = "";
|
||||
renderSuggest("");
|
||||
renderChips();
|
||||
notify();
|
||||
searchEl.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
searchEl.addEventListener("input", () => renderSuggest(searchEl.value));
|
||||
searchEl.addEventListener("focus", () => renderSuggest(searchEl.value));
|
||||
searchEl.addEventListener("blur", () => {
|
||||
// Delay so the click lands first.
|
||||
setTimeout(() => {
|
||||
suggestEl.hidden = true;
|
||||
}, 200);
|
||||
});
|
||||
browseBtn.addEventListener("click", async () => {
|
||||
if (allTypes.length === 0) {
|
||||
// Cache might still be hydrating on a slow first paint — make sure
|
||||
// the modal opens against the freshest data we have.
|
||||
allTypes = await fetchEventTypes();
|
||||
}
|
||||
const result = await openBrowseEventTypesModal({
|
||||
types: allTypes,
|
||||
initialIDs: Array.from(selected),
|
||||
});
|
||||
if (result) {
|
||||
selected = new Set(result);
|
||||
searchEl.value = "";
|
||||
renderSuggest("");
|
||||
renderChips();
|
||||
notify();
|
||||
}
|
||||
});
|
||||
|
||||
addBtn.addEventListener("click", async () => {
|
||||
const created = await openAddEventTypeModal({
|
||||
prefillLabel: searchEl.value.trim(),
|
||||
isAdmin: !!opts.currentUserAdmin,
|
||||
});
|
||||
if (created) {
|
||||
await fetchEventTypes(true);
|
||||
allTypes = (await fetchEventTypes()) ?? [];
|
||||
selected.add(created.id);
|
||||
searchEl.value = "";
|
||||
renderSuggest("");
|
||||
renderChips();
|
||||
notify();
|
||||
}
|
||||
});
|
||||
|
||||
const handle: PickerHandle = {
|
||||
getIDs: () => Array.from(selected),
|
||||
setIDs: (ids) => {
|
||||
selected = new Set(ids);
|
||||
renderChips();
|
||||
},
|
||||
refresh: async () => {
|
||||
invalidateEventTypeCache();
|
||||
allTypes = await fetchEventTypes(true);
|
||||
renderChips();
|
||||
},
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
allTypes = await fetchEventTypes();
|
||||
renderChips();
|
||||
})();
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Multi-select filter (listbox panel) — used on /deadlines + /agenda
|
||||
// ============================================================================
|
||||
|
||||
interface FilterOptions {
|
||||
initialIDs?: string[];
|
||||
initialIncludeUntyped?: boolean;
|
||||
onChange?: (ids: string[], includeUntyped: boolean) => void;
|
||||
}
|
||||
|
||||
export interface FilterHandle {
|
||||
getIDs(): string[];
|
||||
getIncludeUntyped(): boolean;
|
||||
setSelection(ids: string[], includeUntyped: boolean): void;
|
||||
refresh(): Promise<void>;
|
||||
/** Serialise to the `?event_type=` query-param value (or "" when "Alle"). */
|
||||
toQueryValue(): string;
|
||||
}
|
||||
|
||||
export function attachEventTypeMultiSelectFilter(
|
||||
trigger: HTMLButtonElement,
|
||||
panel: HTMLElement,
|
||||
opts: FilterOptions = {},
|
||||
): FilterHandle {
|
||||
let selected = new Set<string>(opts.initialIDs ?? []);
|
||||
let includeUntyped = !!opts.initialIncludeUntyped;
|
||||
let allTypes: EventType[] = [];
|
||||
let searchQuery = "";
|
||||
|
||||
trigger.classList.add("multi-trigger");
|
||||
trigger.setAttribute("aria-haspopup", "listbox");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
trigger.innerHTML = `
|
||||
<span class="multi-label" data-role="label"></span>
|
||||
<span class="multi-chevron" aria-hidden="true">▾</span>
|
||||
`;
|
||||
panel.classList.add("multi-panel");
|
||||
panel.hidden = true;
|
||||
|
||||
function updateLabel() {
|
||||
const labelEl = trigger.querySelector<HTMLElement>("[data-role=label]")!;
|
||||
const total = selected.size + (includeUntyped ? 1 : 0);
|
||||
if (total === 0) {
|
||||
labelEl.textContent = t("event_types.filter.all");
|
||||
} else if (total === 1 && includeUntyped) {
|
||||
labelEl.textContent = t("event_types.filter.untyped");
|
||||
} else if (total === 1 && selected.size === 1) {
|
||||
const id = Array.from(selected)[0];
|
||||
const et = allTypes.find((x) => x.id === id);
|
||||
labelEl.textContent = et ? eventTypeLabel(et) : t("event_types.filter.n_selected").replace("{n}", "1");
|
||||
} else {
|
||||
labelEl.textContent = t("event_types.filter.n_selected").replace("{n}", String(total));
|
||||
}
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
const groups = groupByCategory(allTypes);
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
const matches = (et: EventType) => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
et.label_de.toLowerCase().includes(q) ||
|
||||
et.label_en.toLowerCase().includes(q) ||
|
||||
et.slug.toLowerCase().includes(q)
|
||||
);
|
||||
};
|
||||
const renderGroup = (cat: string) => {
|
||||
const list = (groups.get(cat) ?? []).filter(matches);
|
||||
if (list.length === 0) return "";
|
||||
return `<div class="multi-group">
|
||||
<div class="multi-group-label">${esc(categoryLabel(cat))}</div>
|
||||
${list
|
||||
.map(
|
||||
(et) => `<label class="multi-option">
|
||||
<input type="checkbox" data-id="${esc(et.id)}" ${selected.has(et.id) ? "checked" : ""} />
|
||||
<span>${esc(eventTypeLabel(et))}</span>
|
||||
</label>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>`;
|
||||
};
|
||||
panel.innerHTML = `
|
||||
<div class="multi-search-row">
|
||||
<input type="text" class="multi-search" data-role="search" placeholder="${esc(t("event_types.filter.search"))}" value="${esc(searchQuery)}" />
|
||||
</div>
|
||||
<div class="multi-specials">
|
||||
<label class="multi-option multi-special">
|
||||
<input type="checkbox" data-role="all" ${selected.size === 0 && !includeUntyped ? "checked" : ""} />
|
||||
<span>${esc(t("event_types.filter.all"))}</span>
|
||||
</label>
|
||||
<label class="multi-option multi-special">
|
||||
<input type="checkbox" data-role="untyped" ${includeUntyped ? "checked" : ""} />
|
||||
<span>${esc(t("event_types.filter.untyped"))}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="multi-list">
|
||||
${CATEGORY_ORDER.map(renderGroup).join("")}
|
||||
</div>
|
||||
<div class="multi-actions">
|
||||
<button type="button" class="btn-cancel" data-role="reset">${esc(t("event_types.filter.reset"))}</button>
|
||||
<button type="button" class="btn-primary btn-cta-lime" data-role="close">${esc(t("event_types.filter.apply"))}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const searchInput = panel.querySelector<HTMLInputElement>("[data-role=search]")!;
|
||||
searchInput.addEventListener("input", () => {
|
||||
searchQuery = searchInput.value;
|
||||
renderPanel();
|
||||
// re-focus the search after re-render
|
||||
const fresh = panel.querySelector<HTMLInputElement>("[data-role=search]")!;
|
||||
fresh.focus();
|
||||
fresh.setSelectionRange(searchInput.selectionStart ?? 0, searchInput.selectionEnd ?? 0);
|
||||
});
|
||||
|
||||
const allCb = panel.querySelector<HTMLInputElement>("[data-role=all]")!;
|
||||
allCb.addEventListener("change", () => {
|
||||
if (allCb.checked) {
|
||||
selected.clear();
|
||||
includeUntyped = false;
|
||||
renderPanel();
|
||||
updateLabel();
|
||||
opts.onChange?.([], false);
|
||||
} else {
|
||||
// Re-tick "Alle" if user tried to uncheck it without ticking anything
|
||||
// else — a "no filter" state with everything off doesn't exist.
|
||||
allCb.checked = true;
|
||||
}
|
||||
});
|
||||
|
||||
const untypedCb = panel.querySelector<HTMLInputElement>("[data-role=untyped]")!;
|
||||
untypedCb.addEventListener("change", () => {
|
||||
includeUntyped = untypedCb.checked;
|
||||
renderPanel();
|
||||
updateLabel();
|
||||
opts.onChange?.(Array.from(selected), includeUntyped);
|
||||
});
|
||||
|
||||
panel.querySelectorAll<HTMLInputElement>(".multi-list input[type=checkbox]").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const id = cb.dataset.id!;
|
||||
if (cb.checked) selected.add(id);
|
||||
else selected.delete(id);
|
||||
renderPanel();
|
||||
updateLabel();
|
||||
opts.onChange?.(Array.from(selected), includeUntyped);
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelector<HTMLButtonElement>("[data-role=reset]")!.addEventListener("click", () => {
|
||||
selected.clear();
|
||||
includeUntyped = false;
|
||||
searchQuery = "";
|
||||
renderPanel();
|
||||
updateLabel();
|
||||
opts.onChange?.([], false);
|
||||
});
|
||||
panel.querySelector<HTMLButtonElement>("[data-role=close]")!.addEventListener("click", () => {
|
||||
closePanel();
|
||||
});
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
panel.hidden = false;
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
renderPanel();
|
||||
setTimeout(() => {
|
||||
const search = panel.querySelector<HTMLInputElement>("[data-role=search]");
|
||||
search?.focus();
|
||||
}, 0);
|
||||
}
|
||||
function closePanel() {
|
||||
panel.hidden = true;
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
trigger.addEventListener("click", () => {
|
||||
if (panel.hidden) openPanel();
|
||||
else closePanel();
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const target = e.target as Node;
|
||||
if (panel.hidden) return;
|
||||
if (panel.contains(target) || trigger.contains(target)) return;
|
||||
closePanel();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && !panel.hidden) closePanel();
|
||||
});
|
||||
|
||||
const handle: FilterHandle = {
|
||||
getIDs: () => Array.from(selected),
|
||||
getIncludeUntyped: () => includeUntyped,
|
||||
setSelection: (ids, untyped) => {
|
||||
selected = new Set(ids);
|
||||
includeUntyped = untyped;
|
||||
updateLabel();
|
||||
if (!panel.hidden) renderPanel();
|
||||
},
|
||||
refresh: async () => {
|
||||
invalidateEventTypeCache();
|
||||
allTypes = await fetchEventTypes(true);
|
||||
updateLabel();
|
||||
if (!panel.hidden) renderPanel();
|
||||
},
|
||||
toQueryValue: () => {
|
||||
const tokens: string[] = [];
|
||||
for (const id of selected) tokens.push(id);
|
||||
if (includeUntyped) tokens.push("none");
|
||||
return tokens.join(",");
|
||||
},
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
allTypes = await fetchEventTypes();
|
||||
updateLabel();
|
||||
})();
|
||||
|
||||
// Trigger label and (when open) panel content come from t() — re-render
|
||||
// when the language changes so "Alle Typen" / "All types" stays in sync
|
||||
// with the active locale (t-paliad-117).
|
||||
onLangChange(() => {
|
||||
updateLabel();
|
||||
if (!panel.hidden) renderPanel();
|
||||
});
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Add modal
|
||||
// ============================================================================
|
||||
|
||||
interface AddModalOptions {
|
||||
prefillLabel?: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export function openAddEventTypeModal(opts: AddModalOptions): Promise<EventType | null> {
|
||||
return new Promise<EventType | null>((resolve) => {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "modal-overlay event-type-modal-overlay";
|
||||
overlay.innerHTML = `
|
||||
<div class="modal event-type-add-modal" role="dialog" aria-modal="true" aria-labelledby="event-type-add-title">
|
||||
<h2 id="event-type-add-title">${esc(t("event_types.add.title"))}</h2>
|
||||
<div class="event-type-suggest-warn" data-role="warn" hidden></div>
|
||||
<div class="form-field">
|
||||
<label for="event-type-add-label-de">${esc(t("event_types.add.label_de"))}</label>
|
||||
<input type="text" id="event-type-add-label-de" autofocus value="${esc(opts.prefillLabel || "")}" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="event-type-add-label-en">${esc(t("event_types.add.label_en"))}</label>
|
||||
<input type="text" id="event-type-add-label-en" />
|
||||
</div>
|
||||
<div class="form-field-row">
|
||||
<div class="form-field">
|
||||
<label for="event-type-add-category">${esc(t("event_types.add.category"))}</label>
|
||||
<select id="event-type-add-category">
|
||||
${CATEGORY_ORDER.map((c) => `<option value="${c}">${esc(categoryLabel(c))}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="event-type-add-jurisdiction">${esc(t("event_types.add.jurisdiction"))}</label>
|
||||
<select id="event-type-add-jurisdiction">
|
||||
<option value="">${esc(t("event_types.add.jurisdiction.none"))}</option>
|
||||
<option value="UPC">UPC</option>
|
||||
<option value="EPO">EPA</option>
|
||||
<option value="DPMA">DPMA</option>
|
||||
<option value="DE">DE</option>
|
||||
<option value="any">${esc(t("event_types.add.jurisdiction.any"))}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-field event-type-add-firm-wide" ${opts.isAdmin ? "" : "hidden"}>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="event-type-add-firm-wide" />
|
||||
<span>${esc(t("event_types.add.firm_wide"))}</span>
|
||||
</label>
|
||||
<p class="form-hint">${esc(t("event_types.add.firm_wide.hint"))}</p>
|
||||
</div>
|
||||
<p class="form-msg" id="event-type-add-msg"></p>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-cancel" data-role="cancel">${esc(t("common.cancel"))}</button>
|
||||
<button type="button" class="btn-primary btn-cta-lime" data-role="submit">${esc(t("event_types.add.submit"))}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const labelDE = overlay.querySelector<HTMLInputElement>("#event-type-add-label-de")!;
|
||||
const labelEN = overlay.querySelector<HTMLInputElement>("#event-type-add-label-en")!;
|
||||
const catSel = overlay.querySelector<HTMLSelectElement>("#event-type-add-category")!;
|
||||
const jurSel = overlay.querySelector<HTMLSelectElement>("#event-type-add-jurisdiction")!;
|
||||
const firmWide = overlay.querySelector<HTMLInputElement>("#event-type-add-firm-wide")!;
|
||||
const msg = overlay.querySelector<HTMLElement>("#event-type-add-msg")!;
|
||||
const warnEl = overlay.querySelector<HTMLElement>("[data-role=warn]")!;
|
||||
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
|
||||
const submitBtn = overlay.querySelector<HTMLButtonElement>("[data-role=submit]")!;
|
||||
|
||||
let suggestTimer: number | null = null;
|
||||
function checkDuplicates(query: string) {
|
||||
if (suggestTimer) window.clearTimeout(suggestTimer);
|
||||
const q = query.trim();
|
||||
if (q.length < 2) {
|
||||
warnEl.hidden = true;
|
||||
warnEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
suggestTimer = window.setTimeout(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/event-types/suggest?q=${encodeURIComponent(q)}`);
|
||||
if (!resp.ok) return;
|
||||
const matches = (await resp.json()) as EventType[];
|
||||
if (matches.length === 0) {
|
||||
warnEl.hidden = true;
|
||||
warnEl.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
warnEl.hidden = false;
|
||||
warnEl.innerHTML = `<strong>${esc(t("event_types.add.duplicate_warn"))}</strong> ` +
|
||||
matches.map((m) => `<span class="event-type-suggest-pill">${esc(eventTypeLabel(m))}</span>`).join(" ");
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
labelDE.addEventListener("input", () => checkDuplicates(labelDE.value));
|
||||
|
||||
function close(value: EventType | null) {
|
||||
overlay.remove();
|
||||
resolve(value);
|
||||
}
|
||||
|
||||
cancelBtn.addEventListener("click", () => close(null));
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) close(null);
|
||||
});
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
close(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
submitBtn.addEventListener("click", async () => {
|
||||
const labelDEv = labelDE.value.trim();
|
||||
if (!labelDEv) {
|
||||
msg.textContent = t("event_types.add.error.required");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
label_de: labelDEv,
|
||||
category: catSel.value,
|
||||
is_firm_wide: opts.isAdmin ? firmWide.checked : false,
|
||||
};
|
||||
if (labelEN.value.trim()) payload.label_en = labelEN.value.trim();
|
||||
if (jurSel.value) payload.jurisdiction = jurSel.value;
|
||||
const resp = await fetch("/api/event-types", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (resp.status === 409) {
|
||||
msg.textContent = t("event_types.add.error.slug_taken");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = data.error || t("event_types.add.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const created = (await resp.json()) as EventType;
|
||||
invalidateEventTypeCache();
|
||||
close(created);
|
||||
} catch {
|
||||
msg.textContent = t("event_types.add.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Browse-all modal — t-paliad-107
|
||||
// ============================================================================
|
||||
//
|
||||
// Companion to the picker's search-as-you-type flow. Lists every available
|
||||
// event type grouped by category, multi-select checkboxes, sticky search,
|
||||
// pre-populated from the picker's current selection. Apply replaces; Cancel
|
||||
// discards.
|
||||
|
||||
interface BrowseModalOptions {
|
||||
types: EventType[];
|
||||
initialIDs: string[];
|
||||
}
|
||||
|
||||
export function openBrowseEventTypesModal(
|
||||
opts: BrowseModalOptions,
|
||||
): Promise<string[] | null> {
|
||||
return new Promise<string[] | null>((resolve) => {
|
||||
let selected = new Set<string>(opts.initialIDs);
|
||||
let searchQuery = "";
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "modal-overlay event-type-browse-overlay";
|
||||
overlay.innerHTML = `
|
||||
<div class="modal event-type-browse-modal" role="dialog" aria-modal="true" aria-labelledby="event-type-browse-title">
|
||||
<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>
|
||||
<div class="event-type-browse-list" data-role="list" tabindex="-1"></div>
|
||||
<div class="event-type-browse-actions">
|
||||
<span class="event-type-browse-count" data-role="count" aria-live="polite"></span>
|
||||
<button type="button" class="btn-cancel" data-role="cancel">${esc(t("event_types.browse.cancel"))}</button>
|
||||
<button type="button" class="btn-primary btn-cta-lime" data-role="apply">${esc(t("event_types.browse.apply"))}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const modalEl = overlay.querySelector<HTMLElement>(".event-type-browse-modal")!;
|
||||
const searchEl = overlay.querySelector<HTMLInputElement>("[data-role=search]")!;
|
||||
const listEl = overlay.querySelector<HTMLElement>("[data-role=list]")!;
|
||||
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 groups = groupByCategory(opts.types);
|
||||
|
||||
function jurisdictionLabel(j: string | null | undefined): string {
|
||||
if (!j) return "";
|
||||
if (j === "any") return t("event_types.browse.jurisdiction.none");
|
||||
if (j === "EPO") return "EPA";
|
||||
return j;
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
countEl.textContent = t("event_types.browse.selected_count").replace(
|
||||
"{n}",
|
||||
String(selected.size),
|
||||
);
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
const matches = (et: EventType) => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
et.label_de.toLowerCase().includes(q) ||
|
||||
et.label_en.toLowerCase().includes(q) ||
|
||||
et.slug.toLowerCase().includes(q)
|
||||
);
|
||||
};
|
||||
const sections = CATEGORY_ORDER.map((cat) => {
|
||||
const list = (groups.get(cat) ?? []).filter(matches);
|
||||
if (list.length === 0) return "";
|
||||
return `<section class="event-type-browse-group">
|
||||
<h3 class="event-type-browse-group-label">${esc(categoryLabel(cat))}</h3>
|
||||
<ul class="event-type-browse-options" role="group" aria-label="${esc(categoryLabel(cat))}">
|
||||
${list
|
||||
.map((et) => {
|
||||
const checked = selected.has(et.id) ? "checked" : "";
|
||||
const jur = jurisdictionLabel(et.jurisdiction);
|
||||
const jurBadge = jur
|
||||
? `<span class="event-type-browse-jurisdiction">${esc(jur)}</span>`
|
||||
: "";
|
||||
return `<li>
|
||||
<label class="event-type-browse-option">
|
||||
<input type="checkbox" data-id="${esc(et.id)}" ${checked} />
|
||||
<span class="event-type-browse-option-label">${esc(eventTypeLabel(et))}</span>
|
||||
${jurBadge}
|
||||
</label>
|
||||
</li>`;
|
||||
})
|
||||
.join("")}
|
||||
</ul>
|
||||
</section>`;
|
||||
}).join("");
|
||||
|
||||
listEl.innerHTML =
|
||||
sections ||
|
||||
`<div class="event-type-browse-empty">${esc(t("event_types.browse.empty"))}</div>`;
|
||||
|
||||
listEl.querySelectorAll<HTMLInputElement>("input[type=checkbox]").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const id = cb.dataset.id!;
|
||||
if (cb.checked) selected.add(id);
|
||||
else selected.delete(id);
|
||||
updateCount();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
searchEl.addEventListener("input", () => {
|
||||
searchQuery = searchEl.value;
|
||||
renderList();
|
||||
});
|
||||
|
||||
function close(value: string[] | null) {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
overlay.remove();
|
||||
resolve(value);
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
close(null);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
// Lightweight focus trap: keep tabbing inside the modal.
|
||||
const focusables = modalEl.querySelectorAll<HTMLElement>(
|
||||
'input, button, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
const visible = Array.from(focusables).filter(
|
||||
(el) => !el.hasAttribute("disabled") && el.offsetParent !== null,
|
||||
);
|
||||
if (visible.length === 0) return;
|
||||
const first = visible[0];
|
||||
const last = visible[visible.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (e.shiftKey && active === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
cancelBtn.addEventListener("click", () => close(null));
|
||||
applyBtn.addEventListener("click", () => close(Array.from(selected)));
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) close(null);
|
||||
});
|
||||
|
||||
updateCount();
|
||||
renderList();
|
||||
setTimeout(() => searchEl.focus(), 0);
|
||||
});
|
||||
}
|
||||
1059
frontend/src/client/events.ts
Normal file
@@ -1,343 +0,0 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Frist {
|
||||
id: string;
|
||||
akte_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
due_date: string;
|
||||
status: string;
|
||||
source: string;
|
||||
rule_id?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
interface Akte {
|
||||
id: string;
|
||||
aktenzeichen: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface DeadlineRule {
|
||||
id: string;
|
||||
code?: string;
|
||||
name: string;
|
||||
rule_code?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
id: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
let frist: Frist | null = null;
|
||||
let akte: Akte | null = null;
|
||||
let rule: DeadlineRule | null = null;
|
||||
let me: Me | null = null;
|
||||
|
||||
function parseFristID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] !== "fristen" || !parts[1]) return null;
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso + (iso.length === 10 ? "T00:00:00" : ""));
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
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 + "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 loadFrist(id: string): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`/api/fristen/${id}`);
|
||||
if (!resp.ok) return false;
|
||||
frist = await resp.json();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAkte(akteID: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/akten/${akteID}`);
|
||||
if (resp.ok) akte = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRule(ruleID: string) {
|
||||
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;
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMe() {
|
||||
try {
|
||||
const resp = await fetch("/api/me");
|
||||
if (resp.ok) me = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!frist) return;
|
||||
(document.getElementById("frist-title-display") as HTMLElement).textContent = frist.title;
|
||||
(document.getElementById("frist-title-edit") as HTMLInputElement).value = frist.title;
|
||||
|
||||
const dueChip = document.getElementById("frist-due-chip")!;
|
||||
dueChip.className = `frist-due-chip ${urgencyClass(frist.due_date, frist.status)}`;
|
||||
dueChip.textContent = fmtDate(frist.due_date);
|
||||
(document.getElementById("frist-due-display") as HTMLElement).textContent = fmtDate(frist.due_date);
|
||||
(document.getElementById("frist-due-edit") as HTMLInputElement).value = frist.due_date.slice(0, 10);
|
||||
|
||||
const statusChip = document.getElementById("frist-status-chip")!;
|
||||
statusChip.className = `akten-status-chip akten-status-${frist.status}`;
|
||||
statusChip.textContent = t(`fristen.status.${frist.status}`) || frist.status;
|
||||
|
||||
const akteLink = document.getElementById("frist-akte-link") as HTMLAnchorElement;
|
||||
if (akte) {
|
||||
akteLink.href = `/akten/${akte.id}`;
|
||||
akteLink.textContent = `${akte.aktenzeichen} \u2014 ${akte.title}`;
|
||||
} else {
|
||||
akteLink.href = `/akten/${frist.akte_id}`;
|
||||
akteLink.textContent = "\u2014";
|
||||
}
|
||||
|
||||
const ruleEl = document.getElementById("frist-rule-display")!;
|
||||
if (rule) {
|
||||
const code = rule.rule_code || rule.code || "";
|
||||
ruleEl.textContent = code ? `${code} \u2014 ${rule.name}` : rule.name;
|
||||
} else {
|
||||
ruleEl.textContent = "\u2014";
|
||||
}
|
||||
|
||||
(document.getElementById("frist-source-display") as HTMLElement).textContent =
|
||||
t(`fristen.source.${frist.source}`) || frist.source;
|
||||
|
||||
(document.getElementById("frist-notes-display") as HTMLElement).textContent = frist.notes || "\u2014";
|
||||
(document.getElementById("frist-notes-edit") as HTMLTextAreaElement).value = frist.notes || "";
|
||||
|
||||
(document.getElementById("frist-created-display") as HTMLElement).textContent = fmtDateTime(frist.created_at);
|
||||
|
||||
const completedLabel = document.getElementById("frist-completed-row-label")!;
|
||||
const completedDD = document.getElementById("frist-completed-display")!;
|
||||
if (frist.completed_at) {
|
||||
completedLabel.style.display = "";
|
||||
completedDD.style.display = "";
|
||||
completedDD.textContent = fmtDateTime(frist.completed_at);
|
||||
} else {
|
||||
completedLabel.style.display = "none";
|
||||
completedDD.style.display = "none";
|
||||
}
|
||||
|
||||
const completeBtn = document.getElementById("frist-complete-btn") as HTMLButtonElement;
|
||||
if (frist.status === "completed") {
|
||||
completeBtn.disabled = true;
|
||||
completeBtn.textContent = t("fristen.detail.completed.already");
|
||||
} else {
|
||||
completeBtn.disabled = false;
|
||||
completeBtn.textContent = t("fristen.detail.complete");
|
||||
}
|
||||
|
||||
const deleteWrap = document.getElementById("frist-delete-wrap")!;
|
||||
if (me && (me.role === "partner" || me.role === "admin")) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
function initEdit() {
|
||||
const titleDisplay = document.getElementById("frist-title-display")!;
|
||||
const titleEdit = document.getElementById("frist-title-edit") as HTMLInputElement;
|
||||
const dueDisplay = document.getElementById("frist-due-display")!;
|
||||
const dueEdit = document.getElementById("frist-due-edit") as HTMLInputElement;
|
||||
const notesDisplay = document.getElementById("frist-notes-display")!;
|
||||
const notesEdit = document.getElementById("frist-notes-edit") as HTMLTextAreaElement;
|
||||
const editBtn = document.getElementById("frist-edit-btn") as HTMLButtonElement;
|
||||
const saveBtn = document.getElementById("frist-save-btn") as HTMLButtonElement;
|
||||
|
||||
function enterEdit() {
|
||||
titleDisplay.style.display = "none";
|
||||
titleEdit.style.display = "";
|
||||
dueDisplay.style.display = "none";
|
||||
dueEdit.style.display = "";
|
||||
notesDisplay.style.display = "none";
|
||||
notesEdit.style.display = "";
|
||||
saveBtn.style.display = "";
|
||||
editBtn.style.display = "none";
|
||||
titleEdit.focus();
|
||||
titleEdit.select();
|
||||
}
|
||||
function exitEdit() {
|
||||
titleDisplay.style.display = "";
|
||||
titleEdit.style.display = "none";
|
||||
dueDisplay.style.display = "";
|
||||
dueEdit.style.display = "none";
|
||||
notesDisplay.style.display = "";
|
||||
notesEdit.style.display = "none";
|
||||
saveBtn.style.display = "none";
|
||||
editBtn.style.display = "";
|
||||
}
|
||||
|
||||
editBtn.addEventListener("click", enterEdit);
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!frist) return;
|
||||
const newTitle = titleEdit.value.trim();
|
||||
const newDue = dueEdit.value;
|
||||
const newNotes = notesEdit.value;
|
||||
if (!newTitle || !newDue) return;
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/fristen/${frist.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: newTitle, due_date: newDue, notes: newNotes }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
frist = await resp.json();
|
||||
render();
|
||||
}
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
exitEdit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initComplete() {
|
||||
const btn = document.getElementById("frist-complete-btn") as HTMLButtonElement;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!frist || frist.status === "completed") return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/fristen/${frist.id}/complete`, { method: "PATCH" });
|
||||
if (resp.ok) {
|
||||
frist = await resp.json();
|
||||
render();
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initDelete() {
|
||||
const btn = document.getElementById("frist-delete-btn")!;
|
||||
const modal = document.getElementById("frist-delete-modal")!;
|
||||
const close = document.getElementById("frist-delete-modal-close")!;
|
||||
const cancel = document.getElementById("frist-delete-modal-cancel")!;
|
||||
const confirmBtn = document.getElementById("frist-delete-modal-confirm") as HTMLButtonElement;
|
||||
|
||||
btn.addEventListener("click", () => {
|
||||
modal.style.display = "flex";
|
||||
});
|
||||
const closeModal = () => {
|
||||
modal.style.display = "none";
|
||||
};
|
||||
close.addEventListener("click", closeModal);
|
||||
cancel.addEventListener("click", closeModal);
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
});
|
||||
confirmBtn.addEventListener("click", async () => {
|
||||
if (!frist) return;
|
||||
confirmBtn.disabled = true;
|
||||
const resp = await fetch(`/api/fristen/${frist.id}`, { method: "DELETE" });
|
||||
if (resp.ok) {
|
||||
const target = akte ? `/akten/${akte.id}/fristen` : "/fristen";
|
||||
window.location.href = target;
|
||||
} else {
|
||||
confirmBtn.disabled = false;
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const id = parseFristID();
|
||||
const loading = document.getElementById("frist-loading")!;
|
||||
const notfound = document.getElementById("frist-notfound")!;
|
||||
const body = document.getElementById("frist-body")!;
|
||||
if (!id) {
|
||||
loading.style.display = "none";
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
await loadMe();
|
||||
const ok = await loadFrist(id);
|
||||
if (!ok || !frist) {
|
||||
loading.style.display = "none";
|
||||
notfound.style.display = "block";
|
||||
return;
|
||||
}
|
||||
await loadAkte(frist.akte_id);
|
||||
if (frist.rule_id) await loadRule(frist.rule_id);
|
||||
|
||||
loading.style.display = "none";
|
||||
body.style.display = "";
|
||||
render();
|
||||
initEdit();
|
||||
initComplete();
|
||||
initDelete();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(render);
|
||||
main();
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Akte {
|
||||
id: string;
|
||||
aktenzeichen: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface DeadlineRule {
|
||||
id: string;
|
||||
code?: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
rule_code?: string;
|
||||
}
|
||||
|
||||
let preselectedAkteID = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function showError(msg: string) {
|
||||
const el = document.getElementById("frist-neu-msg")!;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg form-msg-error";
|
||||
}
|
||||
|
||||
async function loadAkten() {
|
||||
const sel = document.getElementById("frist-akte") as HTMLSelectElement;
|
||||
const hint = document.getElementById("frist-akte-empty-hint")!;
|
||||
try {
|
||||
const resp = await fetch("/api/akten");
|
||||
if (!resp.ok) return;
|
||||
const akten: Akte[] = await resp.json();
|
||||
if (akten.length === 0) {
|
||||
hint.style.display = "";
|
||||
hint.innerHTML = `${esc(t("fristen.field.akte.empty"))} <a href="/akten/neu">${esc(t("fristen.field.akte.empty.link"))}</a>`;
|
||||
return;
|
||||
}
|
||||
const options: string[] = [
|
||||
`<option value="" disabled${preselectedAkteID ? "" : " selected"} data-i18n="fristen.field.akte.choose">${esc(t("fristen.field.akte.choose"))}</option>`,
|
||||
];
|
||||
for (const a of akten) {
|
||||
const isSelected = preselectedAkteID === a.id ? " selected" : "";
|
||||
options.push(
|
||||
`<option value="${esc(a.id)}"${isSelected}>${esc(a.aktenzeichen)} \u2014 ${esc(a.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = options.join("");
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
// Optional: load rules so user can attach. We pull all rules; small set.
|
||||
const sel = document.getElementById("frist-rule") as HTMLSelectElement;
|
||||
try {
|
||||
const resp = await fetch("/api/deadline-rules");
|
||||
if (!resp.ok) return;
|
||||
const rules: DeadlineRule[] = await resp.json();
|
||||
const opts: string[] = [
|
||||
`<option value="" data-i18n="fristen.field.rule.none">${esc(t("fristen.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("");
|
||||
} catch {
|
||||
/* non-fatal — rule select stays at "no rule" */
|
||||
}
|
||||
}
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedAkteID) {
|
||||
const back = document.getElementById("frist-neu-back") as HTMLAnchorElement;
|
||||
const cancel = document.getElementById("frist-neu-cancel") as HTMLAnchorElement;
|
||||
back.href = `/akten/${preselectedAkteID}/fristen`;
|
||||
cancel.href = `/akten/${preselectedAkteID}/fristen`;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm(e: Event) {
|
||||
e.preventDefault();
|
||||
const submitBtn = document.querySelector<HTMLButtonElement>("#frist-neu-form button[type=submit]")!;
|
||||
const msg = document.getElementById("frist-neu-msg")!;
|
||||
|
||||
const akteID = (document.getElementById("frist-akte") as HTMLSelectElement).value;
|
||||
const title = (document.getElementById("frist-title") as HTMLInputElement).value.trim();
|
||||
const due = (document.getElementById("frist-due") as HTMLInputElement).value;
|
||||
const ruleID = (document.getElementById("frist-rule") as HTMLSelectElement).value;
|
||||
const notes = (document.getElementById("frist-notes") as HTMLTextAreaElement).value.trim();
|
||||
|
||||
if (!akteID || !title || !due) {
|
||||
showError(t("fristen.error.required"));
|
||||
return;
|
||||
}
|
||||
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
submitBtn.disabled = true;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title,
|
||||
due_date: due,
|
||||
source: "manual",
|
||||
};
|
||||
if (ruleID) payload.rule_id = ruleID;
|
||||
if (notes) payload.notes = notes;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/akten/${encodeURIComponent(akteID)}/fristen`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||||
showError(data.error || t("fristen.error.generic"));
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
if (preselectedAkteID) {
|
||||
window.location.href = `/akten/${preselectedAkteID}/fristen`;
|
||||
} else {
|
||||
window.location.href = `/fristen/${created.id}`;
|
||||
}
|
||||
} catch {
|
||||
showError(t("fristen.error.generic"));
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function detectPreselect() {
|
||||
// Path /akten/{id}/fristen/neu pre-selects that akte.
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] === "akten" && parts[1] && parts[2] === "fristen" && parts[3] === "neu") {
|
||||
preselectedAkteID = parts[1];
|
||||
}
|
||||
// Or ?akte_id= query string
|
||||
const qp = new URLSearchParams(window.location.search);
|
||||
const fromQuery = qp.get("akte_id");
|
||||
if (fromQuery) preselectedAkteID = fromQuery;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
detectPreselect();
|
||||
initBackLinks();
|
||||
document.getElementById("frist-neu-form")!.addEventListener("submit", submitForm);
|
||||
// Default due to today
|
||||
const dueInput = document.getElementById("frist-due") as HTMLInputElement;
|
||||
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
|
||||
await Promise.all([loadAkten(), loadRules()]);
|
||||
});
|
||||
@@ -1,265 +0,0 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface Frist {
|
||||
id: string;
|
||||
akte_id: string;
|
||||
title: string;
|
||||
due_date: string;
|
||||
status: string;
|
||||
source: string;
|
||||
rule_id?: string;
|
||||
akte_aktenzeichen: string;
|
||||
akte_title: string;
|
||||
akte_office: string;
|
||||
rule_code?: string;
|
||||
}
|
||||
|
||||
interface Akte {
|
||||
id: string;
|
||||
aktenzeichen: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
overdue: number;
|
||||
this_week: number;
|
||||
upcoming: number;
|
||||
completed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
let allFristen: Frist[] = [];
|
||||
let allAkten: Akte[] = [];
|
||||
let statusFilter = "pending";
|
||||
let akteFilter = "";
|
||||
let loadedOK = false;
|
||||
|
||||
function urlParams(): URLSearchParams {
|
||||
return new URLSearchParams(window.location.search);
|
||||
}
|
||||
|
||||
async function loadAkten() {
|
||||
try {
|
||||
const resp = await fetch("/api/akten");
|
||||
if (resp.ok) allAkten = await resp.json();
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
try {
|
||||
const url = akteFilter
|
||||
? `/api/fristen/summary?akte_id=${encodeURIComponent(akteFilter)}`
|
||||
: `/api/fristen/summary`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return;
|
||||
const sum: Summary = await resp.json();
|
||||
setCount("sum-overdue", sum.overdue);
|
||||
setCount("sum-week", sum.this_week);
|
||||
setCount("sum-upcoming", sum.upcoming);
|
||||
setCount("sum-completed", sum.completed);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
function setCount(id: string, n: number) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = String(n);
|
||||
}
|
||||
|
||||
async function loadFristen() {
|
||||
const unavailable = document.getElementById("fristen-unavailable")!;
|
||||
const tableWrap = document.querySelector<HTMLElement>(".akten-table-wrap")!;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter) params.set("status", statusFilter);
|
||||
if (akteFilter) params.set("akte_id", akteFilter);
|
||||
const resp = await fetch(`/api/fristen?${params.toString()}`);
|
||||
if (resp.status === 503) {
|
||||
unavailable.style.display = "block";
|
||||
tableWrap.style.display = "none";
|
||||
document.getElementById("fristen-empty")!.style.display = "none";
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
unavailable.style.display = "block";
|
||||
tableWrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
allFristen = await resp.json();
|
||||
loadedOK = true;
|
||||
render();
|
||||
} catch {
|
||||
unavailable.style.display = "block";
|
||||
tableWrap.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
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 + "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";
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso + "T00:00:00");
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!loadedOK) return;
|
||||
const tbody = document.getElementById("fristen-body")!;
|
||||
const empty = document.getElementById("fristen-empty")!;
|
||||
const emptyFiltered = document.getElementById("fristen-empty-filtered")!;
|
||||
const tableWrap = document.querySelector<HTMLElement>(".akten-table-wrap")!;
|
||||
|
||||
if (allFristen.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
tableWrap.style.display = "none";
|
||||
if (statusFilter === "all" && !akteFilter) {
|
||||
empty.style.display = "block";
|
||||
emptyFiltered.style.display = "none";
|
||||
} else {
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "block";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
tableWrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
emptyFiltered.style.display = "none";
|
||||
|
||||
tbody.innerHTML = allFristen
|
||||
.map((f) => {
|
||||
const urgency = urgencyClass(f.due_date, f.status);
|
||||
const statusLabel = t(`fristen.status.${f.status}`) || f.status;
|
||||
const ruleLabel = f.rule_code ? esc(f.rule_code) : "—";
|
||||
const checked = f.status === "completed" ? "checked" : "";
|
||||
const disabled = f.status === "completed" ? "disabled" : "";
|
||||
const titleClass = f.status === "completed" ? "frist-title-done" : "";
|
||||
return `<tr class="frist-row" data-id="${esc(f.id)}">
|
||||
<td class="frist-col-check">
|
||||
<input type="checkbox" class="frist-complete-cb" ${checked} ${disabled}
|
||||
aria-label="${esc(t("fristen.complete.action"))}" />
|
||||
</td>
|
||||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDate(f.due_date)}</td>
|
||||
<td class="frist-col-title ${titleClass}">${esc(f.title)}</td>
|
||||
<td class="frist-col-akte">
|
||||
<a class="akten-ref-link" href="/akten/${esc(f.akte_id)}">${esc(f.akte_aktenzeichen)}</a>
|
||||
<span class="frist-akte-title">${esc(f.akte_title)}</span>
|
||||
</td>
|
||||
<td class="frist-col-rule">${ruleLabel}</td>
|
||||
<td><span class="akten-status-chip akten-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
tbody.querySelectorAll<HTMLTableRowElement>(".frist-row").forEach((row) => {
|
||||
const id = row.dataset.id!;
|
||||
row.addEventListener("click", (e) => {
|
||||
// Don't navigate if clicking the checkbox or a link
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".frist-complete-cb") || target.closest("a")) return;
|
||||
window.location.href = `/fristen/${id}`;
|
||||
});
|
||||
const cb = row.querySelector<HTMLInputElement>(".frist-complete-cb");
|
||||
if (cb) {
|
||||
cb.addEventListener("change", async () => {
|
||||
if (!cb.checked) return;
|
||||
cb.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/fristen/${id}/complete`, { method: "PATCH" });
|
||||
if (resp.ok) {
|
||||
await Promise.all([loadFristen(), loadSummary()]);
|
||||
} else {
|
||||
cb.checked = false;
|
||||
cb.disabled = false;
|
||||
}
|
||||
} catch {
|
||||
cb.checked = false;
|
||||
cb.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initFilters() {
|
||||
const status = document.getElementById("frist-filter-status") as HTMLSelectElement;
|
||||
const akte = document.getElementById("frist-filter-akte") as HTMLSelectElement;
|
||||
|
||||
// Pre-fill from URL
|
||||
const params = urlParams();
|
||||
if (params.has("status")) statusFilter = params.get("status")!;
|
||||
if (params.has("akte_id")) akteFilter = params.get("akte_id")!;
|
||||
status.value = statusFilter;
|
||||
|
||||
status.addEventListener("change", async () => {
|
||||
statusFilter = status.value;
|
||||
await Promise.all([loadFristen(), loadSummary()]);
|
||||
});
|
||||
akte.addEventListener("change", async () => {
|
||||
akteFilter = akte.value;
|
||||
await Promise.all([loadFristen(), loadSummary()]);
|
||||
});
|
||||
}
|
||||
|
||||
function populateAkteFilter() {
|
||||
const sel = document.getElementById("frist-filter-akte") as HTMLSelectElement;
|
||||
// Keep the first "all" option, then append sorted Akten.
|
||||
const options: string[] = [
|
||||
`<option value="" data-i18n="fristen.filter.akte.all">${esc(t("fristen.filter.akte.all"))}</option>`,
|
||||
];
|
||||
for (const a of allAkten) {
|
||||
options.push(
|
||||
`<option value="${esc(a.id)}">${esc(a.aktenzeichen)} \u2014 ${esc(a.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = options.join("");
|
||||
if (akteFilter) sel.value = akteFilter;
|
||||
}
|
||||
|
||||
function initSummaryCards() {
|
||||
document.querySelectorAll<HTMLButtonElement>(".frist-summary-card").forEach((card) => {
|
||||
card.addEventListener("click", async () => {
|
||||
const newStatus = card.dataset.status!;
|
||||
statusFilter = newStatus;
|
||||
(document.getElementById("frist-filter-status") as HTMLSelectElement).value = newStatus;
|
||||
await Promise.all([loadFristen(), loadSummary()]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initFilters();
|
||||
initSummaryCards();
|
||||
onLangChange(render);
|
||||
await loadAkten();
|
||||
populateAkteFilter();
|
||||
await Promise.all([loadFristen(), loadSummary()]);
|
||||
});
|
||||
@@ -15,7 +15,7 @@ let searchQuery = "";
|
||||
const ICON_FEEDBACK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
|
||||
|
||||
async function loadTerms() {
|
||||
const resp = await fetch("/api/glossar");
|
||||
const resp = await fetch("/api/glossary");
|
||||
if (!resp.ok) return;
|
||||
allTerms = await resp.json();
|
||||
render();
|
||||
@@ -85,6 +85,13 @@ function initSearch() {
|
||||
searchQuery = input.value;
|
||||
render();
|
||||
});
|
||||
// Honor `?q=` from the global search-palette deep links. render() runs
|
||||
// after loadTerms() resolves and reads the module-level searchQuery.
|
||||
const q = new URLSearchParams(location.search).get("q");
|
||||
if (q) {
|
||||
input.value = q;
|
||||
searchQuery = q;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Category filters ---
|
||||
@@ -162,7 +169,7 @@ async function submitSuggestion(e: Event) {
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch("/api/glossar/suggest", {
|
||||
const resp = await fetch("/api/glossary/suggest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
277
frontend/src/client/inbox.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { initI18n, t, getLang, type I18nKey } from "./i18n";
|
||||
|
||||
// /inbox client. Two tabs (pending-mine / mine), action buttons (approve /
|
||||
// reject / revoke), and a small inline diff for update / complete / delete
|
||||
// lifecycle events.
|
||||
//
|
||||
// State is URL-driven via ?tab= so back/forward buttons work and the bell
|
||||
// badge can deep-link to either tab. The badge in the sidebar (id
|
||||
// sidebar-inbox-badge) is updated by the shared global polling loop in
|
||||
// sidebar.ts; this module just keeps the page content in sync.
|
||||
|
||||
type Lifecycle = "create" | "update" | "complete" | "delete";
|
||||
type RequestStatus = "pending" | "approved" | "rejected" | "revoked" | "superseded";
|
||||
type DecisionKind = "peer" | "admin_override";
|
||||
|
||||
interface ApprovalRequestView {
|
||||
id: string;
|
||||
project_id: string;
|
||||
project_title: string;
|
||||
entity_type: "deadline" | "appointment";
|
||||
entity_id: string;
|
||||
entity_title?: string;
|
||||
lifecycle_event: Lifecycle;
|
||||
pre_image?: Record<string, unknown> | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
required_role: string;
|
||||
status: RequestStatus;
|
||||
requested_at: string;
|
||||
requested_by: string;
|
||||
requester_name: string;
|
||||
decided_at?: string;
|
||||
decided_by?: string;
|
||||
decider_name?: string;
|
||||
decision_kind?: DecisionKind;
|
||||
decision_note?: string;
|
||||
}
|
||||
|
||||
type Tab = "pending-mine" | "mine";
|
||||
|
||||
let currentTab: Tab = "pending-mine";
|
||||
|
||||
initI18n();
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const url = new URL(window.location.href);
|
||||
const t = url.searchParams.get("tab");
|
||||
if (t === "mine") currentTab = "mine";
|
||||
bindTabs();
|
||||
refresh();
|
||||
});
|
||||
|
||||
function bindTabs() {
|
||||
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = (btn.dataset.tab as Tab) || "pending-mine";
|
||||
if (tab === currentTab) return;
|
||||
currentTab = tab;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tab);
|
||||
history.replaceState({}, "", url.toString());
|
||||
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((b) => {
|
||||
b.classList.toggle("active", b.dataset.tab === tab);
|
||||
});
|
||||
refresh();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const loading = document.getElementById("inbox-loading") as HTMLElement | null;
|
||||
const empty = document.getElementById("inbox-empty") as HTMLElement | null;
|
||||
const list = document.getElementById("inbox-list") as HTMLUListElement | null;
|
||||
if (!loading || !empty || !list) return;
|
||||
loading.style.display = "";
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = "";
|
||||
const path = currentTab === "pending-mine" ? "/api/inbox/pending-mine" : "/api/inbox/mine";
|
||||
let rows: ApprovalRequestView[] = [];
|
||||
try {
|
||||
const r = await fetch(path, { credentials: "include" });
|
||||
if (r.ok) rows = (await r.json()) as ApprovalRequestView[];
|
||||
} catch (_e) {
|
||||
// Network errors fall through to empty render.
|
||||
}
|
||||
loading.style.display = "none";
|
||||
if (rows.length === 0) {
|
||||
empty.textContent = t(
|
||||
currentTab === "pending-mine"
|
||||
? "approvals.empty.pending_mine"
|
||||
: "approvals.empty.mine"
|
||||
);
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
for (const row of rows) list.appendChild(renderRow(row));
|
||||
}
|
||||
|
||||
function renderRow(row: ApprovalRequestView): HTMLLIElement {
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row";
|
||||
|
||||
// Header: project / entity / lifecycle / required-role
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
const entityLabel = t(("approvals.entity." + row.entity_type) as I18nKey);
|
||||
const lifecycleLabel = t(("approvals.lifecycle." + row.lifecycle_event) as I18nKey);
|
||||
const entityTitle = row.entity_title || "—";
|
||||
title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`;
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const reqByLabel = t("approvals.requested_by");
|
||||
const roleLabel = t(("approvals.required_role." + row.required_role) as I18nKey);
|
||||
meta.textContent = `${row.project_title} · ${reqByLabel} ${row.requester_name} · ${roleLabel}+ · ${formatRelativeTime(row.requested_at)}`;
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
// Diff for update / complete (date-bearing fields)
|
||||
const diff = renderDiff(row);
|
||||
if (diff) li.appendChild(diff);
|
||||
|
||||
// Decision note if any
|
||||
if (row.decision_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "inbox-row-note";
|
||||
note.textContent = row.decision_note;
|
||||
li.appendChild(note);
|
||||
}
|
||||
|
||||
// Action row
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (row.status === "pending" && currentTab === "pending-mine") {
|
||||
actions.appendChild(actionButton("approve", row.id, () => doDecision(row.id, "approve")));
|
||||
actions.appendChild(actionButton("reject", row.id, () => doDecision(row.id, "reject")));
|
||||
} else if (row.status === "pending" && currentTab === "mine") {
|
||||
actions.appendChild(actionButton("revoke", row.id, () => doDecision(row.id, "revoke")));
|
||||
} else {
|
||||
// historic — show status pill
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "approval-pill approval-pill--historic";
|
||||
pill.textContent = t(("approvals.status." + row.status) as I18nKey);
|
||||
if (row.decider_name && row.status !== "revoked") {
|
||||
const decided = document.createElement("span");
|
||||
decided.className = "inbox-row-decided";
|
||||
decided.textContent = ` · ${t("approvals.decided_by")} ${row.decider_name}`;
|
||||
pill.appendChild(decided);
|
||||
}
|
||||
actions.appendChild(pill);
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderDiff(row: ApprovalRequestView): HTMLElement | null {
|
||||
const before = (row.pre_image || {}) as Record<string, unknown>;
|
||||
const after = (row.payload || {}) as Record<string, unknown>;
|
||||
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]));
|
||||
if (keys.length === 0) return null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "inbox-row-diff";
|
||||
for (const k of keys) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "inbox-row-diff-line";
|
||||
const label = document.createElement("span");
|
||||
label.className = "inbox-row-diff-key";
|
||||
label.textContent = k;
|
||||
line.appendChild(label);
|
||||
const span = document.createElement("span");
|
||||
span.className = "inbox-row-diff-values";
|
||||
const fmt = (v: unknown) =>
|
||||
v === null || v === undefined ? "—" : String(v);
|
||||
if (k in before && k in after) {
|
||||
span.textContent = `${fmt(before[k])} → ${fmt(after[k])}`;
|
||||
} else if (k in before) {
|
||||
span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`;
|
||||
} else {
|
||||
span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`;
|
||||
}
|
||||
line.appendChild(span);
|
||||
wrap.appendChild(line);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function actionButton(action: "approve" | "reject" | "revoke", _requestID: string, onClick: () => void): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `btn btn-${action === "approve" ? "primary" : action === "reject" ? "danger" : "secondary"} inbox-row-action`;
|
||||
btn.textContent = t(("approvals.action." + action) as I18nKey);
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function doDecision(requestID: string, action: "approve" | "reject" | "revoke") {
|
||||
let note = "";
|
||||
if (action === "reject") {
|
||||
note = window.prompt(t("approvals.note.placeholder")) || "";
|
||||
}
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(`/api/approval-requests/${requestID}/${action}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
} catch (_e) {
|
||||
alert("Network error");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
const errKey = (body && body.error) || "internal";
|
||||
const msg = mapApprovalError(errKey);
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
// Update sidebar bell count.
|
||||
refreshInboxBadge();
|
||||
}
|
||||
|
||||
function mapApprovalError(key: string): string {
|
||||
switch (key) {
|
||||
case "self_approval_blocked":
|
||||
return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver":
|
||||
return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending":
|
||||
return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized":
|
||||
return t("approvals.error.not_authorized");
|
||||
case "request_not_pending":
|
||||
return t("approvals.error.request_not_pending");
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const t0 = Date.parse(iso);
|
||||
if (isNaN(t0)) return iso;
|
||||
const diffMs = Date.now() - t0;
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
|
||||
}
|
||||
|
||||
// Update the sidebar inbox badge (shared with sidebar.ts polling).
|
||||
async function refreshInboxBadge() {
|
||||
const badge = document.getElementById("sidebar-inbox-badge");
|
||||
if (!badge) return;
|
||||
try {
|
||||
const r = await fetch("/api/inbox/count", { credentials: "include" });
|
||||
if (!r.ok) return;
|
||||
const data = (await r.json()) as { count: number };
|
||||
if (data.count > 0) {
|
||||
badge.textContent = String(data.count);
|
||||
badge.style.display = "";
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
} catch (_e) {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initBottomNav } from "./bottom-nav";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initBottomNav();
|
||||
});
|
||||
|
||||